同步上游至最新版本并重新应用自定义配置
上游新增功能: - 双模式用户消息队列(串行队列 + 软性限速) - 自定义菜单页面(iframe嵌入 + CSP注入) - 代理URL集中验证与全局fail-fast - 新用户默认订阅设置 - 指纹缓存TTL懒续期机制 - 分组用量分布图表 - 代理密码可见性 + 复制代理URL - 大量bug修复和性能优化 自定义配置保留: - 品牌化:Sub2API → StarFireAPI - 链接:GitHub → anthropic.edu.pl 官网 - docker-compose:starfireapi镜像、端口6580、外部Redis、项目名xinghuoapi - 更新模块禁用:后端始终返回无更新、前端隐藏更新UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,16 +39,6 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => appStore.siteName,
|
||||
(newName) => {
|
||||
if (newName) {
|
||||
document.title = `${newName} - AI API Gateway`
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for authentication state and manage subscription data
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
|
||||
@@ -58,12 +58,16 @@ describe('ImportDataModal', () => {
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: () => Promise.resolve('invalid json')
|
||||
})
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [file]
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed')
|
||||
})
|
||||
|
||||
@@ -58,12 +58,16 @@ describe('Proxy ImportDataModal', () => {
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: () => Promise.resolve('invalid json')
|
||||
})
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [file]
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportParseFailed')
|
||||
})
|
||||
|
||||
208
frontend/src/api/__tests__/client.spec.ts
Normal file
208
frontend/src/api/__tests__/client.spec.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
|
||||
// 需要在导入 client 之前设置 mock
|
||||
vi.mock('@/i18n', () => ({
|
||||
getLocale: () => 'zh-CN',
|
||||
}))
|
||||
|
||||
describe('API Client', () => {
|
||||
let apiClient: AxiosInstance
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear()
|
||||
// 每次测试重新导入以获取干净的模块状态
|
||||
vi.resetModules()
|
||||
const mod = await import('@/api/client')
|
||||
apiClient = mod.apiClient
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// --- 请求拦截器 ---
|
||||
|
||||
describe('请求拦截器', () => {
|
||||
it('自动附加 Authorization 头', async () => {
|
||||
localStorage.setItem('auth_token', 'my-jwt-token')
|
||||
|
||||
// 拦截实际请求
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/test')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('Authorization')).toBe('Bearer my-jwt-token')
|
||||
})
|
||||
|
||||
it('无 token 时不附加 Authorization 头', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/test')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('Authorization')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('GET 请求自动附加 timezone 参数', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/test')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.params).toHaveProperty('timezone')
|
||||
})
|
||||
|
||||
it('POST 请求不附加 timezone 参数', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.post('/test', { foo: 'bar' })
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.params?.timezone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// --- 响应拦截器 ---
|
||||
|
||||
describe('响应拦截器', () => {
|
||||
it('code=0 时解包 data 字段', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: { name: 'test' }, message: 'ok' },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
const response = await apiClient.get('/test')
|
||||
expect(response.data).toEqual({ name: 'test' })
|
||||
})
|
||||
|
||||
it('code!=0 时拒绝并返回结构化错误', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 1001, message: '参数错误', data: null },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
code: 1001,
|
||||
message: '参数错误',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 401 Token 刷新 ---
|
||||
|
||||
describe('401 Token 刷新', () => {
|
||||
it('无 refresh_token 时 401 清除 localStorage', async () => {
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
// 不设置 refresh_token
|
||||
|
||||
// Mock window.location
|
||||
const originalLocation = window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...originalLocation, pathname: '/dashboard', href: '/dashboard' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const adapter = vi.fn().mockRejectedValue({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
|
||||
},
|
||||
config: {
|
||||
url: '/test',
|
||||
headers: { Authorization: 'Bearer expired-token' },
|
||||
},
|
||||
code: 'ERR_BAD_REQUEST',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toBeDefined()
|
||||
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
|
||||
// 恢复 location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- 网络错误 ---
|
||||
|
||||
describe('网络错误', () => {
|
||||
it('网络错误返回 status 0 的错误', async () => {
|
||||
const adapter = vi.fn().mockRejectedValue({
|
||||
code: 'ERR_NETWORK',
|
||||
message: 'Network Error',
|
||||
config: { url: '/test' },
|
||||
// 没有 response
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 0,
|
||||
message: 'Network error. Please check your connection.',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 请求取消 ---
|
||||
|
||||
describe('请求取消', () => {
|
||||
it('取消的请求保持原始取消错误', async () => {
|
||||
const source = axios.CancelToken.source()
|
||||
|
||||
const adapter = vi.fn().mockRejectedValue(
|
||||
new axios.Cancel('Operation canceled')
|
||||
)
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(
|
||||
apiClient.get('/test', { cancelToken: source.token })
|
||||
).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,9 @@ import type {
|
||||
AccountUsageStatsResponse,
|
||||
TempUnschedulableStatus,
|
||||
AdminDataPayload,
|
||||
AdminDataImportResult
|
||||
AdminDataImportResult,
|
||||
CheckMixedChannelRequest,
|
||||
CheckMixedChannelResponse
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -32,6 +34,7 @@ export async function list(
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
group?: string
|
||||
search?: string
|
||||
},
|
||||
options?: {
|
||||
@@ -49,6 +52,58 @@ export async function list(
|
||||
return data
|
||||
}
|
||||
|
||||
export interface AccountListWithEtagResult {
|
||||
notModified: boolean
|
||||
etag: string | null
|
||||
data: PaginatedResponse<Account> | null
|
||||
}
|
||||
|
||||
export async function listWithEtag(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
etag?: string | null
|
||||
}
|
||||
): Promise<AccountListWithEtagResult> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (options?.etag) {
|
||||
headers['If-None-Match'] = options.etag
|
||||
}
|
||||
|
||||
const response = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters
|
||||
},
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
validateStatus: (status) => (status >= 200 && status < 300) || status === 304
|
||||
})
|
||||
|
||||
const etagHeader = typeof response.headers?.etag === 'string' ? response.headers.etag : null
|
||||
if (response.status === 304) {
|
||||
return {
|
||||
notModified: true,
|
||||
etag: etagHeader,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notModified: false,
|
||||
etag: etagHeader,
|
||||
data: response.data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account by ID
|
||||
* @param id - Account ID
|
||||
@@ -80,6 +135,16 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Check mixed-channel risk for account-group binding.
|
||||
*/
|
||||
export async function checkMixedChannelRisk(
|
||||
payload: CheckMixedChannelRequest
|
||||
): Promise<CheckMixedChannelResponse> {
|
||||
const { data } = await apiClient.post<CheckMixedChannelResponse>('/admin/accounts/check-mixed-channel', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete account
|
||||
* @param id - Account ID
|
||||
@@ -164,10 +229,10 @@ export async function getUsage(id: number): Promise<AccountUsageInfo> {
|
||||
/**
|
||||
* Clear account rate limit status
|
||||
* @param id - Account ID
|
||||
* @returns Success confirmation
|
||||
* @returns Updated account
|
||||
*/
|
||||
export async function clearRateLimit(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>(
|
||||
export async function clearRateLimit(id: number): Promise<Account> {
|
||||
const { data } = await apiClient.post<Account>(
|
||||
`/admin/accounts/${id}/clear-rate-limit`
|
||||
)
|
||||
return data
|
||||
@@ -219,7 +284,7 @@ export async function generateAuthUrl(
|
||||
*/
|
||||
export async function exchangeCode(
|
||||
endpoint: string,
|
||||
exchangeData: { session_id: string; code: string; proxy_id?: number }
|
||||
exchangeData: { session_id: string; code: string; state?: string; proxy_id?: number }
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, exchangeData)
|
||||
return data
|
||||
@@ -304,6 +369,22 @@ export async function getTodayStats(id: number): Promise<WindowStats> {
|
||||
return data
|
||||
}
|
||||
|
||||
export interface BatchTodayStatsResponse {
|
||||
stats: Record<string, WindowStats>
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个账号的今日统计
|
||||
* @param accountIds - 账号 ID 列表
|
||||
* @returns 以账号 ID(字符串)为键的统计映射
|
||||
*/
|
||||
export async function getBatchTodayStats(accountIds: number[]): Promise<BatchTodayStatsResponse> {
|
||||
const { data } = await apiClient.post<BatchTodayStatsResponse>('/admin/accounts/today-stats/batch', {
|
||||
account_ids: accountIds
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Set account schedulable status
|
||||
* @param id - Account ID
|
||||
@@ -327,11 +408,34 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
|
||||
return data
|
||||
}
|
||||
|
||||
export interface CRSPreviewAccount {
|
||||
crs_account_id: string
|
||||
kind: string
|
||||
name: string
|
||||
platform: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface PreviewFromCRSResult {
|
||||
new_accounts: CRSPreviewAccount[]
|
||||
existing_accounts: CRSPreviewAccount[]
|
||||
}
|
||||
|
||||
export async function previewFromCrs(params: {
|
||||
base_url: string
|
||||
username: string
|
||||
password: string
|
||||
}): Promise<PreviewFromCRSResult> {
|
||||
const { data } = await apiClient.post<PreviewFromCRSResult>('/admin/accounts/sync/crs/preview', params)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function syncFromCrs(params: {
|
||||
base_url: string
|
||||
username: string
|
||||
password: string
|
||||
sync_proxies?: boolean
|
||||
selected_account_ids?: string[]
|
||||
}): Promise<{
|
||||
created: number
|
||||
updated: number
|
||||
@@ -345,7 +449,19 @@ export async function syncFromCrs(params: {
|
||||
error?: string
|
||||
}>
|
||||
}> {
|
||||
const { data } = await apiClient.post('/admin/accounts/sync/crs', params)
|
||||
const { data } = await apiClient.post<{
|
||||
created: number
|
||||
updated: number
|
||||
skipped: number
|
||||
failed: number
|
||||
items: Array<{
|
||||
crs_account_id: string
|
||||
kind: string
|
||||
name: string
|
||||
action: string
|
||||
error?: string
|
||||
}>
|
||||
}>('/admin/accounts/sync/crs', params)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -398,11 +514,56 @@ export async function getAntigravityDefaultModelMapping(): Promise<Record<string
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OpenAI token using refresh token
|
||||
* @param refreshToken - The refresh token
|
||||
* @param proxyId - Optional proxy ID
|
||||
* @returns Token information including access_token, email, etc.
|
||||
*/
|
||||
export async function refreshOpenAIToken(
|
||||
refreshToken: string,
|
||||
proxyId?: number | null,
|
||||
endpoint: string = '/admin/openai/refresh-token'
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: { refresh_token: string; proxy_id?: number } = {
|
||||
refresh_token: refreshToken
|
||||
}
|
||||
if (proxyId) {
|
||||
payload.proxy_id = proxyId
|
||||
}
|
||||
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Sora session token and exchange to access token
|
||||
* @param sessionToken - Sora session token
|
||||
* @param proxyId - Optional proxy ID
|
||||
* @param endpoint - API endpoint path
|
||||
* @returns Token information including access_token
|
||||
*/
|
||||
export async function validateSoraSessionToken(
|
||||
sessionToken: string,
|
||||
proxyId?: number | null,
|
||||
endpoint: string = '/admin/sora/st2at'
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: { session_token: string; proxy_id?: number } = {
|
||||
session_token: sessionToken
|
||||
}
|
||||
if (proxyId) {
|
||||
payload.proxy_id = proxyId
|
||||
}
|
||||
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
listWithEtag,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
checkMixedChannelRisk,
|
||||
delete: deleteAccount,
|
||||
toggleStatus,
|
||||
testAccount,
|
||||
@@ -411,6 +572,7 @@ export const accountsAPI = {
|
||||
clearError,
|
||||
getUsage,
|
||||
getTodayStats,
|
||||
getBatchTodayStats,
|
||||
clearRateLimit,
|
||||
getTempUnschedulableStatus,
|
||||
resetTempUnschedulable,
|
||||
@@ -418,9 +580,12 @@ export const accountsAPI = {
|
||||
getAvailableModels,
|
||||
generateAuthUrl,
|
||||
exchangeCode,
|
||||
refreshOpenAIToken,
|
||||
validateSoraSessionToken,
|
||||
batchCreate,
|
||||
batchUpdateCredentials,
|
||||
bulkUpdate,
|
||||
previewFromCrs,
|
||||
syncFromCrs,
|
||||
exportData,
|
||||
importData,
|
||||
|
||||
@@ -53,4 +53,18 @@ export async function exchangeCode(
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode }
|
||||
export async function refreshAntigravityToken(
|
||||
refreshToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<AntigravityTokenInfo> {
|
||||
const payload: Record<string, any> = { refresh_token: refreshToken }
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
|
||||
const { data } = await apiClient.post<AntigravityTokenInfo>(
|
||||
'/admin/antigravity/oauth/refresh-token',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode, refreshAntigravityToken }
|
||||
|
||||
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
|
||||
@@ -8,8 +8,10 @@ import type {
|
||||
DashboardStats,
|
||||
TrendDataPoint,
|
||||
ModelStat,
|
||||
GroupStat,
|
||||
ApiKeyUsageTrendPoint,
|
||||
UserUsageTrendPoint
|
||||
UserUsageTrendPoint,
|
||||
UsageRequestType
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -49,6 +51,7 @@ export interface TrendParams {
|
||||
model?: string
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
@@ -78,6 +81,7 @@ export interface ModelStatsParams {
|
||||
model?: string
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
@@ -98,6 +102,34 @@ export async function getModelStats(params?: ModelStatsParams): Promise<ModelSta
|
||||
return data
|
||||
}
|
||||
|
||||
export interface GroupStatsParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface GroupStatsResponse {
|
||||
groups: GroupStat[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group usage statistics
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Group usage statistics
|
||||
*/
|
||||
export async function getGroupStats(params?: GroupStatsParams): Promise<GroupStatsResponse> {
|
||||
const { data } = await apiClient.get<GroupStatsResponse>('/admin/dashboard/groups', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export interface ApiKeyTrendParams extends TrendParams {
|
||||
limit?: number
|
||||
}
|
||||
@@ -200,6 +232,7 @@ export const dashboardAPI = {
|
||||
getRealtimeMetrics,
|
||||
getUsageTrend,
|
||||
getModelStats,
|
||||
getGroupStats,
|
||||
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
|
||||
@@ -21,6 +21,7 @@ export interface ErrorPassthroughRule {
|
||||
response_code: number | null
|
||||
passthrough_body: boolean
|
||||
custom_message: string | null
|
||||
skip_monitoring: boolean
|
||||
description: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -41,6 +42,7 @@ export interface CreateRuleRequest {
|
||||
response_code?: number | null
|
||||
passthrough_body?: boolean
|
||||
custom_message?: string | null
|
||||
skip_monitoring?: boolean
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ export interface UpdateRuleRequest {
|
||||
response_code?: number | null
|
||||
passthrough_body?: boolean
|
||||
custom_message?: string | null
|
||||
skip_monitoring?: boolean
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,20 @@ export async function getGroupApiKeys(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group sort orders
|
||||
* @param updates - Array of { id, sort_order } objects
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function updateSortOrder(
|
||||
updates: Array<{ id: number; sort_order: number }>
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.put<{ message: string }>('/admin/groups/sort-order', {
|
||||
updates
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const groupsAPI = {
|
||||
list,
|
||||
getAll,
|
||||
@@ -163,7 +177,8 @@ export const groupsAPI = {
|
||||
delete: deleteGroup,
|
||||
toggleStatus,
|
||||
getStats,
|
||||
getGroupApiKeys
|
||||
getGroupApiKeys,
|
||||
updateSortOrder
|
||||
}
|
||||
|
||||
export default groupsAPI
|
||||
|
||||
@@ -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,40 @@ export interface OpsErrorDistributionResponse {
|
||||
items: OpsErrorDistributionItem[]
|
||||
}
|
||||
|
||||
export type OpsOpenAITokenStatsTimeRange = '30m' | '1h' | '1d' | '15d' | '30d'
|
||||
|
||||
export interface OpsOpenAITokenStatsItem {
|
||||
model: string
|
||||
request_count: number
|
||||
avg_tokens_per_sec?: number | null
|
||||
avg_first_token_ms?: number | null
|
||||
total_output_tokens: number
|
||||
avg_duration_ms: number
|
||||
requests_with_first_token: number
|
||||
}
|
||||
|
||||
export interface OpsOpenAITokenStatsResponse {
|
||||
time_range: OpsOpenAITokenStatsTimeRange
|
||||
start_time: string
|
||||
end_time: string
|
||||
platform?: string
|
||||
group_id?: number | null
|
||||
items: OpsOpenAITokenStatsItem[]
|
||||
total: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
top_n?: number | null
|
||||
}
|
||||
|
||||
export interface OpsOpenAITokenStatsParams {
|
||||
time_range?: OpsOpenAITokenStatsTimeRange
|
||||
platform?: string
|
||||
group_id?: number | null
|
||||
page?: number
|
||||
page_size?: number
|
||||
top_n?: number
|
||||
}
|
||||
|
||||
export interface OpsSystemMetricsSnapshot {
|
||||
id: number
|
||||
created_at: string
|
||||
@@ -376,7 +410,6 @@ export interface PlatformAvailability {
|
||||
total_accounts: number
|
||||
available_count: number
|
||||
rate_limit_count: number
|
||||
scope_rate_limit_count?: Record<string, number>
|
||||
error_count: number
|
||||
}
|
||||
|
||||
@@ -387,7 +420,6 @@ export interface GroupAvailability {
|
||||
total_accounts: number
|
||||
available_count: number
|
||||
rate_limit_count: number
|
||||
scope_rate_limit_count?: Record<string, number>
|
||||
error_count: number
|
||||
}
|
||||
|
||||
@@ -402,7 +434,6 @@ export interface AccountAvailability {
|
||||
is_rate_limited: boolean
|
||||
rate_limit_reset_at?: string
|
||||
rate_limit_remaining_sec?: number
|
||||
scope_rate_limits?: Record<string, number>
|
||||
is_overloaded: boolean
|
||||
overload_until?: string
|
||||
overload_remaining_sec?: number
|
||||
@@ -819,6 +850,77 @@ export interface OpsAggregationSettings {
|
||||
aggregation_enabled: boolean
|
||||
}
|
||||
|
||||
export interface OpsRuntimeLogConfig {
|
||||
level: 'debug' | 'info' | 'warn' | 'error'
|
||||
enable_sampling: boolean
|
||||
sampling_initial: number
|
||||
sampling_thereafter: number
|
||||
caller: boolean
|
||||
stacktrace_level: 'none' | 'error' | 'fatal'
|
||||
retention_days: number
|
||||
source?: string
|
||||
updated_at?: string
|
||||
updated_by_user_id?: number
|
||||
}
|
||||
|
||||
export interface OpsSystemLog {
|
||||
id: number
|
||||
created_at: string
|
||||
level: string
|
||||
component: string
|
||||
message: string
|
||||
request_id?: string
|
||||
client_request_id?: string
|
||||
user_id?: number | null
|
||||
account_id?: number | null
|
||||
platform?: string
|
||||
model?: string
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
export type OpsSystemLogListResponse = PaginatedResponse<OpsSystemLog>
|
||||
|
||||
export interface OpsSystemLogQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
time_range?: '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d'
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
level?: string
|
||||
component?: string
|
||||
request_id?: string
|
||||
client_request_id?: string
|
||||
user_id?: number | null
|
||||
account_id?: number | null
|
||||
platform?: string
|
||||
model?: string
|
||||
q?: string
|
||||
}
|
||||
|
||||
export interface OpsSystemLogCleanupRequest {
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
level?: string
|
||||
component?: string
|
||||
request_id?: string
|
||||
client_request_id?: string
|
||||
user_id?: number | null
|
||||
account_id?: number | null
|
||||
platform?: string
|
||||
model?: string
|
||||
q?: string
|
||||
}
|
||||
|
||||
export interface OpsSystemLogSinkHealth {
|
||||
queue_depth: number
|
||||
queue_capacity: number
|
||||
dropped_count: number
|
||||
write_failed_count: number
|
||||
written_count: number
|
||||
avg_write_delay_ms: number
|
||||
last_error?: string
|
||||
}
|
||||
|
||||
export interface OpsErrorLog {
|
||||
id: number
|
||||
created_at: string
|
||||
@@ -974,6 +1076,17 @@ export async function getErrorDistribution(
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getOpenAITokenStats(
|
||||
params: OpsOpenAITokenStatsParams,
|
||||
options: OpsRequestOptions = {}
|
||||
): Promise<OpsOpenAITokenStatsResponse> {
|
||||
const { data } = await apiClient.get<OpsOpenAITokenStatsResponse>('/admin/ops/dashboard/openai-token-stats', {
|
||||
params,
|
||||
signal: options.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export type OpsErrorListView = 'errors' | 'excluded' | 'all'
|
||||
|
||||
export type OpsErrorListQueryParams = {
|
||||
@@ -1163,6 +1276,36 @@ export async function updateAlertRuntimeSettings(config: OpsAlertRuntimeSettings
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getRuntimeLogConfig(): Promise<OpsRuntimeLogConfig> {
|
||||
const { data } = await apiClient.get<OpsRuntimeLogConfig>('/admin/ops/runtime/logging')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateRuntimeLogConfig(config: OpsRuntimeLogConfig): Promise<OpsRuntimeLogConfig> {
|
||||
const { data } = await apiClient.put<OpsRuntimeLogConfig>('/admin/ops/runtime/logging', config)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resetRuntimeLogConfig(): Promise<OpsRuntimeLogConfig> {
|
||||
const { data } = await apiClient.post<OpsRuntimeLogConfig>('/admin/ops/runtime/logging/reset')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listSystemLogs(params: OpsSystemLogQuery): Promise<OpsSystemLogListResponse> {
|
||||
const { data } = await apiClient.get<OpsSystemLogListResponse>('/admin/ops/system-logs', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function cleanupSystemLogs(payload: OpsSystemLogCleanupRequest): Promise<{ deleted: number }> {
|
||||
const { data } = await apiClient.post<{ deleted: number }>('/admin/ops/system-logs/cleanup', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSystemLogSinkHealth(): Promise<OpsSystemLogSinkHealth> {
|
||||
const { data } = await apiClient.get<OpsSystemLogSinkHealth>('/admin/ops/system-logs/health')
|
||||
return data
|
||||
}
|
||||
|
||||
// Advanced settings (DB-backed)
|
||||
export async function getAdvancedSettings(): Promise<OpsAdvancedSettings> {
|
||||
const { data } = await apiClient.get<OpsAdvancedSettings>('/admin/ops/advanced-settings')
|
||||
@@ -1191,6 +1334,7 @@ export const opsAPI = {
|
||||
getLatencyHistogram,
|
||||
getErrorTrend,
|
||||
getErrorDistribution,
|
||||
getOpenAITokenStats,
|
||||
getConcurrencyStats,
|
||||
getUserConcurrencyStats,
|
||||
getAccountAvailabilityStats,
|
||||
@@ -1229,10 +1373,16 @@ export const opsAPI = {
|
||||
updateEmailNotificationConfig,
|
||||
getAlertRuntimeSettings,
|
||||
updateAlertRuntimeSettings,
|
||||
getRuntimeLogConfig,
|
||||
updateRuntimeLogConfig,
|
||||
resetRuntimeLogConfig,
|
||||
getAdvancedSettings,
|
||||
updateAdvancedSettings,
|
||||
getMetricThresholds,
|
||||
updateMetricThresholds
|
||||
updateMetricThresholds,
|
||||
listSystemLogs,
|
||||
cleanupSystemLogs,
|
||||
getSystemLogSinkHealth
|
||||
}
|
||||
|
||||
export default opsAPI
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiClient } from '../client'
|
||||
import type {
|
||||
Proxy,
|
||||
ProxyAccountSummary,
|
||||
ProxyQualityCheckResult,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
PaginatedResponse,
|
||||
@@ -143,6 +144,16 @@ export async function testProxy(id: number): Promise<{
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Check proxy quality across common AI targets
|
||||
* @param id - Proxy ID
|
||||
* @returns Quality check result
|
||||
*/
|
||||
export async function checkProxyQuality(id: number): Promise<ProxyQualityCheckResult> {
|
||||
const { data } = await apiClient.post<ProxyQualityCheckResult>(`/admin/proxies/${id}/quality-check`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy usage statistics
|
||||
* @param id - Proxy ID
|
||||
@@ -248,6 +259,7 @@ export const proxiesAPI = {
|
||||
delete: deleteProxy,
|
||||
toggleStatus,
|
||||
testProxy,
|
||||
checkProxyQuality,
|
||||
getStats,
|
||||
getProxyAccounts,
|
||||
batchCreate,
|
||||
|
||||
@@ -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
|
||||
@@ -20,6 +26,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 +38,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,6 +75,9 @@ 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
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
@@ -77,6 +89,7 @@ export interface UpdateSettingsRequest {
|
||||
totp_enabled?: boolean // TOTP 双因素认证
|
||||
default_balance?: number
|
||||
default_concurrency?: number
|
||||
default_subscriptions?: DefaultSubscriptionSetting[]
|
||||
site_name?: string
|
||||
site_logo?: string
|
||||
site_subtitle?: string
|
||||
@@ -87,6 +100,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 +127,7 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,6 +267,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 +412,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
|
||||
@@ -104,6 +106,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
|
||||
@@ -145,8 +145,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
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ apiClient.interceptors.response.use(
|
||||
return Promise.reject({
|
||||
status,
|
||||
code: apiData.code,
|
||||
error: apiData.error,
|
||||
message: apiData.message || apiData.detail || error.message
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
184
frontend/src/components/__tests__/ApiKeyCreate.spec.ts
Normal file
184
frontend/src/components/__tests__/ApiKeyCreate.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* API Key 创建逻辑测试
|
||||
* 通过封装组件测试 API Key 创建的核心流程
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { defineComponent, ref, reactive } from 'vue'
|
||||
|
||||
// Mock keysAPI
|
||||
const mockCreate = vi.fn()
|
||||
const mockList = vi.fn()
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
keysAPI: {
|
||||
create: (...args: any[]) => mockCreate(...args),
|
||||
list: (...args: any[]) => mockList(...args),
|
||||
},
|
||||
authAPI: {
|
||||
getCurrentUser: vi.fn().mockResolvedValue({ data: {} }),
|
||||
logout: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
},
|
||||
isTotp2FARequired: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
// Mock app store - 使用固定引用确保组件和测试共享同一对象
|
||||
const mockShowSuccess = vi.fn()
|
||||
const mockShowError = vi.fn()
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: mockShowSuccess,
|
||||
showError: mockShowError,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
/**
|
||||
* 简化的 API Key 创建测试组件
|
||||
*/
|
||||
const ApiKeyCreateTestComponent = defineComponent({
|
||||
setup() {
|
||||
const appStore = useAppStore()
|
||||
const loading = ref(false)
|
||||
const createdKey = ref('')
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
group_id: null as number | null,
|
||||
})
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData.name) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await mockCreate({
|
||||
name: formData.name,
|
||||
group_id: formData.group_id,
|
||||
})
|
||||
createdKey.value = result.key
|
||||
appStore.showSuccess('API Key 创建成功')
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.message || '创建失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { formData, loading, createdKey, handleCreate }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<form @submit.prevent="handleCreate">
|
||||
<input id="name" v-model="formData.name" placeholder="Key 名称" />
|
||||
<select id="group" v-model="formData.group_id">
|
||||
<option :value="null">默认</option>
|
||||
<option :value="1">Group 1</option>
|
||||
</select>
|
||||
<button type="submit" :disabled="loading">创建</button>
|
||||
</form>
|
||||
<div v-if="createdKey" class="created-key">{{ createdKey }}</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
describe('ApiKey 创建流程', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('创建 API Key 调用 API 并显示结果', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 1,
|
||||
key: 'sk-test-key-12345',
|
||||
name: 'My Test Key',
|
||||
})
|
||||
|
||||
const wrapper = mount(ApiKeyCreateTestComponent)
|
||||
|
||||
await wrapper.find('#name').setValue('My Test Key')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'My Test Key',
|
||||
group_id: null,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.created-key').text()).toBe('sk-test-key-12345')
|
||||
})
|
||||
|
||||
it('选择分组后正确传参', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 2,
|
||||
key: 'sk-group-key',
|
||||
name: 'Group Key',
|
||||
})
|
||||
|
||||
const wrapper = mount(ApiKeyCreateTestComponent)
|
||||
|
||||
await wrapper.find('#name').setValue('Group Key')
|
||||
// 选择 group_id = 1
|
||||
await wrapper.find('#group').setValue('1')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'Group Key',
|
||||
group_id: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('创建失败时显示错误', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('配额不足'))
|
||||
|
||||
const wrapper = mount(ApiKeyCreateTestComponent)
|
||||
|
||||
await wrapper.find('#name').setValue('Fail Key')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockShowError).toHaveBeenCalledWith('配额不足')
|
||||
expect(wrapper.find('.created-key').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('名称为空时不提交', async () => {
|
||||
const wrapper = mount(ApiKeyCreateTestComponent)
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('创建过程中按钮被禁用', async () => {
|
||||
let resolveCreate: (v: any) => void
|
||||
mockCreate.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveCreate = resolve })
|
||||
)
|
||||
|
||||
const wrapper = mount(ApiKeyCreateTestComponent)
|
||||
|
||||
await wrapper.find('#name').setValue('Test Key')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
|
||||
|
||||
resolveCreate!({ id: 1, key: 'sk-test', name: 'Test Key' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
172
frontend/src/components/__tests__/Dashboard.spec.ts
Normal file
172
frontend/src/components/__tests__/Dashboard.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Dashboard 数据加载逻辑测试
|
||||
* 通过封装组件测试仪表板核心数据加载流程
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { defineComponent, ref, onMounted, nextTick } from 'vue'
|
||||
|
||||
// Mock API
|
||||
const mockGetDashboardStats = vi.fn()
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
authAPI: {
|
||||
getCurrentUser: vi.fn().mockResolvedValue({
|
||||
data: { id: 1, username: 'test', email: 'test@example.com', role: 'user', balance: 100, concurrency: 5, status: 'active', allowed_groups: null, created_at: '', updated_at: '' },
|
||||
}),
|
||||
logout: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
},
|
||||
isTotp2FARequired: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/usage', () => ({
|
||||
usageAPI: {
|
||||
getDashboardStats: (...args: any[]) => mockGetDashboardStats(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
interface DashboardStats {
|
||||
balance: number
|
||||
api_key_count: number
|
||||
active_api_key_count: number
|
||||
today_requests: number
|
||||
today_cost: number
|
||||
today_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化的 Dashboard 测试组件
|
||||
*/
|
||||
const DashboardTestComponent = defineComponent({
|
||||
setup() {
|
||||
const stats = ref<DashboardStats | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
stats.value = await mockGetDashboardStats()
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
|
||||
return { stats, loading, error, loadStats }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="stats" class="stats">
|
||||
<span class="balance">{{ stats.balance }}</span>
|
||||
<span class="api-keys">{{ stats.api_key_count }}</span>
|
||||
<span class="today-requests">{{ stats.today_requests }}</span>
|
||||
<span class="today-cost">{{ stats.today_cost }}</span>
|
||||
</div>
|
||||
<button class="refresh" @click="loadStats">刷新</button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
describe('Dashboard 数据加载', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const fakeStats: DashboardStats = {
|
||||
balance: 100.5,
|
||||
api_key_count: 3,
|
||||
active_api_key_count: 2,
|
||||
today_requests: 150,
|
||||
today_cost: 2.5,
|
||||
today_tokens: 50000,
|
||||
total_tokens: 1000000,
|
||||
}
|
||||
|
||||
it('挂载后自动加载数据', async () => {
|
||||
mockGetDashboardStats.mockResolvedValue(fakeStats)
|
||||
|
||||
const wrapper = mount(DashboardTestComponent)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetDashboardStats).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('.balance').text()).toBe('100.5')
|
||||
expect(wrapper.find('.api-keys').text()).toBe('3')
|
||||
expect(wrapper.find('.today-requests').text()).toBe('150')
|
||||
expect(wrapper.find('.today-cost').text()).toBe('2.5')
|
||||
})
|
||||
|
||||
it('加载中显示 loading 状态', async () => {
|
||||
let resolveStats: (v: any) => void
|
||||
mockGetDashboardStats.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveStats = resolve })
|
||||
)
|
||||
|
||||
const wrapper = mount(DashboardTestComponent)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.loading').exists()).toBe(true)
|
||||
|
||||
resolveStats!(fakeStats)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading').exists()).toBe(false)
|
||||
expect(wrapper.find('.stats').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('加载失败时显示错误信息', async () => {
|
||||
mockGetDashboardStats.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const wrapper = mount(DashboardTestComponent)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error').text()).toBe('Network error')
|
||||
expect(wrapper.find('.stats').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('点击刷新按钮重新加载数据', async () => {
|
||||
mockGetDashboardStats.mockResolvedValue(fakeStats)
|
||||
|
||||
const wrapper = mount(DashboardTestComponent)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetDashboardStats).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 更新数据
|
||||
const updatedStats = { ...fakeStats, today_requests: 200 }
|
||||
mockGetDashboardStats.mockResolvedValue(updatedStats)
|
||||
|
||||
await wrapper.find('.refresh').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetDashboardStats).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('.today-requests').text()).toBe('200')
|
||||
})
|
||||
|
||||
it('数据为空时不显示统计信息', async () => {
|
||||
mockGetDashboardStats.mockResolvedValue(null)
|
||||
|
||||
const wrapper = mount(DashboardTestComponent)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.stats').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
178
frontend/src/components/__tests__/LoginForm.spec.ts
Normal file
178
frontend/src/components/__tests__/LoginForm.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* LoginView 组件核心逻辑测试
|
||||
* 测试登录表单提交、验证、2FA 等场景
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { defineComponent, reactive, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Mock 所有外部依赖
|
||||
const mockLogin = vi.fn()
|
||||
const mockLogin2FA = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
authAPI: {
|
||||
login: (...args: any[]) => mockLogin(...args),
|
||||
login2FA: (...args: any[]) => mockLogin2FA(...args),
|
||||
logout: vi.fn(),
|
||||
getCurrentUser: vi.fn().mockResolvedValue({ data: {} }),
|
||||
register: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
},
|
||||
isTotp2FARequired: (response: any) => response?.requires_2fa === true,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
/**
|
||||
* 创建一个简化的测试组件来封装登录逻辑
|
||||
* 避免引入 LoginView.vue 的全部依赖(AuthLayout、i18n、Icon 等)
|
||||
*/
|
||||
const LoginFormTestComponent = defineComponent({
|
||||
setup() {
|
||||
const authStore = useAuthStore()
|
||||
const formData = reactive({ email: '', password: '' })
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!formData.email || !formData.password) {
|
||||
errorMessage.value = '请输入邮箱和密码'
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await authStore.login({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
})
|
||||
|
||||
// 2FA 流程由调用方处理
|
||||
if ((response as any)?.requires_2fa) {
|
||||
errorMessage.value = '需要 2FA 验证'
|
||||
return
|
||||
}
|
||||
|
||||
mockPush('/dashboard')
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.message || '登录失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { formData, isLoading, errorMessage, handleLogin }
|
||||
},
|
||||
template: `
|
||||
<form @submit.prevent="handleLogin">
|
||||
<input id="email" v-model="formData.email" type="email" />
|
||||
<input id="password" v-model="formData.password" type="password" />
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
<button type="submit" :disabled="isLoading">登录</button>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
|
||||
describe('LoginForm 核心逻辑', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('成功登录后跳转到 dashboard', async () => {
|
||||
mockLogin.mockResolvedValue({
|
||||
access_token: 'token',
|
||||
token_type: 'Bearer',
|
||||
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', balance: 0, concurrency: 5, status: 'active', allowed_groups: null, created_at: '', updated_at: '' },
|
||||
})
|
||||
|
||||
const wrapper = mount(LoginFormTestComponent)
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockLogin).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
})
|
||||
expect(mockPush).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('登录失败时显示错误信息', async () => {
|
||||
mockLogin.mockRejectedValue(new Error('Invalid credentials'))
|
||||
|
||||
const wrapper = mount(LoginFormTestComponent)
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('wrong')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error').text()).toBe('Invalid credentials')
|
||||
})
|
||||
|
||||
it('空表单提交显示验证错误', async () => {
|
||||
const wrapper = mount(LoginFormTestComponent)
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error').text()).toBe('请输入邮箱和密码')
|
||||
expect(mockLogin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('需要 2FA 时不跳转', async () => {
|
||||
mockLogin.mockResolvedValue({
|
||||
requires_2fa: true,
|
||||
temp_token: 'temp-123',
|
||||
})
|
||||
|
||||
const wrapper = mount(LoginFormTestComponent)
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.error').text()).toBe('需要 2FA 验证')
|
||||
})
|
||||
|
||||
it('提交过程中按钮被禁用', async () => {
|
||||
let resolveLogin: (v: any) => void
|
||||
mockLogin.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveLogin = resolve })
|
||||
)
|
||||
|
||||
const wrapper = mount(LoginFormTestComponent)
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
|
||||
|
||||
resolveLogin!({
|
||||
access_token: 'token',
|
||||
token_type: 'Bearer',
|
||||
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', balance: 0, concurrency: 5, status: 'active', allowed_groups: null, created_at: '', updated_at: '' },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.allGroups', { count: groups.length }) }}
|
||||
{{ t('admin.accounts.groupCountTotal', { count: groups.length }) }}
|
||||
</span>
|
||||
<button
|
||||
@click="showPopover = false"
|
||||
|
||||
@@ -76,34 +76,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scope Rate Limit Indicators (Antigravity) -->
|
||||
<template v-if="activeScopeRateLimits.length > 0">
|
||||
<div v-for="item in activeScopeRateLimits" :key="item.scope" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-orange-100 px-1.5 py-0.5 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
{{ formatScopeName(item.scope) }}
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.scopeRateLimitedUntil', { scope: formatScopeName(item.scope), time: formatTime(item.reset_at) }) }}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" ></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Model Rate Limit Indicators (Antigravity OAuth Smart Retry) -->
|
||||
<template v-if="activeModelRateLimits.length > 0">
|
||||
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative">
|
||||
<div
|
||||
v-if="activeModelRateLimits.length > 0"
|
||||
:class="[
|
||||
activeModelRateLimits.length <= 4
|
||||
? 'flex flex-col gap-1'
|
||||
: activeModelRateLimits.length <= 8
|
||||
? 'columns-2 gap-x-2'
|
||||
: 'columns-3 gap-x-2'
|
||||
]"
|
||||
>
|
||||
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative mb-1 break-inside-avoid">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
{{ formatScopeName(item.model) }}
|
||||
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
@@ -115,7 +105,7 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Overload Indicator (529) -->
|
||||
<div v-if="isOverloaded" class="group relative">
|
||||
@@ -160,15 +150,6 @@ const isRateLimited = computed(() => {
|
||||
return new Date(props.account.rate_limit_reset_at) > new Date()
|
||||
})
|
||||
|
||||
// Computed: active scope rate limits (Antigravity)
|
||||
const activeScopeRateLimits = computed(() => {
|
||||
const scopeLimits = props.account.scope_rate_limits
|
||||
if (!scopeLimits) return []
|
||||
const now = new Date()
|
||||
return Object.entries(scopeLimits)
|
||||
.filter(([, info]) => new Date(info.reset_at) > now)
|
||||
.map(([scope, info]) => ({ scope, reset_at: info.reset_at }))
|
||||
})
|
||||
|
||||
// Computed: active model rate limits (Antigravity OAuth Smart Retry)
|
||||
const activeModelRateLimits = computed(() => {
|
||||
@@ -183,17 +164,52 @@ const activeModelRateLimits = computed(() => {
|
||||
})
|
||||
|
||||
const formatScopeName = (scope: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
const aliases: Record<string, string> = {
|
||||
// Claude 系列
|
||||
'claude-opus-4-6': 'COpus46',
|
||||
'claude-opus-4-6-thinking': 'COpus46T',
|
||||
'claude-sonnet-4-6': 'CSon46',
|
||||
'claude-sonnet-4-5': 'CSon45',
|
||||
'claude-sonnet-4-5-thinking': 'CSon45T',
|
||||
// Gemini 2.5 系列
|
||||
'gemini-2.5-flash': 'G25F',
|
||||
'gemini-2.5-flash-lite': 'G25FL',
|
||||
'gemini-2.5-flash-thinking': 'G25FT',
|
||||
'gemini-2.5-pro': 'G25P',
|
||||
// Gemini 3 系列
|
||||
'gemini-3-flash': 'G3F',
|
||||
'gemini-3.1-pro-high': 'G3PH',
|
||||
'gemini-3.1-pro-low': 'G3PL',
|
||||
'gemini-3-pro-image': 'G3PI',
|
||||
'gemini-3.1-flash-image': 'GImage',
|
||||
// 其他
|
||||
'gpt-oss-120b-medium': 'GPT120',
|
||||
'tab_flash_lite_preview': 'TabFL',
|
||||
// 旧版 scope 别名(兼容)
|
||||
claude: 'Claude',
|
||||
claude_sonnet: 'Claude Sonnet',
|
||||
claude_opus: 'Claude Opus',
|
||||
claude_haiku: 'Claude Haiku',
|
||||
claude_sonnet: 'CSon',
|
||||
claude_opus: 'COpus',
|
||||
claude_haiku: 'CHaiku',
|
||||
gemini_text: 'Gemini',
|
||||
gemini_image: 'Image',
|
||||
gemini_flash: 'Gemini Flash',
|
||||
gemini_pro: 'Gemini Pro'
|
||||
gemini_image: 'GImg',
|
||||
gemini_flash: 'GFlash',
|
||||
gemini_pro: 'GPro',
|
||||
}
|
||||
return names[scope] || scope
|
||||
return aliases[scope] || scope
|
||||
}
|
||||
|
||||
const formatModelResetTime = (resetAt: string): string => {
|
||||
const date = new Date(resetAt)
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
if (diffMs <= 0) return ''
|
||||
const totalSecs = Math.floor(diffMs / 1000)
|
||||
const h = Math.floor(totalSecs / 3600)
|
||||
const m = Math.floor((totalSecs % 3600) / 60)
|
||||
const s = totalSecs % 60
|
||||
if (h > 0) return `${h}h${m}m`
|
||||
if (m > 0) return `${m}m${s}s`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
// Computed: is overloaded (529)
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="!isSoraAccount" class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.selectTestModel') }}
|
||||
</label>
|
||||
@@ -54,6 +54,12 @@
|
||||
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
|
||||
>
|
||||
{{ t('admin.accounts.soraTestHint') }}
|
||||
</div>
|
||||
|
||||
<!-- Terminal Output -->
|
||||
<div class="group relative">
|
||||
@@ -135,12 +141,12 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="cpu" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testModel') }}
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chatBubble" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testPrompt') }}
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestMode') : t('admin.accounts.testPrompt') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,10 +162,10 @@
|
||||
</button>
|
||||
<button
|
||||
@click="startTest"
|
||||
:disabled="status === 'connecting' || !selectedModelId"
|
||||
:disabled="status === 'connecting' || (!isSoraAccount && !selectedModelId)"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
status === 'connecting' || !selectedModelId
|
||||
status === 'connecting' || (!isSoraAccount && !selectedModelId)
|
||||
? 'cursor-not-allowed bg-primary-400 text-white'
|
||||
: status === 'success'
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
@@ -232,7 +238,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
@@ -267,6 +273,7 @@ const availableModels = ref<ClaudeModel[]>([])
|
||||
const selectedModelId = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
const isSoraAccount = computed(() => props.account?.platform === 'sora')
|
||||
|
||||
// Load available models when modal opens
|
||||
watch(
|
||||
@@ -283,6 +290,12 @@ watch(
|
||||
|
||||
const loadAvailableModels = async () => {
|
||||
if (!props.account) return
|
||||
if (props.account.platform === 'sora') {
|
||||
availableModels.value = []
|
||||
selectedModelId.value = ''
|
||||
loadingModels.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loadingModels.value = true
|
||||
selectedModelId.value = '' // Reset selection before loading
|
||||
@@ -350,7 +363,7 @@ const scrollToBottom = async () => {
|
||||
}
|
||||
|
||||
const startTest = async () => {
|
||||
if (!props.account || !selectedModelId.value) return
|
||||
if (!props.account || (!isSoraAccount.value && !selectedModelId.value)) return
|
||||
|
||||
resetState()
|
||||
status.value = 'connecting'
|
||||
@@ -371,7 +384,9 @@ const startTest = async () => {
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ model_id: selectedModelId.value })
|
||||
body: JSON.stringify(
|
||||
isSoraAccount.value ? {} : { model_id: selectedModelId.value }
|
||||
)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -428,7 +443,10 @@ const handleEvent = (event: {
|
||||
if (event.model) {
|
||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||
}
|
||||
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400')
|
||||
addLine(
|
||||
isSoraAccount.value ? t('admin.accounts.soraTestingFlow') : t('admin.accounts.sendingTestMessage'),
|
||||
'text-gray-400'
|
||||
)
|
||||
addLine('', 'text-gray-300')
|
||||
addLine(t('admin.accounts.response'), 'text-yellow-400')
|
||||
break
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -172,12 +172,12 @@
|
||||
color="purple"
|
||||
/>
|
||||
|
||||
<!-- Claude 4.5 -->
|
||||
<!-- Claude -->
|
||||
<UsageProgressBar
|
||||
v-if="antigravityClaude45UsageFromAPI !== null"
|
||||
:label="t('admin.accounts.usageWindow.claude45')"
|
||||
:utilization="antigravityClaude45UsageFromAPI.utilization"
|
||||
:resets-at="antigravityClaude45UsageFromAPI.resetTime"
|
||||
v-if="antigravityClaudeUsageFromAPI !== null"
|
||||
:label="t('admin.accounts.usageWindow.claude')"
|
||||
:utilization="antigravityClaudeUsageFromAPI.utilization"
|
||||
:resets-at="antigravityClaudeUsageFromAPI.resetTime"
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
@@ -282,6 +282,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
|
||||
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
|
||||
@@ -326,153 +327,18 @@ const geminiUsageAvailable = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const codex5hWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '5h'))
|
||||
const codex7dWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '7d'))
|
||||
|
||||
// OpenAI Codex usage computed properties
|
||||
const hasCodexUsage = computed(() => {
|
||||
const extra = props.account.extra
|
||||
return (
|
||||
extra &&
|
||||
// Check for new canonical fields first
|
||||
(extra.codex_5h_used_percent !== undefined ||
|
||||
extra.codex_7d_used_percent !== undefined ||
|
||||
// Fallback to legacy fields
|
||||
extra.codex_primary_used_percent !== undefined ||
|
||||
extra.codex_secondary_used_percent !== undefined)
|
||||
)
|
||||
return codex5hWindow.value.usedPercent !== null || codex7dWindow.value.usedPercent !== null
|
||||
})
|
||||
|
||||
// 5h window usage (prefer canonical field)
|
||||
const codex5hUsedPercent = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra) return null
|
||||
|
||||
// Prefer canonical field
|
||||
if (extra.codex_5h_used_percent !== undefined) {
|
||||
return extra.codex_5h_used_percent
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes <= 360
|
||||
) {
|
||||
return extra.codex_primary_used_percent ?? null
|
||||
}
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes <= 360
|
||||
) {
|
||||
return extra.codex_secondary_used_percent ?? null
|
||||
}
|
||||
|
||||
// Legacy assumption: secondary = 5h (may be incorrect)
|
||||
return extra.codex_secondary_used_percent ?? null
|
||||
})
|
||||
|
||||
const codex5hResetAt = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra) return null
|
||||
|
||||
// Prefer canonical field
|
||||
if (extra.codex_5h_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_5h_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes <= 360
|
||||
) {
|
||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes <= 360
|
||||
) {
|
||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy assumption: secondary = 5h
|
||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
// 7d window usage (prefer canonical field)
|
||||
const codex7dUsedPercent = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra) return null
|
||||
|
||||
// Prefer canonical field
|
||||
if (extra.codex_7d_used_percent !== undefined) {
|
||||
return extra.codex_7d_used_percent
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes >= 10000
|
||||
) {
|
||||
return extra.codex_primary_used_percent ?? null
|
||||
}
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes >= 10000
|
||||
) {
|
||||
return extra.codex_secondary_used_percent ?? null
|
||||
}
|
||||
|
||||
// Legacy assumption: primary = 7d (may be incorrect)
|
||||
return extra.codex_primary_used_percent ?? null
|
||||
})
|
||||
|
||||
const codex7dResetAt = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra) return null
|
||||
|
||||
// Prefer canonical field
|
||||
if (extra.codex_7d_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_7d_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes >= 10000
|
||||
) {
|
||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes >= 10000
|
||||
) {
|
||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy assumption: primary = 7d
|
||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
const codex5hUsedPercent = computed(() => codex5hWindow.value.usedPercent)
|
||||
const codex5hResetAt = computed(() => codex5hWindow.value.resetAt)
|
||||
const codex7dUsedPercent = computed(() => codex7dWindow.value.usedPercent)
|
||||
const codex7dResetAt = computed(() => codex7dWindow.value.resetAt)
|
||||
|
||||
// Antigravity quota types (用于 API 返回的数据)
|
||||
interface AntigravityUsageResult {
|
||||
@@ -531,12 +397,17 @@ const antigravity3ProUsageFromAPI = computed(() =>
|
||||
// Gemini 3 Flash from API
|
||||
const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-flash']))
|
||||
|
||||
// Gemini 3 Image from API
|
||||
const antigravity3ImageUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-pro-image']))
|
||||
// Gemini Image from API
|
||||
const antigravity3ImageUsageFromAPI = computed(() =>
|
||||
getAntigravityUsageFromAPI(['gemini-3.1-flash-image', 'gemini-3-pro-image'])
|
||||
)
|
||||
|
||||
// Claude 4.5 from API
|
||||
const antigravityClaude45UsageFromAPI = computed(() =>
|
||||
getAntigravityUsageFromAPI(['claude-sonnet-4-5', 'claude-opus-4-5-thinking'])
|
||||
// Claude from API (all Claude model variants)
|
||||
const antigravityClaudeUsageFromAPI = computed(() =>
|
||||
getAntigravityUsageFromAPI([
|
||||
'claude-sonnet-4-5', 'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-opus-4-6-thinking',
|
||||
])
|
||||
)
|
||||
|
||||
// Antigravity 账户类型(从 load_code_assist 响应中提取)
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mixed platform warning -->
|
||||
<div v-if="isMixedPlatform" class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
<svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: selectedPlatforms.join(', ') }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Base URL (API Key only) -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -157,7 +167,7 @@
|
||||
<!-- Model Checkbox List -->
|
||||
<div class="mb-3 grid grid-cols-2 gap-2">
|
||||
<label
|
||||
v-for="model in allModels"
|
||||
v-for="model in filteredModels"
|
||||
:key="model.value"
|
||||
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
|
||||
:class="
|
||||
@@ -278,7 +288,7 @@
|
||||
<!-- Quick Add Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presetMappings"
|
||||
v-for="preset in filteredPresets"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
@@ -575,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">
|
||||
@@ -641,6 +777,17 @@
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showMixedChannelWarning"
|
||||
:title="t('admin.accounts.mixedChannelWarningTitle')"
|
||||
:message="mixedChannelWarningMessage"
|
||||
:confirm-text="t('common.confirm')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="handleMixedChannelConfirm"
|
||||
@cancel="handleMixedChannelCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -648,17 +795,21 @@ import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy, AdminGroup } from '@/types'
|
||||
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { buildModelMappingObject as buildModelMappingPayload } from '@/composables/useModelWhitelist'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
accountIds: number[]
|
||||
proxies: Proxy[]
|
||||
selectedPlatforms: AccountPlatform[]
|
||||
selectedTypes: AccountType[]
|
||||
proxies: ProxyConfig[]
|
||||
groups: AdminGroup[]
|
||||
}
|
||||
|
||||
@@ -671,6 +822,40 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
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-', 'gemini-', 'gpt-oss-', 'tab_'],
|
||||
openai: ['gpt-'],
|
||||
gemini: ['gemini-'],
|
||||
sora: []
|
||||
}
|
||||
|
||||
const filteredModels = computed(() => {
|
||||
if (props.selectedPlatforms.length === 0) return allModels
|
||||
const prefixes = [...new Set(props.selectedPlatforms.flatMap(p => platformModelPrefix[p] || []))]
|
||||
if (prefixes.length === 0) return allModels
|
||||
return allModels.filter(m => prefixes.some(prefix => m.value.startsWith(prefix)))
|
||||
})
|
||||
|
||||
const filteredPresets = computed(() => {
|
||||
if (props.selectedPlatforms.length === 0) return presetMappings
|
||||
const prefixes = [...new Set(props.selectedPlatforms.flatMap(p => platformModelPrefix[p] || []))]
|
||||
if (prefixes.length === 0) return presetMappings
|
||||
return presetMappings.filter(m => prefixes.some(prefix => m.from.startsWith(prefix)))
|
||||
})
|
||||
|
||||
// Model mapping type
|
||||
interface ModelMapping {
|
||||
from: string
|
||||
@@ -688,9 +873,13 @@ const enablePriority = ref(false)
|
||||
const enableRateMultiplier = ref(false)
|
||||
const enableStatus = ref(false)
|
||||
const enableGroups = ref(false)
|
||||
const enableRpmLimit = ref(false)
|
||||
|
||||
// State - field values
|
||||
const submitting = ref(false)
|
||||
const showMixedChannelWarning = ref(false)
|
||||
const mixedChannelWarningMessage = ref('')
|
||||
const pendingUpdatesForConfirm = ref<Record<string, unknown> | null>(null)
|
||||
const baseUrl = ref('')
|
||||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const allowedModels = ref<string[]>([])
|
||||
@@ -704,10 +893,21 @@ const priority = ref(1)
|
||||
const rateMultiplier = ref(1)
|
||||
const status = ref<'active' | 'inactive'>('active')
|
||||
const groupIds = ref<number[]>([])
|
||||
const rpmLimitEnabled = ref(false)
|
||||
const bulkBaseRpm = ref<number | null>(null)
|
||||
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||||
const bulkRpmStickyBuffer = ref<number | null>(null)
|
||||
const userMsgQueueMode = ref<string | null>(null)
|
||||
const umqModeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.quotaControl.rpmLimit.umqModeOff') },
|
||||
{ value: 'throttle', label: t('admin.accounts.quotaControl.rpmLimit.umqModeThrottle') },
|
||||
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
|
||||
])
|
||||
|
||||
// All models list (combined Anthropic + OpenAI)
|
||||
// All models list (combined Anthropic + OpenAI + Gemini)
|
||||
const allModels = [
|
||||
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
|
||||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||||
@@ -716,16 +916,25 @@ const allModels = [
|
||||
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
|
||||
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
|
||||
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
|
||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
|
||||
{ value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
|
||||
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' },
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-3.1-flash-image', label: 'Gemini 3.1 Flash Image' },
|
||||
{ value: 'gemini-3-pro-image', label: 'Gemini 3 Pro Image (Legacy)' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }
|
||||
]
|
||||
|
||||
// Preset mappings (combined Anthropic + OpenAI)
|
||||
// Preset mappings (combined Anthropic + OpenAI + Gemini)
|
||||
const presetMappings = [
|
||||
{
|
||||
label: 'Sonnet 4',
|
||||
@@ -750,16 +959,85 @@ const presetMappings = [
|
||||
{
|
||||
label: 'Opus 4.6',
|
||||
from: 'claude-opus-4-6',
|
||||
to: 'claude-opus-4-6',
|
||||
to: 'claude-opus-4-6-thinking',
|
||||
color:
|
||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
},
|
||||
{
|
||||
label: 'Opus 4.6-thinking',
|
||||
from: 'claude-opus-4-6-thinking',
|
||||
to: 'claude-opus-4-6-thinking',
|
||||
color:
|
||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
},
|
||||
{
|
||||
label: 'Sonnet 4.6',
|
||||
from: 'claude-sonnet-4-6',
|
||||
to: 'claude-sonnet-4-6',
|
||||
color:
|
||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
},
|
||||
{
|
||||
label: 'Sonnet4→4.6',
|
||||
from: 'claude-sonnet-4-20250514',
|
||||
to: 'claude-sonnet-4-6',
|
||||
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
|
||||
},
|
||||
{
|
||||
label: 'Sonnet4.5→4.6',
|
||||
from: 'claude-sonnet-4-5-20250929',
|
||||
to: 'claude-sonnet-4-6',
|
||||
color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400'
|
||||
},
|
||||
{
|
||||
label: 'Sonnet3.5→4.6',
|
||||
from: 'claude-3-5-sonnet-20241022',
|
||||
to: 'claude-sonnet-4-6',
|
||||
color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400'
|
||||
},
|
||||
{
|
||||
label: 'Opus4.5→4.6',
|
||||
from: 'claude-opus-4-5-20251101',
|
||||
to: 'claude-opus-4-6-thinking',
|
||||
color:
|
||||
'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
},
|
||||
{
|
||||
label: 'Opus->Sonnet',
|
||||
from: 'claude-opus-4-5-20251101',
|
||||
to: 'claude-sonnet-4-5-20250929',
|
||||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
},
|
||||
{
|
||||
label: 'Gemini 3.1 Image',
|
||||
from: 'gemini-3.1-flash-image',
|
||||
to: 'gemini-3.1-flash-image',
|
||||
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
|
||||
},
|
||||
{
|
||||
label: 'G3 Image→3.1',
|
||||
from: 'gemini-3-pro-image',
|
||||
to: 'gemini-3.1-flash-image',
|
||||
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.3 Codex',
|
||||
from: 'gpt-5.3-codex',
|
||||
to: 'gpt-5.3-codex',
|
||||
color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.3 Spark',
|
||||
from: 'gpt-5.3-codex-spark',
|
||||
to: 'gpt-5.3-codex-spark',
|
||||
color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
},
|
||||
{
|
||||
label: '5.2→5.3',
|
||||
from: 'gpt-5.2-codex',
|
||||
to: 'gpt-5.3-codex',
|
||||
color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.2',
|
||||
from: 'gpt-5.2-2025-12-11',
|
||||
@@ -777,6 +1055,36 @@ const presetMappings = [
|
||||
from: 'gpt-5.1-codex-max',
|
||||
to: 'gpt-5.1-codex',
|
||||
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400'
|
||||
},
|
||||
{
|
||||
label: '3-Pro-Preview→3.1-Pro-High',
|
||||
from: 'gemini-3-pro-preview',
|
||||
to: 'gemini-3.1-pro-high',
|
||||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
},
|
||||
{
|
||||
label: '3-Pro-High→3.1-Pro-High',
|
||||
from: 'gemini-3-pro-high',
|
||||
to: 'gemini-3.1-pro-high',
|
||||
color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
},
|
||||
{
|
||||
label: '3-Pro-Low→3.1-Pro-Low',
|
||||
from: 'gemini-3-pro-low',
|
||||
to: 'gemini-3.1-pro-low',
|
||||
color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
},
|
||||
{
|
||||
label: '3-Flash透传',
|
||||
from: 'gemini-3-flash',
|
||||
to: 'gemini-3-flash',
|
||||
color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400'
|
||||
},
|
||||
{
|
||||
label: '2.5-Flash-Lite透传',
|
||||
from: 'gemini-2.5-flash-lite',
|
||||
to: 'gemini-2.5-flash-lite',
|
||||
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -866,23 +1174,11 @@ const removeErrorCode = (code: number) => {
|
||||
}
|
||||
|
||||
const buildModelMappingObject = (): Record<string, string> | null => {
|
||||
const mapping: Record<string, string> = {}
|
||||
|
||||
if (modelRestrictionMode.value === 'whitelist') {
|
||||
for (const model of allowedModels.value) {
|
||||
mapping[model] = model
|
||||
}
|
||||
} else {
|
||||
for (const m of modelMappings.value) {
|
||||
const from = m.from.trim()
|
||||
const to = m.to.trim()
|
||||
if (from && to) {
|
||||
mapping[from] = to
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(mapping).length > 0 ? mapping : null
|
||||
return buildModelMappingPayload(
|
||||
modelRestrictionMode.value,
|
||||
allowedModels.value,
|
||||
modelMappings.value
|
||||
)
|
||||
}
|
||||
|
||||
const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
@@ -960,13 +1256,77 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
updates.credentials = credentials
|
||||
}
|
||||
|
||||
// RPM limit settings (写入 extra 字段)
|
||||
if (enableRpmLimit.value) {
|
||||
const extra: Record<string, unknown> = {}
|
||||
if (rpmLimitEnabled.value && bulkBaseRpm.value != null && bulkBaseRpm.value > 0) {
|
||||
extra.base_rpm = bulkBaseRpm.value
|
||||
extra.rpm_strategy = bulkRpmStrategy.value
|
||||
if (bulkRpmStickyBuffer.value != null && bulkRpmStickyBuffer.value > 0) {
|
||||
extra.rpm_sticky_buffer = bulkRpmStickyBuffer.value
|
||||
}
|
||||
} else {
|
||||
// 关闭 RPM 限制 - 设置 base_rpm 为 0,并用空值覆盖关联字段
|
||||
// 后端使用 JSONB || merge 语义,不会删除已有 key,
|
||||
// 所以必须显式发送空值来重置(后端读取时会 fallback 到默认值)
|
||||
extra.base_rpm = 0
|
||||
extra.rpm_strategy = ''
|
||||
extra.rpm_sticky_buffer = 0
|
||||
}
|
||||
updates.extra = extra
|
||||
}
|
||||
|
||||
// UMQ mode(独立于 RPM 保存)
|
||||
if (userMsgQueueMode.value !== null) {
|
||||
if (!updates.extra) updates.extra = {}
|
||||
const umqExtra = updates.extra as Record<string, unknown>
|
||||
umqExtra.user_msg_queue_mode = userMsgQueueMode.value // '' = 清除账号级覆盖
|
||||
umqExtra.user_msg_queue_enabled = false // 清理旧字段(JSONB merge)
|
||||
}
|
||||
|
||||
return Object.keys(updates).length > 0 ? updates : null
|
||||
}
|
||||
|
||||
const mixedChannelConfirmed = ref(false)
|
||||
|
||||
// 是否需要预检查:改了分组 + 全是单一的 antigravity 或 anthropic 平台
|
||||
// 多平台混合的情况由 submitBulkUpdate 的 409 catch 兜底
|
||||
const canPreCheck = () =>
|
||||
enableGroups.value &&
|
||||
groupIds.value.length > 0 &&
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
(props.selectedPlatforms[0] === 'antigravity' || props.selectedPlatforms[0] === 'anthropic')
|
||||
|
||||
const handleClose = () => {
|
||||
showMixedChannelWarning.value = false
|
||||
mixedChannelWarningMessage.value = ''
|
||||
pendingUpdatesForConfirm.value = null
|
||||
mixedChannelConfirmed.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 预检查:提交前调接口检测,有风险就弹窗阻止,返回 false 表示需要用户确认
|
||||
const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise<boolean> => {
|
||||
if (!canPreCheck()) return true
|
||||
if (mixedChannelConfirmed.value) return true
|
||||
|
||||
try {
|
||||
const result = await adminAPI.accounts.checkMixedChannelRisk({
|
||||
platform: props.selectedPlatforms[0],
|
||||
group_ids: groupIds.value
|
||||
})
|
||||
if (!result.has_risk) return true
|
||||
|
||||
pendingUpdatesForConfirm.value = built
|
||||
mixedChannelWarningMessage.value = result.message || t('admin.accounts.bulkEdit.failed')
|
||||
showMixedChannelWarning.value = true
|
||||
return false
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.message || t('admin.accounts.bulkEdit.failed'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (props.accountIds.length === 0) {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
|
||||
@@ -983,19 +1343,33 @@ 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'))
|
||||
return
|
||||
}
|
||||
|
||||
const updates = buildUpdatePayload()
|
||||
if (!updates) {
|
||||
const built = buildUpdatePayload()
|
||||
if (!built) {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
|
||||
return
|
||||
}
|
||||
|
||||
const canContinue = await preCheckMixedChannelRisk(built)
|
||||
if (!canContinue) return
|
||||
|
||||
await submitBulkUpdate(built)
|
||||
}
|
||||
|
||||
const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
|
||||
// 无论是预检查确认还是 409 兜底确认,只要 mixedChannelConfirmed 为 true 就带上 flag
|
||||
const updates = mixedChannelConfirmed.value
|
||||
? { ...baseUpdates, confirm_mixed_channel_risk: true }
|
||||
: baseUpdates
|
||||
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
@@ -1012,17 +1386,38 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
if (success > 0) {
|
||||
pendingUpdatesForConfirm.value = null
|
||||
emit('updated')
|
||||
handleClose()
|
||||
}
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.bulkEdit.failed'))
|
||||
console.error('Error bulk updating accounts:', error)
|
||||
// 兜底:多平台混合场景下,预检查跳过,由后端 409 触发确认框
|
||||
if (error.status === 409 && error.error === 'mixed_channel_warning') {
|
||||
pendingUpdatesForConfirm.value = baseUpdates
|
||||
mixedChannelWarningMessage.value = error.message
|
||||
showMixedChannelWarning.value = true
|
||||
} else {
|
||||
appStore.showError(error.message || t('admin.accounts.bulkEdit.failed'))
|
||||
console.error('Error bulk updating accounts:', error)
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMixedChannelConfirm = async () => {
|
||||
showMixedChannelWarning.value = false
|
||||
mixedChannelConfirmed.value = true
|
||||
if (pendingUpdatesForConfirm.value) {
|
||||
await submitBulkUpdate(pendingUpdatesForConfirm.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMixedChannelCancel = () => {
|
||||
showMixedChannelWarning.value = false
|
||||
pendingUpdatesForConfirm.value = null
|
||||
}
|
||||
|
||||
// Reset form when modal closes
|
||||
watch(
|
||||
() => props.show,
|
||||
@@ -1039,6 +1434,7 @@ watch(
|
||||
enableRateMultiplier.value = false
|
||||
enableStatus.value = false
|
||||
enableGroups.value = false
|
||||
enableRpmLimit.value = false
|
||||
|
||||
// Reset all values
|
||||
baseUrl.value = ''
|
||||
@@ -1054,6 +1450,17 @@ 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
|
||||
mixedChannelWarningMessage.value = ''
|
||||
pendingUpdatesForConfirm.value = null
|
||||
mixedChannelConfirmed.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -144,11 +144,17 @@ const showDropdown = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const customModel = ref('')
|
||||
const isComposing = ref(false)
|
||||
const availableOptions = computed(() => {
|
||||
if (props.platform === 'sora') {
|
||||
return getModelsByPlatform('sora').map(m => ({ value: m, label: m }))
|
||||
}
|
||||
return allModels
|
||||
})
|
||||
|
||||
const filteredModels = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
if (!query) return allModels
|
||||
return allModels.filter(
|
||||
if (!query) return availableOptions.value
|
||||
return availableOptions.value.filter(
|
||||
m => m.value.toLowerCase().includes(query) || m.label.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
@@ -197,4 +203,5 @@ const fillRelated = () => {
|
||||
const clearAll = () => {
|
||||
emit('update:modelValue', [])
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ oauthTitle }}</h4>
|
||||
|
||||
<!-- Auth Method Selection -->
|
||||
<div v-if="showCookieOption" class="mb-4">
|
||||
<div v-if="showMethodSelection" class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
{{ methodLabel }}
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
@@ -26,7 +26,7 @@
|
||||
t('admin.accounts.oauth.manualAuth')
|
||||
}}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<label v-if="showCookieOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
@@ -37,6 +37,334 @@
|
||||
t('admin.accounts.oauth.cookieAutoAuth')
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-if="showRefreshTokenOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
value="refresh_token"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||
t(getOAuthKey('refreshTokenAuth'))
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-if="showSessionTokenOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
value="session_token"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||
t(getOAuthKey('sessionTokenAuth'))
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-if="showAccessTokenOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
value="access_token"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||
t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT')
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refresh Token Input (OpenAI / Antigravity) -->
|
||||
<div v-if="inputMethod === 'refresh_token'" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t(getOAuthKey('refreshTokenDesc')) }}
|
||||
</p>
|
||||
|
||||
<!-- Refresh Token Input -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Icon name="key" size="sm" class="text-blue-500" />
|
||||
Refresh Token
|
||||
<span
|
||||
v-if="parsedRefreshTokenCount > 1"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.keysCount', { count: parsedRefreshTokenCount }) }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="refreshTokenInput"
|
||||
rows="3"
|
||||
class="input w-full resize-y font-mono text-sm"
|
||||
:placeholder="t(getOAuthKey('refreshTokenPlaceholder'))"
|
||||
></textarea>
|
||||
<p
|
||||
v-if="parsedRefreshTokenCount > 1"
|
||||
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedRefreshTokenCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Validate Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="loading || !refreshTokenInput.trim()"
|
||||
@click="handleValidateRefreshToken"
|
||||
>
|
||||
<svg
|
||||
v-if="loading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
||||
{{
|
||||
loading
|
||||
? t(getOAuthKey('validating'))
|
||||
: t(getOAuthKey('validateAndCreate'))
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Token Input (Sora) -->
|
||||
<div v-if="inputMethod === 'session_token'" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t(getOAuthKey('sessionTokenDesc')) }}
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Icon name="key" size="sm" class="text-blue-500" />
|
||||
{{ t(getOAuthKey('sessionTokenRawLabel')) }}
|
||||
<span
|
||||
v-if="parsedSessionTokenCount > 1"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.keysCount', { count: parsedSessionTokenCount }) }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="sessionTokenInput"
|
||||
rows="3"
|
||||
class="input w-full resize-y font-mono text-sm"
|
||||
:placeholder="t(getOAuthKey('sessionTokenRawPlaceholder'))"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
{{ t(getOAuthKey('sessionTokenRawHint')) }}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary px-2 py-1 text-xs"
|
||||
@click="handleOpenSoraSessionUrl"
|
||||
>
|
||||
{{ t(getOAuthKey('openSessionUrl')) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary px-2 py-1 text-xs"
|
||||
@click="handleCopySoraSessionUrl"
|
||||
>
|
||||
{{ t(getOAuthKey('copySessionUrl')) }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 break-all text-xs text-blue-600 dark:text-blue-400">
|
||||
{{ soraSessionUrl }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t(getOAuthKey('sessionUrlHint')) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="parsedSessionTokenCount > 1"
|
||||
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedSessionTokenCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="sessionTokenInput.trim()" class="mb-4 space-y-3">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t(getOAuthKey('parsedSessionTokensLabel')) }}
|
||||
<span
|
||||
v-if="parsedSessionTokenCount > 0"
|
||||
class="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white"
|
||||
>
|
||||
{{ parsedSessionTokenCount }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
:value="parsedSessionTokensText"
|
||||
rows="2"
|
||||
readonly
|
||||
class="input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
||||
></textarea>
|
||||
<p
|
||||
v-if="parsedSessionTokenCount === 0"
|
||||
class="mt-1 text-xs text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{{ t(getOAuthKey('parsedSessionTokensEmpty')) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t(getOAuthKey('parsedAccessTokensLabel')) }}
|
||||
<span
|
||||
v-if="parsedAccessTokenFromSessionInputCount > 0"
|
||||
class="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white"
|
||||
>
|
||||
{{ parsedAccessTokenFromSessionInputCount }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
:value="parsedAccessTokensText"
|
||||
rows="2"
|
||||
readonly
|
||||
class="input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="loading || parsedSessionTokenCount === 0"
|
||||
@click="handleValidateSessionToken"
|
||||
>
|
||||
<svg
|
||||
v-if="loading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
||||
{{
|
||||
loading
|
||||
? t(getOAuthKey('validating'))
|
||||
: t(getOAuthKey('validateAndCreate'))
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token Input (Sora) -->
|
||||
<div v-if="inputMethod === 'access_token'" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.accounts.oauth.openai.accessTokenDesc', '直接粘贴 Access Token 创建账号,无需 OAuth 授权流程。支持批量导入(每行一个)。') }}
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Icon name="key" size="sm" class="text-blue-500" />
|
||||
Access Token
|
||||
<span
|
||||
v-if="parsedAccessTokenCount > 1"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.keysCount', { count: parsedAccessTokenCount }) }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="accessTokenInput"
|
||||
rows="3"
|
||||
class="input w-full resize-y font-mono text-sm"
|
||||
:placeholder="t('admin.accounts.oauth.openai.accessTokenPlaceholder', '粘贴 Access Token,每行一个')"
|
||||
></textarea>
|
||||
<p
|
||||
v-if="parsedAccessTokenCount > 1"
|
||||
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedAccessTokenCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="loading || !accessTokenInput.trim()"
|
||||
@click="handleImportAccessToken"
|
||||
>
|
||||
<Icon name="sparkles" size="sm" class="mr-2" />
|
||||
{{ t('admin.accounts.oauth.openai.importAccessToken', '导入 Access Token') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -173,7 +501,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Manual Authorization Flow -->
|
||||
<div v-else class="space-y-4">
|
||||
<div v-if="inputMethod === 'manual'" class="space-y-4">
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
{{ oauthFollowSteps }}
|
||||
</p>
|
||||
@@ -414,8 +742,10 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { parseSoraRawTokens } from '@/utils/soraTokenParser'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
|
||||
import type { AccountPlatform } from '@/types'
|
||||
|
||||
interface Props {
|
||||
addMethod: AddMethod
|
||||
@@ -428,7 +758,10 @@ interface Props {
|
||||
allowMultiple?: boolean
|
||||
methodLabel?: string
|
||||
showCookieOption?: boolean // Whether to show cookie auto-auth option
|
||||
platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' // Platform type for different UI/text
|
||||
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
|
||||
showSessionTokenOption?: boolean // Whether to show session token input option (Sora only)
|
||||
showAccessTokenOption?: boolean // Whether to show access token input option (Sora only)
|
||||
platform?: AccountPlatform // Platform type for different UI/text
|
||||
showProjectId?: boolean // New prop to control project ID visibility
|
||||
}
|
||||
|
||||
@@ -442,6 +775,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
allowMultiple: false,
|
||||
methodLabel: 'Authorization Method',
|
||||
showCookieOption: true,
|
||||
showRefreshTokenOption: false,
|
||||
showSessionTokenOption: false,
|
||||
showAccessTokenOption: false,
|
||||
platform: 'anthropic',
|
||||
showProjectId: true
|
||||
})
|
||||
@@ -450,16 +786,19 @@ const emit = defineEmits<{
|
||||
'generate-url': []
|
||||
'exchange-code': [code: string]
|
||||
'cookie-auth': [sessionKey: string]
|
||||
'validate-refresh-token': [refreshToken: string]
|
||||
'validate-session-token': [sessionToken: string]
|
||||
'import-access-token': [accessToken: string]
|
||||
'update:inputMethod': [method: AuthInputMethod]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isOpenAI = computed(() => props.platform === 'openai')
|
||||
const isOpenAI = computed(() => props.platform === 'openai' || props.platform === 'sora')
|
||||
|
||||
// Get translation key based on platform
|
||||
const getOAuthKey = (key: string) => {
|
||||
if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
|
||||
if (props.platform === 'openai' || props.platform === 'sora') return `admin.accounts.oauth.openai.${key}`
|
||||
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
|
||||
if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}`
|
||||
return `admin.accounts.oauth.${key}`
|
||||
@@ -478,7 +817,7 @@ const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
|
||||
const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder')))
|
||||
const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
|
||||
const oauthImportantNotice = computed(() => {
|
||||
if (props.platform === 'openai') return t('admin.accounts.oauth.openai.importantNotice')
|
||||
if (props.platform === 'openai' || props.platform === 'sora') return t('admin.accounts.oauth.openai.importantNotice')
|
||||
if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice')
|
||||
return ''
|
||||
})
|
||||
@@ -487,10 +826,16 @@ const oauthImportantNotice = computed(() => {
|
||||
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
|
||||
const authCodeInput = ref('')
|
||||
const sessionKeyInput = ref('')
|
||||
const refreshTokenInput = ref('')
|
||||
const sessionTokenInput = ref('')
|
||||
const accessTokenInput = ref('')
|
||||
const showHelpDialog = ref(false)
|
||||
const oauthState = ref('')
|
||||
const projectId = ref('')
|
||||
|
||||
// Computed: show method selection when either cookie or refresh token option is enabled
|
||||
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption)
|
||||
|
||||
// Clipboard
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
|
||||
@@ -502,6 +847,41 @@ const parsedKeyCount = computed(() => {
|
||||
.filter((k) => k).length
|
||||
})
|
||||
|
||||
// Computed: count of refresh tokens entered
|
||||
const parsedRefreshTokenCount = computed(() => {
|
||||
return refreshTokenInput.value
|
||||
.split('\n')
|
||||
.map((rt) => rt.trim())
|
||||
.filter((rt) => rt).length
|
||||
})
|
||||
|
||||
const parsedSoraRawTokens = computed(() => parseSoraRawTokens(sessionTokenInput.value))
|
||||
|
||||
const parsedSessionTokenCount = computed(() => {
|
||||
return parsedSoraRawTokens.value.sessionTokens.length
|
||||
})
|
||||
|
||||
const parsedSessionTokensText = computed(() => {
|
||||
return parsedSoraRawTokens.value.sessionTokens.join('\n')
|
||||
})
|
||||
|
||||
const parsedAccessTokenFromSessionInputCount = computed(() => {
|
||||
return parsedSoraRawTokens.value.accessTokens.length
|
||||
})
|
||||
|
||||
const parsedAccessTokensText = computed(() => {
|
||||
return parsedSoraRawTokens.value.accessTokens.join('\n')
|
||||
})
|
||||
|
||||
const soraSessionUrl = 'https://sora.chatgpt.com/api/auth/session'
|
||||
|
||||
const parsedAccessTokenCount = computed(() => {
|
||||
return accessTokenInput.value
|
||||
.split('\n')
|
||||
.map((at) => at.trim())
|
||||
.filter((at) => at).length
|
||||
})
|
||||
|
||||
// Watchers
|
||||
watch(inputMethod, (newVal) => {
|
||||
emit('update:inputMethod', newVal)
|
||||
@@ -510,7 +890,7 @@ watch(inputMethod, (newVal) => {
|
||||
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity)
|
||||
// e.g., http://localhost:8085/callback?code=xxx...&state=...
|
||||
watch(authCodeInput, (newVal) => {
|
||||
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity') return
|
||||
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity' && props.platform !== 'sora') return
|
||||
|
||||
const trimmed = newVal.trim()
|
||||
// Check if it looks like a URL with code parameter
|
||||
@@ -520,7 +900,7 @@ watch(authCodeInput, (newVal) => {
|
||||
const url = new URL(trimmed)
|
||||
const code = url.searchParams.get('code')
|
||||
const stateParam = url.searchParams.get('state')
|
||||
if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
|
||||
if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
|
||||
oauthState.value = stateParam
|
||||
}
|
||||
if (code && code !== trimmed) {
|
||||
@@ -531,7 +911,7 @@ watch(authCodeInput, (newVal) => {
|
||||
// If URL parsing fails, try regex extraction
|
||||
const match = trimmed.match(/[?&]code=([^&]+)/)
|
||||
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
|
||||
if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
|
||||
if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
|
||||
oauthState.value = stateMatch[1]
|
||||
}
|
||||
if (match && match[1] && match[1] !== trimmed) {
|
||||
@@ -563,18 +943,48 @@ const handleCookieAuth = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidateRefreshToken = () => {
|
||||
if (refreshTokenInput.value.trim()) {
|
||||
emit('validate-refresh-token', refreshTokenInput.value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidateSessionToken = () => {
|
||||
if (parsedSessionTokenCount.value > 0) {
|
||||
emit('validate-session-token', parsedSessionTokensText.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenSoraSessionUrl = () => {
|
||||
window.open(soraSessionUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const handleCopySoraSessionUrl = () => {
|
||||
copyToClipboard(soraSessionUrl, 'URL copied to clipboard')
|
||||
}
|
||||
|
||||
const handleImportAccessToken = () => {
|
||||
if (accessTokenInput.value.trim()) {
|
||||
emit('import-access-token', accessTokenInput.value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods and state
|
||||
defineExpose({
|
||||
authCode: authCodeInput,
|
||||
oauthState,
|
||||
projectId,
|
||||
sessionKey: sessionKeyInput,
|
||||
refreshToken: refreshTokenInput,
|
||||
sessionToken: sessionTokenInput,
|
||||
inputMethod,
|
||||
reset: () => {
|
||||
authCodeInput.value = ''
|
||||
oauthState.value = ''
|
||||
projectId.value = ''
|
||||
sessionKeyInput.value = ''
|
||||
refreshTokenInput.value = ''
|
||||
sessionTokenInput.value = ''
|
||||
inputMethod.value = 'manual'
|
||||
showHelpDialog.value = false
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div
|
||||
:class="[
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
|
||||
isOpenAI
|
||||
isOpenAILike
|
||||
? 'from-green-500 to-green-600'
|
||||
: isGemini
|
||||
? 'from-blue-500 to-blue-600'
|
||||
@@ -33,6 +33,8 @@
|
||||
{{
|
||||
isOpenAI
|
||||
? t('admin.accounts.openaiAccount')
|
||||
: isSora
|
||||
? t('admin.accounts.soraAccount')
|
||||
: isGemini
|
||||
? t('admin.accounts.geminiAccount')
|
||||
: isAntigravity
|
||||
@@ -128,7 +130,7 @@
|
||||
:show-cookie-option="isAnthropic"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
@@ -224,7 +226,8 @@ const { t } = useI18n()
|
||||
|
||||
// OAuth composables
|
||||
const claudeOAuth = useAccountOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
|
||||
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
|
||||
const geminiOAuth = useGeminiOAuth()
|
||||
const antigravityOAuth = useAntigravityOAuth()
|
||||
|
||||
@@ -237,31 +240,34 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
|
||||
|
||||
// Computed - check platform
|
||||
const isOpenAI = computed(() => props.account?.platform === 'openai')
|
||||
const isSora = computed(() => props.account?.platform === 'sora')
|
||||
const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
|
||||
const isGemini = computed(() => props.account?.platform === 'gemini')
|
||||
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
|
||||
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
|
||||
const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
|
||||
|
||||
// Computed - current OAuth state based on platform
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.authUrl.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
|
||||
if (isGemini.value) return geminiOAuth.authUrl.value
|
||||
if (isAntigravity.value) return antigravityOAuth.authUrl.value
|
||||
return claudeOAuth.authUrl.value
|
||||
})
|
||||
const currentSessionId = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.sessionId.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
|
||||
if (isGemini.value) return geminiOAuth.sessionId.value
|
||||
if (isAntigravity.value) return antigravityOAuth.sessionId.value
|
||||
return claudeOAuth.sessionId.value
|
||||
})
|
||||
const currentLoading = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.loading.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
|
||||
if (isGemini.value) return geminiOAuth.loading.value
|
||||
if (isAntigravity.value) return antigravityOAuth.loading.value
|
||||
return claudeOAuth.loading.value
|
||||
})
|
||||
const currentError = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.error.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
|
||||
if (isGemini.value) return geminiOAuth.error.value
|
||||
if (isAntigravity.value) return antigravityOAuth.error.value
|
||||
return claudeOAuth.error.value
|
||||
@@ -269,8 +275,8 @@ const currentError = computed(() => {
|
||||
|
||||
// Computed
|
||||
const isManualInputMethod = computed(() => {
|
||||
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
// OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
@@ -313,6 +319,7 @@ const resetState = () => {
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
claudeOAuth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
soraOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
@@ -325,8 +332,8 @@ const handleClose = () => {
|
||||
const handleGenerateUrl = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
if (isOpenAI.value) {
|
||||
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
if (isOpenAILike.value) {
|
||||
await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
|
||||
} else if (isGemini.value) {
|
||||
const creds = (props.account.credentials || {}) as Record<string, unknown>
|
||||
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
|
||||
@@ -345,21 +352,29 @@ const handleExchangeCode = async () => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
if (!authCode.trim()) return
|
||||
|
||||
if (isOpenAI.value) {
|
||||
if (isOpenAILike.value) {
|
||||
// OpenAI OAuth flow
|
||||
const sessionId = openaiOAuth.sessionId.value
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
const sessionId = oauthClient.sessionId.value
|
||||
if (!sessionId) return
|
||||
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
|
||||
if (!stateToUse) {
|
||||
oauthClient.error.value = t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(oauthClient.error.value)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(
|
||||
const tokenInfo = await oauthClient.exchangeAuthCode(
|
||||
authCode.trim(),
|
||||
sessionId,
|
||||
stateToUse,
|
||||
props.account.proxy_id
|
||||
)
|
||||
if (!tokenInfo) return
|
||||
|
||||
// Build credentials and extra info
|
||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
||||
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
|
||||
const credentials = oauthClient.buildCredentials(tokenInfo)
|
||||
const extra = oauthClient.buildExtraInfo(tokenInfo)
|
||||
|
||||
try {
|
||||
// Update account with new credentials
|
||||
@@ -376,8 +391,8 @@ const handleExchangeCode = async () => {
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(openaiOAuth.error.value)
|
||||
oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(oauthClient.error.value)
|
||||
}
|
||||
} else if (isGemini.value) {
|
||||
const sessionId = geminiOAuth.sessionId.value
|
||||
@@ -490,7 +505,7 @@ const handleExchangeCode = async () => {
|
||||
}
|
||||
|
||||
const handleCookieAuth = async (sessionKey: string) => {
|
||||
if (!props.account || isOpenAI.value) return
|
||||
if (!props.account || isOpenAILike.value) return
|
||||
|
||||
claudeOAuth.loading.value = true
|
||||
claudeOAuth.error.value = ''
|
||||
|
||||
@@ -6,15 +6,20 @@
|
||||
close-on-click-outside
|
||||
@close="handleClose"
|
||||
>
|
||||
<form id="sync-from-crs-form" class="space-y-4" @submit.prevent="handleSync">
|
||||
<!-- Step 1: Input credentials -->
|
||||
<form
|
||||
v-if="currentStep === 'input'"
|
||||
id="sync-from-crs-form"
|
||||
class="space-y-4"
|
||||
@submit.prevent="handlePreview"
|
||||
>
|
||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ t('admin.accounts.syncFromCrsDesc') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
|
||||
>
|
||||
已有账号仅同步 CRS
|
||||
返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
|
||||
{{ t('admin.accounts.crsUpdateBehaviorNote') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
|
||||
@@ -24,26 +29,30 @@
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
|
||||
<label for="crs-base-url" class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
|
||||
<input
|
||||
id="crs-base-url"
|
||||
v-model="form.base_url"
|
||||
type="text"
|
||||
class="input"
|
||||
required
|
||||
:placeholder="t('admin.accounts.crsBaseUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
|
||||
<input v-model="form.username" type="text" class="input" autocomplete="username" />
|
||||
<label for="crs-username" class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
|
||||
<input id="crs-username" v-model="form.username" type="text" class="input" required autocomplete="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
|
||||
<label for="crs-password" class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
|
||||
<input
|
||||
id="crs-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="input"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
@@ -58,9 +67,101 @@
|
||||
{{ t('admin.accounts.syncProxies') }}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Step 2: Preview & select -->
|
||||
<div v-else-if="currentStep === 'preview' && previewResult" class="space-y-4">
|
||||
<!-- Existing accounts (read-only info) -->
|
||||
<div
|
||||
v-if="previewResult.existing_accounts.length"
|
||||
class="rounded-lg bg-gray-50 p-3 dark:bg-dark-700/60"
|
||||
>
|
||||
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-dark-300">
|
||||
{{ t('admin.accounts.crsExistingAccounts') }}
|
||||
<span class="ml-1 text-xs text-gray-400">({{ previewResult.existing_accounts.length }})</span>
|
||||
</div>
|
||||
<div class="max-h-32 overflow-auto text-xs text-gray-500 dark:text-dark-400">
|
||||
<div
|
||||
v-for="acc in previewResult.existing_accounts"
|
||||
:key="acc.crs_account_id"
|
||||
class="flex items-center gap-2 py-0.5"
|
||||
>
|
||||
<span
|
||||
class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>{{ acc.platform }} / {{ acc.type }}</span>
|
||||
<span class="truncate">{{ acc.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New accounts (selectable) -->
|
||||
<div v-if="previewResult.new_accounts.length">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.crsNewAccounts') }}
|
||||
<span class="ml-1 text-xs text-gray-400">({{ previewResult.new_accounts.length }})</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
@click="selectAll"
|
||||
>{{ t('admin.accounts.crsSelectAll') }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-gray-500 hover:text-gray-600 dark:text-gray-400"
|
||||
@click="selectNone"
|
||||
>{{ t('admin.accounts.crsSelectNone') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-48 overflow-auto rounded-lg border border-gray-200 p-2 dark:border-dark-600"
|
||||
>
|
||||
<label
|
||||
v-for="acc in previewResult.new_accounts"
|
||||
:key="acc.crs_account_id"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(acc.crs_account_id)"
|
||||
class="rounded border-gray-300 dark:border-dark-600"
|
||||
@change="toggleSelect(acc.crs_account_id)"
|
||||
/>
|
||||
<span
|
||||
class="inline-block rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
>{{ acc.platform }} / {{ acc.type }}</span>
|
||||
<span class="truncate text-sm text-gray-700 dark:text-dark-300">{{ acc.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{{ t('admin.accounts.crsSelectedCount', { count: selectedIds.size }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync options summary -->
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400">
|
||||
<span>{{ t('admin.accounts.syncProxies') }}:</span>
|
||||
<span :class="form.sync_proxies ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-dark-500'">
|
||||
{{ form.sync_proxies ? t('common.yes') : t('common.no') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- No new accounts -->
|
||||
<div
|
||||
v-if="!previewResult.new_accounts.length"
|
||||
class="rounded-lg bg-gray-50 p-4 text-center text-sm text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
|
||||
>
|
||||
{{ t('admin.accounts.crsNoNewAccounts') }}
|
||||
<span v-if="previewResult.existing_accounts.length">
|
||||
{{ t('admin.accounts.crsWillUpdate', { count: previewResult.existing_accounts.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Result -->
|
||||
<div v-else-if="currentStep === 'result' && result" class="space-y-4">
|
||||
<div
|
||||
v-if="result"
|
||||
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
@@ -84,21 +185,56 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" type="button" :disabled="syncing" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
form="sync-from-crs-form"
|
||||
:disabled="syncing"
|
||||
>
|
||||
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
|
||||
</button>
|
||||
<!-- Step 1: Input -->
|
||||
<template v-if="currentStep === 'input'">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="previewing"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
form="sync-from-crs-form"
|
||||
:disabled="previewing"
|
||||
>
|
||||
{{ previewing ? t('admin.accounts.crsPreviewing') : t('admin.accounts.crsPreview') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Preview -->
|
||||
<template v-else-if="currentStep === 'preview'">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="syncing"
|
||||
@click="handleBack"
|
||||
>
|
||||
{{ t('admin.accounts.crsBack') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
:disabled="syncing || hasNewButNoneSelected"
|
||||
@click="handleSync"
|
||||
>
|
||||
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Step 3: Result -->
|
||||
<template v-else-if="currentStep === 'result'">
|
||||
<button class="btn btn-secondary" type="button" @click="handleClose">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
@@ -110,6 +246,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { PreviewFromCRSResult } from '@/api/admin/accounts'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -126,7 +263,12 @@ const emit = defineEmits<Emits>()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
type Step = 'input' | 'preview' | 'result'
|
||||
const currentStep = ref<Step>('input')
|
||||
const previewing = ref(false)
|
||||
const syncing = ref(false)
|
||||
const previewResult = ref<PreviewFromCRSResult | null>(null)
|
||||
const selectedIds = ref(new Set<string>())
|
||||
const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
@@ -136,28 +278,90 @@ const form = reactive({
|
||||
sync_proxies: true
|
||||
})
|
||||
|
||||
const hasNewButNoneSelected = computed(() => {
|
||||
if (!previewResult.value) return false
|
||||
return previewResult.value.new_accounts.length > 0 && selectedIds.value.size === 0
|
||||
})
|
||||
|
||||
const errorItems = computed(() => {
|
||||
if (!result.value?.items) return []
|
||||
return result.value.items.filter((i) => i.action === 'failed' || i.action === 'skipped')
|
||||
return result.value.items.filter(
|
||||
(i) => i.action === 'failed' || (i.action === 'skipped' && i.error !== 'not selected')
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
currentStep.value = 'input'
|
||||
previewResult.value = null
|
||||
selectedIds.value = new Set()
|
||||
result.value = null
|
||||
form.base_url = ''
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.sync_proxies = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
// 防止在同步进行中关闭对话框
|
||||
if (syncing.value) {
|
||||
if (syncing.value || previewing.value) {
|
||||
return
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
currentStep.value = 'input'
|
||||
previewResult.value = null
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
if (!previewResult.value) return
|
||||
selectedIds.value = new Set(previewResult.value.new_accounts.map((a) => a.crs_account_id))
|
||||
}
|
||||
|
||||
const selectNone = () => {
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
const s = new Set(selectedIds.value)
|
||||
if (s.has(id)) {
|
||||
s.delete(id)
|
||||
} else {
|
||||
s.add(id)
|
||||
}
|
||||
selectedIds.value = s
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
|
||||
appStore.showError(t('admin.accounts.syncMissingFields'))
|
||||
return
|
||||
}
|
||||
|
||||
previewing.value = true
|
||||
try {
|
||||
const res = await adminAPI.accounts.previewFromCrs({
|
||||
base_url: form.base_url.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password
|
||||
})
|
||||
previewResult.value = res
|
||||
// Auto-select all new accounts
|
||||
selectedIds.value = new Set(res.new_accounts.map((a) => a.crs_account_id))
|
||||
currentStep.value = 'preview'
|
||||
} catch (error: any) {
|
||||
appStore.showError(error?.message || t('admin.accounts.crsPreviewFailed'))
|
||||
} finally {
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
|
||||
appStore.showError(t('admin.accounts.syncMissingFields'))
|
||||
@@ -170,16 +374,18 @@ const handleSync = async () => {
|
||||
base_url: form.base_url.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
sync_proxies: form.sync_proxies
|
||||
sync_proxies: form.sync_proxies,
|
||||
selected_account_ids: [...selectedIds.value]
|
||||
})
|
||||
result.value = res
|
||||
currentStep.value = 'result'
|
||||
|
||||
if (res.failed > 0) {
|
||||
appStore.showError(t('admin.accounts.syncCompletedWithErrors', res))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.accounts.syncCompleted', res))
|
||||
emit('synced')
|
||||
}
|
||||
emit('synced')
|
||||
} catch (error: any) {
|
||||
appStore.showError(error?.message || t('admin.accounts.syncFailed'))
|
||||
} finally {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { applyInterceptWarmup } from '../credentialsBuilder'
|
||||
|
||||
describe('applyInterceptWarmup', () => {
|
||||
it('create + enabled=true: should set intercept_warmup_requests to true', () => {
|
||||
const creds: Record<string, unknown> = { access_token: 'tok' }
|
||||
applyInterceptWarmup(creds, true, 'create')
|
||||
expect(creds.intercept_warmup_requests).toBe(true)
|
||||
})
|
||||
|
||||
it('create + enabled=false: should not add the field', () => {
|
||||
const creds: Record<string, unknown> = { access_token: 'tok' }
|
||||
applyInterceptWarmup(creds, false, 'create')
|
||||
expect('intercept_warmup_requests' in creds).toBe(false)
|
||||
})
|
||||
|
||||
it('edit + enabled=true: should set intercept_warmup_requests to true', () => {
|
||||
const creds: Record<string, unknown> = { api_key: 'sk' }
|
||||
applyInterceptWarmup(creds, true, 'edit')
|
||||
expect(creds.intercept_warmup_requests).toBe(true)
|
||||
})
|
||||
|
||||
it('edit + enabled=false + field exists: should delete the field', () => {
|
||||
const creds: Record<string, unknown> = { api_key: 'sk', intercept_warmup_requests: true }
|
||||
applyInterceptWarmup(creds, false, 'edit')
|
||||
expect('intercept_warmup_requests' in creds).toBe(false)
|
||||
})
|
||||
|
||||
it('edit + enabled=false + field absent: should not throw', () => {
|
||||
const creds: Record<string, unknown> = { api_key: 'sk' }
|
||||
applyInterceptWarmup(creds, false, 'edit')
|
||||
expect('intercept_warmup_requests' in creds).toBe(false)
|
||||
})
|
||||
|
||||
it('should not affect other fields', () => {
|
||||
const creds: Record<string, unknown> = {
|
||||
api_key: 'sk',
|
||||
base_url: 'url',
|
||||
intercept_warmup_requests: true
|
||||
}
|
||||
applyInterceptWarmup(creds, false, 'edit')
|
||||
expect(creds.api_key).toBe('sk')
|
||||
expect(creds.base_url).toBe('url')
|
||||
expect('intercept_warmup_requests' in creds).toBe(false)
|
||||
})
|
||||
})
|
||||
11
frontend/src/components/account/credentialsBuilder.ts
Normal file
11
frontend/src/components/account/credentialsBuilder.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function applyInterceptWarmup(
|
||||
credentials: Record<string, unknown>,
|
||||
enabled: boolean,
|
||||
mode: 'create' | 'edit'
|
||||
): void {
|
||||
if (enabled) {
|
||||
credentials.intercept_warmup_requests = true
|
||||
} else if (mode === 'edit') {
|
||||
delete credentials.intercept_warmup_requests
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,16 @@
|
||||
{{ rule.passthrough_body ? t('admin.errorPassthrough.passthrough') : t('admin.errorPassthrough.custom') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="rule.skip_monitoring" class="flex items-center gap-1">
|
||||
<Icon
|
||||
name="checkCircle"
|
||||
size="xs"
|
||||
class="text-yellow-500"
|
||||
/>
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.skipMonitoring') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
@@ -366,6 +376,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skip Monitoring -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.skip_monitoring"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 text-yellow-600 focus:ring-yellow-500"
|
||||
/>
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.errorPassthrough.form.skipMonitoring') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="input-hint text-xs -mt-3">{{ t('admin.errorPassthrough.form.skipMonitoringHint') }}</p>
|
||||
|
||||
<!-- Enabled -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input
|
||||
@@ -453,6 +476,7 @@ const form = reactive({
|
||||
response_code: null as number | null,
|
||||
passthrough_body: true,
|
||||
custom_message: null as string | null,
|
||||
skip_monitoring: false,
|
||||
description: null as string | null
|
||||
})
|
||||
|
||||
@@ -497,6 +521,7 @@ const resetForm = () => {
|
||||
form.response_code = null
|
||||
form.passthrough_body = true
|
||||
form.custom_message = null
|
||||
form.skip_monitoring = false
|
||||
form.description = null
|
||||
errorCodesInput.value = ''
|
||||
keywordsInput.value = ''
|
||||
@@ -520,6 +545,7 @@ const handleEdit = (rule: ErrorPassthroughRule) => {
|
||||
form.response_code = rule.response_code
|
||||
form.passthrough_body = rule.passthrough_body
|
||||
form.custom_message = rule.custom_message
|
||||
form.skip_monitoring = rule.skip_monitoring
|
||||
form.description = rule.description
|
||||
errorCodesInput.value = rule.error_codes.join(', ')
|
||||
keywordsInput.value = rule.keywords.join('\n')
|
||||
@@ -575,6 +601,7 @@ const handleSubmit = async () => {
|
||||
response_code: form.passthrough_code ? null : form.response_code,
|
||||
passthrough_body: form.passthrough_body,
|
||||
custom_message: form.passthrough_body ? null : form.custom_message,
|
||||
skip_monitoring: form.skip_monitoring,
|
||||
description: form.description?.trim() || null
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,19 @@ import type { Account } from '@/types'
|
||||
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
|
||||
const emit = defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
|
||||
const { t } = useI18n()
|
||||
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
|
||||
const isRateLimited = computed(() => {
|
||||
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
|
||||
return true
|
||||
}
|
||||
const modelLimits = (props.account?.extra as Record<string, unknown> | undefined)?.model_rate_limits as
|
||||
| Record<string, { rate_limit_reset_at: string }>
|
||||
| undefined
|
||||
if (modelLimits) {
|
||||
const now = new Date()
|
||||
return Object.values(modelLimits).some(info => new Date(info.rate_limit_reset_at) > now)
|
||||
}
|
||||
return false
|
||||
})
|
||||
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
|
||||
@@ -10,16 +10,21 @@
|
||||
<Select :model-value="filters.platform" class="w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
|
||||
<Select :model-value="filters.type" class="w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
|
||||
<Select :model-value="filters.status" class="w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
|
||||
<Select :model-value="filters.group" class="w-40" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
|
||||
const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
|
||||
import type { AdminGroup } from '@/types'
|
||||
const props = defineProps<{ searchQuery: string; filters: Record<string, any>; groups?: AdminGroup[] }>()
|
||||
const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
|
||||
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
|
||||
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
|
||||
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
||||
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }])
|
||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }])
|
||||
const gOpts = computed(() => [{ value: '', label: t('admin.accounts.allGroups') }, ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))])
|
||||
</script>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="!isSoraAccount" class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.selectTestModel') }}
|
||||
</label>
|
||||
@@ -54,6 +54,12 @@
|
||||
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
|
||||
>
|
||||
{{ t('admin.accounts.soraTestHint') }}
|
||||
</div>
|
||||
|
||||
<!-- Terminal Output -->
|
||||
<div class="group relative">
|
||||
@@ -114,12 +120,12 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="grid" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testModel') }}
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chat" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testPrompt') }}
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestMode') : t('admin.accounts.testPrompt') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,10 +141,10 @@
|
||||
</button>
|
||||
<button
|
||||
@click="startTest"
|
||||
:disabled="status === 'connecting' || !selectedModelId"
|
||||
:disabled="status === 'connecting' || (!isSoraAccount && !selectedModelId)"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
status === 'connecting' || !selectedModelId
|
||||
status === 'connecting' || (!isSoraAccount && !selectedModelId)
|
||||
? 'cursor-not-allowed bg-primary-400 text-white'
|
||||
: status === 'success'
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
@@ -172,7 +178,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
@@ -207,6 +213,7 @@ const availableModels = ref<ClaudeModel[]>([])
|
||||
const selectedModelId = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
const isSoraAccount = computed(() => props.account?.platform === 'sora')
|
||||
|
||||
// Load available models when modal opens
|
||||
watch(
|
||||
@@ -223,6 +230,12 @@ watch(
|
||||
|
||||
const loadAvailableModels = async () => {
|
||||
if (!props.account) return
|
||||
if (props.account.platform === 'sora') {
|
||||
availableModels.value = []
|
||||
selectedModelId.value = ''
|
||||
loadingModels.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loadingModels.value = true
|
||||
selectedModelId.value = '' // Reset selection before loading
|
||||
@@ -290,7 +303,7 @@ const scrollToBottom = async () => {
|
||||
}
|
||||
|
||||
const startTest = async () => {
|
||||
if (!props.account || !selectedModelId.value) return
|
||||
if (!props.account || (!isSoraAccount.value && !selectedModelId.value)) return
|
||||
|
||||
resetState()
|
||||
status.value = 'connecting'
|
||||
@@ -311,7 +324,9 @@ const startTest = async () => {
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ model_id: selectedModelId.value })
|
||||
body: JSON.stringify(
|
||||
isSoraAccount.value ? {} : { model_id: selectedModelId.value }
|
||||
)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -368,7 +383,10 @@ const handleEvent = (event: {
|
||||
if (event.model) {
|
||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||
}
|
||||
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400')
|
||||
addLine(
|
||||
isSoraAccount.value ? t('admin.accounts.soraTestingFlow') : t('admin.accounts.sendingTestMessage'),
|
||||
'text-gray-400'
|
||||
)
|
||||
addLine('', 'text-gray-300')
|
||||
addLine(t('admin.accounts.response'), 'text-yellow-400')
|
||||
break
|
||||
|
||||
@@ -143,6 +143,24 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
if (typeof sourceFile.text === 'function') {
|
||||
return sourceFile.text()
|
||||
}
|
||||
|
||||
if (typeof sourceFile.arrayBuffer === 'function') {
|
||||
const buffer = await sourceFile.arrayBuffer()
|
||||
return new TextDecoder().decode(buffer)
|
||||
}
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||||
reader.readAsText(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
@@ -151,7 +169,7 @@ const handleImport = async () => {
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const text = await readFileAsText(file.value)
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.accounts.importData({
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div
|
||||
:class="[
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
|
||||
isOpenAI
|
||||
isOpenAILike
|
||||
? 'from-green-500 to-green-600'
|
||||
: isGemini
|
||||
? 'from-blue-500 to-blue-600'
|
||||
@@ -33,6 +33,8 @@
|
||||
{{
|
||||
isOpenAI
|
||||
? t('admin.accounts.openaiAccount')
|
||||
: isSora
|
||||
? t('admin.accounts.soraAccount')
|
||||
: isGemini
|
||||
? t('admin.accounts.geminiAccount')
|
||||
: isAntigravity
|
||||
@@ -128,7 +130,7 @@
|
||||
:show-cookie-option="isAnthropic"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
@@ -216,7 +218,7 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
reauthorized: []
|
||||
reauthorized: [account: Account]
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
@@ -224,7 +226,8 @@ const { t } = useI18n()
|
||||
|
||||
// OAuth composables
|
||||
const claudeOAuth = useAccountOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
|
||||
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
|
||||
const geminiOAuth = useGeminiOAuth()
|
||||
const antigravityOAuth = useAntigravityOAuth()
|
||||
|
||||
@@ -237,31 +240,34 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
|
||||
|
||||
// Computed - check platform
|
||||
const isOpenAI = computed(() => props.account?.platform === 'openai')
|
||||
const isSora = computed(() => props.account?.platform === 'sora')
|
||||
const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
|
||||
const isGemini = computed(() => props.account?.platform === 'gemini')
|
||||
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
|
||||
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
|
||||
const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
|
||||
|
||||
// Computed - current OAuth state based on platform
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.authUrl.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
|
||||
if (isGemini.value) return geminiOAuth.authUrl.value
|
||||
if (isAntigravity.value) return antigravityOAuth.authUrl.value
|
||||
return claudeOAuth.authUrl.value
|
||||
})
|
||||
const currentSessionId = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.sessionId.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
|
||||
if (isGemini.value) return geminiOAuth.sessionId.value
|
||||
if (isAntigravity.value) return antigravityOAuth.sessionId.value
|
||||
return claudeOAuth.sessionId.value
|
||||
})
|
||||
const currentLoading = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.loading.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
|
||||
if (isGemini.value) return geminiOAuth.loading.value
|
||||
if (isAntigravity.value) return antigravityOAuth.loading.value
|
||||
return claudeOAuth.loading.value
|
||||
})
|
||||
const currentError = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.error.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
|
||||
if (isGemini.value) return geminiOAuth.error.value
|
||||
if (isAntigravity.value) return antigravityOAuth.error.value
|
||||
return claudeOAuth.error.value
|
||||
@@ -269,8 +275,8 @@ const currentError = computed(() => {
|
||||
|
||||
// Computed
|
||||
const isManualInputMethod = computed(() => {
|
||||
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
// OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
@@ -313,6 +319,7 @@ const resetState = () => {
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
claudeOAuth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
soraOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
@@ -325,8 +332,8 @@ const handleClose = () => {
|
||||
const handleGenerateUrl = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
if (isOpenAI.value) {
|
||||
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
if (isOpenAILike.value) {
|
||||
await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
|
||||
} else if (isGemini.value) {
|
||||
const creds = (props.account.credentials || {}) as Record<string, unknown>
|
||||
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
|
||||
@@ -345,21 +352,29 @@ const handleExchangeCode = async () => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
if (!authCode.trim()) return
|
||||
|
||||
if (isOpenAI.value) {
|
||||
if (isOpenAILike.value) {
|
||||
// OpenAI OAuth flow
|
||||
const sessionId = openaiOAuth.sessionId.value
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
const sessionId = oauthClient.sessionId.value
|
||||
if (!sessionId) return
|
||||
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
|
||||
if (!stateToUse) {
|
||||
oauthClient.error.value = t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(oauthClient.error.value)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(
|
||||
const tokenInfo = await oauthClient.exchangeAuthCode(
|
||||
authCode.trim(),
|
||||
sessionId,
|
||||
stateToUse,
|
||||
props.account.proxy_id
|
||||
)
|
||||
if (!tokenInfo) return
|
||||
|
||||
// Build credentials and extra info
|
||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
||||
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
|
||||
const credentials = oauthClient.buildCredentials(tokenInfo)
|
||||
const extra = oauthClient.buildExtraInfo(tokenInfo)
|
||||
|
||||
try {
|
||||
// Update account with new credentials
|
||||
@@ -370,14 +385,14 @@ const handleExchangeCode = async () => {
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(openaiOAuth.error.value)
|
||||
oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(oauthClient.error.value)
|
||||
}
|
||||
} else if (isGemini.value) {
|
||||
const sessionId = geminiOAuth.sessionId.value
|
||||
@@ -404,9 +419,9 @@ const handleExchangeCode = async () => {
|
||||
type: 'oauth',
|
||||
credentials
|
||||
})
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
@@ -436,9 +451,9 @@ const handleExchangeCode = async () => {
|
||||
type: 'oauth',
|
||||
credentials
|
||||
})
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
@@ -475,10 +490,10 @@ const handleExchangeCode = async () => {
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
@@ -490,7 +505,7 @@ const handleExchangeCode = async () => {
|
||||
}
|
||||
|
||||
const handleCookieAuth = async (sessionKey: string) => {
|
||||
if (!props.account || isOpenAI.value) return
|
||||
if (!props.account || isOpenAILike.value) return
|
||||
|
||||
claudeOAuth.loading.value = true
|
||||
claudeOAuth.error.value = ''
|
||||
@@ -518,10 +533,10 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
claudeOAuth.error.value =
|
||||
|
||||
@@ -143,6 +143,24 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
if (typeof sourceFile.text === 'function') {
|
||||
return sourceFile.text()
|
||||
}
|
||||
|
||||
if (typeof sourceFile.arrayBuffer === 'function') {
|
||||
const buffer = await sourceFile.arrayBuffer()
|
||||
return new TextDecoder().decode(buffer)
|
||||
}
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||||
reader.readAsText(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.proxies.dataImportSelectFile'))
|
||||
@@ -151,7 +169,7 @@ const handleImport = async () => {
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const text = await readFileAsText(file.value)
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.proxies.importData({ data: dataPayload })
|
||||
|
||||
@@ -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 -->
|
||||
@@ -160,6 +160,7 @@
|
||||
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<slot name="after-reset" />
|
||||
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
||||
{{ t('admin.usage.cleanup.button') }}
|
||||
</button>
|
||||
@@ -232,10 +233,11 @@ let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
|
||||
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
|
||||
|
||||
const streamTypeOptions = ref<SelectOption[]>([
|
||||
const requestTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allTypes') },
|
||||
{ value: true, label: t('usage.stream') },
|
||||
{ value: false, label: t('usage.sync') }
|
||||
{ value: 'ws_v2', label: t('usage.ws') },
|
||||
{ value: 'stream', label: t('usage.stream') },
|
||||
{ value: 'sync', label: t('usage.sync') }
|
||||
])
|
||||
|
||||
const billingTypeOptions = ref<SelectOption[]>([
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-auto">
|
||||
<DataTable :columns="cols" :data="data" :loading="loading">
|
||||
<DataTable :columns="columns" :data="data" :loading="loading">
|
||||
<template #cell-user="{ row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
|
||||
@@ -35,8 +35,8 @@
|
||||
</template>
|
||||
|
||||
<template #cell-stream="{ row }">
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.stream ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'">
|
||||
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getRequestTypeBadgeClass(row)">
|
||||
{{ getRequestTypeLabel(row) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
|
||||
<span v-if="row.cache_creation_1h_tokens > 0" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-100 text-orange-600 ring-1 ring-inset ring-orange-200 dark:bg-orange-500/20 dark:text-orange-400 dark:ring-orange-500/30">1h</span>
|
||||
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,7 +123,7 @@
|
||||
</template>
|
||||
|
||||
<template #cell-user_agent="{ row }">
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 max-w-[150px] truncate block" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] truncate" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
@@ -157,9 +159,36 @@
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
|
||||
<!-- 有 5m/1h 明细时,展开显示 -->
|
||||
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
|
||||
<div v-if="tokenTooltipData.cache_creation_5m_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400 flex items-center gap-1.5">
|
||||
{{ t('admin.usage.cacheCreation5mTokens') }}
|
||||
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-amber-500/20 text-amber-400 ring-1 ring-inset ring-amber-500/30">5m</span>
|
||||
</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_5m_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData.cache_creation_1h_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400 flex items-center gap-1.5">
|
||||
{{ t('admin.usage.cacheCreation1hTokens') }}
|
||||
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-500/20 text-orange-400 ring-1 ring-inset ring-orange-500/30">1h</span>
|
||||
</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_1h_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 无明细时,只显示聚合值 -->
|
||||
<div v-else class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_ttl_overridden" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400 flex items-center gap-1.5">
|
||||
{{ t('usage.cacheTtlOverriddenLabel') }}
|
||||
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-500/20 text-rose-400 ring-1 ring-inset ring-rose-500/30">R-{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? '5m' : '1H' }}</span>
|
||||
</span>
|
||||
<span class="font-medium text-rose-400">{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? t('usage.cacheTtlOverridden1h') : t('usage.cacheTtlOverridden5m') }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||||
@@ -239,15 +268,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||
import { resolveUsageRequestType } from '@/utils/usageRequestType'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AdminUsageLog } from '@/types'
|
||||
|
||||
defineProps(['data', 'loading'])
|
||||
defineProps(['data', 'loading', 'columns'])
|
||||
const { t } = useI18n()
|
||||
|
||||
// Tooltip state - cost
|
||||
@@ -260,23 +290,21 @@ const tokenTooltipVisible = ref(false)
|
||||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tokenTooltipData = ref<AdminUsageLog | null>(null)
|
||||
|
||||
const cols = computed(() => [
|
||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||
{ key: 'account', label: t('admin.usage.account'), sortable: false },
|
||||
{ key: 'model', label: t('usage.model'), sortable: true },
|
||||
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
|
||||
{ key: 'group', label: t('admin.usage.group'), sortable: false },
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
|
||||
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
|
||||
])
|
||||
const getRequestTypeLabel = (row: AdminUsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(row)
|
||||
if (requestType === 'ws_v2') return t('usage.ws')
|
||||
if (requestType === 'stream') return t('usage.stream')
|
||||
if (requestType === 'sync') return t('usage.sync')
|
||||
return t('usage.unknown')
|
||||
}
|
||||
|
||||
const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(row)
|
||||
if (requestType === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200'
|
||||
if (requestType === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
if (requestType === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
}
|
||||
const formatCacheTokens = (tokens: number): string => {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
@@ -284,16 +312,7 @@ const formatCacheTokens = (tokens: number): string => {
|
||||
}
|
||||
|
||||
const formatUserAgent = (ua: string): string => {
|
||||
// 提取主要客户端标识
|
||||
if (ua.includes('claude-cli')) return ua.match(/claude-cli\/[\d.]+/)?.[0] || 'Claude CLI'
|
||||
if (ua.includes('Cursor')) return 'Cursor'
|
||||
if (ua.includes('VSCode') || ua.includes('vscode')) return 'VS Code'
|
||||
if (ua.includes('Continue')) return 'Continue'
|
||||
if (ua.includes('Cline')) return 'Cline'
|
||||
if (ua.includes('OpenAI')) return 'OpenAI SDK'
|
||||
if (ua.includes('anthropic')) return 'Anthropic SDK'
|
||||
// 截断过长的 UA
|
||||
return ua.length > 30 ? ua.substring(0, 30) + '...' : ua
|
||||
return ua
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number | null | undefined): string => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div><label class="input-label">{{ t('admin.users.notes') }}</label><textarea v-model="form.notes" rows="3" class="input"></textarea></div>
|
||||
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4"><div class="flex items-center justify-between text-sm"><span>{{ t('admin.users.newBalance') }}:</span><span class="font-bold">${{ formatBalance(calculateNewBalance()) }}</span></div></div>
|
||||
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950"><div class="flex items-center justify-between text-sm"><span class="text-gray-700 dark:text-gray-300">{{ t('admin.users.newBalance') }}:</span><span class="font-bold text-gray-900 dark:text-gray-100">${{ formatBalance(calculateNewBalance()) }}</span></div></div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
|
||||
@@ -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)
|
||||
|
||||
152
frontend/src/components/charts/GroupDistributionChart.vue
Normal file
152
frontend/src/components/charts/GroupDistributionChart.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dashboard.groupDistribution') }}
|
||||
</h3>
|
||||
<div v-if="loading" class="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-else-if="groupStats.length > 0 && chartData" class="flex items-center gap-6">
|
||||
<div class="h-48 w-48">
|
||||
<Doughnut :data="chartData" :options="doughnutOptions" />
|
||||
</div>
|
||||
<div class="max-h-48 flex-1 overflow-y-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500 dark:text-gray-400">
|
||||
<th class="pb-2 text-left">{{ t('admin.dashboard.group') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="group in groupStats"
|
||||
:key="group.group_id"
|
||||
class="border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<td
|
||||
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
|
||||
:title="group.group_name || String(group.group_id)"
|
||||
>
|
||||
{{ group.group_name || t('admin.dashboard.noGroup') }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatNumber(group.requests) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatTokens(group.total_tokens) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
||||
${{ formatCost(group.actual_cost) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
|
||||
${{ formatCost(group.cost) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import type { GroupStat } from '@/types'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
groupStats: GroupStat[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const chartColors = [
|
||||
'#3b82f6',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#14b8a6',
|
||||
'#f97316',
|
||||
'#6366f1',
|
||||
'#84cc16'
|
||||
]
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.groupStats?.length) return null
|
||||
|
||||
return {
|
||||
labels: props.groupStats.map((g) => g.group_name || String(g.group_id)),
|
||||
datasets: [
|
||||
{
|
||||
data: props.groupStats.map((g) => g.total_tokens),
|
||||
backgroundColor: chartColors.slice(0, props.groupStats.length),
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const doughnutOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const value = context.raw as number
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = ((value / total) * 100).toFixed(1)
|
||||
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||
} else if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`
|
||||
} else if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}K`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatNumber = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(2) + 'K'
|
||||
} else if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (value >= 0.01) {
|
||||
return value.toFixed(3)
|
||||
}
|
||||
return value.toFixed(4)
|
||||
}
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<template v-if="loading">
|
||||
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="space-y-3">
|
||||
<div v-for="column in columns.filter(c => c.key !== 'actions')" :key="column.key" class="flex justify-between">
|
||||
<div v-for="column in dataColumns" :key="column.key" class="flex justify-between">
|
||||
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="column in columns.filter(c => c.key !== 'actions')"
|
||||
v-for="column in dataColumns"
|
||||
:key="column.key"
|
||||
class="flex items-start justify-between gap-4"
|
||||
>
|
||||
@@ -439,10 +439,15 @@ const resolveRowKey = (row: any, index: number) => {
|
||||
return key ?? index
|
||||
}
|
||||
|
||||
const dataColumns = computed(() => props.columns.filter((column) => column.key !== 'actions'))
|
||||
const columnsSignature = computed(() =>
|
||||
props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|')
|
||||
)
|
||||
|
||||
// 数据/列变化时重新检查滚动状态
|
||||
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
|
||||
watch(
|
||||
[() => props.data.length, () => props.columns],
|
||||
[() => props.data.length, columnsSignature],
|
||||
async () => {
|
||||
await nextTick()
|
||||
checkScrollable()
|
||||
@@ -555,7 +560,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.columns,
|
||||
columnsSignature,
|
||||
() => {
|
||||
// If current sort key is no longer sortable/visible, fall back to default/persisted.
|
||||
const normalized = normalizeSortKey(sortKey.value)
|
||||
@@ -575,7 +580,7 @@ watch(
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(
|
||||
|
||||
@@ -116,6 +116,9 @@ const labelClass = computed(() => {
|
||||
if (props.platform === 'gemini') {
|
||||
return `${base} bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
|
||||
}
|
||||
if (props.platform === 'sora') {
|
||||
return `${base} bg-rose-200/60 text-rose-800 dark:bg-rose-800/40 dark:text-rose-300`
|
||||
}
|
||||
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
|
||||
})
|
||||
|
||||
@@ -137,6 +140,11 @@ const badgeClass = computed(() => {
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
|
||||
}
|
||||
if (props.platform === 'sora') {
|
||||
return isSubscription.value
|
||||
? 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
|
||||
: 'bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400'
|
||||
}
|
||||
// Fallback: original colors
|
||||
return isSubscription.value
|
||||
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
/>
|
||||
<GroupBadge
|
||||
:name="group.name"
|
||||
:platform="group.platform"
|
||||
:subscription-type="group.subscription_type"
|
||||
:rate-multiplier="group.rate_multiplier"
|
||||
class="min-w-0 flex-1"
|
||||
|
||||
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>
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="relative" ref="dropdownRef">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
:disabled="switching"
|
||||
class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
:title="currentLocale?.name"
|
||||
>
|
||||
@@ -23,6 +24,7 @@
|
||||
<button
|
||||
v-for="locale in availableLocales"
|
||||
:key="locale.code"
|
||||
:disabled="switching"
|
||||
@click="selectLocale(locale.code)"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
|
||||
:class="{
|
||||
@@ -49,6 +51,7 @@ const { locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const switching = ref(false)
|
||||
|
||||
const currentLocaleCode = computed(() => locale.value)
|
||||
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
|
||||
@@ -57,9 +60,18 @@ function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function selectLocale(code: string) {
|
||||
setLocale(code)
|
||||
isOpen.value = false
|
||||
async function selectLocale(code: string) {
|
||||
if (switching.value || code === currentLocaleCode.value) {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
switching.value = true
|
||||
try {
|
||||
await setLocale(code)
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
switching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
|
||||
<!-- Page numbers -->
|
||||
<button
|
||||
v-for="pageNum in visiblePages"
|
||||
:key="pageNum"
|
||||
v-for="(pageNum, index) in visiblePages"
|
||||
:key="`${pageNum}-${index}`"
|
||||
@click="typeof pageNum === 'number' && goToPage(pageNum)"
|
||||
:disabled="typeof pageNum !== 'number'"
|
||||
:class="[
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
|
||||
</svg>
|
||||
<!-- Sora logo (sparkle) -->
|
||||
<svg v-else-if="platform === 'sora'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2.5l2.1 4.7 5.1.5-3.9 3.4 1.2 5-4.5-2.6-4.5 2.6 1.2-5-3.9-3.4 5.1-.5L12 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Fallback: generic platform icon -->
|
||||
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
||||
@@ -48,6 +48,7 @@ const platformLabel = computed(() => {
|
||||
if (props.platform === 'anthropic') return 'Anthropic'
|
||||
if (props.platform === 'openai') return 'OpenAI'
|
||||
if (props.platform === 'antigravity') return 'Antigravity'
|
||||
if (props.platform === 'sora') return 'Sora'
|
||||
return 'Gemini'
|
||||
})
|
||||
|
||||
@@ -74,6 +75,9 @@ const platformClass = computed(() => {
|
||||
if (props.platform === 'antigravity') {
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}
|
||||
if (props.platform === 'sora') {
|
||||
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
|
||||
@@ -87,6 +91,9 @@ const typeClass = computed(() => {
|
||||
if (props.platform === 'antigravity') {
|
||||
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}
|
||||
if (props.platform === 'sora') {
|
||||
return 'bg-rose-100 text-rose-600 dark:bg-rose-900/30 dark:text-rose-400'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="stat-label truncate">{{ title }}</p>
|
||||
<div class="mt-1 flex items-baseline gap-2">
|
||||
<p class="stat-value">{{ formattedValue }}</p>
|
||||
<p class="stat-value" :title="String(formattedValue)">{{ formattedValue }}</p>
|
||||
<span v-if="change !== undefined" :class="['stat-trend', trendClass]">
|
||||
<Icon
|
||||
v-if="changeType !== 'neutral'"
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
<!-- Progress bar -->
|
||||
<div v-if="toast.duration" class="h-1 bg-gray-100 dark:bg-dark-700">
|
||||
<div
|
||||
:class="['h-full transition-all', getProgressBarColor(toast.type)]"
|
||||
:style="{ width: `${getProgress(toast)}%` }"
|
||||
:class="['h-full toast-progress', getProgressBarColor(toast.type)]"
|
||||
:style="{ animationDuration: `${toast.duration}ms` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
@@ -129,36 +129,25 @@ const getProgressBarColor = (type: string): string => {
|
||||
return colors[type] || colors.info
|
||||
}
|
||||
|
||||
const getProgress = (toast: any): number => {
|
||||
if (!toast.duration || !toast.startTime) return 100
|
||||
const elapsed = Date.now() - toast.startTime
|
||||
const progress = Math.max(0, 100 - (elapsed / toast.duration) * 100)
|
||||
return progress
|
||||
}
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
appStore.hideToast(id)
|
||||
}
|
||||
|
||||
let intervalId: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
// Check for expired toasts every 100ms
|
||||
intervalId = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
toasts.value.forEach((toast) => {
|
||||
if (toast.duration && toast.startTime) {
|
||||
if (now - toast.startTime >= toast.duration) {
|
||||
removeToast(toast.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId !== undefined) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-progress {
|
||||
width: 100%;
|
||||
animation-name: toast-progress-shrink;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-progress-shrink {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -58,6 +58,7 @@ const icons = {
|
||||
arrowLeft: 'M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18',
|
||||
arrowUp: 'M5 10l7-7m0 0l7 7m-7-7v18',
|
||||
arrowDown: 'M19 14l-7 7m0 0l-7-7m7 7V3',
|
||||
arrowsUpDown: 'M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5',
|
||||
chevronUp: 'M5 15l7-7 7 7',
|
||||
externalLink: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14',
|
||||
|
||||
|
||||
@@ -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]: {
|
||||
@@ -534,8 +579,72 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
}
|
||||
}
|
||||
const openaiModels = {
|
||||
'gpt-5.2-codex': {
|
||||
name: 'GPT-5.2 Codex',
|
||||
'gpt-5-codex': {
|
||||
name: 'GPT-5 Codex',
|
||||
limit: {
|
||||
context: 400000,
|
||||
output: 128000
|
||||
},
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {}
|
||||
}
|
||||
},
|
||||
'gpt-5.1-codex': {
|
||||
name: 'GPT-5.1 Codex',
|
||||
limit: {
|
||||
context: 400000,
|
||||
output: 128000
|
||||
},
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {}
|
||||
}
|
||||
},
|
||||
'gpt-5.1-codex-max': {
|
||||
name: 'GPT-5.1 Codex Max',
|
||||
limit: {
|
||||
context: 400000,
|
||||
output: 128000
|
||||
},
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {}
|
||||
}
|
||||
},
|
||||
'gpt-5.1-codex-mini': {
|
||||
name: 'GPT-5.1 Codex Mini',
|
||||
limit: {
|
||||
context: 400000,
|
||||
output: 128000
|
||||
},
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {}
|
||||
}
|
||||
},
|
||||
'gpt-5.2': {
|
||||
name: 'GPT-5.2',
|
||||
limit: {
|
||||
context: 400000,
|
||||
output: 128000
|
||||
},
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
@@ -545,30 +654,314 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
high: {},
|
||||
xhigh: {}
|
||||
}
|
||||
},
|
||||
'gpt-5.3-codex-spark': {
|
||||
name: 'GPT-5.3 Codex Spark',
|
||||
limit: {
|
||||
context: 128000,
|
||||
output: 32000
|
||||
},
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {},
|
||||
xhigh: {}
|
||||
}
|
||||
},
|
||||
'gpt-5.3-codex': {
|
||||
name: 'GPT-5.3 Codex',
|
||||
limit: {
|
||||
context: 400000,
|
||||
output: 128000
|
||||
},
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {},
|
||||
xhigh: {}
|
||||
}
|
||||
},
|
||||
'gpt-5.2-codex': {
|
||||
name: 'GPT-5.2 Codex',
|
||||
limit: {
|
||||
context: 400000,
|
||||
output: 128000
|
||||
},
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {},
|
||||
xhigh: {}
|
||||
}
|
||||
},
|
||||
'codex-mini-latest': {
|
||||
name: 'Codex Mini',
|
||||
limit: {
|
||||
context: 200000,
|
||||
output: 100000
|
||||
},
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
const geminiModels = {
|
||||
'gemini-2.0-flash': { name: 'Gemini 2.0 Flash' },
|
||||
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
|
||||
'gemini-2.5-pro': { name: 'Gemini 2.5 Pro' },
|
||||
'gemini-3-flash-preview': { name: 'Gemini 3 Flash Preview' },
|
||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' }
|
||||
'gemini-2.0-flash': {
|
||||
name: 'Gemini 2.0 Flash',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
}
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
name: 'Gemini 2.5 Flash',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
}
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
name: 'Gemini 2.5 Pro',
|
||||
limit: {
|
||||
context: 2097152,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
},
|
||||
'gemini-3-flash-preview': {
|
||||
name: 'Gemini 3 Flash Preview',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
}
|
||||
},
|
||||
'gemini-3-pro-preview': {
|
||||
name: 'Gemini 3 Pro Preview',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
},
|
||||
'gemini-3.1-pro-preview': {
|
||||
name: 'Gemini 3.1 Pro Preview',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const antigravityGeminiModels = {
|
||||
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
|
||||
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' },
|
||||
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
|
||||
'gemini-3-flash': { name: 'Gemini 3 Flash' },
|
||||
'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' },
|
||||
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' },
|
||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' },
|
||||
'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' }
|
||||
'gemini-2.5-flash': {
|
||||
name: 'Gemini 2.5 Flash',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'disable'
|
||||
}
|
||||
}
|
||||
},
|
||||
'gemini-2.5-flash-lite': {
|
||||
name: 'Gemini 2.5 Flash Lite',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
},
|
||||
'gemini-2.5-flash-thinking': {
|
||||
name: 'Gemini 2.5 Flash (Thinking)',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
},
|
||||
'gemini-3-flash': {
|
||||
name: 'Gemini 3 Flash',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
},
|
||||
'gemini-3.1-pro-low': {
|
||||
name: 'Gemini 3.1 Pro Low',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
},
|
||||
'gemini-3.1-pro-high': {
|
||||
name: 'Gemini 3.1 Pro High',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
},
|
||||
'gemini-3.1-flash-image': {
|
||||
name: 'Gemini 3.1 Flash Image',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image'],
|
||||
output: ['image']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const claudeModels = {
|
||||
'claude-opus-4-5-thinking': { name: 'Claude Opus 4.5 Thinking' },
|
||||
'claude-sonnet-4-5-thinking': { name: 'Claude Sonnet 4.5 Thinking' },
|
||||
'claude-sonnet-4-5': { name: 'Claude Sonnet 4.5' }
|
||||
'claude-opus-4-6-thinking': {
|
||||
name: 'Claude 4.6 Opus (Thinking)',
|
||||
limit: {
|
||||
context: 200000,
|
||||
output: 128000
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
},
|
||||
'claude-sonnet-4-6': {
|
||||
name: 'Claude 4.6 Sonnet',
|
||||
limit: {
|
||||
context: 200000,
|
||||
output: 64000
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image', 'pdf'],
|
||||
output: ['text']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'gemini') {
|
||||
|
||||
@@ -250,6 +250,13 @@ 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 items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||
const menuItem = items.find((item) => item.id === id)
|
||||
if (menuItem?.label) return menuItem.label
|
||||
}
|
||||
const titleKey = route.meta.titleKey as string
|
||||
if (titleKey) {
|
||||
return t(titleKey)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="sidebar-header">
|
||||
<!-- Custom Logo or Default Logo -->
|
||||
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||
<img v-if="settingsLoaded" :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="!sidebarCollapsed" class="flex flex-col">
|
||||
@@ -47,7 +47,8 @@
|
||||
"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -71,7 +72,8 @@
|
||||
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -92,7 +94,8 @@
|
||||
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -149,6 +152,15 @@ import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
import { sanitizeSvg } from '@/utils/sanitize'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: unknown
|
||||
iconSvg?: string
|
||||
hideInSimpleMode?: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -167,6 +179,7 @@ const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
const siteName = computed(() => appStore.siteName)
|
||||
const siteLogo = computed(() => appStore.siteLogo)
|
||||
const siteVersion = computed(() => appStore.siteVersion)
|
||||
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
|
||||
|
||||
// SVG Icon Components
|
||||
const DashboardIcon = {
|
||||
@@ -289,6 +302,26 @@ const CreditCardIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const RechargeSubscriptionIcon = {
|
||||
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: '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'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M6.75 12h3m4.5 0h3m-3-3v6'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const GlobeIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -319,6 +352,36 @@ const ServerIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const DatabaseIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3.75 5.25C3.75 4.007 7.443 3 12 3s8.25 1.007 8.25 2.25S16.557 7.5 12 7.5 3.75 6.493 3.75 5.25z'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3.75 5.25v4.5C3.75 10.993 7.443 12 12 12s8.25-1.007 8.25-2.25v-4.5'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3.75 9.75v4.5c0 1.243 3.693 2.25 8.25 2.25s8.25-1.007 8.25-2.25v-4.5'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3.75 14.25v4.5C3.75 19.993 7.443 21 12 21s8.25-1.007 8.25-2.25v-4.5'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const BellIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -414,6 +477,21 @@ const ChevronDoubleLeftIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const SoraIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ChevronDoubleRightIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -430,53 +508,86 @@ const ChevronDoubleRightIcon = {
|
||||
}
|
||||
|
||||
// User navigation items (for regular users)
|
||||
const userNavItems = computed(() => {
|
||||
const items = [
|
||||
const userNavItems = computed((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.sora_client_enabled
|
||||
? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
|
||||
: []),
|
||||
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
|
||||
? [
|
||||
{
|
||||
path: '/purchase',
|
||||
label: t('nav.buySubscription'),
|
||||
icon: CreditCardIcon,
|
||||
icon: RechargeSubscriptionIcon,
|
||||
hideInSimpleMode: true
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||
path: `/custom/${item.id}`,
|
||||
label: item.label,
|
||||
icon: null,
|
||||
iconSvg: item.icon_svg,
|
||||
})),
|
||||
]
|
||||
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
|
||||
})
|
||||
|
||||
// Personal navigation items (for admin's "My Account" section, without Dashboard)
|
||||
const personalNavItems = computed(() => {
|
||||
const items = [
|
||||
const personalNavItems = computed((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.sora_client_enabled
|
||||
? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
|
||||
: []),
|
||||
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
|
||||
? [
|
||||
{
|
||||
path: '/purchase',
|
||||
label: t('nav.buySubscription'),
|
||||
icon: CreditCardIcon,
|
||||
icon: RechargeSubscriptionIcon,
|
||||
hideInSimpleMode: true
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||
path: `/custom/${item.id}`,
|
||||
label: item.label,
|
||||
icon: null,
|
||||
iconSvg: item.icon_svg,
|
||||
})),
|
||||
]
|
||||
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
|
||||
})
|
||||
|
||||
// Custom menu items filtered by visibility
|
||||
const customMenuItemsForUser = computed(() => {
|
||||
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||
return items
|
||||
.filter((item) => item.visibility === 'user')
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
})
|
||||
|
||||
const customMenuItemsForAdmin = computed(() => {
|
||||
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||
return items
|
||||
.filter((item) => item.visibility === 'admin')
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
})
|
||||
|
||||
// Admin navigation items
|
||||
const adminNavItems = computed(() => {
|
||||
const baseItems = [
|
||||
const adminNavItems = computed((): NavItem[] => {
|
||||
const baseItems: NavItem[] = [
|
||||
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
...(adminSettingsStore.opsMonitoringEnabled
|
||||
? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }]
|
||||
@@ -489,18 +600,28 @@ const adminNavItems = computed(() => {
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
|
||||
]
|
||||
|
||||
// 简单模式下,在系统设置前插入 API密钥
|
||||
if (authStore.isSimpleMode) {
|
||||
const filtered = baseItems.filter(item => !item.hideInSimpleMode)
|
||||
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
|
||||
filtered.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
|
||||
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||
// Add admin custom menu items after settings
|
||||
for (const cm of customMenuItemsForAdmin.value) {
|
||||
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
baseItems.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
|
||||
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||
// Add admin custom menu items after settings
|
||||
for (const cm of customMenuItemsForAdmin.value) {
|
||||
baseItems.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||
}
|
||||
return baseItems
|
||||
})
|
||||
|
||||
@@ -580,4 +701,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>
|
||||
|
||||
@@ -29,17 +29,19 @@
|
||||
<!-- Logo/Brand -->
|
||||
<div class="mb-8 text-center">
|
||||
<!-- Custom Logo or Default Logo -->
|
||||
<div
|
||||
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
|
||||
>
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<h1 class="text-gradient mb-2 text-3xl font-bold">
|
||||
{{ siteName }}
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ siteSubtitle }}
|
||||
</p>
|
||||
<template v-if="settingsLoaded">
|
||||
<div
|
||||
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
|
||||
>
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<h1 class="text-gradient mb-2 text-3xl font-bold">
|
||||
{{ siteName }}
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ siteSubtitle }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Card Container -->
|
||||
@@ -61,25 +63,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const siteName = ref('StarFireAPI')
|
||||
const siteLogo = ref('')
|
||||
const siteSubtitle = ref('Subscription to API Conversion Platform')
|
||||
const appStore = useAppStore()
|
||||
|
||||
const siteName = computed(() => appStore.siteName || 'StarFireAPI')
|
||||
const siteLogo = computed(() => sanitizeUrl(appStore.siteLogo || '', { allowRelative: true, allowDataUrl: true }))
|
||||
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'Subscription to API Conversion Platform')
|
||||
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
|
||||
|
||||
const currentYear = computed(() => new Date().getFullYear())
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
siteName.value = settings.site_name || 'StarFireAPI'
|
||||
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
|
||||
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
onMounted(() => {
|
||||
appStore.fetchPublicSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
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>
|
||||
@@ -143,7 +143,7 @@
|
||||
<!-- Options (for select/multi_select) -->
|
||||
<div v-if="form.type === 'select' || form.type === 'multi_select'" class="space-y-2">
|
||||
<label class="input-label">{{ t('admin.users.attributes.options') }}</label>
|
||||
<div v-for="(option, index) in form.options" :key="index" class="flex items-center gap-2">
|
||||
<div v-for="(option, index) in form.options" :key="getOptionKey(option)" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="option.value"
|
||||
type="text"
|
||||
@@ -246,6 +246,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -270,6 +271,7 @@ const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editingAttribute = ref<UserAttributeDefinition | null>(null)
|
||||
const deletingAttribute = ref<UserAttributeDefinition | null>(null)
|
||||
const getOptionKey = createStableObjectKeyResolver<UserAttributeOption>('user-attr-option')
|
||||
|
||||
const form = reactive({
|
||||
key: '',
|
||||
@@ -315,7 +317,7 @@ const openEditModal = (attr: UserAttributeDefinition) => {
|
||||
form.placeholder = attr.placeholder || ''
|
||||
form.required = attr.required
|
||||
form.enabled = attr.enabled
|
||||
form.options = attr.options ? [...attr.options] : []
|
||||
form.options = attr.options ? attr.options.map((opt) => ({ ...opt })) : []
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
|
||||
43
frontend/src/components/user/UserConcurrencyCell.vue
Normal file
43
frontend/src/components/user/UserConcurrencyCell.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
|
||||
statusClass
|
||||
]"
|
||||
>
|
||||
<!-- Four-square grid icon -->
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ current }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ max }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
current: number
|
||||
max: number
|
||||
}>()
|
||||
|
||||
// Status color based on usage
|
||||
const statusClass = computed(() => {
|
||||
const { current, max } = props
|
||||
|
||||
// Full: red
|
||||
if (current >= max && max > 0) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
// In use: yellow
|
||||
if (current > 0) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
// Idle: gray
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
})
|
||||
</script>
|
||||
@@ -88,7 +88,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
@@ -107,6 +107,7 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const form = ref({
|
||||
emailCode: '',
|
||||
password: ''
|
||||
@@ -139,10 +140,17 @@ const handleSendCode = async () => {
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
cooldownTimer.value = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
@@ -176,4 +184,11 @@ const handleDisable = async () => {
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
@@ -198,6 +198,7 @@ const verifyForm = ref({ emailCode: '', password: '' })
|
||||
const verifyError = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const setupLoading = ref(false)
|
||||
const setupData = ref<TotpSetupResponse | null>(null)
|
||||
@@ -338,10 +339,17 @@ const handleSendCode = async () => {
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
cooldownTimer.value = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
@@ -397,4 +405,11 @@ const handleVerify = async () => {
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import TotpSetupModal from '@/components/user/profile/TotpSetupModal.vue'
|
||||
import TotpDisableDialog from '@/components/user/profile/TotpDisableDialog.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
getVerificationMethod: vi.fn(),
|
||||
sendVerifyCode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: mocks.showSuccess,
|
||||
showError: mocks.showError
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
totpAPI: {
|
||||
getVerificationMethod: mocks.getVerificationMethod,
|
||||
sendVerifyCode: mocks.sendVerifyCode,
|
||||
initiateSetup: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const flushPromises = async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('TOTP 弹窗定时器清理', () => {
|
||||
let intervalSeed = 1000
|
||||
let setIntervalSpy: ReturnType<typeof vi.spyOn>
|
||||
let clearIntervalSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
intervalSeed = 1000
|
||||
mocks.showSuccess.mockReset()
|
||||
mocks.showError.mockReset()
|
||||
mocks.getVerificationMethod.mockReset()
|
||||
mocks.sendVerifyCode.mockReset()
|
||||
|
||||
mocks.getVerificationMethod.mockResolvedValue({ method: 'email' })
|
||||
mocks.sendVerifyCode.mockResolvedValue({ success: true })
|
||||
|
||||
setIntervalSpy = vi.spyOn(window, 'setInterval').mockImplementation(((handler: TimerHandler) => {
|
||||
void handler
|
||||
intervalSeed += 1
|
||||
return intervalSeed as unknown as number
|
||||
}) as typeof window.setInterval)
|
||||
clearIntervalSpy = vi.spyOn(window, 'clearInterval')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setIntervalSpy.mockRestore()
|
||||
clearIntervalSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('TotpSetupModal 卸载时清理倒计时定时器', async () => {
|
||||
const wrapper = mount(TotpSetupModal)
|
||||
await flushPromises()
|
||||
|
||||
const sendButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('profile.totp.sendCode'))
|
||||
|
||||
expect(sendButton).toBeTruthy()
|
||||
await sendButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
const timerId = setIntervalSpy.mock.results[0]?.value
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(timerId)
|
||||
})
|
||||
|
||||
it('TotpDisableDialog 卸载时清理倒计时定时器', async () => {
|
||||
const wrapper = mount(TotpDisableDialog)
|
||||
await flushPromises()
|
||||
|
||||
const sendButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('profile.totp.sendCode'))
|
||||
|
||||
expect(sendButton).toBeTruthy()
|
||||
await sendButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
const timerId = setIntervalSpy.mock.results[0]?.value
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(timerId)
|
||||
})
|
||||
})
|
||||
143
frontend/src/composables/__tests__/useClipboard.spec.ts
Normal file
143
frontend/src/composables/__tests__/useClipboard.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('@/i18n', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock app store
|
||||
const mockShowSuccess = vi.fn()
|
||||
const mockShowError = vi.fn()
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: mockShowSuccess,
|
||||
showError: mockShowError,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
|
||||
describe('useClipboard', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
|
||||
// 默认模拟安全上下文 + Clipboard API
|
||||
Object.defineProperty(window, 'isSecureContext', { value: true, writable: true })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
// 恢复 execCommand
|
||||
if ('execCommand' in document) {
|
||||
delete (document as any).execCommand
|
||||
}
|
||||
})
|
||||
|
||||
it('复制成功后 copied 变为 true', async () => {
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
|
||||
expect(copied.value).toBe(false)
|
||||
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(copied.value).toBe(true)
|
||||
})
|
||||
|
||||
it('copied 在 2 秒后自动恢复为 false', async () => {
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
|
||||
await copyToClipboard('hello')
|
||||
expect(copied.value).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
expect(copied.value).toBe(false)
|
||||
})
|
||||
|
||||
it('复制成功时调用 showSuccess', async () => {
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
await copyToClipboard('hello', '已复制')
|
||||
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith('已复制')
|
||||
})
|
||||
|
||||
it('无自定义消息时使用 i18n 默认消息', async () => {
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith('common.copiedToClipboard')
|
||||
})
|
||||
|
||||
it('空文本返回 false 且不复制', async () => {
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
|
||||
const result = await copyToClipboard('')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(copied.value).toBe(false)
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Clipboard API 失败时降级到 fallback', async () => {
|
||||
const writeTextMock = navigator.clipboard.writeText as any
|
||||
writeTextMock.mockRejectedValue(new Error('API failed'))
|
||||
|
||||
// jsdom 没有 execCommand,手动定义
|
||||
const documentAny = document as any
|
||||
documentAny.execCommand = vi.fn().mockReturnValue(true)
|
||||
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
const result = await copyToClipboard('fallback text')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(copied.value).toBe(true)
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('非安全上下文使用 fallback', async () => {
|
||||
Object.defineProperty(window, 'isSecureContext', { value: false, writable: true })
|
||||
|
||||
const documentAny = document as any
|
||||
documentAny.execCommand = vi.fn().mockReturnValue(true)
|
||||
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
const result = await copyToClipboard('insecure context text')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(copied.value).toBe(true)
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('所有复制方式均失败时调用 showError', async () => {
|
||||
const writeTextMock = navigator.clipboard.writeText as any
|
||||
writeTextMock.mockRejectedValue(new Error('fail'))
|
||||
|
||||
const documentAny = document as any
|
||||
documentAny.execCommand = vi.fn().mockReturnValue(false)
|
||||
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
const result = await copyToClipboard('text')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(copied.value).toBe(false)
|
||||
expect(mockShowError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
143
frontend/src/composables/__tests__/useForm.spec.ts
Normal file
143
frontend/src/composables/__tests__/useForm.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useForm } from '@/composables/useForm'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
// Mock API 依赖(app store 内部引用了这些)
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useForm', () => {
|
||||
let appStore: ReturnType<typeof useAppStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appStore = useAppStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('submit 期间 loading 为 true,完成后为 false', async () => {
|
||||
let resolveSubmit: () => void
|
||||
const submitFn = vi.fn(
|
||||
() => new Promise<void>((resolve) => { resolveSubmit = resolve })
|
||||
)
|
||||
|
||||
const { loading, submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
|
||||
const submitPromise = submit()
|
||||
// 提交中
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveSubmit!()
|
||||
await submitPromise
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('submit 成功时显示成功消息', async () => {
|
||||
const submitFn = vi.fn().mockResolvedValue(undefined)
|
||||
const showSuccessSpy = vi.spyOn(appStore, 'showSuccess')
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
successMsg: '保存成功',
|
||||
})
|
||||
|
||||
await submit()
|
||||
|
||||
expect(showSuccessSpy).toHaveBeenCalledWith('保存成功')
|
||||
})
|
||||
|
||||
it('submit 成功但无 successMsg 时不调用 showSuccess', async () => {
|
||||
const submitFn = vi.fn().mockResolvedValue(undefined)
|
||||
const showSuccessSpy = vi.spyOn(appStore, 'showSuccess')
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
await submit()
|
||||
|
||||
expect(showSuccessSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submit 失败时显示错误消息并抛出错误', async () => {
|
||||
const error = Object.assign(new Error('提交失败'), {
|
||||
response: { data: { message: '服务器错误' } },
|
||||
})
|
||||
const submitFn = vi.fn().mockRejectedValue(error)
|
||||
const showErrorSpy = vi.spyOn(appStore, 'showError')
|
||||
|
||||
const { submit, loading } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
await expect(submit()).rejects.toThrow('提交失败')
|
||||
|
||||
expect(showErrorSpy).toHaveBeenCalled()
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('submit 失败时使用自定义 errorMsg', async () => {
|
||||
const submitFn = vi.fn().mockRejectedValue(new Error('network'))
|
||||
const showErrorSpy = vi.spyOn(appStore, 'showError')
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
errorMsg: '自定义错误提示',
|
||||
})
|
||||
|
||||
await expect(submit()).rejects.toThrow()
|
||||
|
||||
expect(showErrorSpy).toHaveBeenCalledWith('自定义错误提示')
|
||||
})
|
||||
|
||||
it('loading 中不会重复提交', async () => {
|
||||
let resolveSubmit: () => void
|
||||
const submitFn = vi.fn(
|
||||
() => new Promise<void>((resolve) => { resolveSubmit = resolve })
|
||||
)
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
// 第一次提交
|
||||
const p1 = submit()
|
||||
// 第二次提交(应被忽略,因为 loading=true)
|
||||
submit()
|
||||
|
||||
expect(submitFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveSubmit!()
|
||||
await p1
|
||||
})
|
||||
|
||||
it('传递 form 数据到 submitFn', async () => {
|
||||
const formData = { name: 'test', email: 'test@example.com' }
|
||||
const submitFn = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { submit } = useForm({
|
||||
form: formData,
|
||||
submitFn,
|
||||
})
|
||||
|
||||
await submit()
|
||||
|
||||
expect(submitFn).toHaveBeenCalledWith(formData)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||
|
||||
const flushPromises = () => Promise.resolve()
|
||||
|
||||
describe('useKeyedDebouncedSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('为不同 key 独立防抖触发搜索', async () => {
|
||||
const search = vi.fn().mockResolvedValue([])
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 100,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('a', 'foo')
|
||||
searcher.trigger('b', 'bar')
|
||||
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await flushPromises()
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2)
|
||||
expect(search).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'foo',
|
||||
expect.objectContaining({ key: 'a', signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(search).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'bar',
|
||||
expect.objectContaining({ key: 'b', signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(onSuccess).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('同 key 新请求会取消旧请求并忽略过期响应', async () => {
|
||||
const resolves: Array<(value: string[]) => void> = []
|
||||
const search = vi.fn().mockImplementation(
|
||||
() => new Promise<string[]>((resolve) => {
|
||||
resolves.push(resolve)
|
||||
})
|
||||
)
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 50,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('rule-1', 'first')
|
||||
vi.advanceTimersByTime(50)
|
||||
await flushPromises()
|
||||
|
||||
searcher.trigger('rule-1', 'second')
|
||||
vi.advanceTimersByTime(50)
|
||||
await flushPromises()
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2)
|
||||
|
||||
resolves[1](['second'])
|
||||
await flushPromises()
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(onSuccess).toHaveBeenLastCalledWith('rule-1', ['second'])
|
||||
|
||||
resolves[0](['first'])
|
||||
await flushPromises()
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clearKey 会取消未执行任务', () => {
|
||||
const search = vi.fn().mockResolvedValue([])
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 100,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('a', 'foo')
|
||||
searcher.clearKey('a')
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
251
frontend/src/composables/__tests__/useTableLoader.spec.ts
Normal file
251
frontend/src/composables/__tests__/useTableLoader.spec.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useTableLoader } from '@/composables/useTableLoader'
|
||||
|
||||
// Mock @vueuse/core 的 useDebounceFn
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useDebounceFn: (fn: Function, ms: number) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const debounced = (...args: any[]) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => fn(...args), ms)
|
||||
}
|
||||
debounced.cancel = () => { if (timer) clearTimeout(timer) }
|
||||
return debounced
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Vue 的 onUnmounted(composable 外使用时会报错)
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
...actual,
|
||||
onUnmounted: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const createMockFetchFn = (items: any[] = [], total = 0, pages = 1) => {
|
||||
return vi.fn().mockResolvedValue({ items, total, pages })
|
||||
}
|
||||
|
||||
describe('useTableLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// --- 基础加载 ---
|
||||
|
||||
describe('基础加载', () => {
|
||||
it('load 执行 fetchFn 并更新 items', async () => {
|
||||
const mockItems = [{ id: 1, name: 'item1' }, { id: 2, name: 'item2' }]
|
||||
const fetchFn = createMockFetchFn(mockItems, 2, 1)
|
||||
|
||||
const { items, loading, load, pagination } = useTableLoader({
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(items.value).toHaveLength(0)
|
||||
|
||||
await load()
|
||||
|
||||
expect(items.value).toEqual(mockItems)
|
||||
expect(pagination.total).toBe(2)
|
||||
expect(pagination.pages).toBe(1)
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('load 期间 loading 为 true', async () => {
|
||||
let resolveLoad: (v: any) => void
|
||||
const fetchFn = vi.fn(
|
||||
() => new Promise((resolve) => { resolveLoad = resolve })
|
||||
)
|
||||
|
||||
const { loading, load } = useTableLoader({ fetchFn })
|
||||
|
||||
const p = load()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveLoad!({ items: [], total: 0, pages: 0 })
|
||||
await p
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('使用默认 pageSize=20', async () => {
|
||||
const fetchFn = createMockFetchFn()
|
||||
const { load, pagination } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
1,
|
||||
20,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(pagination.page_size).toBe(20)
|
||||
})
|
||||
|
||||
it('可自定义 pageSize', async () => {
|
||||
const fetchFn = createMockFetchFn()
|
||||
const { load } = useTableLoader({ fetchFn, pageSize: 50 })
|
||||
|
||||
await load()
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 分页 ---
|
||||
|
||||
describe('分页', () => {
|
||||
it('handlePageChange 更新页码并加载', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { handlePageChange, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load() // 初始加载
|
||||
fetchFn.mockClear()
|
||||
|
||||
handlePageChange(3)
|
||||
|
||||
expect(pagination.page).toBe(3)
|
||||
// 等待 load 完成
|
||||
await vi.runAllTimersAsync()
|
||||
expect(fetchFn).toHaveBeenCalledWith(3, 20, expect.anything(), expect.anything())
|
||||
})
|
||||
|
||||
it('handlePageSizeChange 重置到第1页并加载', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { handlePageSizeChange, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
pagination.page = 3
|
||||
fetchFn.mockClear()
|
||||
|
||||
handlePageSizeChange(50)
|
||||
|
||||
expect(pagination.page).toBe(1)
|
||||
expect(pagination.page_size).toBe(50)
|
||||
})
|
||||
|
||||
it('handlePageChange 限制页码范围', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { handlePageChange, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
|
||||
// 超出范围的页码被限制
|
||||
handlePageChange(999)
|
||||
expect(pagination.page).toBe(5) // 限制在 pages=5
|
||||
|
||||
handlePageChange(0)
|
||||
expect(pagination.page).toBe(1) // 最小为 1
|
||||
})
|
||||
})
|
||||
|
||||
// --- 搜索防抖 ---
|
||||
|
||||
describe('搜索防抖', () => {
|
||||
it('debouncedReload 在 300ms 内多次调用只执行一次', async () => {
|
||||
const fetchFn = createMockFetchFn()
|
||||
const { debouncedReload } = useTableLoader({ fetchFn })
|
||||
|
||||
// 快速连续调用
|
||||
debouncedReload()
|
||||
debouncedReload()
|
||||
debouncedReload()
|
||||
|
||||
// 还没到 300ms,不应调用 fetchFn
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
|
||||
// 推进 300ms
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
// 等待异步完成
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reload 重置到第 1 页', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { reload, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
pagination.page = 3
|
||||
|
||||
await reload()
|
||||
|
||||
expect(pagination.page).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 请求取消 ---
|
||||
|
||||
describe('请求取消', () => {
|
||||
it('新请求取消前一个未完成的请求', async () => {
|
||||
let callCount = 0
|
||||
const fetchFn = vi.fn((_page, _size, _params, options) => {
|
||||
callCount++
|
||||
const currentCall = callCount
|
||||
return new Promise((resolve, reject) => {
|
||||
// 模拟监听 abort
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener('abort', () => {
|
||||
reject({ name: 'CanceledError', code: 'ERR_CANCELED' })
|
||||
})
|
||||
}
|
||||
// 异步解决
|
||||
setTimeout(() => {
|
||||
resolve({ items: [{ id: currentCall }], total: 1, pages: 1 })
|
||||
}, 1000)
|
||||
})
|
||||
})
|
||||
|
||||
const { load } = useTableLoader({ fetchFn })
|
||||
|
||||
// 第一次加载
|
||||
const p1 = load()
|
||||
// 第二次加载(应取消第一次)
|
||||
const p2 = load()
|
||||
|
||||
// 推进时间让第二次完成
|
||||
vi.advanceTimersByTime(1000)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
// 等待两个 Promise settle
|
||||
await Promise.allSettled([p1, p2])
|
||||
|
||||
// 第二次请求的结果生效
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 错误处理 ---
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('非取消错误会被抛出', async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue(new Error('Server error'))
|
||||
const { load } = useTableLoader({ fetchFn })
|
||||
|
||||
await expect(load()).rejects.toThrow('Server error')
|
||||
})
|
||||
|
||||
it('取消错误被静默处理', async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue({ name: 'CanceledError', code: 'ERR_CANCELED' })
|
||||
const { load } = useTableLoader({ fetchFn })
|
||||
|
||||
// 不应抛出
|
||||
await load()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
|
||||
export type AddMethod = 'oauth' | 'setup-token'
|
||||
export type AuthInputMethod = 'manual' | 'cookie'
|
||||
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'session_token' | 'access_token'
|
||||
|
||||
export interface OAuthState {
|
||||
authUrl: string
|
||||
|
||||
@@ -83,6 +83,35 @@ export function useAntigravityOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
const validateRefreshToken = async (
|
||||
refreshToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<AntigravityTokenInfo | null> => {
|
||||
if (!refreshToken.trim()) {
|
||||
error.value = t('admin.accounts.oauth.antigravity.pleaseEnterRefreshToken')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const tokenInfo = await adminAPI.antigravity.refreshAntigravityToken(
|
||||
refreshToken.trim(),
|
||||
proxyId
|
||||
)
|
||||
return tokenInfo as AntigravityTokenInfo
|
||||
} catch (err: any) {
|
||||
error.value =
|
||||
err.response?.data?.detail || t('admin.accounts.oauth.antigravity.failedToValidateRT')
|
||||
// Don't show global error toast for batch validation to avoid spamming
|
||||
// appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record<string, unknown> => {
|
||||
let expiresAt: string | undefined
|
||||
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
|
||||
@@ -110,6 +139,7 @@ export function useAntigravityOAuth() {
|
||||
resetState,
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
validateRefreshToken,
|
||||
buildCredentials
|
||||
}
|
||||
}
|
||||
|
||||
103
frontend/src/composables/useKeyedDebouncedSearch.ts
Normal file
103
frontend/src/composables/useKeyedDebouncedSearch.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { getCurrentInstance, onUnmounted } from 'vue'
|
||||
|
||||
export interface KeyedDebouncedSearchContext {
|
||||
key: string
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
interface UseKeyedDebouncedSearchOptions<T> {
|
||||
delay?: number
|
||||
search: (keyword: string, context: KeyedDebouncedSearchContext) => Promise<T>
|
||||
onSuccess: (key: string, result: T) => void
|
||||
onError?: (key: string, error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 多实例隔离的防抖搜索:每个 key 有独立的防抖、请求取消与过期响应保护。
|
||||
*/
|
||||
export function useKeyedDebouncedSearch<T>(options: UseKeyedDebouncedSearchOptions<T>) {
|
||||
const delay = options.delay ?? 300
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const controllers = new Map<string, AbortController>()
|
||||
const versions = new Map<string, number>()
|
||||
|
||||
const clearKey = (key: string) => {
|
||||
const timer = timers.get(key)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timers.delete(key)
|
||||
}
|
||||
|
||||
const controller = controllers.get(key)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
controllers.delete(key)
|
||||
}
|
||||
|
||||
versions.delete(key)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
const allKeys = new Set<string>([
|
||||
...timers.keys(),
|
||||
...controllers.keys(),
|
||||
...versions.keys()
|
||||
])
|
||||
|
||||
allKeys.forEach((key) => clearKey(key))
|
||||
}
|
||||
|
||||
const trigger = (key: string, keyword: string) => {
|
||||
const nextVersion = (versions.get(key) ?? 0) + 1
|
||||
versions.set(key, nextVersion)
|
||||
|
||||
const existingTimer = timers.get(key)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
timers.delete(key)
|
||||
}
|
||||
|
||||
const inFlight = controllers.get(key)
|
||||
if (inFlight) {
|
||||
inFlight.abort()
|
||||
controllers.delete(key)
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
timers.delete(key)
|
||||
|
||||
const controller = new AbortController()
|
||||
controllers.set(key, controller)
|
||||
const requestVersion = versions.get(key)
|
||||
|
||||
try {
|
||||
const result = await options.search(keyword, { key, signal: controller.signal })
|
||||
if (controller.signal.aborted) return
|
||||
if (versions.get(key) !== requestVersion) return
|
||||
options.onSuccess(key, result)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
if (versions.get(key) !== requestVersion) return
|
||||
options.onError?.(key, error)
|
||||
} finally {
|
||||
if (controllers.get(key) === controller) {
|
||||
controllers.delete(key)
|
||||
}
|
||||
}
|
||||
}, delay)
|
||||
|
||||
timers.set(key, timer)
|
||||
}
|
||||
|
||||
if (getCurrentInstance()) {
|
||||
onUnmounted(() => {
|
||||
clearAll()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
trigger,
|
||||
clearKey,
|
||||
clearAll
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const openaiModels = [
|
||||
'o4-mini',
|
||||
// GPT-5 系列(同步后端定价文件)
|
||||
'gpt-5', 'gpt-5-2025-08-07', 'gpt-5-chat', 'gpt-5-chat-latest',
|
||||
'gpt-5-codex', 'gpt-5-pro', 'gpt-5-pro-2025-10-06',
|
||||
'gpt-5-codex', 'gpt-5.3-codex-spark', 'gpt-5-pro', 'gpt-5-pro-2025-10-06',
|
||||
'gpt-5-mini', 'gpt-5-mini-2025-08-07',
|
||||
'gpt-5-nano', 'gpt-5-nano-2025-08-07',
|
||||
// GPT-5.1 系列
|
||||
@@ -24,6 +24,8 @@ const openaiModels = [
|
||||
// GPT-5.2 系列
|
||||
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
|
||||
'gpt-5.2-codex', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
|
||||
// GPT-5.3 系列
|
||||
'gpt-5.3-codex', 'gpt-5.3-codex-spark',
|
||||
'chatgpt-4o-latest',
|
||||
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
|
||||
]
|
||||
@@ -39,6 +41,7 @@ export const claudeModels = [
|
||||
'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001',
|
||||
'claude-opus-4-5-20251101',
|
||||
'claude-opus-4-6',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-2.1', 'claude-2.0', 'claude-instant-1.2'
|
||||
]
|
||||
|
||||
@@ -53,12 +56,30 @@ const geminiModels = [
|
||||
'gemini-3-pro-preview'
|
||||
]
|
||||
|
||||
// Sora
|
||||
const soraModels = [
|
||||
'gpt-image', 'gpt-image-landscape', 'gpt-image-portrait',
|
||||
'sora2-landscape-10s', 'sora2-portrait-10s',
|
||||
'sora2-landscape-15s', 'sora2-portrait-15s',
|
||||
'sora2-landscape-25s', 'sora2-portrait-25s',
|
||||
'sora2pro-landscape-10s', 'sora2pro-portrait-10s',
|
||||
'sora2pro-landscape-15s', 'sora2pro-portrait-15s',
|
||||
'sora2pro-landscape-25s', 'sora2pro-portrait-25s',
|
||||
'sora2pro-hd-landscape-10s', 'sora2pro-hd-portrait-10s',
|
||||
'sora2pro-hd-landscape-15s', 'sora2pro-hd-portrait-15s',
|
||||
'prompt-enhance-short-10s', 'prompt-enhance-short-15s', 'prompt-enhance-short-20s',
|
||||
'prompt-enhance-medium-10s', 'prompt-enhance-medium-15s', 'prompt-enhance-medium-20s',
|
||||
'prompt-enhance-long-10s', 'prompt-enhance-long-15s', 'prompt-enhance-long-20s'
|
||||
]
|
||||
|
||||
// Antigravity 官方支持的模型(精确匹配)
|
||||
// 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+
|
||||
const antigravityModels = [
|
||||
// Claude 4.5+ 系列
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-sonnet-4-5',
|
||||
'claude-sonnet-4-5-thinking',
|
||||
// Gemini 2.5 系列
|
||||
@@ -70,6 +91,10 @@ const antigravityModels = [
|
||||
'gemini-3-flash',
|
||||
'gemini-3-pro-high',
|
||||
'gemini-3-pro-low',
|
||||
// Gemini 3.1 系列
|
||||
'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro-low',
|
||||
'gemini-3.1-flash-image',
|
||||
'gemini-3-pro-image',
|
||||
// 其他
|
||||
'gpt-oss-120b-medium',
|
||||
@@ -206,6 +231,7 @@ const allModelsList: string[] = [
|
||||
...openaiModels,
|
||||
...claudeModels,
|
||||
...geminiModels,
|
||||
...soraModels,
|
||||
...zhipuModels,
|
||||
...qwenModels,
|
||||
...deepseekModels,
|
||||
@@ -233,6 +259,7 @@ export const allModels = allModelsList.map(m => ({ value: m, label: m }))
|
||||
const anthropicPresetMappings = [
|
||||
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
@@ -247,11 +274,14 @@ const openaiPresetMappings = [
|
||||
{ label: 'o1', from: 'o1', to: 'o1', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: 'GPT-5.3 Codex Spark', from: 'gpt-5.3-codex-spark', to: 'gpt-5.3-codex-spark', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
|
||||
{ label: 'GPT-5.1', from: 'gpt-5.1', to: 'gpt-5.1', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' },
|
||||
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }
|
||||
]
|
||||
|
||||
const soraPresetMappings: { label: string; from: string; to: string; color: string }[] = []
|
||||
|
||||
const geminiPresetMappings = [
|
||||
{ label: 'Flash 2.0', from: 'gemini-2.0-flash', to: 'gemini-2.0-flash', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: '2.5 Flash', from: 'gemini-2.5-flash', to: 'gemini-2.5-flash', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
@@ -265,11 +295,25 @@ const antigravityPresetMappings = [
|
||||
{ label: 'Sonnet→Sonnet', from: 'claude-sonnet-*', to: 'claude-sonnet-4-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Opus→Opus', from: 'claude-opus-*', to: 'claude-opus-4-6-thinking', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Haiku→Sonnet', from: 'claude-haiku-*', to: 'claude-sonnet-4-5', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'Sonnet4→4.6', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-6', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: 'Sonnet4.5→4.6', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Sonnet3.5→4.6', from: 'claude-3-5-sonnet-20241022', to: 'claude-sonnet-4-6', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
|
||||
{ label: 'Opus4.5→4.6', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-6-thinking', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' },
|
||||
// Gemini 3→3.1 映射
|
||||
{ label: '3-Pro-Preview→3.1-Pro-High', from: 'gemini-3-pro-preview', to: 'gemini-3.1-pro-high', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: '3-Pro-High→3.1-Pro-High', from: 'gemini-3-pro-high', to: 'gemini-3.1-pro-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '3-Pro-Low→3.1-Pro-Low', from: 'gemini-3-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
{ label: '3.1-Pro-High透传', from: 'gemini-3.1-pro-high', to: 'gemini-3.1-pro-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '3.1-Pro-Low透传', from: 'gemini-3.1-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
// Gemini 通配符映射
|
||||
{ label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
{ label: 'Gemini 2.5→Flash', from: 'gemini-2.5*', to: 'gemini-2.5-flash', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '3-Flash透传', from: 'gemini-3-flash', to: 'gemini-3-flash', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
|
||||
{ label: '2.5-Flash-Lite透传', from: 'gemini-2.5-flash-lite', to: 'gemini-2.5-flash-lite', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
// 精确映射
|
||||
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'claude-sonnet-4-5', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6-thinking', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Opus 4.6-thinking', from: 'claude-opus-4-6-thinking', to: 'claude-opus-4-6-thinking', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }
|
||||
]
|
||||
|
||||
@@ -318,6 +362,7 @@ export function getModelsByPlatform(platform: string): string[] {
|
||||
case 'anthropic':
|
||||
case 'claude': return claudeModels
|
||||
case 'gemini': return geminiModels
|
||||
case 'sora': return soraModels
|
||||
case 'antigravity': return antigravityModels
|
||||
case 'zhipu': return zhipuModels
|
||||
case 'qwen': return qwenModels
|
||||
@@ -342,6 +387,7 @@ export function getModelsByPlatform(platform: string): string[] {
|
||||
export function getPresetMappingsByPlatform(platform: string) {
|
||||
if (platform === 'openai') return openaiPresetMappings
|
||||
if (platform === 'gemini') return geminiPresetMappings
|
||||
if (platform === 'sora') return soraPresetMappings
|
||||
if (platform === 'antigravity') return antigravityPresetMappings
|
||||
return anthropicPresetMappings
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -19,12 +20,21 @@ export interface OpenAITokenInfo {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function useOpenAIOAuth() {
|
||||
export type OpenAIOAuthPlatform = 'openai' | 'sora'
|
||||
|
||||
interface UseOpenAIOAuthOptions {
|
||||
platform?: OpenAIOAuthPlatform
|
||||
}
|
||||
|
||||
export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
|
||||
const appStore = useAppStore()
|
||||
const oauthPlatform = options?.platform ?? 'openai'
|
||||
const endpointPrefix = oauthPlatform === 'sora' ? '/admin/sora' : '/admin/openai'
|
||||
|
||||
// State
|
||||
const authUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const oauthState = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
@@ -32,6 +42,7 @@ export function useOpenAIOAuth() {
|
||||
const resetState = () => {
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
oauthState.value = ''
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
@@ -44,6 +55,7 @@ export function useOpenAIOAuth() {
|
||||
loading.value = true
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
oauthState.value = ''
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
@@ -56,11 +68,17 @@ export function useOpenAIOAuth() {
|
||||
}
|
||||
|
||||
const response = await adminAPI.accounts.generateAuthUrl(
|
||||
'/admin/openai/generate-auth-url',
|
||||
`${endpointPrefix}/generate-auth-url`,
|
||||
payload
|
||||
)
|
||||
authUrl.value = response.auth_url
|
||||
sessionId.value = response.session_id
|
||||
try {
|
||||
const parsed = new URL(response.auth_url)
|
||||
oauthState.value = parsed.searchParams.get('state') || ''
|
||||
} catch {
|
||||
oauthState.value = ''
|
||||
}
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to generate OpenAI auth URL'
|
||||
@@ -75,10 +93,11 @@ export function useOpenAIOAuth() {
|
||||
const exchangeAuthCode = async (
|
||||
code: string,
|
||||
currentSessionId: string,
|
||||
state: string,
|
||||
proxyId?: number | null
|
||||
): Promise<OpenAITokenInfo | null> => {
|
||||
if (!code.trim() || !currentSessionId) {
|
||||
error.value = 'Missing auth code or session ID'
|
||||
if (!code.trim() || !currentSessionId || !state.trim()) {
|
||||
error.value = 'Missing auth code, session ID, or state'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -86,15 +105,16 @@ export function useOpenAIOAuth() {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: { session_id: string; code: string; proxy_id?: number } = {
|
||||
const payload: { session_id: string; code: string; state: string; proxy_id?: number } = {
|
||||
session_id: currentSessionId,
|
||||
code: code.trim()
|
||||
code: code.trim(),
|
||||
state: state.trim()
|
||||
}
|
||||
if (proxyId) {
|
||||
payload.proxy_id = proxyId
|
||||
}
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode('/admin/openai/exchange-code', payload)
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(`${endpointPrefix}/exchange-code`, payload)
|
||||
return tokenInfo as OpenAITokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to exchange OpenAI auth code'
|
||||
@@ -105,6 +125,63 @@ export function useOpenAIOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate refresh token and get full token info
|
||||
const validateRefreshToken = async (
|
||||
refreshToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<OpenAITokenInfo | null> => {
|
||||
if (!refreshToken.trim()) {
|
||||
error.value = 'Missing refresh token'
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// Use dedicated refresh-token endpoint
|
||||
const tokenInfo = await adminAPI.accounts.refreshOpenAIToken(
|
||||
refreshToken.trim(),
|
||||
proxyId,
|
||||
`${endpointPrefix}/refresh-token`
|
||||
)
|
||||
return tokenInfo as OpenAITokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to validate refresh token'
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Sora session token and get access token
|
||||
const validateSessionToken = async (
|
||||
sessionToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<OpenAITokenInfo | null> => {
|
||||
if (!sessionToken.trim()) {
|
||||
error.value = 'Missing session token'
|
||||
return null
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const tokenInfo = await adminAPI.accounts.validateSoraSessionToken(
|
||||
sessionToken.trim(),
|
||||
proxyId,
|
||||
`${endpointPrefix}/st2at`
|
||||
)
|
||||
return tokenInfo as OpenAITokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to validate session token'
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Build credentials for OpenAI OAuth account
|
||||
const buildCredentials = (tokenInfo: OpenAITokenInfo): Record<string, unknown> => {
|
||||
const creds: Record<string, unknown> = {
|
||||
@@ -116,6 +193,10 @@ export function useOpenAIOAuth() {
|
||||
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
|
||||
@@ -146,12 +227,15 @@ export function useOpenAIOAuth() {
|
||||
// State
|
||||
authUrl,
|
||||
sessionId,
|
||||
oauthState,
|
||||
loading,
|
||||
error,
|
||||
// Methods
|
||||
resetState,
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
validateRefreshToken,
|
||||
validateSessionToken,
|
||||
buildCredentials,
|
||||
buildExtraInfo
|
||||
}
|
||||
|
||||
@@ -1,53 +1,91 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locales/en'
|
||||
import zh from './locales/zh'
|
||||
|
||||
type LocaleCode = 'en' | 'zh'
|
||||
|
||||
type LocaleMessages = Record<string, any>
|
||||
|
||||
const LOCALE_KEY = 'sub2api_locale'
|
||||
const DEFAULT_LOCALE: LocaleCode = 'en'
|
||||
|
||||
function getDefaultLocale(): string {
|
||||
// Check localStorage first
|
||||
const localeLoaders: Record<LocaleCode, () => Promise<{ default: LocaleMessages }>> = {
|
||||
en: () => import('./locales/en'),
|
||||
zh: () => import('./locales/zh')
|
||||
}
|
||||
|
||||
function isLocaleCode(value: string): value is LocaleCode {
|
||||
return value === 'en' || value === 'zh'
|
||||
}
|
||||
|
||||
function getDefaultLocale(): LocaleCode {
|
||||
const saved = localStorage.getItem(LOCALE_KEY)
|
||||
if (saved && ['en', 'zh'].includes(saved)) {
|
||||
if (saved && isLocaleCode(saved)) {
|
||||
return saved
|
||||
}
|
||||
|
||||
// Check browser language
|
||||
const browserLang = navigator.language.toLowerCase()
|
||||
if (browserLang.startsWith('zh')) {
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
return 'en'
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: getDefaultLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en,
|
||||
zh
|
||||
},
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
messages: {},
|
||||
// 禁用 HTML 消息警告 - 引导步骤使用富文本内容(driver.js 支持 HTML)
|
||||
// 这些内容是内部定义的,不存在 XSS 风险
|
||||
warnHtmlMessage: false
|
||||
})
|
||||
|
||||
export function setLocale(locale: string) {
|
||||
if (['en', 'zh'].includes(locale)) {
|
||||
i18n.global.locale.value = locale as 'en' | 'zh'
|
||||
localStorage.setItem(LOCALE_KEY, locale)
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
const loadedLocales = new Set<LocaleCode>()
|
||||
|
||||
export async function loadLocaleMessages(locale: LocaleCode): Promise<void> {
|
||||
if (loadedLocales.has(locale)) {
|
||||
return
|
||||
}
|
||||
|
||||
const loader = localeLoaders[locale]
|
||||
const module = await loader()
|
||||
i18n.global.setLocaleMessage(locale, module.default)
|
||||
loadedLocales.add(locale)
|
||||
}
|
||||
|
||||
export function getLocale(): string {
|
||||
return i18n.global.locale.value
|
||||
export async function initI18n(): Promise<void> {
|
||||
const current = getLocale()
|
||||
await loadLocaleMessages(current)
|
||||
document.documentElement.setAttribute('lang', current)
|
||||
}
|
||||
|
||||
export async function setLocale(locale: string): Promise<void> {
|
||||
if (!isLocaleCode(locale)) {
|
||||
return
|
||||
}
|
||||
|
||||
await loadLocaleMessages(locale)
|
||||
i18n.global.locale.value = locale
|
||||
localStorage.setItem(LOCALE_KEY, locale)
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
|
||||
// 同步更新浏览器页签标题,使其跟随语言切换
|
||||
const { resolveDocumentTitle } = await import('@/router/title')
|
||||
const { default: router } = await import('@/router')
|
||||
const { useAppStore } = await import('@/stores/app')
|
||||
const route = router.currentRoute.value
|
||||
const appStore = useAppStore()
|
||||
document.title = resolveDocumentTitle(route.meta.title, appStore.siteName, route.meta.titleKey as string)
|
||||
}
|
||||
|
||||
export function getLocale(): LocaleCode {
|
||||
const current = i18n.global.locale.value
|
||||
return isLocaleCode(current) ? current : DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export const availableLocales = [
|
||||
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
{ code: 'zh', name: '中文', flag: '🇨🇳' }
|
||||
]
|
||||
] as const
|
||||
|
||||
export default i18n
|
||||
|
||||
@@ -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',
|
||||
@@ -279,8 +280,9 @@ export default {
|
||||
logout: 'Logout',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: 'My Subscriptions',
|
||||
buySubscription: 'Purchase Subscription',
|
||||
docs: 'Docs'
|
||||
buySubscription: 'Recharge / Subscription',
|
||||
docs: 'Docs',
|
||||
sora: 'Sora Studio'
|
||||
},
|
||||
|
||||
// Auth
|
||||
@@ -406,9 +408,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',
|
||||
@@ -478,6 +483,7 @@ export default {
|
||||
today: 'Today',
|
||||
total: 'Total',
|
||||
quota: 'Quota',
|
||||
lastUsedAt: 'Last Used',
|
||||
useKey: 'Use Key',
|
||||
useKeyModal: {
|
||||
title: 'Use API Key',
|
||||
@@ -498,6 +504,7 @@ export default {
|
||||
claudeCode: 'Claude Code',
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
codexCliWs: 'Codex CLI (WebSocket)',
|
||||
opencode: 'OpenCode',
|
||||
},
|
||||
antigravity: {
|
||||
@@ -576,6 +583,10 @@ export default {
|
||||
description: 'View and analyze your API usage history',
|
||||
costDetails: 'Cost Breakdown',
|
||||
tokenDetails: 'Token Breakdown',
|
||||
cacheTtlOverriddenHint: 'Cache TTL Override enabled',
|
||||
cacheTtlOverriddenLabel: 'TTL Override',
|
||||
cacheTtlOverridden5m: 'Billed as 5m',
|
||||
cacheTtlOverridden1h: 'Billed as 1h',
|
||||
totalRequests: 'Total Requests',
|
||||
totalTokens: 'Total Tokens',
|
||||
totalCost: 'Total Cost',
|
||||
@@ -607,8 +618,10 @@ export default {
|
||||
firstToken: 'First Token',
|
||||
duration: 'Duration',
|
||||
time: 'Time',
|
||||
ws: 'WS',
|
||||
stream: 'Stream',
|
||||
sync: 'Sync',
|
||||
unknown: 'Unknown',
|
||||
in: 'In',
|
||||
out: 'Out',
|
||||
cacheRead: 'Read',
|
||||
@@ -822,9 +835,12 @@ export default {
|
||||
day: 'Day',
|
||||
hour: 'Hour',
|
||||
modelDistribution: 'Model 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',
|
||||
@@ -834,6 +850,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',
|
||||
@@ -841,7 +1032,7 @@ export default {
|
||||
createUser: 'Create User',
|
||||
editUser: 'Edit User',
|
||||
deleteUser: 'Delete User',
|
||||
searchUsers: 'Search users...',
|
||||
searchUsers: 'Search by email, username, notes, or API key...',
|
||||
allRoles: 'All Roles',
|
||||
allStatus: 'All Status',
|
||||
admin: 'Admin',
|
||||
@@ -892,6 +1083,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',
|
||||
@@ -907,6 +1101,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.",
|
||||
@@ -1042,6 +1238,10 @@ export default {
|
||||
createGroup: 'Create Group',
|
||||
editGroup: 'Edit Group',
|
||||
deleteGroup: 'Delete Group',
|
||||
sortOrder: 'Sort',
|
||||
sortOrderHint: 'Drag groups to adjust display order, groups at the top will be displayed first',
|
||||
sortOrderUpdated: 'Sort order updated',
|
||||
failedToUpdateSortOrder: 'Failed to update sort order',
|
||||
allPlatforms: 'All Platforms',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
@@ -1100,7 +1300,8 @@ export default {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
deleteConfirm:
|
||||
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
|
||||
@@ -1123,7 +1324,17 @@ export default {
|
||||
},
|
||||
imagePricing: {
|
||||
title: 'Image Generation Pricing',
|
||||
description: 'Configure pricing for gemini-3-pro-image model. Leave empty to use default prices.'
|
||||
description: 'Configure pricing for image generation models. Leave empty to use default prices.'
|
||||
},
|
||||
soraPricing: {
|
||||
title: 'Sora Per-Request Pricing',
|
||||
description: 'Configure per-request pricing for Sora image/video generation. Leave empty to disable billing.',
|
||||
image360: 'Image 360px ($)',
|
||||
image540: 'Image 540px ($)',
|
||||
video: 'Video (standard) ($)',
|
||||
videoHd: 'Video (Pro-HD) ($)',
|
||||
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',
|
||||
@@ -1267,6 +1478,8 @@ export default {
|
||||
refreshInterval15s: '15 seconds',
|
||||
refreshInterval30s: '30 seconds',
|
||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||
listPendingSyncHint: 'List changes are pending sync. Click sync to load latest rows.',
|
||||
listPendingSyncAction: 'Sync now',
|
||||
syncFromCrs: 'Sync from CRS',
|
||||
dataExport: 'Export',
|
||||
dataExportSelected: 'Export Selected',
|
||||
@@ -1305,10 +1518,23 @@ export default {
|
||||
syncResult: 'Sync Result',
|
||||
syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}',
|
||||
syncErrors: 'Errors / Skipped Details',
|
||||
syncCompleted: 'Sync completed: created {created}, updated {updated}',
|
||||
syncCompleted: 'Sync completed: created {created}, updated {updated}, skipped {skipped}',
|
||||
syncCompletedWithErrors:
|
||||
'Sync completed with errors: failed {failed} (created {created}, updated {updated})',
|
||||
'Sync completed with errors: failed {failed} (created {created}, updated {updated}, skipped {skipped})',
|
||||
syncFailed: 'Sync failed',
|
||||
crsPreview: 'Preview',
|
||||
crsPreviewing: 'Previewing...',
|
||||
crsPreviewFailed: 'Preview failed',
|
||||
crsExistingAccounts: 'Existing accounts (will be updated)',
|
||||
crsNewAccounts: 'New accounts (select to sync)',
|
||||
crsSelectAll: 'Select all',
|
||||
crsSelectNone: 'Select none',
|
||||
crsNoNewAccounts: 'All CRS accounts are already synced.',
|
||||
crsWillUpdate: 'Will update {count} existing accounts.',
|
||||
crsSelectedCount: '{count} new accounts selected',
|
||||
crsUpdateBehaviorNote:
|
||||
'Existing accounts only sync fields returned by CRS; missing fields keep their current values. Credentials are merged by key — keys not returned by CRS are preserved. Proxies are kept when "Sync proxies" is unchecked.',
|
||||
crsBack: 'Back',
|
||||
editAccount: 'Edit Account',
|
||||
deleteAccount: 'Delete Account',
|
||||
searchAccounts: 'Search accounts...',
|
||||
@@ -1318,6 +1544,7 @@ export default {
|
||||
allPlatforms: 'All Platforms',
|
||||
allTypes: 'All Types',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
oauthType: 'OAuth',
|
||||
setupToken: 'Setup Token',
|
||||
apiKey: 'API Key',
|
||||
@@ -1327,13 +1554,14 @@ export default {
|
||||
schedulableEnabled: 'Scheduling enabled',
|
||||
schedulableDisabled: 'Scheduling disabled',
|
||||
failedToToggleSchedulable: 'Failed to toggle scheduling status',
|
||||
allGroups: '{count} groups total',
|
||||
groupCountTotal: '{count} groups total',
|
||||
platforms: {
|
||||
anthropic: 'Anthropic',
|
||||
claude: 'Claude',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
types: {
|
||||
oauth: 'OAuth',
|
||||
@@ -1342,6 +1570,11 @@ export default {
|
||||
googleOauth: 'Google OAuth',
|
||||
codeAssist: 'Code Assist',
|
||||
antigravityOauth: 'Antigravity OAuth',
|
||||
antigravityApikey: 'Connect via Base URL + API Key',
|
||||
soraApiKey: 'API Key / Upstream',
|
||||
soraApiKeyHint: 'Connect to another StarFireAPI 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'
|
||||
},
|
||||
@@ -1356,7 +1589,6 @@ export default {
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited until {time}',
|
||||
scopeRateLimitedUntil: '{scope} rate limited until {time}',
|
||||
modelRateLimitedUntil: '{model} rate limited until {time}',
|
||||
overloadedUntil: 'Overloaded until {time}',
|
||||
viewTempUnschedDetails: 'View temp unschedulable details'
|
||||
@@ -1391,7 +1623,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',
|
||||
@@ -1470,7 +1714,8 @@ export default {
|
||||
partialSuccess: 'Partially updated: {success} succeeded, {failed} failed',
|
||||
failed: 'Bulk update failed',
|
||||
noSelection: 'Please select accounts to edit',
|
||||
noFieldsSelected: 'Select at least one field to update'
|
||||
noFieldsSelected: 'Select at least one field to update',
|
||||
mixedPlatformWarning: 'Selected accounts span multiple platforms ({platforms}). Model mapping presets shown are combined — ensure mappings are appropriate for each platform.'
|
||||
},
|
||||
bulkDeleteTitle: 'Bulk Delete Accounts',
|
||||
bulkDeleteConfirm: 'Delete the selected {count} account(s)? This action cannot be undone.',
|
||||
@@ -1503,7 +1748,39 @@ export default {
|
||||
// OpenAI specific hints
|
||||
openai: {
|
||||
baseUrlHint: 'Leave default for official OpenAI API',
|
||||
apiKeyHint: 'Your OpenAI API Key'
|
||||
apiKeyHint: 'Your OpenAI API Key',
|
||||
oauthPassthrough: 'Auto passthrough (auth only)',
|
||||
oauthPassthroughDesc:
|
||||
'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.',
|
||||
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.',
|
||||
modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.',
|
||||
enableSora: 'Enable Sora simultaneously',
|
||||
enableSoraHint: 'Sora uses the same OpenAI account. Enable to create Sora account simultaneously.'
|
||||
},
|
||||
anthropic: {
|
||||
apiKeyPassthrough: 'Auto passthrough (auth only)',
|
||||
apiKeyPassthroughDesc:
|
||||
'Only applies to Anthropic API Key accounts. When enabled, messages/count_tokens are forwarded in passthrough mode with auth replacement only, while billing/concurrency/audit and safety filtering are preserved. Disable to roll back immediately.'
|
||||
},
|
||||
modelRestriction: 'Model Restriction (Optional)',
|
||||
modelWhitelist: 'Model Whitelist',
|
||||
@@ -1513,6 +1790,9 @@ export default {
|
||||
'Map request models to actual models. Left is the requested model, right is the actual model sent to API.',
|
||||
selectedModels: 'Selected {count} model(s)',
|
||||
supportsAllModels: '(supports all models)',
|
||||
soraModelsLoadFailed: 'Failed to load Sora models, fallback to default list',
|
||||
soraModelsLoading: 'Loading Sora models...',
|
||||
soraModelsRetry: 'Load failed, click to retry',
|
||||
requestModel: 'Request model',
|
||||
actualModel: 'Actual model',
|
||||
addMapping: 'Add Mapping',
|
||||
@@ -1570,6 +1850,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'
|
||||
@@ -1577,6 +1878,12 @@ export default {
|
||||
sessionIdMasking: {
|
||||
label: 'Session ID Masking',
|
||||
hint: 'When enabled, fixes the session ID in metadata.user_id for 15 minutes, making upstream think requests come from the same session'
|
||||
},
|
||||
cacheTTLOverride: {
|
||||
label: 'Cache TTL Override',
|
||||
hint: 'Force all cache creation tokens to be billed as the selected TTL tier (5m or 1h)',
|
||||
target: 'Target TTL',
|
||||
targetHint: 'Select the TTL tier for billing'
|
||||
}
|
||||
},
|
||||
expired: 'Expired',
|
||||
@@ -1597,6 +1904,8 @@ export default {
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
accountCreated: 'Account created successfully',
|
||||
soraAccountCreated: 'Sora account created simultaneously',
|
||||
soraAccountFailed: 'Failed to create Sora account, please add manually later',
|
||||
accountUpdated: 'Account updated successfully',
|
||||
failedToCreate: 'Failed to create account',
|
||||
failedToUpdate: 'Failed to update account',
|
||||
@@ -1609,7 +1918,7 @@ export default {
|
||||
// Upstream type
|
||||
upstream: {
|
||||
baseUrl: 'Upstream Base URL',
|
||||
baseUrlHint: 'The address of the upstream Antigravity service, e.g., https://s.konstants.xyz',
|
||||
baseUrlHint: 'The address of the upstream Antigravity service, e.g., https://cloudcode-pa.googleapis.com',
|
||||
apiKey: 'Upstream API Key',
|
||||
apiKeyHint: 'API Key for the upstream service',
|
||||
pleaseEnterBaseUrl: 'Please enter upstream Base URL',
|
||||
@@ -1662,6 +1971,9 @@ export default {
|
||||
cookieAuthFailed: 'Cookie authorization failed',
|
||||
keyAuthFailed: 'Key {index}: {error}',
|
||||
successCreated: 'Successfully created {count} account(s)',
|
||||
batchSuccess: 'Successfully created {count} account(s)',
|
||||
batchPartialSuccess: 'Partial success: {success} succeeded, {failed} failed',
|
||||
batchFailed: 'Batch creation failed',
|
||||
// OpenAI specific
|
||||
openai: {
|
||||
title: 'OpenAI Account Authorization',
|
||||
@@ -1680,7 +1992,27 @@ export default {
|
||||
authCodePlaceholder:
|
||||
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
|
||||
authCodeHint:
|
||||
'You can copy the entire URL or just the code parameter value, the system will auto-detect'
|
||||
'You can copy the entire URL or just the code parameter value, the system will auto-detect',
|
||||
// Refresh Token auth
|
||||
refreshTokenAuth: 'Manual RT Input',
|
||||
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
|
||||
sessionTokenAuth: 'Manual ST Input',
|
||||
sessionTokenDesc: 'Enter your existing Sora Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||
sessionTokenPlaceholder: 'Paste your Sora Session Token...\nSupports multiple, one per line',
|
||||
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',
|
||||
pleaseEnterSessionToken: 'Please enter Session Token'
|
||||
},
|
||||
// Gemini specific
|
||||
gemini: {
|
||||
@@ -1747,13 +2079,20 @@ export default {
|
||||
authCode: 'Authorization URL or Code',
|
||||
authCodePlaceholder:
|
||||
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
|
||||
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
|
||||
failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
|
||||
missingExchangeParams: 'Missing code, session ID, or state',
|
||||
failedToExchangeCode: 'Failed to exchange Antigravity auth code'
|
||||
}
|
||||
},
|
||||
// Gemini specific (platform-wide)
|
||||
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
|
||||
failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
|
||||
missingExchangeParams: 'Missing code, session ID, or state',
|
||||
failedToExchangeCode: 'Failed to exchange Antigravity auth code',
|
||||
// Refresh Token auth
|
||||
refreshTokenAuth: 'Manual RT',
|
||||
refreshTokenDesc: 'Enter your existing Antigravity Refresh Token. Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||
refreshTokenPlaceholder: 'Paste your Antigravity Refresh Token...\nSupports multiple tokens, one per line',
|
||||
validating: 'Validating...',
|
||||
validateAndCreate: 'Validate & Create',
|
||||
pleaseEnterRefreshToken: 'Please enter Refresh Token',
|
||||
failedToValidateRT: 'Failed to validate Refresh Token'
|
||||
}
|
||||
}, // Gemini specific (platform-wide)
|
||||
gemini: {
|
||||
helpButton: 'Help',
|
||||
helpDialog: {
|
||||
@@ -1894,6 +2233,7 @@ export default {
|
||||
reAuthorizeAccount: 'Re-Authorize Account',
|
||||
claudeCodeAccount: 'Claude Code Account',
|
||||
openaiAccount: 'OpenAI Account',
|
||||
soraAccount: 'Sora Account',
|
||||
geminiAccount: 'Gemini Account',
|
||||
antigravityAccount: 'Antigravity Account',
|
||||
inputMethod: 'Input Method',
|
||||
@@ -1919,6 +2259,11 @@ export default {
|
||||
selectTestModel: 'Select Test Model',
|
||||
testModel: 'Test model',
|
||||
testPrompt: 'Prompt: "hi"',
|
||||
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another StarFireAPI 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',
|
||||
soraTestingFlow: 'Running Sora connectivity and capability checks...',
|
||||
// Stats Modal
|
||||
viewStats: 'View Stats',
|
||||
usageStatistics: 'Usage Statistics',
|
||||
@@ -1960,8 +2305,8 @@ export default {
|
||||
geminiFlashDaily: 'Flash',
|
||||
gemini3Pro: 'G3P',
|
||||
gemini3Flash: 'G3F',
|
||||
gemini3Image: 'G3I',
|
||||
claude45: 'C4.5'
|
||||
gemini3Image: 'GImage',
|
||||
claude: 'Claude'
|
||||
},
|
||||
tier: {
|
||||
free: 'Free',
|
||||
@@ -2005,6 +2350,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',
|
||||
@@ -2018,6 +2365,7 @@ export default {
|
||||
name: 'Name',
|
||||
protocol: 'Protocol',
|
||||
address: 'Address',
|
||||
auth: 'Auth',
|
||||
location: 'Location',
|
||||
status: 'Status',
|
||||
accounts: 'Accounts',
|
||||
@@ -2025,6 +2373,8 @@ export default {
|
||||
actions: 'Actions'
|
||||
},
|
||||
testConnection: 'Test Connection',
|
||||
qualityCheck: 'Quality Check',
|
||||
batchQualityCheck: 'Batch Quality Check',
|
||||
batchTest: 'Test All Proxies',
|
||||
testFailed: 'Failed',
|
||||
latencyFailed: 'Connection failed',
|
||||
@@ -2085,6 +2435,29 @@ export default {
|
||||
proxyWorking: 'Proxy is working!',
|
||||
proxyWorkingWithLatency: 'Proxy is working! Latency: {latency}ms',
|
||||
proxyTestFailed: 'Proxy test failed',
|
||||
qualityCheckDone: 'Quality check completed: score {score} ({grade})',
|
||||
qualityCheckFailed: 'Failed to run proxy quality check',
|
||||
batchQualityDone:
|
||||
'Batch quality check completed for {count} proxies: healthy {healthy}, warn {warn}, challenge {challenge}, abnormal {failed}',
|
||||
batchQualityFailed: 'Batch quality check failed',
|
||||
batchQualityEmpty: 'No proxies available for quality check',
|
||||
qualityReportTitle: 'Proxy Quality Report',
|
||||
qualityGrade: 'Grade {grade}',
|
||||
qualityExitIP: 'Exit IP',
|
||||
qualityCountry: 'Exit Region',
|
||||
qualityBaseLatency: 'Base Latency',
|
||||
qualityCheckedAt: 'Checked At',
|
||||
qualityTableTarget: 'Target',
|
||||
qualityTableStatus: 'Status',
|
||||
qualityTableLatency: 'Latency',
|
||||
qualityTableMessage: 'Message',
|
||||
qualityInline: 'Quality {grade}/{score}',
|
||||
qualityStatusHealthy: 'Healthy',
|
||||
qualityStatusPass: 'Pass',
|
||||
qualityStatusWarn: 'Warn',
|
||||
qualityStatusFail: 'Fail',
|
||||
qualityStatusChallenge: 'Challenge',
|
||||
qualityTargetBase: 'Base Connectivity',
|
||||
failedToLoad: 'Failed to load proxies',
|
||||
failedToCreate: 'Failed to create proxy',
|
||||
failedToUpdate: 'Failed to update proxy',
|
||||
@@ -2102,7 +2475,7 @@ export default {
|
||||
title: 'Redeem Code Management',
|
||||
description: 'Generate and manage redeem codes',
|
||||
generateCodes: 'Generate Codes',
|
||||
searchCodes: 'Search codes...',
|
||||
searchCodes: 'Search codes or email...',
|
||||
allTypes: 'All Types',
|
||||
allStatus: 'All Status',
|
||||
balance: 'Balance',
|
||||
@@ -2325,6 +2698,8 @@ export default {
|
||||
inputTokens: 'Input Tokens',
|
||||
outputTokens: 'Output Tokens',
|
||||
cacheCreationTokens: 'Cache Creation Tokens',
|
||||
cacheCreation5mTokens: 'Cache Write',
|
||||
cacheCreation1hTokens: 'Cache Write',
|
||||
cacheReadTokens: 'Cache Read Tokens',
|
||||
failedToLoad: 'Failed to load usage records',
|
||||
billingType: 'Billing Type',
|
||||
@@ -2460,11 +2835,33 @@ export default {
|
||||
'5m': 'Last 5 minutes',
|
||||
'30m': 'Last 30 minutes',
|
||||
'1h': 'Last 1 hour',
|
||||
'1d': 'Last 1 day',
|
||||
'15d': 'Last 15 days',
|
||||
'6h': 'Last 6 hours',
|
||||
'24h': 'Last 24 hours',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days'
|
||||
},
|
||||
openaiTokenStats: {
|
||||
title: 'OpenAI Token Request Stats',
|
||||
viewModeTopN: 'TopN',
|
||||
viewModePagination: 'Pagination',
|
||||
prevPage: 'Previous',
|
||||
nextPage: 'Next',
|
||||
pageInfo: 'Page {page}/{total}',
|
||||
totalModels: 'Total models: {total}',
|
||||
failedToLoad: 'Failed to load OpenAI token stats',
|
||||
empty: 'No OpenAI token stats for the current filters',
|
||||
table: {
|
||||
model: 'Model',
|
||||
requestCount: 'Requests',
|
||||
avgTokensPerSec: 'Avg Tokens/sec',
|
||||
avgFirstTokenMs: 'Avg First Token Latency (ms)',
|
||||
totalOutputTokens: 'Total Output Tokens',
|
||||
avgDurationMs: 'Avg Duration (ms)',
|
||||
requestsWithFirstToken: 'Requests With First Token'
|
||||
}
|
||||
},
|
||||
fullscreen: {
|
||||
enter: 'Enter Fullscreen'
|
||||
},
|
||||
@@ -3049,7 +3446,6 @@ export default {
|
||||
empty: 'No data',
|
||||
queued: 'Queue {count}',
|
||||
rateLimited: 'Rate-limited {count}',
|
||||
scopeRateLimitedTooltip: '{scope} rate-limited ({count} accounts)',
|
||||
errorAccounts: 'Errors {count}',
|
||||
loadFailed: 'Failed to load concurrency data'
|
||||
},
|
||||
@@ -3164,7 +3560,23 @@ 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.'
|
||||
},
|
||||
site: {
|
||||
title: 'Site Settings',
|
||||
@@ -3200,15 +3612,44 @@ export default {
|
||||
hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page'
|
||||
},
|
||||
purchase: {
|
||||
title: 'Purchase Page',
|
||||
description: 'Show a "Purchase Subscription" entry in the sidebar and open the configured URL in an iframe',
|
||||
enabled: 'Show Purchase Entry',
|
||||
title: 'Recharge / Subscription Page',
|
||||
description: 'Show a "Recharge / Subscription" entry in the sidebar and open the configured URL in an iframe',
|
||||
enabled: 'Show Recharge / Subscription Entry',
|
||||
enabledHint: 'Only shown in standard mode (not simple mode)',
|
||||
url: 'Purchase URL',
|
||||
url: 'Recharge / Subscription URL',
|
||||
urlPlaceholder: 'https://example.com/purchase',
|
||||
urlHint: 'Must be an absolute http(s) URL',
|
||||
iframeWarning:
|
||||
'⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.'
|
||||
'⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.',
|
||||
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',
|
||||
@@ -3281,6 +3722,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',
|
||||
@@ -3327,6 +3822,7 @@ export default {
|
||||
custom: 'Custom',
|
||||
code: 'Code',
|
||||
body: 'Body',
|
||||
skipMonitoring: 'Skip Monitoring',
|
||||
|
||||
// Columns
|
||||
columns: {
|
||||
@@ -3371,6 +3867,8 @@ export default {
|
||||
passthroughBody: 'Passthrough upstream error message',
|
||||
customMessage: 'Custom error message',
|
||||
customMessagePlaceholder: 'Error message to return to client...',
|
||||
skipMonitoring: 'Skip monitoring',
|
||||
skipMonitoringHint: 'When enabled, errors matching this rule will not be recorded in ops monitoring',
|
||||
enabled: 'Enable this rule'
|
||||
},
|
||||
|
||||
@@ -3429,16 +3927,26 @@ export default {
|
||||
retry: 'Retry'
|
||||
},
|
||||
|
||||
// Purchase Subscription Page
|
||||
// Recharge / Subscription Page
|
||||
purchase: {
|
||||
title: 'Purchase Subscription',
|
||||
description: 'Purchase a subscription via the embedded page',
|
||||
title: 'Recharge / Subscription',
|
||||
description: 'Recharge balance or purchase subscription via the embedded page',
|
||||
openInNewTab: 'Open in new tab',
|
||||
notEnabledTitle: 'Feature not enabled',
|
||||
notEnabledDesc: 'The administrator has not enabled the purchase page. Please contact admin.',
|
||||
notConfiguredTitle: 'Purchase URL not configured',
|
||||
notEnabledDesc: 'The administrator has not enabled the recharge/subscription entry. Please contact admin.',
|
||||
notConfiguredTitle: 'Recharge / Subscription URL not configured',
|
||||
notConfiguredDesc:
|
||||
'The administrator enabled the entry but has not configured a purchase URL. Please contact admin.'
|
||||
'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
|
||||
@@ -3636,5 +4144,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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ export default {
|
||||
redeemCodes: '兑换码',
|
||||
ops: '运维监控',
|
||||
promoCodes: '优惠码',
|
||||
dataManagement: '数据管理',
|
||||
settings: '系统设置',
|
||||
myAccount: '我的账户',
|
||||
lightMode: '浅色模式',
|
||||
@@ -279,8 +280,9 @@ export default {
|
||||
logout: '退出登录',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: '我的订阅',
|
||||
buySubscription: '购买订阅',
|
||||
docs: '文档'
|
||||
buySubscription: '充值/订阅',
|
||||
docs: '文档',
|
||||
sora: 'Sora 创作'
|
||||
},
|
||||
|
||||
// Auth
|
||||
@@ -407,9 +409,12 @@ export default {
|
||||
day: '按天',
|
||||
hour: '按小时',
|
||||
modelDistribution: '模型分布',
|
||||
groupDistribution: '分组使用分布',
|
||||
tokenUsageTrend: 'Token 使用趋势',
|
||||
noDataAvailable: '暂无数据',
|
||||
model: '模型',
|
||||
group: '分组',
|
||||
noGroup: '无分组',
|
||||
requests: '请求',
|
||||
tokens: 'Token',
|
||||
actual: '实际',
|
||||
@@ -479,6 +484,7 @@ export default {
|
||||
today: '今日',
|
||||
total: '累计',
|
||||
quota: '额度',
|
||||
lastUsedAt: '上次使用时间',
|
||||
useKey: '使用密钥',
|
||||
useKeyModal: {
|
||||
title: '使用 API 密钥',
|
||||
@@ -500,6 +506,7 @@ export default {
|
||||
claudeCode: 'Claude Code',
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
codexCliWs: 'Codex CLI (WebSocket)',
|
||||
opencode: 'OpenCode'
|
||||
},
|
||||
antigravity: {
|
||||
@@ -582,6 +589,10 @@ export default {
|
||||
description: '查看和分析您的 API 使用历史',
|
||||
costDetails: '成本明细',
|
||||
tokenDetails: 'Token 明细',
|
||||
cacheTtlOverriddenHint: '缓存 TTL Override 已启用',
|
||||
cacheTtlOverriddenLabel: 'TTL 替换',
|
||||
cacheTtlOverridden5m: '按 5m 计费',
|
||||
cacheTtlOverridden1h: '按 1h 计费',
|
||||
totalRequests: '总请求数',
|
||||
totalTokens: '总 Token',
|
||||
totalCost: '总消费',
|
||||
@@ -613,8 +624,10 @@ export default {
|
||||
firstToken: '首 Token',
|
||||
duration: '耗时',
|
||||
time: '时间',
|
||||
ws: 'WS',
|
||||
stream: '流式',
|
||||
sync: '同步',
|
||||
unknown: '未知',
|
||||
in: '输入',
|
||||
out: '输出',
|
||||
cacheRead: '读取',
|
||||
@@ -836,9 +849,12 @@ export default {
|
||||
day: '按天',
|
||||
hour: '按小时',
|
||||
modelDistribution: '模型分布',
|
||||
groupDistribution: '分组使用分布',
|
||||
tokenUsageTrend: 'Token 使用趋势',
|
||||
noDataAvailable: '暂无数据',
|
||||
model: '模型',
|
||||
group: '分组',
|
||||
noGroup: '无分组',
|
||||
requests: '请求',
|
||||
tokens: 'Token',
|
||||
cache: '缓存',
|
||||
@@ -857,6 +873,181 @@ export default {
|
||||
failedToLoad: '加载仪表盘数据失败'
|
||||
},
|
||||
|
||||
dataManagement: {
|
||||
title: '数据管理',
|
||||
description: '统一管理数据管理代理状态、对象存储配置和备份任务',
|
||||
agent: {
|
||||
title: '数据管理代理状态',
|
||||
description: '系统会自动探测固定 Unix Socket,仅在可连通时启用数据管理功能。',
|
||||
enabled: '数据管理代理已就绪,可继续进行数据管理操作。',
|
||||
disabled: '数据管理代理不可用,当前仅可查看诊断信息。',
|
||||
socketPath: 'Socket 路径',
|
||||
version: '版本',
|
||||
status: '状态',
|
||||
uptime: '运行时长',
|
||||
reasonLabel: '不可用原因',
|
||||
reason: {
|
||||
DATA_MANAGEMENT_AGENT_SOCKET_MISSING: '未检测到数据管理 Socket 文件',
|
||||
DATA_MANAGEMENT_AGENT_UNAVAILABLE: '数据管理代理不可连通',
|
||||
BACKUP_AGENT_SOCKET_MISSING: '未检测到备份 Socket 文件',
|
||||
BACKUP_AGENT_UNAVAILABLE: '备份代理不可连通',
|
||||
UNKNOWN: '未知原因'
|
||||
}
|
||||
},
|
||||
sections: {
|
||||
config: {
|
||||
title: '备份配置',
|
||||
description: '配置备份源、保留策略与 S3 存储参数。'
|
||||
},
|
||||
s3: {
|
||||
title: 'S3 对象存储',
|
||||
description: '配置并测试备份产物上传到标准 S3 对象存储。'
|
||||
},
|
||||
backup: {
|
||||
title: '备份操作',
|
||||
description: '触发 PostgreSQL、Redis 与全量备份任务。'
|
||||
},
|
||||
history: {
|
||||
title: '备份历史',
|
||||
description: '查看备份任务执行状态、错误与产物信息。'
|
||||
}
|
||||
},
|
||||
form: {
|
||||
sourceMode: '源模式',
|
||||
backupRoot: '备份根目录',
|
||||
activePostgresProfile: '当前激活 PostgreSQL 配置',
|
||||
activeRedisProfile: '当前激活 Redis 配置',
|
||||
activeS3Profile: '当前激活 S3 账号',
|
||||
retentionDays: '保留天数',
|
||||
keepLast: '至少保留最近任务数',
|
||||
uploadToS3: '上传到 S3',
|
||||
useActivePostgresProfile: '使用当前激活 PostgreSQL 配置',
|
||||
useActiveRedisProfile: '使用当前激活 Redis 配置',
|
||||
useActiveS3Profile: '使用当前激活账号',
|
||||
idempotencyKey: '幂等键(可选)',
|
||||
secretConfigured: '已配置,留空不变',
|
||||
source: {
|
||||
profileID: '配置 ID(唯一)',
|
||||
profileName: '配置名称',
|
||||
setActive: '创建后立即设为激活配置'
|
||||
},
|
||||
postgres: {
|
||||
title: 'PostgreSQL',
|
||||
host: '主机',
|
||||
port: '端口',
|
||||
user: '用户名',
|
||||
password: '密码',
|
||||
database: '数据库',
|
||||
sslMode: 'SSL 模式',
|
||||
containerName: '容器名(docker_exec 模式)'
|
||||
},
|
||||
redis: {
|
||||
title: 'Redis',
|
||||
addr: '地址(host:port)',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
db: '数据库编号',
|
||||
containerName: '容器名(docker_exec 模式)'
|
||||
},
|
||||
s3: {
|
||||
enabled: '启用 S3 上传',
|
||||
profileID: '账号 ID(唯一)',
|
||||
profileName: '账号名称',
|
||||
endpoint: 'Endpoint(可选)',
|
||||
region: 'Region',
|
||||
bucket: 'Bucket',
|
||||
accessKeyID: 'Access Key ID',
|
||||
secretAccessKey: 'Secret Access Key',
|
||||
prefix: '对象前缀',
|
||||
forcePathStyle: '强制 path-style',
|
||||
useSSL: '使用 SSL',
|
||||
setActive: '创建后立即设为激活账号'
|
||||
}
|
||||
},
|
||||
sourceProfiles: {
|
||||
createTitle: '创建数据源配置',
|
||||
editTitle: '编辑数据源配置',
|
||||
empty: '暂无配置,请先创建',
|
||||
deleteConfirm: '确定删除配置 {profileID} 吗?',
|
||||
columns: {
|
||||
profile: '配置',
|
||||
active: '激活状态',
|
||||
connection: '连接信息',
|
||||
database: '数据库',
|
||||
updatedAt: '更新时间',
|
||||
actions: '操作'
|
||||
}
|
||||
},
|
||||
s3Profiles: {
|
||||
createTitle: '创建 S3 账号',
|
||||
editTitle: '编辑 S3 账号',
|
||||
empty: '暂无 S3 账号,请先创建',
|
||||
editHint: '点击“编辑”将在右侧抽屉中修改账号信息。',
|
||||
deleteConfirm: '确定删除 S3 账号 {profileID} 吗?',
|
||||
columns: {
|
||||
profile: '账号',
|
||||
active: '激活状态',
|
||||
storage: '存储配置',
|
||||
updatedAt: '更新时间',
|
||||
actions: '操作'
|
||||
}
|
||||
},
|
||||
history: {
|
||||
total: '共 {count} 条',
|
||||
empty: '暂无备份任务',
|
||||
columns: {
|
||||
jobID: '任务 ID',
|
||||
type: '类型',
|
||||
status: '状态',
|
||||
triggeredBy: '触发人',
|
||||
pgProfile: 'PostgreSQL 配置',
|
||||
redisProfile: 'Redis 配置',
|
||||
s3Profile: 'S3 账号',
|
||||
finishedAt: '完成时间',
|
||||
artifact: '产物',
|
||||
error: '错误'
|
||||
},
|
||||
status: {
|
||||
queued: '排队中',
|
||||
running: '执行中',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
partial_succeeded: '部分成功'
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
refresh: '刷新状态',
|
||||
disabledHint: '请先启动 datamanagementd 并确认 Socket 可连通。',
|
||||
reloadConfig: '加载配置',
|
||||
reloadSourceProfiles: '刷新数据源配置',
|
||||
reloadProfiles: '刷新账号列表',
|
||||
newSourceProfile: '新建数据源配置',
|
||||
saveConfig: '保存配置',
|
||||
configSaved: '配置保存成功',
|
||||
testS3: '测试 S3 连接',
|
||||
s3TestOK: 'S3 连接测试成功',
|
||||
s3TestFailed: 'S3 连接测试失败',
|
||||
newProfile: '新建账号',
|
||||
saveProfile: '保存账号',
|
||||
activateProfile: '设为激活',
|
||||
profileIDRequired: '请输入账号 ID',
|
||||
profileNameRequired: '请输入账号名称',
|
||||
profileSelectRequired: '请先选择要编辑的账号',
|
||||
profileCreated: 'S3 账号创建成功',
|
||||
profileSaved: 'S3 账号保存成功',
|
||||
profileActivated: 'S3 账号已切换为激活',
|
||||
profileDeleted: 'S3 账号删除成功',
|
||||
sourceProfileCreated: '数据源配置创建成功',
|
||||
sourceProfileSaved: '数据源配置保存成功',
|
||||
sourceProfileActivated: '数据源配置已切换为激活',
|
||||
sourceProfileDeleted: '数据源配置删除成功',
|
||||
createBackup: '创建备份任务',
|
||||
jobCreated: '备份任务已创建:{jobID}({status})',
|
||||
refreshJobs: '刷新任务',
|
||||
loadMore: '加载更多'
|
||||
}
|
||||
},
|
||||
|
||||
// Users Management
|
||||
users: {
|
||||
title: '用户管理',
|
||||
@@ -865,8 +1056,8 @@ export default {
|
||||
editUser: '编辑用户',
|
||||
deleteUser: '删除用户',
|
||||
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
|
||||
searchPlaceholder: '搜索用户邮箱或用户名、备注、支持模糊查询...',
|
||||
searchUsers: '搜索用户邮箱或用户名、备注、支持模糊查询',
|
||||
searchPlaceholder: '邮箱/用户名/备注/API Key 模糊搜索...',
|
||||
searchUsers: '邮箱/用户名/备注/API Key 模糊搜索',
|
||||
roleFilter: '角色筛选',
|
||||
allRoles: '全部角色',
|
||||
allStatus: '全部状态',
|
||||
@@ -920,6 +1111,9 @@ export default {
|
||||
noApiKeys: '此用户暂无 API 密钥',
|
||||
group: '分组',
|
||||
none: '无',
|
||||
groupChangedSuccess: '分组修改成功',
|
||||
groupChangedWithGrant: '分组修改成功,已自动为用户添加「{group}」分组权限',
|
||||
groupChangeFailed: '分组修改失败',
|
||||
noUsersYet: '暂无用户',
|
||||
createFirstUser: '创建您的第一个用户以开始使用系统',
|
||||
userCreated: '用户创建成功',
|
||||
@@ -973,6 +1167,8 @@ export default {
|
||||
failedToAdjust: '调整失败',
|
||||
emailRequired: '请输入邮箱',
|
||||
concurrencyMin: '并发数不能小于1',
|
||||
soraStorageQuota: 'Sora 存储配额',
|
||||
soraStorageQuotaHint: '单位 GB,0 表示使用分组或系统默认配额',
|
||||
amountRequired: '请输入有效金额',
|
||||
insufficientBalance: '余额不足',
|
||||
setAllowedGroups: '设置允许分组',
|
||||
@@ -1099,6 +1295,10 @@ export default {
|
||||
createGroup: '创建分组',
|
||||
editGroup: '编辑分组',
|
||||
deleteGroup: '删除分组',
|
||||
sortOrder: '排序',
|
||||
sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示',
|
||||
sortOrderUpdated: '排序已更新',
|
||||
failedToUpdateSortOrder: '更新排序失败',
|
||||
deleteConfirm: "确定要删除分组 '{name}' 吗?所有关联的 API 密钥将不再属于任何分组。",
|
||||
deleteConfirmSubscription:
|
||||
"确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。",
|
||||
@@ -1158,7 +1358,8 @@ export default {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
saving: '保存中...',
|
||||
noGroups: '暂无分组',
|
||||
@@ -1210,7 +1411,17 @@ export default {
|
||||
},
|
||||
imagePricing: {
|
||||
title: '图片生成计费',
|
||||
description: '配置 gemini-3-pro-image 模型的图片生成价格,留空则使用默认价格'
|
||||
description: '配置图片生成模型的图片生成价格,留空则使用默认价格'
|
||||
},
|
||||
soraPricing: {
|
||||
title: 'Sora 按次计费',
|
||||
description: '配置 Sora 图片/视频按次收费价格,留空则默认不计费',
|
||||
image360: '图片 360px ($)',
|
||||
image540: '图片 540px ($)',
|
||||
video: '视频(标准)($)',
|
||||
videoHd: '视频(Pro-HD)($)',
|
||||
storageQuota: '存储配额',
|
||||
storageQuotaHint: '单位 GB,设置该分组用户的 Sora 存储配额上限,0 表示使用系统默认'
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code 客户端限制',
|
||||
@@ -1355,6 +1566,8 @@ export default {
|
||||
refreshInterval15s: '15 秒',
|
||||
refreshInterval30s: '30 秒',
|
||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||
listPendingSyncHint: '列表存在待同步变更,点击同步可补齐最新数据。',
|
||||
listPendingSyncAction: '立即同步',
|
||||
syncFromCrs: '从 CRS 同步',
|
||||
dataExport: '导出',
|
||||
dataExportSelected: '导出选中',
|
||||
@@ -1393,9 +1606,22 @@ export default {
|
||||
syncResult: '同步结果',
|
||||
syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}',
|
||||
syncErrors: '错误/跳过详情',
|
||||
syncCompleted: '同步完成:创建 {created},更新 {updated}',
|
||||
syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated})',
|
||||
syncCompleted: '同步完成:创建 {created},更新 {updated},跳过 {skipped}',
|
||||
syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated},跳过 {skipped})',
|
||||
syncFailed: '同步失败',
|
||||
crsPreview: '预览',
|
||||
crsPreviewing: '预览中...',
|
||||
crsPreviewFailed: '预览失败',
|
||||
crsExistingAccounts: '将自动更新的已有账号',
|
||||
crsNewAccounts: '新账号(可选择)',
|
||||
crsSelectAll: '全选',
|
||||
crsSelectNone: '全不选',
|
||||
crsNoNewAccounts: '所有 CRS 账号均已同步。',
|
||||
crsWillUpdate: '将更新 {count} 个已有账号。',
|
||||
crsSelectedCount: '已选择 {count} 个新账号',
|
||||
crsUpdateBehaviorNote:
|
||||
'已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。',
|
||||
crsBack: '返回',
|
||||
editAccount: '编辑账号',
|
||||
deleteAccount: '删除账号',
|
||||
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
|
||||
@@ -1409,6 +1635,7 @@ export default {
|
||||
allPlatforms: '全部平台',
|
||||
allTypes: '全部类型',
|
||||
allStatus: '全部状态',
|
||||
allGroups: '全部分组',
|
||||
oauthType: 'OAuth',
|
||||
// Schedulable toggle
|
||||
schedulable: '参与调度',
|
||||
@@ -1416,7 +1643,7 @@ export default {
|
||||
schedulableEnabled: '调度已开启',
|
||||
schedulableDisabled: '调度已关闭',
|
||||
failedToToggleSchedulable: '切换调度状态失败',
|
||||
allGroups: '共 {count} 个分组',
|
||||
groupCountTotal: '共 {count} 个分组',
|
||||
columns: {
|
||||
name: '名称',
|
||||
platformType: '平台/类型',
|
||||
@@ -1447,7 +1674,19 @@ export default {
|
||||
sessions: {
|
||||
full: '活跃会话已满,新会话需等待(空闲超时:{idle}分钟)',
|
||||
normal: '活跃会话正常(空闲超时:{idle}分钟)'
|
||||
}
|
||||
},
|
||||
rpm: {
|
||||
full: '已达 RPM 上限',
|
||||
warning: 'RPM 接近上限',
|
||||
normal: 'RPM 正常',
|
||||
tieredNormal: 'RPM 限制 (三区模型) - 正常',
|
||||
tieredWarning: 'RPM 限制 (三区模型) - 接近阈值',
|
||||
tieredStickyOnly: 'RPM 限制 (三区模型) - 仅粘性会话 | 缓冲区: {buffer}',
|
||||
tieredBlocked: 'RPM 限制 (三区模型) - 已阻塞 | 缓冲区: {buffer}',
|
||||
stickyExemptNormal: 'RPM 限制 (粘性豁免) - 正常',
|
||||
stickyExemptWarning: 'RPM 限制 (粘性豁免) - 接近阈值',
|
||||
stickyExemptOver: 'RPM 限制 (粘性豁免) - 超限,仅粘性会话'
|
||||
},
|
||||
},
|
||||
clearRateLimit: '清除速率限制',
|
||||
testConnection: '测试连接',
|
||||
@@ -1467,7 +1706,8 @@ export default {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
types: {
|
||||
oauth: 'OAuth',
|
||||
@@ -1476,6 +1716,11 @@ export default {
|
||||
googleOauth: 'Google OAuth',
|
||||
codeAssist: 'Code Assist',
|
||||
antigravityOauth: 'Antigravity OAuth',
|
||||
antigravityApikey: '通过 Base URL + API Key 连接',
|
||||
soraApiKey: 'API Key / 上游透传',
|
||||
soraApiKeyHint: '连接另一个 StarFireAPI 或兼容 API',
|
||||
soraBaseUrlRequired: 'Sora apikey 账号必须设置上游地址(Base URL)',
|
||||
soraBaseUrlInvalidScheme: 'Base URL 必须以 http:// 或 https:// 开头',
|
||||
upstream: '对接上游',
|
||||
upstreamDesc: '通过 Base URL + API Key 连接上游',
|
||||
api_key: 'API Key',
|
||||
@@ -1492,7 +1737,6 @@ export default {
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||
scopeRateLimitedUntil: '{scope} 限流中,重置时间:{time}',
|
||||
modelRateLimitedUntil: '{model} 限流至 {time}',
|
||||
overloadedUntil: '负载过重,重置时间:{time}',
|
||||
viewTempUnschedDetails: '查看临时不可调度详情'
|
||||
@@ -1547,8 +1791,8 @@ export default {
|
||||
geminiFlashDaily: 'Flash',
|
||||
gemini3Pro: 'G3P',
|
||||
gemini3Flash: 'G3F',
|
||||
gemini3Image: 'G3I',
|
||||
claude45: 'C4.5'
|
||||
gemini3Image: 'GImage',
|
||||
claude: 'Claude'
|
||||
},
|
||||
tier: {
|
||||
free: 'Free',
|
||||
@@ -1617,7 +1861,8 @@ export default {
|
||||
partialSuccess: '部分更新成功:成功 {success} 个,失败 {failed} 个',
|
||||
failed: '批量更新失败',
|
||||
noSelection: '请选择要编辑的账号',
|
||||
noFieldsSelected: '请至少选择一个要更新的字段'
|
||||
noFieldsSelected: '请至少选择一个要更新的字段',
|
||||
mixedPlatformWarning: '所选账号跨越多个平台({platforms})。显示的模型映射预设为合并结果——请确保映射对每个平台都适用。'
|
||||
},
|
||||
bulkDeleteTitle: '批量删除账号',
|
||||
bulkDeleteConfirm: '确定要删除选中的 {count} 个账号吗?此操作无法撤销。',
|
||||
@@ -1652,7 +1897,36 @@ export default {
|
||||
// OpenAI specific hints
|
||||
openai: {
|
||||
baseUrlHint: '留空使用官方 OpenAI API',
|
||||
apiKeyHint: '您的 OpenAI API Key'
|
||||
apiKeyHint: '您的 OpenAI API Key',
|
||||
oauthPassthrough: '自动透传(仅替换认证)',
|
||||
oauthPassthroughDesc:
|
||||
'开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。',
|
||||
responsesWebsocketsV2: 'Responses WebSocket v2',
|
||||
responsesWebsocketsV2Desc:
|
||||
'默认关闭。开启后可启用 responses_websockets_v2 协议能力(受网关全局开关与账号类型开关约束)。',
|
||||
wsMode: 'WS mode',
|
||||
wsModeDesc: '仅对当前 OpenAI 账号类型生效。',
|
||||
wsModeOff: '关闭(off)',
|
||||
wsModeShared: '共享(shared)',
|
||||
wsModeDedicated: '独享(dedicated)',
|
||||
wsModeConcurrencyHint: '启用 WS mode 后,该账号并发数将作为该账号 WS 连接池上限。',
|
||||
oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode',
|
||||
oauthResponsesWebsocketsV2Desc:
|
||||
'仅对 OpenAI OAuth 生效。开启后该账号才允许使用 OpenAI WebSocket Mode 协议。',
|
||||
apiKeyResponsesWebsocketsV2: 'API Key WebSocket Mode',
|
||||
apiKeyResponsesWebsocketsV2Desc:
|
||||
'仅对 OpenAI API Key 生效。开启后该账号才允许使用 OpenAI WebSocket Mode 协议。',
|
||||
responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。',
|
||||
codexCLIOnly: '仅允许 Codex 官方客户端',
|
||||
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
|
||||
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
|
||||
enableSora: '同时启用 Sora',
|
||||
enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
|
||||
},
|
||||
anthropic: {
|
||||
apiKeyPassthrough: '自动透传(仅替换认证)',
|
||||
apiKeyPassthroughDesc:
|
||||
'仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。'
|
||||
},
|
||||
modelRestriction: '模型限制(可选)',
|
||||
modelWhitelist: '模型白名单',
|
||||
@@ -1661,6 +1935,9 @@ export default {
|
||||
mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。',
|
||||
selectedModels: '已选择 {count} 个模型',
|
||||
supportsAllModels: '(支持所有模型)',
|
||||
soraModelsLoadFailed: '加载 Sora 模型列表失败,已回退到默认列表',
|
||||
soraModelsLoading: '正在加载 Sora 模型...',
|
||||
soraModelsRetry: '加载失败,点击重试',
|
||||
requestModel: '请求模型',
|
||||
actualModel: '实际模型',
|
||||
addMapping: '添加映射',
|
||||
@@ -1716,6 +1993,27 @@ export default {
|
||||
idleTimeoutPlaceholder: '5',
|
||||
idleTimeoutHint: '会话空闲超时后自动释放'
|
||||
},
|
||||
rpmLimit: {
|
||||
label: 'RPM 限制',
|
||||
hint: '限制每分钟请求数量,保护上游账号',
|
||||
baseRpm: '基础 RPM',
|
||||
baseRpmPlaceholder: '15',
|
||||
baseRpmHint: '每分钟最大请求数,0 或留空表示不限制',
|
||||
strategy: 'RPM 策略',
|
||||
strategyTiered: '三区模型',
|
||||
strategyStickyExempt: '粘性豁免',
|
||||
strategyTieredHint: '绿区→黄区→仅粘性→阻塞,逐步限流',
|
||||
strategyStickyExemptHint: '超限后仅允许粘性会话',
|
||||
strategyHint: '三区模型: 超限后逐步限制; 粘性豁免: 已有会话不受限',
|
||||
stickyBuffer: '粘性缓冲区',
|
||||
stickyBufferPlaceholder: '默认: base RPM 的 20%',
|
||||
stickyBufferHint: '超过 base RPM 后,粘性会话额外允许的请求数。为空则使用默认值(base RPM 的 20%,最小为 1)',
|
||||
userMsgQueue: '用户消息限速',
|
||||
userMsgQueueHint: '对用户消息施加发送限制,避免触发上游 RPM 限制',
|
||||
umqModeOff: '关闭',
|
||||
umqModeThrottle: '软性限速',
|
||||
umqModeSerialize: '串行队列',
|
||||
},
|
||||
tlsFingerprint: {
|
||||
label: 'TLS 指纹模拟',
|
||||
hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹'
|
||||
@@ -1723,6 +2021,12 @@ export default {
|
||||
sessionIdMasking: {
|
||||
label: '会话 ID 伪装',
|
||||
hint: '启用后将在 15 分钟内固定 metadata.user_id 中的 session ID,使上游认为请求来自同一会话'
|
||||
},
|
||||
cacheTTLOverride: {
|
||||
label: '缓存 TTL 强制替换',
|
||||
hint: '将所有缓存创建 token 强制按指定的 TTL 类型(5分钟或1小时)计费',
|
||||
target: '目标 TTL',
|
||||
targetHint: '选择计费使用的 TTL 类型'
|
||||
}
|
||||
},
|
||||
expired: '已过期',
|
||||
@@ -1743,6 +2047,8 @@ export default {
|
||||
creating: '创建中...',
|
||||
updating: '更新中...',
|
||||
accountCreated: '账号创建成功',
|
||||
soraAccountCreated: 'Sora 账号已同时创建',
|
||||
soraAccountFailed: 'Sora 账号创建失败,请稍后手动添加',
|
||||
accountUpdated: '账号更新成功',
|
||||
failedToCreate: '创建账号失败',
|
||||
failedToUpdate: '更新账号失败',
|
||||
@@ -1755,7 +2061,7 @@ export default {
|
||||
// Upstream type
|
||||
upstream: {
|
||||
baseUrl: '上游 Base URL',
|
||||
baseUrlHint: '上游 Antigravity 服务的地址,例如:https://s.konstants.xyz',
|
||||
baseUrlHint: '上游 Antigravity 服务的地址,例如:https://cloudcode-pa.googleapis.com',
|
||||
apiKey: '上游 API Key',
|
||||
apiKeyHint: '上游服务的 API Key',
|
||||
pleaseEnterBaseUrl: '请输入上游 Base URL',
|
||||
@@ -1804,6 +2110,9 @@ export default {
|
||||
cookieAuthFailed: 'Cookie 授权失败',
|
||||
keyAuthFailed: '密钥 {index}: {error}',
|
||||
successCreated: '成功创建 {count} 个账号',
|
||||
batchSuccess: '成功创建 {count} 个账号',
|
||||
batchPartialSuccess: '部分成功:{success} 个成功,{failed} 个失败',
|
||||
batchFailed: '批量创建失败',
|
||||
// OpenAI specific
|
||||
openai: {
|
||||
title: 'OpenAI 账户授权',
|
||||
@@ -1820,7 +2129,27 @@ export default {
|
||||
authCode: '授权链接或 Code',
|
||||
authCodePlaceholder:
|
||||
'方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
|
||||
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别'
|
||||
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
|
||||
// Refresh Token auth
|
||||
refreshTokenAuth: '手动输入 RT',
|
||||
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
|
||||
sessionTokenAuth: '手动输入 ST',
|
||||
sessionTokenDesc: '输入您已有的 Sora Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
sessionTokenPlaceholder: '粘贴您的 Sora Session Token...\n支持多个,每行一个',
|
||||
sessionTokenRawLabel: '原始字符串',
|
||||
sessionTokenRawPlaceholder: '粘贴 /api/auth/session 原始数据或 Session Token...',
|
||||
sessionTokenRawHint: '支持粘贴完整 JSON,系统会自动解析 ST 和 AT。',
|
||||
openSessionUrl: '打开获取链接',
|
||||
copySessionUrl: '复制链接',
|
||||
sessionUrlHint: '该链接通常可获取 AT。若返回中无 sessionToken,请从浏览器 Cookie 复制 __Secure-next-auth.session-token 作为 ST。',
|
||||
parsedSessionTokensLabel: '解析出的 ST',
|
||||
parsedSessionTokensEmpty: '未解析到 ST,请检查输入内容',
|
||||
parsedAccessTokensLabel: '解析出的 AT',
|
||||
validating: '验证中...',
|
||||
validateAndCreate: '验证并创建账号',
|
||||
pleaseEnterRefreshToken: '请输入 Refresh Token',
|
||||
pleaseEnterSessionToken: '请输入 Session Token'
|
||||
},
|
||||
// Gemini specific
|
||||
gemini: {
|
||||
@@ -1886,7 +2215,15 @@ export default {
|
||||
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
|
||||
failedToGenerateUrl: '生成 Antigravity 授权链接失败',
|
||||
missingExchangeParams: '缺少 code / session_id / state',
|
||||
failedToExchangeCode: 'Antigravity 授权码兑换失败'
|
||||
failedToExchangeCode: 'Antigravity 授权码兑换失败',
|
||||
// Refresh Token auth
|
||||
refreshTokenAuth: '手动输入 RT',
|
||||
refreshTokenDesc: '输入您已有的 Antigravity Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
refreshTokenPlaceholder: '粘贴您的 Antigravity Refresh Token...\n支持多个,每行一个',
|
||||
validating: '验证中...',
|
||||
validateAndCreate: '验证并创建账号',
|
||||
pleaseEnterRefreshToken: '请输入 Refresh Token',
|
||||
failedToValidateRT: '验证 Refresh Token 失败'
|
||||
}
|
||||
},
|
||||
// Gemini specific (platform-wide)
|
||||
@@ -2028,6 +2365,7 @@ export default {
|
||||
reAuthorizeAccount: '重新授权账号',
|
||||
claudeCodeAccount: 'Claude Code 账号',
|
||||
openaiAccount: 'OpenAI 账号',
|
||||
soraAccount: 'Sora 账号',
|
||||
geminiAccount: 'Gemini 账号',
|
||||
antigravityAccount: 'Antigravity 账号',
|
||||
inputMethod: '输入方式',
|
||||
@@ -2051,6 +2389,11 @@ export default {
|
||||
selectTestModel: '选择测试模型',
|
||||
testModel: '测试模型',
|
||||
testPrompt: '提示词:"hi"',
|
||||
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 StarFireAPI 实例或兼容 API)',
|
||||
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
|
||||
soraTestTarget: '检测目标:Sora 账号能力',
|
||||
soraTestMode: '模式:连通性 + 能力探测',
|
||||
soraTestingFlow: '执行 Sora 连通性与能力检测...',
|
||||
// Stats Modal
|
||||
viewStats: '查看统计',
|
||||
usageStatistics: '使用统计',
|
||||
@@ -2121,6 +2464,7 @@ export default {
|
||||
name: '名称',
|
||||
protocol: '协议',
|
||||
address: '地址',
|
||||
auth: '认证',
|
||||
location: '地理位置',
|
||||
status: '状态',
|
||||
accounts: '账号数',
|
||||
@@ -2148,6 +2492,8 @@ export default {
|
||||
allStatuses: '全部状态'
|
||||
},
|
||||
// Additional keys used in ProxiesView
|
||||
copyProxyUrl: '复制代理 URL',
|
||||
urlCopied: '代理 URL 已复制',
|
||||
allProtocols: '全部协议',
|
||||
allStatus: '全部状态',
|
||||
searchProxies: '搜索代理...',
|
||||
@@ -2168,6 +2514,8 @@ export default {
|
||||
noProxiesYet: '暂无代理',
|
||||
createFirstProxy: '添加您的第一个代理以开始使用。',
|
||||
testConnection: '测试连接',
|
||||
qualityCheck: '质量检测',
|
||||
batchQualityCheck: '批量质量检测',
|
||||
batchTest: '批量测试',
|
||||
testFailed: '失败',
|
||||
latencyFailed: '链接失败',
|
||||
@@ -2215,6 +2563,28 @@ export default {
|
||||
proxyWorking: '代理连接正常',
|
||||
proxyWorkingWithLatency: '代理连接正常,延迟 {latency}ms',
|
||||
proxyTestFailed: '代理测试失败',
|
||||
qualityCheckDone: '质量检测完成:评分 {score}({grade})',
|
||||
qualityCheckFailed: '代理质量检测失败',
|
||||
batchQualityDone: '批量质量检测完成,共检测 {count} 个;优质 {healthy} 个,告警 {warn} 个,挑战 {challenge} 个,异常 {failed} 个',
|
||||
batchQualityFailed: '批量质量检测失败',
|
||||
batchQualityEmpty: '暂无可检测质量的代理',
|
||||
qualityReportTitle: '代理质量检测报告',
|
||||
qualityGrade: '等级 {grade}',
|
||||
qualityExitIP: '出口 IP',
|
||||
qualityCountry: '出口地区',
|
||||
qualityBaseLatency: '基础延迟',
|
||||
qualityCheckedAt: '检测时间',
|
||||
qualityTableTarget: '检测项',
|
||||
qualityTableStatus: '状态',
|
||||
qualityTableLatency: '延迟',
|
||||
qualityTableMessage: '说明',
|
||||
qualityInline: '质量 {grade}/{score}',
|
||||
qualityStatusHealthy: '优质',
|
||||
qualityStatusPass: '通过',
|
||||
qualityStatusWarn: '告警',
|
||||
qualityStatusFail: '失败',
|
||||
qualityStatusChallenge: '挑战',
|
||||
qualityTargetBase: '基础连通性',
|
||||
proxyCreatedSuccess: '代理添加成功',
|
||||
proxyUpdatedSuccess: '代理更新成功',
|
||||
proxyDeletedSuccess: '代理删除成功',
|
||||
@@ -2265,7 +2635,7 @@ export default {
|
||||
allStatus: '全部状态',
|
||||
unused: '未使用',
|
||||
used: '已使用',
|
||||
searchCodes: '搜索兑换码...',
|
||||
searchCodes: '搜索兑换码或邮箱...',
|
||||
exportCsv: '导出 CSV',
|
||||
deleteAllUnused: '删除全部未使用',
|
||||
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
|
||||
@@ -2491,6 +2861,8 @@ export default {
|
||||
inputTokens: '输入 Token',
|
||||
outputTokens: '输出 Token',
|
||||
cacheCreationTokens: '缓存创建 Token',
|
||||
cacheCreation5mTokens: '缓存创建',
|
||||
cacheCreation1hTokens: '缓存创建',
|
||||
cacheReadTokens: '缓存读取 Token',
|
||||
failedToLoad: '加载使用记录失败',
|
||||
billingType: '计费类型',
|
||||
@@ -2627,12 +2999,34 @@ export default {
|
||||
'5m': '近5分钟',
|
||||
'30m': '近30分钟',
|
||||
'1h': '近1小时',
|
||||
'1d': '近1天',
|
||||
'15d': '近15天',
|
||||
'6h': '近6小时',
|
||||
'24h': '近24小时',
|
||||
'7d': '近7天',
|
||||
'30d': '近30天',
|
||||
custom: '自定义'
|
||||
},
|
||||
openaiTokenStats: {
|
||||
title: 'OpenAI Token 请求统计',
|
||||
viewModeTopN: 'TopN',
|
||||
viewModePagination: '分页',
|
||||
prevPage: '上一页',
|
||||
nextPage: '下一页',
|
||||
pageInfo: '第 {page}/{total} 页',
|
||||
totalModels: '模型总数:{total}',
|
||||
failedToLoad: '加载 OpenAI Token 统计失败',
|
||||
empty: '当前筛选条件下暂无 OpenAI Token 请求统计数据',
|
||||
table: {
|
||||
model: '模型',
|
||||
requestCount: '请求数',
|
||||
avgTokensPerSec: '平均 Tokens/秒',
|
||||
avgFirstTokenMs: '平均首 Token 延迟(ms)',
|
||||
totalOutputTokens: '输出 Token 总数',
|
||||
avgDurationMs: '平均时长(ms)',
|
||||
requestsWithFirstToken: '首 Token 样本数'
|
||||
}
|
||||
},
|
||||
customTimeRange: {
|
||||
startTime: '开始时间',
|
||||
endTime: '结束时间'
|
||||
@@ -3222,7 +3616,6 @@ export default {
|
||||
empty: '暂无数据',
|
||||
queued: '队列 {count}',
|
||||
rateLimited: '限流 {count}',
|
||||
scopeRateLimitedTooltip: '{scope} 限流中 ({count} 个账号)',
|
||||
errorAccounts: '异常 {count}',
|
||||
loadFailed: '加载并发数据失败'
|
||||
},
|
||||
@@ -3337,7 +3730,21 @@ 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 格式)。留空则不检查版本。'
|
||||
},
|
||||
site: {
|
||||
title: '站点设置',
|
||||
@@ -3375,15 +3782,44 @@ export default {
|
||||
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
|
||||
},
|
||||
purchase: {
|
||||
title: '购买订阅页面',
|
||||
description: '在侧边栏展示“购买订阅”入口,并在页面内通过 iframe 打开指定链接',
|
||||
enabled: '显示购买订阅入口',
|
||||
title: '充值/订阅页面',
|
||||
description: '在侧边栏展示“充值/订阅”入口,并在页面内通过 iframe 打开指定链接',
|
||||
enabled: '显示充值/订阅入口',
|
||||
enabledHint: '仅在标准模式(非简单模式)下展示',
|
||||
url: '购买页面 URL',
|
||||
url: '充值/订阅页面 URL',
|
||||
urlPlaceholder: 'https://example.com/purchase',
|
||||
urlHint: '必须是完整的 http(s) 链接',
|
||||
iframeWarning:
|
||||
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用“新窗口打开”。'
|
||||
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。',
|
||||
integrationDoc: '支付集成文档',
|
||||
integrationDocHint: '包含接口说明、幂等语义及示例代码'
|
||||
},
|
||||
soraClient: {
|
||||
title: 'Sora 客户端',
|
||||
description: '控制是否在侧边栏展示 Sora 客户端入口',
|
||||
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 设置',
|
||||
@@ -3455,6 +3891,60 @@ export default {
|
||||
securityWarning: '警告:此密钥拥有完整的管理员权限,请妥善保管。',
|
||||
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-api-key>'
|
||||
},
|
||||
soraS3: {
|
||||
title: 'Sora S3 存储配置',
|
||||
description: '以多配置列表方式管理 Sora S3 端点,并可切换生效配置',
|
||||
newProfile: '新建配置',
|
||||
reloadProfiles: '刷新列表',
|
||||
empty: '暂无 Sora S3 配置,请先创建',
|
||||
createTitle: '新建 Sora S3 配置',
|
||||
editTitle: '编辑 Sora S3 配置',
|
||||
profileID: '配置 ID',
|
||||
profileName: '配置名称',
|
||||
setActive: '创建后设为生效',
|
||||
saveProfile: '保存配置',
|
||||
activateProfile: '设为生效',
|
||||
profileCreated: 'Sora S3 配置创建成功',
|
||||
profileSaved: 'Sora S3 配置保存成功',
|
||||
profileDeleted: 'Sora S3 配置删除成功',
|
||||
profileActivated: 'Sora S3 生效配置已切换',
|
||||
profileIDRequired: '请填写配置 ID',
|
||||
profileNameRequired: '请填写配置名称',
|
||||
profileSelectRequired: '请先选择配置',
|
||||
endpointRequired: '启用时必须填写 S3 端点',
|
||||
bucketRequired: '启用时必须填写存储桶',
|
||||
accessKeyRequired: '启用时必须填写 Access Key ID',
|
||||
deleteConfirm: '确定删除 Sora S3 配置 {profileID} 吗?',
|
||||
columns: {
|
||||
profile: '配置',
|
||||
active: '生效状态',
|
||||
endpoint: '端点',
|
||||
bucket: '存储桶',
|
||||
quota: '默认配额',
|
||||
updatedAt: '更新时间',
|
||||
actions: '操作'
|
||||
},
|
||||
enabled: '启用 S3 存储',
|
||||
enabledHint: '启用后,Sora 生成的媒体文件将自动上传到 S3 存储',
|
||||
endpoint: 'S3 端点',
|
||||
region: '区域',
|
||||
bucket: '存储桶',
|
||||
prefix: '对象前缀',
|
||||
accessKeyId: 'Access Key ID',
|
||||
secretAccessKey: 'Secret Access Key',
|
||||
secretConfigured: '(已配置,留空保持不变)',
|
||||
cdnUrl: 'CDN URL',
|
||||
cdnUrlHint: '可选,配置后使用 CDN URL 访问文件,否则使用预签名 URL',
|
||||
forcePathStyle: '强制路径风格(Path Style)',
|
||||
defaultQuota: '默认存储配额',
|
||||
defaultQuotaHint: '未在用户或分组级别指定配额时的默认值,0 表示无限制',
|
||||
testConnection: '测试连接',
|
||||
testing: '测试中...',
|
||||
testSuccess: 'S3 连接测试成功',
|
||||
testFailed: 'S3 连接测试失败',
|
||||
saved: 'Sora S3 设置保存成功',
|
||||
saveFailed: '保存 Sora S3 设置失败'
|
||||
},
|
||||
streamTimeout: {
|
||||
title: '流超时处理',
|
||||
description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中',
|
||||
@@ -3501,6 +3991,7 @@ export default {
|
||||
custom: '自定义',
|
||||
code: '状态码',
|
||||
body: '消息体',
|
||||
skipMonitoring: '跳过监控',
|
||||
|
||||
// Columns
|
||||
columns: {
|
||||
@@ -3545,6 +4036,8 @@ export default {
|
||||
passthroughBody: '透传上游错误信息',
|
||||
customMessage: '自定义错误信息',
|
||||
customMessagePlaceholder: '返回给客户端的错误信息...',
|
||||
skipMonitoring: '跳过运维监控记录',
|
||||
skipMonitoringHint: '开启后,匹配此规则的错误不会被记录到运维监控中',
|
||||
enabled: '启用此规则'
|
||||
},
|
||||
|
||||
@@ -3603,15 +4096,25 @@ export default {
|
||||
retry: '重试'
|
||||
},
|
||||
|
||||
// Purchase Subscription Page
|
||||
// Recharge / Subscription Page
|
||||
purchase: {
|
||||
title: '购买订阅',
|
||||
description: '通过内嵌页面完成订阅购买',
|
||||
title: '充值/订阅',
|
||||
description: '通过内嵌页面完成充值/订阅',
|
||||
openInNewTab: '新窗口打开',
|
||||
notEnabledTitle: '该功能未开启',
|
||||
notEnabledDesc: '管理员暂未开启购买订阅入口,请联系管理员。',
|
||||
notConfiguredTitle: '购买链接未配置',
|
||||
notConfiguredDesc: '管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。'
|
||||
notEnabledDesc: '管理员暂未开启充值/订阅入口,请联系管理员。',
|
||||
notConfiguredTitle: '充值/订阅链接未配置',
|
||||
notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。'
|
||||
},
|
||||
|
||||
// Custom Page (iframe embed)
|
||||
customPage: {
|
||||
title: '自定义页面',
|
||||
openInNewTab: '新窗口打开',
|
||||
notFoundTitle: '页面不存在',
|
||||
notFoundDesc: '该自定义页面不存在或已被删除。',
|
||||
notConfiguredTitle: '页面链接未配置',
|
||||
notConfiguredDesc: '该自定义页面的 URL 未正确配置。',
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
@@ -3832,5 +4335,93 @@ export default {
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Sora 创作
|
||||
sora: {
|
||||
title: 'Sora 创作',
|
||||
description: '使用 Sora AI 生成视频与图片',
|
||||
notEnabled: '功能未开放',
|
||||
notEnabledDesc: '管理员尚未启用 Sora 创作功能,请联系管理员开通。',
|
||||
tabGenerate: '生成',
|
||||
tabLibrary: '作品库',
|
||||
noActiveGenerations: '暂无生成任务',
|
||||
startGenerating: '在下方输入提示词,开始创作',
|
||||
storage: '存储',
|
||||
promptPlaceholder: '描述你想创作的内容...',
|
||||
generate: '生成',
|
||||
generating: '生成中...',
|
||||
selectModel: '选择模型',
|
||||
statusPending: '等待中',
|
||||
statusGenerating: '生成中',
|
||||
statusCompleted: '已完成',
|
||||
statusFailed: '失败',
|
||||
statusCancelled: '已取消',
|
||||
cancel: '取消',
|
||||
delete: '删除',
|
||||
save: '保存到云端',
|
||||
saved: '已保存',
|
||||
retry: '重试',
|
||||
download: '下载',
|
||||
justNow: '刚刚',
|
||||
minutesAgo: '{n} 分钟前',
|
||||
hoursAgo: '{n} 小时前',
|
||||
noSavedWorks: '暂无保存的作品',
|
||||
saveWorksHint: '生成完成后,将作品保存到作品库',
|
||||
filterAll: '全部',
|
||||
filterVideo: '视频',
|
||||
filterImage: '图片',
|
||||
confirmDelete: '确定删除此作品?',
|
||||
loading: '加载中...',
|
||||
loadMore: '加载更多',
|
||||
noStorageWarningTitle: '未配置存储',
|
||||
noStorageWarningDesc: '生成的内容仅通过上游临时链接提供,约 15 分钟后过期。建议管理员配置 S3 存储。',
|
||||
mediaTypeVideo: '视频',
|
||||
mediaTypeImage: '图片',
|
||||
notificationCompleted: '生成完成',
|
||||
notificationFailed: '生成失败',
|
||||
notificationCompletedBody: '您的 {model} 任务已完成',
|
||||
notificationFailedBody: '您的 {model} 任务失败了',
|
||||
upstreamExpiresSoon: '即将过期',
|
||||
upstreamExpired: '链接已过期',
|
||||
upstreamCountdown: '剩余 {time}',
|
||||
previewTitle: '作品预览',
|
||||
closePreview: '关闭',
|
||||
beforeUnloadWarning: '您有未保存的生成内容,确定要离开吗?',
|
||||
downloadTitle: '下载生成内容',
|
||||
downloadExpirationWarning: '此链接约 15 分钟后过期,请尽快下载保存。',
|
||||
downloadNow: '立即下载',
|
||||
referenceImage: '参考图',
|
||||
removeImage: '移除',
|
||||
imageTooLarge: '图片大小不能超过 20MB',
|
||||
// Sora 暗色主题新增
|
||||
welcomeTitle: '将你的想象力变成视频',
|
||||
welcomeSubtitle: '输入一段描述,Sora 将为你创作逼真的视频或图片。尝试以下示例开始创作。',
|
||||
queueTasks: '个任务',
|
||||
queueWaiting: '队列中等待',
|
||||
waiting: '等待中',
|
||||
waited: '已等待',
|
||||
errorCategory: '内容策略限制',
|
||||
savedToCloud: '已保存到云端',
|
||||
downloadLocal: '本地下载',
|
||||
canDownload: '可下载',
|
||||
regenrate: '重新生成',
|
||||
creatorPlaceholder: '描述你想要生成的视频或图片...',
|
||||
videoModels: '视频模型',
|
||||
imageModels: '图片模型',
|
||||
noStorageConfigured: '存储未配置',
|
||||
selectCredential: '选择凭证',
|
||||
apiKeys: 'API 密钥',
|
||||
subscriptions: '订阅',
|
||||
subscription: '订阅',
|
||||
noCredentialHint: '请先创建 API Key 或联系管理员分配订阅',
|
||||
uploadReference: '上传参考图片',
|
||||
generatingCount: '正在生成 {current}/{max}',
|
||||
noStorageToastMessage: '管理员未开通云存储,生成完成后请使用"本地下载"保存文件,否则将会丢失。',
|
||||
galleryCount: '共 {count} 个作品',
|
||||
galleryEmptyTitle: '还没有任何作品',
|
||||
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
|
||||
startCreating: '开始创作',
|
||||
yesterday: '昨天'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,44 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
import i18n, { initI18n } from './i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// Initialize settings from injected config BEFORE mounting (prevents flash)
|
||||
// This must happen after pinia is installed but before router and i18n
|
||||
import { useAppStore } from '@/stores/app'
|
||||
const appStore = useAppStore()
|
||||
appStore.initFromInjectedConfig()
|
||||
|
||||
// Set document title immediately after config is loaded
|
||||
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
|
||||
document.title = `${appStore.siteName} - AI API Gateway`
|
||||
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)
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
async function bootstrap() {
|
||||
// Apply theme class globally before app mount to keep all routes consistent.
|
||||
initThemeClass()
|
||||
|
||||
// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染
|
||||
router.isReady().then(() => {
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// Initialize settings from injected config BEFORE mounting (prevents flash)
|
||||
// This must happen after pinia is installed but before router and i18n
|
||||
const appStore = useAppStore()
|
||||
appStore.initFromInjectedConfig()
|
||||
|
||||
// Set document title immediately after config is loaded
|
||||
if (appStore.siteName && appStore.siteName !== 'StarFireAPI') {
|
||||
document.title = `${appStore.siteName} - AI API Gateway`
|
||||
}
|
||||
|
||||
await initI18n()
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染
|
||||
await router.isReady()
|
||||
app.mount('#app')
|
||||
})
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
|
||||
267
frontend/src/router/__tests__/guards.spec.ts
Normal file
267
frontend/src/router/__tests__/guards.spec.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock 导航加载状态
|
||||
vi.mock('@/composables/useNavigationLoading', () => {
|
||||
const mockStart = vi.fn()
|
||||
const mockEnd = vi.fn()
|
||||
return {
|
||||
useNavigationLoadingState: () => ({
|
||||
startNavigation: mockStart,
|
||||
endNavigation: mockEnd,
|
||||
isLoading: { value: false },
|
||||
}),
|
||||
useNavigationLoading: () => ({
|
||||
startNavigation: mockStart,
|
||||
endNavigation: mockEnd,
|
||||
isLoading: { value: false },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock 路由预加载
|
||||
vi.mock('@/composables/useRoutePrefetch', () => ({
|
||||
useRoutePrefetch: () => ({
|
||||
triggerPrefetch: vi.fn(),
|
||||
cancelPendingPrefetch: vi.fn(),
|
||||
resetPrefetchState: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API 相关模块
|
||||
vi.mock('@/api', () => ({
|
||||
authAPI: {
|
||||
getCurrentUser: vi.fn().mockResolvedValue({ data: {} }),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
isTotp2FARequired: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
|
||||
// 用于测试的 auth 状态
|
||||
interface MockAuthState {
|
||||
isAuthenticated: boolean
|
||||
isAdmin: boolean
|
||||
isSimpleMode: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 router/index.ts 中 beforeEach 守卫的核心逻辑提取为可测试的函数
|
||||
*/
|
||||
function simulateGuard(
|
||||
toPath: string,
|
||||
toMeta: Record<string, any>,
|
||||
authState: MockAuthState
|
||||
): string | null {
|
||||
const requiresAuth = toMeta.requiresAuth !== false
|
||||
const requiresAdmin = toMeta.requiresAdmin === true
|
||||
|
||||
// 不需要认证的路由
|
||||
if (!requiresAuth) {
|
||||
if (
|
||||
authState.isAuthenticated &&
|
||||
(toPath === '/login' || toPath === '/register')
|
||||
) {
|
||||
return authState.isAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
}
|
||||
return null // 允许通过
|
||||
}
|
||||
|
||||
// 需要认证但未登录
|
||||
if (!authState.isAuthenticated) {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
// 需要管理员但不是管理员
|
||||
if (requiresAdmin && !authState.isAdmin) {
|
||||
return '/dashboard'
|
||||
}
|
||||
|
||||
// 简易模式限制
|
||||
if (authState.isSimpleMode) {
|
||||
const restrictedPaths = [
|
||||
'/admin/groups',
|
||||
'/admin/subscriptions',
|
||||
'/admin/redeem',
|
||||
'/subscriptions',
|
||||
'/redeem',
|
||||
]
|
||||
if (restrictedPaths.some((path) => toPath.startsWith(path))) {
|
||||
return authState.isAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
return null // 允许通过
|
||||
}
|
||||
|
||||
describe('路由守卫逻辑', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
// --- 未认证用户 ---
|
||||
|
||||
describe('未认证用户', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: false,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
}
|
||||
|
||||
it('访问需要认证的页面重定向到 /login', () => {
|
||||
const redirect = simulateGuard('/dashboard', {}, authState)
|
||||
expect(redirect).toBe('/login')
|
||||
})
|
||||
|
||||
it('访问管理页面重定向到 /login', () => {
|
||||
const redirect = simulateGuard('/admin/dashboard', { requiresAdmin: true }, authState)
|
||||
expect(redirect).toBe('/login')
|
||||
})
|
||||
|
||||
it('访问公开页面允许通过', () => {
|
||||
const redirect = simulateGuard('/login', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
|
||||
it('访问 /home 公开页面允许通过', () => {
|
||||
const redirect = simulateGuard('/home', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --- 已认证普通用户 ---
|
||||
|
||||
describe('已认证普通用户', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
}
|
||||
|
||||
it('访问 /login 重定向到 /dashboard', () => {
|
||||
const redirect = simulateGuard('/login', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('访问 /register 重定向到 /dashboard', () => {
|
||||
const redirect = simulateGuard('/register', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('访问 /dashboard 允许通过', () => {
|
||||
const redirect = simulateGuard('/dashboard', {}, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
|
||||
it('访问管理页面被拒绝,重定向到 /dashboard', () => {
|
||||
const redirect = simulateGuard('/admin/dashboard', { requiresAdmin: true }, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('访问 /admin/users 被拒绝', () => {
|
||||
const redirect = simulateGuard('/admin/users', { requiresAdmin: true }, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
})
|
||||
|
||||
// --- 已认证管理员 ---
|
||||
|
||||
describe('已认证管理员', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: true,
|
||||
isSimpleMode: false,
|
||||
}
|
||||
|
||||
it('访问 /login 重定向到 /admin/dashboard', () => {
|
||||
const redirect = simulateGuard('/login', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBe('/admin/dashboard')
|
||||
})
|
||||
|
||||
it('访问管理页面允许通过', () => {
|
||||
const redirect = simulateGuard('/admin/dashboard', { requiresAdmin: true }, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
|
||||
it('访问用户页面允许通过', () => {
|
||||
const redirect = simulateGuard('/dashboard', {}, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --- 简易模式 ---
|
||||
|
||||
describe('简易模式受限路由', () => {
|
||||
it('普通用户简易模式访问 /subscriptions 重定向到 /dashboard', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard('/subscriptions', {}, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('普通用户简易模式访问 /redeem 重定向到 /dashboard', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard('/redeem', {}, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('管理员简易模式访问 /admin/groups 重定向到 /admin/dashboard', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: true,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard('/admin/groups', { requiresAdmin: true }, authState)
|
||||
expect(redirect).toBe('/admin/dashboard')
|
||||
})
|
||||
|
||||
it('管理员简易模式访问 /admin/subscriptions 重定向', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: true,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard(
|
||||
'/admin/subscriptions',
|
||||
{ requiresAdmin: true },
|
||||
authState
|
||||
)
|
||||
expect(redirect).toBe('/admin/dashboard')
|
||||
})
|
||||
|
||||
it('简易模式下非受限页面正常访问', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard('/dashboard', {}, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
|
||||
it('简易模式下 /keys 正常访问', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard('/keys', {}, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
25
frontend/src/router/__tests__/title.spec.ts
Normal file
25
frontend/src/router/__tests__/title.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveDocumentTitle } from '@/router/title'
|
||||
|
||||
describe('resolveDocumentTitle', () => {
|
||||
it('路由存在标题时,使用“路由标题 - 站点名”格式', () => {
|
||||
expect(resolveDocumentTitle('Usage Records', 'My Site')).toBe('Usage Records - My Site')
|
||||
})
|
||||
|
||||
it('路由无标题时,回退到站点名', () => {
|
||||
expect(resolveDocumentTitle(undefined, 'My Site')).toBe('My Site')
|
||||
})
|
||||
|
||||
it('站点名为空时,回退默认站点名', () => {
|
||||
expect(resolveDocumentTitle('Dashboard', '')).toBe('Dashboard - StarFireAPI')
|
||||
expect(resolveDocumentTitle(undefined, ' ')).toBe('StarFireAPI')
|
||||
})
|
||||
|
||||
it('站点名变更时仅影响后续路由标题计算', () => {
|
||||
const before = resolveDocumentTitle('Admin Dashboard', 'Alpha')
|
||||
const after = resolveDocumentTitle('Admin Dashboard', 'Beta')
|
||||
|
||||
expect(before).toBe('Admin Dashboard - Alpha')
|
||||
expect(after).toBe('Admin Dashboard - Beta')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
|
||||
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
import { resolveDocumentTitle } from './title'
|
||||
|
||||
/**
|
||||
* Route definitions with lazy loading
|
||||
@@ -40,7 +41,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Login'
|
||||
title: 'Login',
|
||||
titleKey: 'common.login'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -49,7 +51,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Register'
|
||||
title: 'Register',
|
||||
titleKey: 'auth.createAccount'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -85,7 +88,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Forgot Password'
|
||||
title: 'Forgot Password',
|
||||
titleKey: 'auth.forgotPasswordTitle'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -187,6 +191,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 ====================
|
||||
{
|
||||
@@ -313,6 +340,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',
|
||||
@@ -389,11 +428,19 @@ router.beforeEach((to, _from, next) => {
|
||||
|
||||
// Set page title
|
||||
const appStore = useAppStore()
|
||||
const siteName = appStore.siteName || 'Sub2API'
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - ${siteName}`
|
||||
// For custom pages, use menu item label as document title
|
||||
if (to.name === 'CustomPage') {
|
||||
const id = to.params.id as string
|
||||
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||
const menuItem = items.find((item) => item.id === id)
|
||||
if (menuItem?.label) {
|
||||
const siteName = appStore.siteName || 'StarFireAPI'
|
||||
document.title = `${menuItem.label} - ${siteName}`
|
||||
} else {
|
||||
document.title = resolveDocumentTitle(to.meta.title, appStore.siteName, to.meta.titleKey as string)
|
||||
}
|
||||
} else {
|
||||
document.title = siteName
|
||||
document.title = resolveDocumentTitle(to.meta.title, appStore.siteName, to.meta.titleKey as string)
|
||||
}
|
||||
|
||||
// Check if route requires authentication
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user