mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
revert: completely remove all Sora functionality
This commit is contained in:
@@ -1,80 +0,0 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,7 +40,6 @@ export interface SystemSettings {
|
||||
hide_ccs_import_button: boolean
|
||||
purchase_subscription_enabled: boolean
|
||||
purchase_subscription_url: string
|
||||
sora_client_enabled: boolean
|
||||
backend_mode_enabled: boolean
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
custom_endpoints: CustomEndpoint[]
|
||||
@@ -114,7 +113,6 @@ export interface UpdateSettingsRequest {
|
||||
hide_ccs_import_button?: boolean
|
||||
purchase_subscription_enabled?: boolean
|
||||
purchase_subscription_url?: string
|
||||
sora_client_enabled?: boolean
|
||||
backend_mode_enabled?: boolean
|
||||
custom_menu_items?: CustomMenuItem[]
|
||||
custom_endpoints?: CustomEndpoint[]
|
||||
@@ -394,142 +392,6 @@ export async function updateBetaPolicySettings(
|
||||
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,
|
||||
@@ -545,15 +407,7 @@ export const settingsAPI = {
|
||||
getRectifierSettings,
|
||||
updateRectifierSettings,
|
||||
getBetaPolicySettings,
|
||||
updateBetaPolicySettings,
|
||||
getSoraS3Settings,
|
||||
updateSoraS3Settings,
|
||||
testSoraS3Connection,
|
||||
listSoraS3Profiles,
|
||||
createSoraS3Profile,
|
||||
updateSoraS3Profile,
|
||||
deleteSoraS3Profile,
|
||||
setActiveSoraS3Profile
|
||||
updateBetaPolicySettings
|
||||
}
|
||||
|
||||
export default settingsAPI
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -41,7 +41,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!isSoraAccount" class="space-y-1.5">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.selectTestModel') }}
|
||||
</label>
|
||||
@@ -54,12 +54,6 @@
|
||||
: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>
|
||||
|
||||
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
|
||||
<TextArea
|
||||
@@ -152,17 +146,15 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="grid" size="sm" :stroke-width="2" />
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
|
||||
{{ t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chat" size="sm" :stroke-width="2" />
|
||||
{{
|
||||
isSoraAccount
|
||||
? t('admin.accounts.soraTestMode')
|
||||
: supportsGeminiImageTest
|
||||
? t('admin.accounts.geminiImageTestMode')
|
||||
: t('admin.accounts.testPrompt')
|
||||
supportsGeminiImageTest
|
||||
? t('admin.accounts.geminiImageTestMode')
|
||||
: t('admin.accounts.testPrompt')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
@@ -179,10 +171,10 @@
|
||||
</button>
|
||||
<button
|
||||
@click="startTest"
|
||||
:disabled="status === 'connecting' || (!isSoraAccount && !selectedModelId)"
|
||||
:disabled="status === 'connecting' || !selectedModelId"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
status === 'connecting' || (!isSoraAccount && !selectedModelId)
|
||||
status === 'connecting' || !selectedModelId
|
||||
? 'cursor-not-allowed bg-primary-400 text-white'
|
||||
: status === 'success'
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
@@ -258,11 +250,9 @@ const selectedModelId = ref('')
|
||||
const testPrompt = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
const isSoraAccount = computed(() => props.account?.platform === 'sora')
|
||||
const generatedImages = ref<PreviewImage[]>([])
|
||||
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
|
||||
const supportsGeminiImageTest = computed(() => {
|
||||
if (isSoraAccount.value) return false
|
||||
const modelID = selectedModelId.value.toLowerCase()
|
||||
if (!modelID.startsWith('gemini-') || !modelID.includes('-image')) return false
|
||||
|
||||
@@ -302,12 +292,6 @@ watch(selectedModelId, () => {
|
||||
|
||||
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
|
||||
@@ -373,7 +357,7 @@ const scrollToBottom = async () => {
|
||||
}
|
||||
|
||||
const startTest = async () => {
|
||||
if (!props.account || (!isSoraAccount.value && !selectedModelId.value)) return
|
||||
if (!props.account || !selectedModelId.value) return
|
||||
|
||||
resetState()
|
||||
status.value = 'connecting'
|
||||
@@ -394,14 +378,10 @@ const startTest = async () => {
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(
|
||||
isSoraAccount.value
|
||||
? {}
|
||||
: {
|
||||
body: JSON.stringify({
|
||||
model_id: selectedModelId.value,
|
||||
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -461,9 +441,7 @@ const handleEvent = (event: {
|
||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||
}
|
||||
addLine(
|
||||
isSoraAccount.value
|
||||
? t('admin.accounts.soraTestingFlow')
|
||||
: supportsGeminiImageTest.value
|
||||
supportsGeminiImageTest.value
|
||||
? t('admin.accounts.sendingGeminiImageRequest')
|
||||
: t('admin.accounts.sendingTestMessage'),
|
||||
'text-gray-400'
|
||||
|
||||
@@ -109,28 +109,6 @@
|
||||
</svg>
|
||||
OpenAI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="form.platform = 'sora'"
|
||||
:class="[
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
||||
form.platform === 'sora'
|
||||
? 'bg-white text-rose-600 shadow-sm dark:bg-dark-600 dark:text-rose-400'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Sora
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="form.platform = 'gemini'"
|
||||
@@ -172,63 +150,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Sora) -->
|
||||
<div v-if="form.platform === 'sora'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-2 gap-3" data-tour="account-form-type">
|
||||
<button
|
||||
type="button"
|
||||
@click="soraAccountType = 'oauth'; accountCategory = 'oauth-based'; addMethod = 'oauth'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
soraAccountType === 'oauth'
|
||||
? 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
|
||||
: 'border-gray-200 hover:border-rose-300 dark:border-dark-600 dark:hover:border-rose-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
soraAccountType === 'oauth'
|
||||
? 'bg-rose-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.chatgptOauth') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="soraAccountType = 'apikey'; accountCategory = 'apikey'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
soraAccountType === 'apikey'
|
||||
? 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
|
||||
: 'border-gray-200 hover:border-rose-300 dark:border-dark-600 dark:hover:border-rose-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
soraAccountType === 'apikey'
|
||||
? 'bg-rose-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="link" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.soraApiKey') }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.soraApiKeyHint') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Anthropic) -->
|
||||
<div v-if="form.platform === 'anthropic'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
@@ -935,14 +856,14 @@
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="
|
||||
form.platform === 'openai' || form.platform === 'sora'
|
||||
form.platform === 'openai'
|
||||
? 'https://api.openai.com'
|
||||
: form.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ form.platform === 'sora' ? t('admin.accounts.soraUpstreamBaseUrlHint') : baseUrlHint }}</p>
|
||||
<p class="input-hint">{{ baseUrlHint }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
||||
@@ -2543,13 +2464,13 @@
|
||||
:loading="currentOAuthLoading"
|
||||
:error="currentOAuthError"
|
||||
:show-help="form.platform === 'anthropic'"
|
||||
:show-proxy-warning="form.platform !== 'openai' && form.platform !== 'sora' && !!form.proxy_id"
|
||||
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
|
||||
:allow-multiple="form.platform === 'anthropic'"
|
||||
:show-cookie-option="form.platform === 'anthropic'"
|
||||
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'sora' || form.platform === 'antigravity'"
|
||||
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity'"
|
||||
:show-mobile-refresh-token-option="form.platform === 'openai'"
|
||||
:show-session-token-option="form.platform === 'sora'"
|
||||
:show-access-token-option="form.platform === 'sora'"
|
||||
:show-session-token-option="false"
|
||||
:show-access-token-option="false"
|
||||
:platform="form.platform"
|
||||
:show-project-id="geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@@ -2943,7 +2864,7 @@ const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const oauthStepTitle = computed(() => {
|
||||
if (form.platform === 'openai' || form.platform === 'sora') return t('admin.accounts.oauth.openai.title')
|
||||
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
|
||||
if (form.platform === 'gemini') return t('admin.accounts.oauth.gemini.title')
|
||||
if (form.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.title')
|
||||
return t('admin.accounts.oauth.title')
|
||||
@@ -2951,13 +2872,13 @@ const oauthStepTitle = computed(() => {
|
||||
|
||||
// Platform-specific hints for API Key type
|
||||
const baseUrlHint = computed(() => {
|
||||
if (form.platform === 'openai' || form.platform === 'sora') return t('admin.accounts.openai.baseUrlHint')
|
||||
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
|
||||
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
|
||||
return t('admin.accounts.baseUrlHint')
|
||||
})
|
||||
|
||||
const apiKeyHint = computed(() => {
|
||||
if (form.platform === 'openai' || form.platform === 'sora') return t('admin.accounts.openai.apiKeyHint')
|
||||
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
|
||||
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
|
||||
return t('admin.accounts.apiKeyHint')
|
||||
})
|
||||
@@ -2979,35 +2900,33 @@ const appStore = useAppStore()
|
||||
// OAuth composables
|
||||
const oauth = useAccountOAuth() // For Anthropic OAuth
|
||||
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' }) // For OpenAI OAuth
|
||||
const soraOAuth = useOpenAIOAuth({ platform: 'sora' }) // For Sora OAuth
|
||||
const geminiOAuth = useGeminiOAuth() // For Gemini OAuth
|
||||
const antigravityOAuth = useAntigravityOAuth() // For Antigravity OAuth
|
||||
const activeOpenAIOAuth = computed(() => (form.platform === 'sora' ? soraOAuth : openaiOAuth))
|
||||
|
||||
// Computed: current OAuth state for template binding
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (form.platform === 'openai' || form.platform === 'sora') return activeOpenAIOAuth.value.authUrl.value
|
||||
if (form.platform === 'openai') return openaiOAuth.authUrl.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.authUrl.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.authUrl.value
|
||||
return oauth.authUrl.value
|
||||
})
|
||||
|
||||
const currentSessionId = computed(() => {
|
||||
if (form.platform === 'openai' || form.platform === 'sora') return activeOpenAIOAuth.value.sessionId.value
|
||||
if (form.platform === 'openai') return openaiOAuth.sessionId.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.sessionId.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.sessionId.value
|
||||
return oauth.sessionId.value
|
||||
})
|
||||
|
||||
const currentOAuthLoading = computed(() => {
|
||||
if (form.platform === 'openai' || form.platform === 'sora') return activeOpenAIOAuth.value.loading.value
|
||||
if (form.platform === 'openai') return openaiOAuth.loading.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.loading.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.loading.value
|
||||
return oauth.loading.value
|
||||
})
|
||||
|
||||
const currentOAuthError = computed(() => {
|
||||
if (form.platform === 'openai' || form.platform === 'sora') return activeOpenAIOAuth.value.error.value
|
||||
if (form.platform === 'openai') return openaiOAuth.error.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.error.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.error.value
|
||||
return oauth.error.value
|
||||
@@ -3065,7 +2984,6 @@ const anthropicPassthroughEnabled = ref(false)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
|
||||
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
|
||||
const soraAccountType = ref<'oauth' | 'apikey'>('oauth') // For sora: oauth or apikey (upstream)
|
||||
const upstreamBaseUrl = ref('') // For upstream type: base URL
|
||||
const upstreamApiKey = ref('') // For upstream type: API key
|
||||
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
@@ -3277,8 +3195,8 @@ const expiresAtInput = computed({
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
if (form.platform === 'openai' || form.platform === 'sora') {
|
||||
return authCode.trim() && activeOpenAIOAuth.value.sessionId.value && !activeOpenAIOAuth.value.loading.value
|
||||
if (form.platform === 'openai') {
|
||||
return authCode.trim() && openaiOAuth.sessionId.value && !openaiOAuth.loading.value
|
||||
}
|
||||
if (form.platform === 'gemini') {
|
||||
return authCode.trim() && geminiOAuth.sessionId.value && !geminiOAuth.loading.value
|
||||
@@ -3320,18 +3238,13 @@ watch(
|
||||
|
||||
// Sync form.type based on accountCategory, addMethod, and platform-specific type
|
||||
watch(
|
||||
[accountCategory, addMethod, antigravityAccountType, soraAccountType],
|
||||
([category, method, agType, soraType]) => {
|
||||
[accountCategory, addMethod, antigravityAccountType],
|
||||
([category, method, agType]) => {
|
||||
// Antigravity upstream 类型(实际创建为 apikey)
|
||||
if (form.platform === 'antigravity' && agType === 'upstream') {
|
||||
form.type = 'apikey'
|
||||
return
|
||||
}
|
||||
// Sora apikey 类型(上游透传)
|
||||
if (form.platform === 'sora' && soraType === 'apikey') {
|
||||
form.type = 'apikey'
|
||||
return
|
||||
}
|
||||
// Bedrock 类型
|
||||
if (form.platform === 'anthropic' && category === 'bedrock') {
|
||||
form.type = 'bedrock' as AccountType
|
||||
@@ -3352,7 +3265,7 @@ watch(
|
||||
(newPlatform) => {
|
||||
// Reset base URL based on platform
|
||||
apiKeyBaseUrl.value =
|
||||
(newPlatform === 'openai' || newPlatform === 'sora')
|
||||
(newPlatform === 'openai')
|
||||
? 'https://api.openai.com'
|
||||
: newPlatform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
@@ -3387,13 +3300,6 @@ watch(
|
||||
if (newPlatform !== 'anthropic' && newPlatform !== 'antigravity') {
|
||||
interceptWarmupRequests.value = false
|
||||
}
|
||||
if (newPlatform === 'sora') {
|
||||
// 默认 OAuth,但允许用户选择 API Key
|
||||
accountCategory.value = 'oauth-based'
|
||||
addMethod.value = 'oauth'
|
||||
form.type = 'oauth'
|
||||
soraAccountType.value = 'oauth'
|
||||
}
|
||||
if (newPlatform !== 'openai') {
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
@@ -3406,7 +3312,7 @@ watch(
|
||||
// Reset OAuth states
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
soraOAuth.resetState()
|
||||
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
}
|
||||
@@ -3816,7 +3722,6 @@ const resetForm = () => {
|
||||
geminiTierAIStudio.value = 'aistudio_free'
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
soraOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
@@ -3877,29 +3782,6 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
const buildSoraExtra = (
|
||||
base?: Record<string, unknown>,
|
||||
linkedOpenAIAccountId?: string | number
|
||||
): Record<string, unknown> | undefined => {
|
||||
const extra: Record<string, unknown> = { ...(base || {}) }
|
||||
if (linkedOpenAIAccountId !== undefined && linkedOpenAIAccountId !== null) {
|
||||
const id = String(linkedOpenAIAccountId).trim()
|
||||
if (id) {
|
||||
extra.linked_openai_account_id = id
|
||||
}
|
||||
}
|
||||
delete extra.openai_passthrough
|
||||
delete extra.openai_oauth_passthrough
|
||||
delete extra.codex_cli_only
|
||||
delete extra.openai_oauth_responses_websockets_v2_mode
|
||||
delete extra.openai_apikey_responses_websockets_v2_mode
|
||||
delete extra.openai_oauth_responses_websockets_v2_enabled
|
||||
delete extra.openai_apikey_responses_websockets_v2_enabled
|
||||
delete extra.responses_websockets_v2_enabled
|
||||
delete extra.openai_ws_enabled
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
// Helper function to create account with mixed channel warning handling
|
||||
const doCreateAccount = async (payload: CreateAccountRequest) => {
|
||||
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
|
||||
@@ -4064,19 +3946,6 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Sora apikey 账号 base_url 必填 + scheme 校验
|
||||
if (form.platform === 'sora') {
|
||||
const soraBaseUrl = apiKeyBaseUrl.value.trim()
|
||||
if (!soraBaseUrl) {
|
||||
appStore.showError(t('admin.accounts.soraBaseUrlRequired'))
|
||||
return
|
||||
}
|
||||
if (!soraBaseUrl.startsWith('http://') && !soraBaseUrl.startsWith('https://')) {
|
||||
appStore.showError(t('admin.accounts.soraBaseUrlInvalidScheme'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Determine default base URL based on platform
|
||||
const defaultBaseUrl =
|
||||
form.platform === 'openai'
|
||||
@@ -4134,15 +4003,14 @@ const goBackToBasicInfo = () => {
|
||||
step.value = 1
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
soraOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
const handleGenerateUrl = async () => {
|
||||
if (form.platform === 'openai' || form.platform === 'sora') {
|
||||
await activeOpenAIOAuth.value.generateAuthUrl(form.proxy_id)
|
||||
if (form.platform === 'openai') {
|
||||
await openaiOAuth.generateAuthUrl(form.proxy_id)
|
||||
} else if (form.platform === 'gemini') {
|
||||
await geminiOAuth.generateAuthUrl(
|
||||
form.proxy_id,
|
||||
@@ -4158,95 +4026,15 @@ const handleGenerateUrl = async () => {
|
||||
}
|
||||
|
||||
const handleValidateRefreshToken = (rt: string) => {
|
||||
if (form.platform === 'openai' || form.platform === 'sora') {
|
||||
if (form.platform === 'openai') {
|
||||
handleOpenAIValidateRT(rt)
|
||||
} else if (form.platform === 'antigravity') {
|
||||
handleAntigravityValidateRT(rt)
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidateSessionToken = (sessionToken: string) => {
|
||||
if (form.platform === 'sora') {
|
||||
handleSoraValidateST(sessionToken)
|
||||
}
|
||||
}
|
||||
|
||||
// Sora 手动 AT 批量导入
|
||||
const handleImportAccessToken = async (accessTokenInput: string) => {
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
if (!accessTokenInput.trim()) return
|
||||
|
||||
const accessTokens = accessTokenInput
|
||||
.split('\n')
|
||||
.map((at) => at.trim())
|
||||
.filter((at) => at)
|
||||
|
||||
if (accessTokens.length === 0) {
|
||||
oauthClient.error.value = 'Please enter at least one Access Token'
|
||||
return
|
||||
}
|
||||
|
||||
oauthClient.loading.value = true
|
||||
oauthClient.error.value = ''
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
const errors: string[] = []
|
||||
|
||||
try {
|
||||
for (let i = 0; i < accessTokens.length; i++) {
|
||||
try {
|
||||
const credentials: Record<string, unknown> = {
|
||||
access_token: accessTokens[i],
|
||||
}
|
||||
const soraExtra = buildSoraExtra()
|
||||
|
||||
const accountName = accessTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||
await adminAPI.accounts.create({
|
||||
name: accountName,
|
||||
notes: form.notes,
|
||||
platform: 'sora',
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
extra: soraExtra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
load_factor: form.load_factor ?? undefined,
|
||||
priority: form.priority,
|
||||
rate_multiplier: form.rate_multiplier,
|
||||
group_ids: form.group_ids,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
successCount++
|
||||
} catch (error: any) {
|
||||
failedCount++
|
||||
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
|
||||
errors.push(`#${i + 1}: ${errMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 && failedCount === 0) {
|
||||
appStore.showSuccess(
|
||||
accessTokens.length > 1
|
||||
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
|
||||
: t('admin.accounts.accountCreated')
|
||||
)
|
||||
emit('created')
|
||||
handleClose()
|
||||
} else if (successCount > 0 && failedCount > 0) {
|
||||
appStore.showWarning(
|
||||
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
|
||||
)
|
||||
oauthClient.error.value = errors.join('\n')
|
||||
emit('created')
|
||||
} else {
|
||||
oauthClient.error.value = errors.join('\n')
|
||||
appStore.showError(t('admin.accounts.oauth.batchFailed'))
|
||||
}
|
||||
} finally {
|
||||
oauthClient.loading.value = false
|
||||
}
|
||||
const handleValidateSessionToken = (_sessionToken: string) => {
|
||||
// Session token validation removed
|
||||
}
|
||||
|
||||
const formatDateTimeLocal = formatDateTimeLocalInput
|
||||
@@ -4312,7 +4100,7 @@ const createAccountAndFinish = async (
|
||||
|
||||
// OpenAI OAuth 授权码兑换
|
||||
const handleOpenAIExchange = async (authCode: string) => {
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
const oauthClient = openaiOAuth
|
||||
if (!authCode.trim() || !oauthClient.sessionId.value) return
|
||||
|
||||
oauthClient.loading.value = true
|
||||
@@ -4338,7 +4126,6 @@ const handleOpenAIExchange = async (authCode: string) => {
|
||||
const oauthExtra = oauthClient.buildExtraInfo(tokenInfo) as Record<string, unknown> | undefined
|
||||
const extra = buildOpenAIExtra(oauthExtra)
|
||||
const shouldCreateOpenAI = form.platform === 'openai'
|
||||
const shouldCreateSora = form.platform === 'sora'
|
||||
|
||||
// Add model mapping for OpenAI OAuth accounts(透传模式下不应用)
|
||||
if (shouldCreateOpenAI && !isOpenAIModelRestrictionDisabled.value) {
|
||||
@@ -4353,10 +4140,8 @@ const handleOpenAIExchange = async (authCode: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
let openaiAccountId: string | number | undefined
|
||||
|
||||
if (shouldCreateOpenAI) {
|
||||
const openaiAccount = await adminAPI.accounts.create({
|
||||
await adminAPI.accounts.create({
|
||||
name: form.name,
|
||||
notes: form.notes,
|
||||
platform: 'openai',
|
||||
@@ -4372,36 +4157,6 @@ const handleOpenAIExchange = async (authCode: string) => {
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
openaiAccountId = openaiAccount.id
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
}
|
||||
|
||||
if (shouldCreateSora) {
|
||||
const soraCredentials = {
|
||||
access_token: credentials.access_token,
|
||||
refresh_token: credentials.refresh_token,
|
||||
client_id: credentials.client_id,
|
||||
expires_at: credentials.expires_at
|
||||
}
|
||||
|
||||
const soraName = shouldCreateOpenAI ? `${form.name} (Sora)` : form.name
|
||||
const soraExtra = buildSoraExtra(shouldCreateOpenAI ? extra : oauthExtra, openaiAccountId)
|
||||
await adminAPI.accounts.create({
|
||||
name: soraName,
|
||||
notes: form.notes,
|
||||
platform: 'sora',
|
||||
type: 'oauth',
|
||||
credentials: soraCredentials,
|
||||
extra: soraExtra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
load_factor: form.load_factor ?? undefined,
|
||||
priority: form.priority,
|
||||
rate_multiplier: form.rate_multiplier,
|
||||
group_ids: form.group_ids,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
}
|
||||
|
||||
@@ -4416,12 +4171,12 @@ const handleOpenAIExchange = async (authCode: string) => {
|
||||
}
|
||||
|
||||
// OpenAI 手动 RT 批量验证和创建
|
||||
// OpenAI Mobile RT 使用的 client_id(与后端 openai.SoraClientID 一致)
|
||||
// OpenAI Mobile RT client_id
|
||||
const OPENAI_MOBILE_RT_CLIENT_ID = 'app_LlGpXReQgckcGGUo2JrYvtJK'
|
||||
|
||||
// OpenAI/Sora RT 批量验证和创建(共享逻辑)
|
||||
// OpenAI RT 批量验证和创建(共享逻辑)
|
||||
const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) => {
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
const oauthClient = openaiOAuth
|
||||
if (!refreshTokenInput.trim()) return
|
||||
|
||||
const refreshTokens = refreshTokenInput
|
||||
@@ -4441,7 +4196,6 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
|
||||
let failedCount = 0
|
||||
const errors: string[] = []
|
||||
const shouldCreateOpenAI = form.platform === 'openai'
|
||||
const shouldCreateSora = form.platform === 'sora'
|
||||
|
||||
try {
|
||||
for (let i = 0; i < refreshTokens.length; i++) {
|
||||
@@ -4477,10 +4231,8 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
|
||||
const baseName = form.name || tokenInfo.email || 'OpenAI OAuth Account'
|
||||
const accountName = refreshTokens.length > 1 ? `${baseName} #${i + 1}` : baseName
|
||||
|
||||
let openaiAccountId: string | number | undefined
|
||||
|
||||
if (shouldCreateOpenAI) {
|
||||
const openaiAccount = await adminAPI.accounts.create({
|
||||
await adminAPI.accounts.create({
|
||||
name: accountName,
|
||||
notes: form.notes,
|
||||
platform: 'openai',
|
||||
@@ -4496,34 +4248,6 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
openaiAccountId = openaiAccount.id
|
||||
}
|
||||
|
||||
if (shouldCreateSora) {
|
||||
const soraCredentials = {
|
||||
access_token: credentials.access_token,
|
||||
refresh_token: credentials.refresh_token,
|
||||
client_id: credentials.client_id,
|
||||
expires_at: credentials.expires_at
|
||||
}
|
||||
const soraName = shouldCreateOpenAI ? `${accountName} (Sora)` : accountName
|
||||
const soraExtra = buildSoraExtra(shouldCreateOpenAI ? extra : oauthExtra, openaiAccountId)
|
||||
await adminAPI.accounts.create({
|
||||
name: soraName,
|
||||
notes: form.notes,
|
||||
platform: 'sora',
|
||||
type: 'oauth',
|
||||
credentials: soraCredentials,
|
||||
extra: soraExtra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
load_factor: form.load_factor ?? undefined,
|
||||
priority: form.priority,
|
||||
rate_multiplier: form.rate_multiplier,
|
||||
group_ids: form.group_ids,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
}
|
||||
|
||||
successCount++
|
||||
@@ -4561,95 +4285,9 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
|
||||
// 手动输入 RT(Codex CLI client_id,默认)
|
||||
const handleOpenAIValidateRT = (rt: string) => handleOpenAIBatchRT(rt)
|
||||
|
||||
// 手动输入 Mobile RT(SoraClientID)
|
||||
// 手动输入 Mobile RT
|
||||
const handleOpenAIValidateMobileRT = (rt: string) => handleOpenAIBatchRT(rt, OPENAI_MOBILE_RT_CLIENT_ID)
|
||||
|
||||
// Sora 手动 ST 批量验证和创建
|
||||
const handleSoraValidateST = async (sessionTokenInput: string) => {
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
if (!sessionTokenInput.trim()) return
|
||||
|
||||
const sessionTokens = sessionTokenInput
|
||||
.split('\n')
|
||||
.map((st) => st.trim())
|
||||
.filter((st) => st)
|
||||
|
||||
if (sessionTokens.length === 0) {
|
||||
oauthClient.error.value = t('admin.accounts.oauth.openai.pleaseEnterSessionToken')
|
||||
return
|
||||
}
|
||||
|
||||
oauthClient.loading.value = true
|
||||
oauthClient.error.value = ''
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
const errors: string[] = []
|
||||
|
||||
try {
|
||||
for (let i = 0; i < sessionTokens.length; i++) {
|
||||
try {
|
||||
const tokenInfo = await oauthClient.validateSessionToken(sessionTokens[i], form.proxy_id)
|
||||
if (!tokenInfo) {
|
||||
failedCount++
|
||||
errors.push(`#${i + 1}: ${oauthClient.error.value || 'Validation failed'}`)
|
||||
oauthClient.error.value = ''
|
||||
continue
|
||||
}
|
||||
|
||||
const credentials = oauthClient.buildCredentials(tokenInfo)
|
||||
credentials.session_token = sessionTokens[i]
|
||||
const oauthExtra = oauthClient.buildExtraInfo(tokenInfo) as Record<string, unknown> | undefined
|
||||
const soraExtra = buildSoraExtra(oauthExtra)
|
||||
|
||||
const accountName = sessionTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||
await adminAPI.accounts.create({
|
||||
name: accountName,
|
||||
notes: form.notes,
|
||||
platform: 'sora',
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
extra: soraExtra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
load_factor: form.load_factor ?? undefined,
|
||||
priority: form.priority,
|
||||
rate_multiplier: form.rate_multiplier,
|
||||
group_ids: form.group_ids,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
successCount++
|
||||
} catch (error: any) {
|
||||
failedCount++
|
||||
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
|
||||
errors.push(`#${i + 1}: ${errMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 && failedCount === 0) {
|
||||
appStore.showSuccess(
|
||||
sessionTokens.length > 1
|
||||
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
|
||||
: t('admin.accounts.accountCreated')
|
||||
)
|
||||
emit('created')
|
||||
handleClose()
|
||||
} else if (successCount > 0 && failedCount > 0) {
|
||||
appStore.showWarning(
|
||||
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
|
||||
)
|
||||
oauthClient.error.value = errors.join('\n')
|
||||
emit('created')
|
||||
} else {
|
||||
oauthClient.error.value = errors.join('\n')
|
||||
appStore.showError(t('admin.accounts.oauth.batchFailed'))
|
||||
}
|
||||
} finally {
|
||||
oauthClient.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Antigravity 手动 RT 批量验证和创建
|
||||
const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
|
||||
if (!refreshTokenInput.trim()) return
|
||||
@@ -4918,7 +4556,6 @@ const handleExchangeCode = async () => {
|
||||
|
||||
switch (form.platform) {
|
||||
case 'openai':
|
||||
case 'sora':
|
||||
return handleOpenAIExchange(authCode)
|
||||
case 'gemini':
|
||||
return handleGeminiExchange(authCode)
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="
|
||||
account.platform === 'openai' || account.platform === 'sora'
|
||||
account.platform === 'openai'
|
||||
? 'https://api.openai.com'
|
||||
: account.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
@@ -53,7 +53,7 @@
|
||||
type="password"
|
||||
class="input font-mono"
|
||||
:placeholder="
|
||||
account.platform === 'openai' || account.platform === 'sora'
|
||||
account.platform === 'openai'
|
||||
? 'sk-proj-...'
|
||||
: account.platform === 'gemini'
|
||||
? 'AIza...'
|
||||
@@ -1969,7 +1969,7 @@ const tempUnschedPresets = computed(() => [
|
||||
|
||||
// Computed: default base URL based on platform
|
||||
const defaultBaseUrl = computed(() => {
|
||||
if (props.account?.platform === 'openai' || props.account?.platform === 'sora') return 'https://api.openai.com'
|
||||
if (props.account?.platform === 'openai') return 'https://api.openai.com'
|
||||
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
|
||||
return 'https://api.anthropic.com'
|
||||
})
|
||||
@@ -2157,7 +2157,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||||
const credentials = newAccount.credentials as Record<string, unknown>
|
||||
const platformDefaultUrl =
|
||||
newAccount.platform === 'openai' || newAccount.platform === 'sora'
|
||||
newAccount.platform === 'openai'
|
||||
? 'https://api.openai.com'
|
||||
: newAccount.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
@@ -2253,7 +2253,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editBaseUrl.value = (credentials.base_url as string) || ''
|
||||
} else {
|
||||
const platformDefaultUrl =
|
||||
newAccount.platform === 'openai' || newAccount.platform === 'sora'
|
||||
newAccount.platform === 'openai'
|
||||
? 'https://api.openai.com'
|
||||
: newAccount.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
|
||||
@@ -168,217 +168,6 @@
|
||||
</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>
|
||||
|
||||
<!-- Cookie Auto-Auth Form -->
|
||||
<div v-if="inputMethod === 'cookie'" class="space-y-4">
|
||||
<div
|
||||
@@ -753,7 +542,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { parseSoraRawTokens } from '@/utils/soraTokenParser'
|
||||
import { useAppStore } from '@/stores'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
|
||||
import type { AccountPlatform } from '@/types'
|
||||
@@ -771,8 +560,8 @@ interface Props {
|
||||
showCookieOption?: boolean // Whether to show cookie auto-auth option
|
||||
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
|
||||
showMobileRefreshTokenOption?: boolean // Whether to show mobile refresh token 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)
|
||||
showSessionTokenOption?: boolean
|
||||
showAccessTokenOption?: boolean
|
||||
platform?: AccountPlatform // Platform type for different UI/text
|
||||
showProjectId?: boolean // New prop to control project ID visibility
|
||||
}
|
||||
@@ -808,11 +597,11 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isOpenAI = computed(() => props.platform === 'openai' || props.platform === 'sora')
|
||||
const isOpenAI = computed(() => props.platform === 'openai')
|
||||
|
||||
// Get translation key based on platform
|
||||
const getOAuthKey = (key: string) => {
|
||||
if (props.platform === 'openai' || props.platform === 'sora') return `admin.accounts.oauth.openai.${key}`
|
||||
if (props.platform === 'openai') 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}`
|
||||
@@ -831,7 +620,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' || props.platform === 'sora') return t('admin.accounts.oauth.openai.importantNotice')
|
||||
if (props.platform === 'openai') return t('admin.accounts.oauth.openai.importantNotice')
|
||||
if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice')
|
||||
return ''
|
||||
})
|
||||
@@ -869,25 +658,13 @@ const parsedRefreshTokenCount = computed(() => {
|
||||
.filter((rt) => rt).length
|
||||
})
|
||||
|
||||
const parsedSoraRawTokens = computed(() => parseSoraRawTokens(sessionTokenInput.value))
|
||||
const parsedSessionTokenCount = computed(() => 0)
|
||||
|
||||
const parsedSessionTokenCount = computed(() => {
|
||||
return parsedSoraRawTokens.value.sessionTokens.length
|
||||
})
|
||||
const parsedSessionTokensText = computed(() => '')
|
||||
|
||||
const parsedSessionTokensText = computed(() => {
|
||||
return parsedSoraRawTokens.value.sessionTokens.join('\n')
|
||||
})
|
||||
const parsedAccessTokenFromSessionInputCount = computed(() => 0)
|
||||
|
||||
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 parsedAccessTokensText = computed(() => '')
|
||||
|
||||
const parsedAccessTokenCount = computed(() => {
|
||||
return accessTokenInput.value
|
||||
@@ -904,7 +681,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' && props.platform !== 'sora') return
|
||||
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity') return
|
||||
|
||||
const trimmed = newVal.trim()
|
||||
// Check if it looks like a URL with code parameter
|
||||
@@ -914,7 +691,7 @@ watch(authCodeInput, (newVal) => {
|
||||
const url = new URL(trimmed)
|
||||
const code = url.searchParams.get('code')
|
||||
const stateParam = url.searchParams.get('state')
|
||||
if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
|
||||
if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
|
||||
oauthState.value = stateParam
|
||||
}
|
||||
if (code && code !== trimmed) {
|
||||
@@ -925,7 +702,7 @@ watch(authCodeInput, (newVal) => {
|
||||
// If URL parsing fails, try regex extraction
|
||||
const match = trimmed.match(/[?&]code=([^&]+)/)
|
||||
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
|
||||
if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
|
||||
if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
|
||||
oauthState.value = stateMatch[1]
|
||||
}
|
||||
if (match && match[1] && match[1] !== trimmed) {
|
||||
@@ -973,14 +750,6 @@ const handleValidateSessionToken = () => {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
{{
|
||||
isOpenAI
|
||||
? t('admin.accounts.openaiAccount')
|
||||
: isSora
|
||||
? t('admin.accounts.soraAccount')
|
||||
: isGemini
|
||||
? t('admin.accounts.geminiAccount')
|
||||
: isAntigravity
|
||||
@@ -130,7 +128,7 @@
|
||||
:show-cookie-option="isAnthropic"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
@@ -227,7 +225,6 @@ const { t } = useI18n()
|
||||
// OAuth composables
|
||||
const claudeOAuth = useAccountOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
|
||||
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
|
||||
const geminiOAuth = useGeminiOAuth()
|
||||
const antigravityOAuth = useAntigravityOAuth()
|
||||
|
||||
@@ -240,34 +237,32 @@ 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 isOpenAILike = computed(() => isOpenAI.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 (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
|
||||
if (isOpenAILike.value) return openaiOAuth.authUrl.value
|
||||
if (isGemini.value) return geminiOAuth.authUrl.value
|
||||
if (isAntigravity.value) return antigravityOAuth.authUrl.value
|
||||
return claudeOAuth.authUrl.value
|
||||
})
|
||||
const currentSessionId = computed(() => {
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
|
||||
if (isOpenAILike.value) return openaiOAuth.sessionId.value
|
||||
if (isGemini.value) return geminiOAuth.sessionId.value
|
||||
if (isAntigravity.value) return antigravityOAuth.sessionId.value
|
||||
return claudeOAuth.sessionId.value
|
||||
})
|
||||
const currentLoading = computed(() => {
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
|
||||
if (isOpenAILike.value) return openaiOAuth.loading.value
|
||||
if (isGemini.value) return geminiOAuth.loading.value
|
||||
if (isAntigravity.value) return antigravityOAuth.loading.value
|
||||
return claudeOAuth.loading.value
|
||||
})
|
||||
const currentError = computed(() => {
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
|
||||
if (isOpenAILike.value) return openaiOAuth.error.value
|
||||
if (isGemini.value) return geminiOAuth.error.value
|
||||
if (isAntigravity.value) return antigravityOAuth.error.value
|
||||
return claudeOAuth.error.value
|
||||
@@ -275,7 +270,7 @@ const currentError = computed(() => {
|
||||
|
||||
// Computed
|
||||
const isManualInputMethod = computed(() => {
|
||||
// OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
@@ -319,7 +314,6 @@ const resetState = () => {
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
claudeOAuth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
soraOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
@@ -333,7 +327,7 @@ const handleGenerateUrl = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
if (isOpenAILike.value) {
|
||||
await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
|
||||
await openaiOAuth.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
|
||||
@@ -354,7 +348,7 @@ const handleExchangeCode = async () => {
|
||||
|
||||
if (isOpenAILike.value) {
|
||||
// OpenAI OAuth flow
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
const oauthClient = openaiOAuth
|
||||
const sessionId = oauthClient.sessionId.value
|
||||
if (!sessionId) return
|
||||
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
|
||||
|
||||
@@ -25,7 +25,7 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
|
||||
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
||||
const updatePrivacyMode = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, privacy_mode: value }) }
|
||||
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }])
|
||||
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 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') }, { value: 'bedrock', label: 'AWS Bedrock' }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }])
|
||||
const privacyOpts = computed(() => [
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!isSoraAccount" class="space-y-1.5">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.selectTestModel') }}
|
||||
</label>
|
||||
@@ -54,12 +54,6 @@
|
||||
: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>
|
||||
|
||||
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
|
||||
<TextArea
|
||||
@@ -152,17 +146,15 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="grid" size="sm" :stroke-width="2" />
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
|
||||
{{ t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chat" size="sm" :stroke-width="2" />
|
||||
{{
|
||||
isSoraAccount
|
||||
? t('admin.accounts.soraTestMode')
|
||||
: supportsGeminiImageTest
|
||||
? t('admin.accounts.geminiImageTestMode')
|
||||
: t('admin.accounts.testPrompt')
|
||||
supportsGeminiImageTest
|
||||
? t('admin.accounts.geminiImageTestMode')
|
||||
: t('admin.accounts.testPrompt')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
@@ -179,10 +171,10 @@
|
||||
</button>
|
||||
<button
|
||||
@click="startTest"
|
||||
:disabled="status === 'connecting' || (!isSoraAccount && !selectedModelId)"
|
||||
:disabled="status === 'connecting' || !selectedModelId"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
status === 'connecting' || (!isSoraAccount && !selectedModelId)
|
||||
status === 'connecting' || !selectedModelId
|
||||
? 'cursor-not-allowed bg-primary-400 text-white'
|
||||
: status === 'success'
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
@@ -258,11 +250,9 @@ const selectedModelId = ref('')
|
||||
const testPrompt = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
const isSoraAccount = computed(() => props.account?.platform === 'sora')
|
||||
const generatedImages = ref<PreviewImage[]>([])
|
||||
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
|
||||
const supportsGeminiImageTest = computed(() => {
|
||||
if (isSoraAccount.value) return false
|
||||
const modelID = selectedModelId.value.toLowerCase()
|
||||
if (!modelID.startsWith('gemini-') || !modelID.includes('-image')) return false
|
||||
|
||||
@@ -302,12 +292,6 @@ watch(selectedModelId, () => {
|
||||
|
||||
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
|
||||
@@ -373,7 +357,7 @@ const scrollToBottom = async () => {
|
||||
}
|
||||
|
||||
const startTest = async () => {
|
||||
if (!props.account || (!isSoraAccount.value && !selectedModelId.value)) return
|
||||
if (!props.account || !selectedModelId.value) return
|
||||
|
||||
resetState()
|
||||
status.value = 'connecting'
|
||||
@@ -394,14 +378,10 @@ const startTest = async () => {
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(
|
||||
isSoraAccount.value
|
||||
? {}
|
||||
: {
|
||||
body: JSON.stringify({
|
||||
model_id: selectedModelId.value,
|
||||
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -461,9 +441,7 @@ const handleEvent = (event: {
|
||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||
}
|
||||
addLine(
|
||||
isSoraAccount.value
|
||||
? t('admin.accounts.soraTestingFlow')
|
||||
: supportsGeminiImageTest.value
|
||||
supportsGeminiImageTest.value
|
||||
? t('admin.accounts.sendingGeminiImageRequest')
|
||||
: t('admin.accounts.sendingTestMessage'),
|
||||
'text-gray-400'
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
{{
|
||||
isOpenAI
|
||||
? t('admin.accounts.openaiAccount')
|
||||
: isSora
|
||||
? t('admin.accounts.soraAccount')
|
||||
: isGemini
|
||||
? t('admin.accounts.geminiAccount')
|
||||
: isAntigravity
|
||||
@@ -130,7 +128,7 @@
|
||||
:show-cookie-option="isAnthropic"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
@@ -227,7 +225,6 @@ const { t } = useI18n()
|
||||
// OAuth composables
|
||||
const claudeOAuth = useAccountOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
|
||||
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
|
||||
const geminiOAuth = useGeminiOAuth()
|
||||
const antigravityOAuth = useAntigravityOAuth()
|
||||
|
||||
@@ -240,34 +237,32 @@ 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 isOpenAILike = computed(() => isOpenAI.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 (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
|
||||
if (isOpenAILike.value) return openaiOAuth.authUrl.value
|
||||
if (isGemini.value) return geminiOAuth.authUrl.value
|
||||
if (isAntigravity.value) return antigravityOAuth.authUrl.value
|
||||
return claudeOAuth.authUrl.value
|
||||
})
|
||||
const currentSessionId = computed(() => {
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
|
||||
if (isOpenAILike.value) return openaiOAuth.sessionId.value
|
||||
if (isGemini.value) return geminiOAuth.sessionId.value
|
||||
if (isAntigravity.value) return antigravityOAuth.sessionId.value
|
||||
return claudeOAuth.sessionId.value
|
||||
})
|
||||
const currentLoading = computed(() => {
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
|
||||
if (isOpenAILike.value) return openaiOAuth.loading.value
|
||||
if (isGemini.value) return geminiOAuth.loading.value
|
||||
if (isAntigravity.value) return antigravityOAuth.loading.value
|
||||
return claudeOAuth.loading.value
|
||||
})
|
||||
const currentError = computed(() => {
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
|
||||
if (isOpenAILike.value) return openaiOAuth.error.value
|
||||
if (isGemini.value) return geminiOAuth.error.value
|
||||
if (isAntigravity.value) return antigravityOAuth.error.value
|
||||
return claudeOAuth.error.value
|
||||
@@ -275,7 +270,7 @@ const currentError = computed(() => {
|
||||
|
||||
// Computed
|
||||
const isManualInputMethod = computed(() => {
|
||||
// OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
@@ -319,7 +314,6 @@ const resetState = () => {
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
claudeOAuth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
soraOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
@@ -333,7 +327,7 @@ const handleGenerateUrl = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
if (isOpenAILike.value) {
|
||||
await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
|
||||
await openaiOAuth.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
|
||||
@@ -354,7 +348,7 @@ const handleExchangeCode = async () => {
|
||||
|
||||
if (isOpenAILike.value) {
|
||||
// OpenAI OAuth flow
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
const oauthClient = openaiOAuth
|
||||
const sessionId = oauthClient.sessionId.value
|
||||
if (!sessionId) return
|
||||
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
|
||||
|
||||
@@ -184,7 +184,6 @@ export function getPlatformTagClass(platform: string): string {
|
||||
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
case 'sora': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
|
||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,14 +37,6 @@
|
||||
<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>
|
||||
@@ -74,11 +66,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, sora_storage_quota_gb: 0, customAttributes: {} as UserAttributeValuesMap })
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, 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, sora_storage_quota_gb: Number(((u.sora_storage_quota_bytes || 0) / (1024 * 1024 * 1024)).toFixed(2)), customAttributes: {} })
|
||||
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
|
||||
passwordCopied.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
@@ -105,7 +97,7 @@ const handleUpdateUser = async () => {
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
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) }
|
||||
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
|
||||
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)
|
||||
|
||||
@@ -116,9 +116,6 @@ 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`
|
||||
})
|
||||
|
||||
@@ -140,11 +137,6 @@ 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'
|
||||
|
||||
@@ -91,8 +91,6 @@ const ratePillClass = computed(() => {
|
||||
return 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
case 'gemini':
|
||||
return 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
|
||||
case 'sora':
|
||||
return 'bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400'
|
||||
default: // antigravity and others
|
||||
return 'bg-violet-50 text-violet-700 dark:bg-violet-900/20 dark:text-violet-400'
|
||||
}
|
||||
|
||||
@@ -19,12 +19,6 @@
|
||||
<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
|
||||
|
||||
@@ -75,7 +75,6 @@ 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'
|
||||
})
|
||||
|
||||
@@ -124,9 +123,6 @@ 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'
|
||||
})
|
||||
|
||||
@@ -140,9 +136,6 @@ 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'
|
||||
})
|
||||
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
<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>
|
||||
@@ -1,430 +0,0 @@
|
||||
<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>
|
||||
@@ -1,577 +0,0 @@
|
||||
<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 { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
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: getPersistedPageSize()
|
||||
})
|
||||
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>
|
||||
@@ -1,282 +0,0 @@
|
||||
<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>
|
||||
@@ -1,39 +0,0 @@
|
||||
<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>
|
||||
@@ -1,609 +0,0 @@
|
||||
<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>
|
||||
@@ -1,738 +0,0 @@
|
||||
<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>
|
||||
@@ -1,87 +0,0 @@
|
||||
<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>
|
||||
@@ -11,8 +11,7 @@ vi.mock('@/api/admin', () => ({
|
||||
accounts: {
|
||||
generateAuthUrl: vi.fn(),
|
||||
exchangeCode: vi.fn(),
|
||||
refreshOpenAIToken: vi.fn(),
|
||||
validateSoraSessionToken: vi.fn()
|
||||
refreshOpenAIToken: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -21,15 +20,15 @@ import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
|
||||
describe('useOpenAIOAuth.buildCredentials', () => {
|
||||
it('should keep client_id when token response contains it', () => {
|
||||
const oauth = useOpenAIOAuth({ platform: 'sora' })
|
||||
const oauth = useOpenAIOAuth({ platform: 'openai' })
|
||||
const creds = oauth.buildCredentials({
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
client_id: 'app_sora_client',
|
||||
client_id: 'app_test_client',
|
||||
expires_at: 1700000000
|
||||
})
|
||||
|
||||
expect(creds.client_id).toBe('app_sora_client')
|
||||
expect(creds.client_id).toBe('app_test_client')
|
||||
expect(creds.access_token).toBe('at')
|
||||
expect(creds.refresh_token).toBe('rt')
|
||||
})
|
||||
|
||||
@@ -60,22 +60,6 @@ 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 = [
|
||||
@@ -236,7 +220,6 @@ const allModelsList: string[] = [
|
||||
...openaiModels,
|
||||
...claudeModels,
|
||||
...geminiModels,
|
||||
...soraModels,
|
||||
...zhipuModels,
|
||||
...qwenModels,
|
||||
...deepseekModels,
|
||||
@@ -289,8 +272,6 @@ const openaiPresetMappings = [
|
||||
{ label: 'Sonnet→5.4', from: 'claude-sonnet-4-6', to: 'gpt-5.4', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-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' },
|
||||
@@ -385,7 +366,6 @@ 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
|
||||
@@ -410,7 +390,6 @@ 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
|
||||
if (platform === 'bedrock') return bedrockPresetMappings
|
||||
return anthropicPresetMappings
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface OpenAITokenInfo {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type OpenAIOAuthPlatform = 'openai' | 'sora'
|
||||
export type OpenAIOAuthPlatform = 'openai'
|
||||
|
||||
interface UseOpenAIOAuthOptions {
|
||||
platform?: OpenAIOAuthPlatform
|
||||
@@ -31,7 +31,7 @@ interface UseOpenAIOAuthOptions {
|
||||
export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
|
||||
const appStore = useAppStore()
|
||||
const oauthPlatform = options?.platform ?? 'openai'
|
||||
const endpointPrefix = oauthPlatform === 'sora' ? '/admin/sora' : '/admin/openai'
|
||||
const endpointPrefix = '/admin/openai'
|
||||
|
||||
// State
|
||||
const authUrl = ref('')
|
||||
@@ -160,33 +160,6 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (aligned with backend BuildAccountCredentials)
|
||||
const buildCredentials = (tokenInfo: OpenAITokenInfo): Record<string, unknown> => {
|
||||
const creds: Record<string, unknown> = {
|
||||
@@ -250,7 +223,6 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
validateRefreshToken,
|
||||
validateSessionToken,
|
||||
buildCredentials,
|
||||
buildExtraInfo
|
||||
}
|
||||
|
||||
@@ -1611,7 +1611,6 @@ export default {
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
deleteConfirm:
|
||||
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
|
||||
@@ -1636,16 +1635,6 @@ export default {
|
||||
title: 'Image Generation Pricing',
|
||||
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',
|
||||
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
|
||||
@@ -2025,7 +2014,6 @@ export default {
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
types: {
|
||||
oauth: 'OAuth',
|
||||
@@ -2035,10 +2023,6 @@ export default {
|
||||
codeAssist: 'Code Assist',
|
||||
antigravityOauth: 'Antigravity OAuth',
|
||||
antigravityApikey: 'Connect via Base URL + API Key',
|
||||
soraApiKey: 'API Key / Upstream',
|
||||
soraApiKeyHint: 'Connect to another Sub2API or compatible API',
|
||||
soraBaseUrlRequired: 'Sora API Key account requires a Base URL',
|
||||
soraBaseUrlInvalidScheme: 'Base URL must start with http:// or https://',
|
||||
upstream: 'Upstream',
|
||||
upstreamDesc: 'Connect via Base URL + API Key'
|
||||
},
|
||||
@@ -2301,8 +2285,6 @@ export default {
|
||||
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)',
|
||||
@@ -2317,9 +2299,6 @@ 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',
|
||||
@@ -2469,8 +2448,6 @@ 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',
|
||||
@@ -2584,8 +2561,8 @@ export default {
|
||||
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',
|
||||
sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||
sessionTokenPlaceholder: 'Paste your 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.',
|
||||
@@ -2819,7 +2796,6 @@ 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',
|
||||
@@ -2853,11 +2829,6 @@ export default {
|
||||
geminiImageTestMode: 'Mode: Gemini image generation test',
|
||||
geminiImagePreview: 'Generated images:',
|
||||
geminiImageReceived: 'Received test image #{count}',
|
||||
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another Sub2API instance or compatible API)',
|
||||
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
|
||||
soraTestTarget: 'Target: Sora account capability',
|
||||
soraTestMode: 'Mode: Connectivity + Capability checks',
|
||||
soraTestingFlow: 'Running Sora connectivity and capability checks...',
|
||||
// Stats Modal
|
||||
viewStats: 'View Stats',
|
||||
usageStatistics: 'Usage Statistics',
|
||||
@@ -5023,99 +4994,4 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 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',
|
||||
regenerate: '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',
|
||||
landscape: 'Landscape',
|
||||
portrait: 'Portrait',
|
||||
square: 'Square',
|
||||
examplePrompt1: 'A golden Shiba Inu walking through the streets of Shibuya, Tokyo, camera following, cinematic shot, 4K',
|
||||
examplePrompt2: 'Drone aerial view, green aurora reflecting on a glacial lake in Iceland, slow push-in',
|
||||
examplePrompt3: 'Cyberpunk futuristic city, neon lights reflected in rain puddles, nightscape, cinematic colors',
|
||||
examplePrompt4: 'Chinese ink painting style, a small boat drifting among misty mountains and rivers, classical atmosphere'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1648,7 +1648,6 @@ export default {
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
saving: '保存中...',
|
||||
noGroups: '暂无分组',
|
||||
@@ -1722,16 +1721,6 @@ export default {
|
||||
title: '图片生成计费',
|
||||
description: '配置图片生成模型的图片生成价格,留空则使用默认价格'
|
||||
},
|
||||
soraPricing: {
|
||||
title: 'Sora 按次计费',
|
||||
description: '配置 Sora 图片/视频按次收费价格,留空则默认不计费',
|
||||
image360: '图片 360px ($)',
|
||||
image540: '图片 540px ($)',
|
||||
video: '视频(标准)($)',
|
||||
videoHd: '视频(Pro-HD)($)',
|
||||
storageQuota: '存储配额',
|
||||
storageQuotaHint: '单位 GB,设置该分组用户的 Sora 存储配额上限,0 表示使用系统默认'
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code 客户端限制',
|
||||
tooltip:
|
||||
@@ -2207,7 +2196,6 @@ export default {
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
types: {
|
||||
oauth: 'OAuth',
|
||||
@@ -2217,10 +2205,6 @@ export default {
|
||||
codeAssist: 'Code Assist',
|
||||
antigravityOauth: 'Antigravity OAuth',
|
||||
antigravityApikey: '通过 Base URL + API Key 连接',
|
||||
soraApiKey: 'API Key / 上游透传',
|
||||
soraApiKeyHint: '连接另一个 Sub2API 或兼容 API',
|
||||
soraBaseUrlRequired: 'Sora apikey 账号必须设置上游地址(Base URL)',
|
||||
soraBaseUrlInvalidScheme: 'Base URL 必须以 http:// 或 https:// 开头',
|
||||
upstream: '对接上游',
|
||||
upstreamDesc: '通过 Base URL + API Key 连接上游',
|
||||
api_key: 'API Key',
|
||||
@@ -2449,8 +2433,6 @@ export default {
|
||||
codexCLIOnly: '仅允许 Codex 官方客户端',
|
||||
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
|
||||
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
|
||||
enableSora: '同时启用 Sora',
|
||||
enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
|
||||
},
|
||||
anthropic: {
|
||||
apiKeyPassthrough: '自动透传(仅替换认证)',
|
||||
@@ -2464,9 +2446,6 @@ export default {
|
||||
mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。',
|
||||
selectedModels: '已选择 {count} 个模型',
|
||||
supportsAllModels: '(支持所有模型)',
|
||||
soraModelsLoadFailed: '加载 Sora 模型列表失败,已回退到默认列表',
|
||||
soraModelsLoading: '正在加载 Sora 模型...',
|
||||
soraModelsRetry: '加载失败,点击重试',
|
||||
requestModel: '请求模型',
|
||||
actualModel: '实际模型',
|
||||
addMapping: '添加映射',
|
||||
@@ -2613,8 +2592,6 @@ export default {
|
||||
creating: '创建中...',
|
||||
updating: '更新中...',
|
||||
accountCreated: '账号创建成功',
|
||||
soraAccountCreated: 'Sora 账号已同时创建',
|
||||
soraAccountFailed: 'Sora 账号创建失败,请稍后手动添加',
|
||||
accountUpdated: '账号更新成功',
|
||||
failedToCreate: '创建账号失败',
|
||||
failedToUpdate: '更新账号失败',
|
||||
@@ -2722,8 +2699,8 @@ export default {
|
||||
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
|
||||
sessionTokenAuth: '手动输入 ST',
|
||||
sessionTokenDesc: '输入您已有的 Sora Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
sessionTokenPlaceholder: '粘贴您的 Sora Session Token...\n支持多个,每行一个',
|
||||
sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个',
|
||||
sessionTokenRawLabel: '原始字符串',
|
||||
sessionTokenRawPlaceholder: '粘贴 /api/auth/session 原始数据或 Session Token...',
|
||||
sessionTokenRawHint: '支持粘贴完整 JSON,系统会自动解析 ST 和 AT。',
|
||||
@@ -2952,7 +2929,6 @@ export default {
|
||||
reAuthorizeAccount: '重新授权账号',
|
||||
claudeCodeAccount: 'Claude Code 账号',
|
||||
openaiAccount: 'OpenAI 账号',
|
||||
soraAccount: 'Sora 账号',
|
||||
geminiAccount: 'Gemini 账号',
|
||||
antigravityAccount: 'Antigravity 账号',
|
||||
inputMethod: '输入方式',
|
||||
@@ -2984,11 +2960,6 @@ export default {
|
||||
geminiImageTestMode: '模式:Gemini 生图测试',
|
||||
geminiImagePreview: '生成结果:',
|
||||
geminiImageReceived: '已收到第 {count} 张测试图片',
|
||||
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 Sub2API 实例或兼容 API)',
|
||||
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
|
||||
soraTestTarget: '检测目标:Sora 账号能力',
|
||||
soraTestMode: '模式:连通性 + 能力探测',
|
||||
soraTestingFlow: '执行 Sora 连通性与能力检测...',
|
||||
// Stats Modal
|
||||
viewStats: '查看统计',
|
||||
usageStatistics: '使用统计',
|
||||
@@ -5212,99 +5183,4 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 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: '重新生成',
|
||||
regenerate: '重新生成',
|
||||
creatorPlaceholder: '描述你想要生成的视频或图片...',
|
||||
videoModels: '视频模型',
|
||||
imageModels: '图片模型',
|
||||
noStorageConfigured: '存储未配置',
|
||||
selectCredential: '选择凭证',
|
||||
apiKeys: 'API 密钥',
|
||||
subscriptions: '订阅',
|
||||
subscription: '订阅',
|
||||
noCredentialHint: '请先创建 API Key 或联系管理员分配订阅',
|
||||
uploadReference: '上传参考图片',
|
||||
generatingCount: '正在生成 {current}/{max}',
|
||||
noStorageToastMessage: '管理员未开通云存储,生成完成后请使用"本地下载"保存文件,否则将会丢失。',
|
||||
galleryCount: '共 {count} 个作品',
|
||||
galleryEmptyTitle: '还没有任何作品',
|
||||
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
|
||||
startCreating: '开始创作',
|
||||
yesterday: '昨天',
|
||||
landscape: '横屏',
|
||||
portrait: '竖屏',
|
||||
square: '方形',
|
||||
examplePrompt1: '一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
|
||||
examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
|
||||
examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
|
||||
examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseSoraRawTokens } from '@/utils/soraTokenParser'
|
||||
|
||||
describe('parseSoraRawTokens', () => {
|
||||
it('parses sessionToken and accessToken from JSON payload', () => {
|
||||
const payload = JSON.stringify({
|
||||
user: { id: 'u1' },
|
||||
accessToken: 'at-json-1',
|
||||
sessionToken: 'st-json-1'
|
||||
})
|
||||
|
||||
const result = parseSoraRawTokens(payload)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['st-json-1'])
|
||||
expect(result.accessTokens).toEqual(['at-json-1'])
|
||||
})
|
||||
|
||||
it('supports plain session tokens (one per line)', () => {
|
||||
const result = parseSoraRawTokens('st-1\nst-2')
|
||||
|
||||
expect(result.sessionTokens).toEqual(['st-1', 'st-2'])
|
||||
expect(result.accessTokens).toEqual([])
|
||||
})
|
||||
|
||||
it('supports non-standard object snippets via regex', () => {
|
||||
const raw = "sessionToken: 'st-snippet', access_token: \"at-snippet\""
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['st-snippet'])
|
||||
expect(result.accessTokens).toEqual(['at-snippet'])
|
||||
})
|
||||
|
||||
it('keeps unique tokens and extracts JWT-like plain line as AT too', () => {
|
||||
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature'
|
||||
const raw = `st-dup\nst-dup\n${jwt}\n${JSON.stringify({ sessionToken: 'st-json', accessToken: jwt })}`
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['st-json', 'st-dup'])
|
||||
expect(result.accessTokens).toEqual([jwt])
|
||||
})
|
||||
|
||||
it('parses session token from Set-Cookie line and strips cookie attributes', () => {
|
||||
const raw =
|
||||
'__Secure-next-auth.session-token.0=st-cookie-part-0; Domain=.chatgpt.com; Path=/; Expires=Thu, 28 May 2026 11:43:36 GMT; HttpOnly; Secure; SameSite=Lax'
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['st-cookie-part-0'])
|
||||
expect(result.accessTokens).toEqual([])
|
||||
})
|
||||
|
||||
it('merges chunked session-token cookies by numeric suffix order', () => {
|
||||
const raw = [
|
||||
'Set-Cookie: __Secure-next-auth.session-token.1=part-1; Path=/; HttpOnly',
|
||||
'Set-Cookie: __Secure-next-auth.session-token.0=part-0; Path=/; HttpOnly'
|
||||
].join('\n')
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['part-0part-1'])
|
||||
expect(result.accessTokens).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers latest duplicate chunk values when multiple cookie groups exist', () => {
|
||||
const raw = [
|
||||
'Set-Cookie: __Secure-next-auth.session-token.0=old-0; Path=/; HttpOnly',
|
||||
'Set-Cookie: __Secure-next-auth.session-token.1=old-1; Path=/; HttpOnly',
|
||||
'Set-Cookie: __Secure-next-auth.session-token.0=new-0; Path=/; HttpOnly',
|
||||
'Set-Cookie: __Secure-next-auth.session-token.1=new-1; Path=/; HttpOnly'
|
||||
].join('\n')
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['new-0new-1'])
|
||||
expect(result.accessTokens).toEqual([])
|
||||
})
|
||||
|
||||
it('uses latest complete chunk group and ignores incomplete latest group', () => {
|
||||
const raw = [
|
||||
'set-cookie',
|
||||
'__Secure-next-auth.session-token.0=ok-0; Domain=.chatgpt.com; Path=/',
|
||||
'set-cookie',
|
||||
'__Secure-next-auth.session-token.1=ok-1; Domain=.chatgpt.com; Path=/',
|
||||
'set-cookie',
|
||||
'__Secure-next-auth.session-token.0=partial-0; Domain=.chatgpt.com; Path=/'
|
||||
].join('\n')
|
||||
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['ok-0ok-1'])
|
||||
expect(result.accessTokens).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,308 +0,0 @@
|
||||
export interface ParsedSoraTokens {
|
||||
sessionTokens: string[]
|
||||
accessTokens: string[]
|
||||
}
|
||||
|
||||
const sessionKeyNames = new Set(['sessiontoken', 'session_token', 'st'])
|
||||
const accessKeyNames = new Set(['accesstoken', 'access_token', 'at'])
|
||||
|
||||
const sessionRegexes = [
|
||||
/\bsessionToken\b\s*:\s*["']([^"']+)["']/gi,
|
||||
/\bsession_token\b\s*:\s*["']([^"']+)["']/gi
|
||||
]
|
||||
|
||||
const accessRegexes = [
|
||||
/\baccessToken\b\s*:\s*["']([^"']+)["']/gi,
|
||||
/\baccess_token\b\s*:\s*["']([^"']+)["']/gi
|
||||
]
|
||||
|
||||
const sessionCookieRegex =
|
||||
/(?:^|[\n\r;])\s*(?:(?:set-cookie|cookie)\s*:\s*)?__Secure-(?:next-auth|authjs)\.session-token(?:\.(\d+))?=([^;\r\n]+)/gi
|
||||
|
||||
interface SessionCookieChunk {
|
||||
index: number
|
||||
value: string
|
||||
}
|
||||
|
||||
const ignoredPlainLines = new Set([
|
||||
'set-cookie',
|
||||
'cookie',
|
||||
'strict-transport-security',
|
||||
'vary',
|
||||
'x-content-type-options',
|
||||
'x-openai-proxy-wasm'
|
||||
])
|
||||
|
||||
function sanitizeToken(raw: string): string {
|
||||
return raw.trim().replace(/^["'`]+|["'`,;]+$/g, '')
|
||||
}
|
||||
|
||||
function addUnique(list: string[], seen: Set<string>, rawValue: string): void {
|
||||
const token = sanitizeToken(rawValue)
|
||||
if (!token || seen.has(token)) {
|
||||
return
|
||||
}
|
||||
seen.add(token)
|
||||
list.push(token)
|
||||
}
|
||||
|
||||
function isLikelyJWT(token: string): boolean {
|
||||
if (!token.startsWith('eyJ')) {
|
||||
return false
|
||||
}
|
||||
return token.split('.').length === 3
|
||||
}
|
||||
|
||||
function collectFromObject(
|
||||
value: unknown,
|
||||
sessionTokens: string[],
|
||||
sessionSeen: Set<string>,
|
||||
accessTokens: string[],
|
||||
accessSeen: Set<string>
|
||||
): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectFromObject(item, sessionTokens, sessionSeen, accessTokens, accessSeen)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
for (const [key, fieldValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof fieldValue === 'string') {
|
||||
const normalizedKey = key.toLowerCase()
|
||||
if (sessionKeyNames.has(normalizedKey)) {
|
||||
addUnique(sessionTokens, sessionSeen, fieldValue)
|
||||
}
|
||||
if (accessKeyNames.has(normalizedKey)) {
|
||||
addUnique(accessTokens, accessSeen, fieldValue)
|
||||
}
|
||||
continue
|
||||
}
|
||||
collectFromObject(fieldValue, sessionTokens, sessionSeen, accessTokens, accessSeen)
|
||||
}
|
||||
}
|
||||
|
||||
function collectFromJSONString(
|
||||
raw: string,
|
||||
sessionTokens: string[],
|
||||
sessionSeen: Set<string>,
|
||||
accessTokens: string[],
|
||||
accessSeen: Set<string>
|
||||
): void {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = [trimmed]
|
||||
const firstBrace = trimmed.indexOf('{')
|
||||
const lastBrace = trimmed.lastIndexOf('}')
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
candidates.push(trimmed.slice(firstBrace, lastBrace + 1))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate)
|
||||
collectFromObject(parsed, sessionTokens, sessionSeen, accessTokens, accessSeen)
|
||||
return
|
||||
} catch {
|
||||
// ignore and keep trying other candidates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectByRegex(
|
||||
raw: string,
|
||||
regexes: RegExp[],
|
||||
tokens: string[],
|
||||
seen: Set<string>
|
||||
): void {
|
||||
for (const regex of regexes) {
|
||||
regex.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
match = regex.exec(raw)
|
||||
while (match) {
|
||||
if (match[1]) {
|
||||
addUnique(tokens, seen, match[1])
|
||||
}
|
||||
match = regex.exec(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectFromSessionCookies(
|
||||
raw: string,
|
||||
sessionTokens: string[],
|
||||
sessionSeen: Set<string>
|
||||
): void {
|
||||
const chunkMatches: SessionCookieChunk[] = []
|
||||
const singleValues: string[] = []
|
||||
|
||||
sessionCookieRegex.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
match = sessionCookieRegex.exec(raw)
|
||||
while (match) {
|
||||
const chunkIndex = match[1]
|
||||
const rawValue = match[2]
|
||||
const value = sanitizeToken(rawValue || '')
|
||||
if (value) {
|
||||
if (chunkIndex !== undefined && chunkIndex !== '') {
|
||||
const idx = Number.parseInt(chunkIndex, 10)
|
||||
if (Number.isInteger(idx) && idx >= 0) {
|
||||
chunkMatches.push({ index: idx, value })
|
||||
}
|
||||
} else {
|
||||
singleValues.push(value)
|
||||
}
|
||||
}
|
||||
match = sessionCookieRegex.exec(raw)
|
||||
}
|
||||
|
||||
const mergedChunkToken = mergeLatestChunkedSessionToken(chunkMatches)
|
||||
if (mergedChunkToken) {
|
||||
addUnique(sessionTokens, sessionSeen, mergedChunkToken)
|
||||
}
|
||||
|
||||
for (const value of singleValues) {
|
||||
addUnique(sessionTokens, sessionSeen, value)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeChunkSegment(
|
||||
chunks: SessionCookieChunk[],
|
||||
requiredMaxIndex: number,
|
||||
requireComplete: boolean
|
||||
): string {
|
||||
if (chunks.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const byIndex = new Map<number, string>()
|
||||
for (const chunk of chunks) {
|
||||
byIndex.set(chunk.index, chunk.value)
|
||||
}
|
||||
|
||||
if (!byIndex.has(0)) {
|
||||
return ''
|
||||
}
|
||||
if (requireComplete) {
|
||||
for (let i = 0; i <= requiredMaxIndex; i++) {
|
||||
if (!byIndex.has(i)) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedIndexes = Array.from(byIndex.keys()).sort((a, b) => a - b)
|
||||
return orderedIndexes.map((idx) => byIndex.get(idx) || '').join('')
|
||||
}
|
||||
|
||||
function mergeLatestChunkedSessionToken(chunks: SessionCookieChunk[]): string {
|
||||
if (chunks.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const requiredMaxIndex = chunks.reduce((max, chunk) => Math.max(max, chunk.index), 0)
|
||||
|
||||
const groupStarts: number[] = []
|
||||
chunks.forEach((chunk, idx) => {
|
||||
if (chunk.index === 0) {
|
||||
groupStarts.push(idx)
|
||||
}
|
||||
})
|
||||
|
||||
if (groupStarts.length === 0) {
|
||||
return mergeChunkSegment(chunks, requiredMaxIndex, false)
|
||||
}
|
||||
|
||||
for (let i = groupStarts.length - 1; i >= 0; i--) {
|
||||
const start = groupStarts[i]
|
||||
const end = i + 1 < groupStarts.length ? groupStarts[i + 1] : chunks.length
|
||||
const merged = mergeChunkSegment(chunks.slice(start, end), requiredMaxIndex, true)
|
||||
if (merged) {
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
return mergeChunkSegment(chunks, requiredMaxIndex, false)
|
||||
}
|
||||
|
||||
function collectPlainLines(
|
||||
raw: string,
|
||||
sessionTokens: string[],
|
||||
sessionSeen: Set<string>,
|
||||
accessTokens: string[],
|
||||
accessSeen: Set<string>
|
||||
): void {
|
||||
const lines = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
for (const line of lines) {
|
||||
const normalized = line.toLowerCase()
|
||||
if (ignoredPlainLines.has(normalized)) {
|
||||
continue
|
||||
}
|
||||
if (/^__secure-(next-auth|authjs)\.session-token(\.\d+)?=/i.test(line)) {
|
||||
continue
|
||||
}
|
||||
if (line.includes(';')) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(line)) {
|
||||
const parts = line.split('=', 2)
|
||||
const key = parts[0]?.trim().toLowerCase()
|
||||
const value = parts[1]?.trim() || ''
|
||||
if (key && sessionKeyNames.has(key)) {
|
||||
addUnique(sessionTokens, sessionSeen, value)
|
||||
continue
|
||||
}
|
||||
if (key && accessKeyNames.has(key)) {
|
||||
addUnique(accessTokens, accessSeen, value)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes('{') || line.includes('}') || line.includes(':') || /\s/.test(line)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLikelyJWT(line)) {
|
||||
addUnique(accessTokens, accessSeen, line)
|
||||
continue
|
||||
}
|
||||
addUnique(sessionTokens, sessionSeen, line)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSoraRawTokens(rawInput: string): ParsedSoraTokens {
|
||||
const raw = rawInput.trim()
|
||||
if (!raw) {
|
||||
return {
|
||||
sessionTokens: [],
|
||||
accessTokens: []
|
||||
}
|
||||
}
|
||||
|
||||
const sessionTokens: string[] = []
|
||||
const accessTokens: string[] = []
|
||||
const sessionSeen = new Set<string>()
|
||||
const accessSeen = new Set<string>()
|
||||
|
||||
collectFromJSONString(raw, sessionTokens, sessionSeen, accessTokens, accessSeen)
|
||||
collectByRegex(raw, sessionRegexes, sessionTokens, sessionSeen)
|
||||
collectByRegex(raw, accessRegexes, accessTokens, accessSeen)
|
||||
collectFromSessionCookies(raw, sessionTokens, sessionSeen)
|
||||
collectPlainLines(raw, sessionTokens, sessionSeen, accessTokens, accessSeen)
|
||||
|
||||
return {
|
||||
sessionTokens,
|
||||
accessTokens
|
||||
}
|
||||
}
|
||||
@@ -523,7 +523,6 @@ function getPlatformTextColor(platform: string): string {
|
||||
case 'openai': return 'text-emerald-600 dark:text-emerald-400'
|
||||
case 'gemini': return 'text-blue-600 dark:text-blue-400'
|
||||
case 'antigravity': return 'text-purple-600 dark:text-purple-400'
|
||||
case 'sora': return 'text-rose-600 dark:text-rose-400'
|
||||
default: return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
@@ -534,7 +533,6 @@ function getRateBadgeClass(platform: string): string {
|
||||
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
case 'sora': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
|
||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,511 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="card p-6">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.soraS3.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.soraS3.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="startCreateSoraProfile">
|
||||
{{ t('admin.settings.soraS3.newProfile') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="loadingSoraProfiles" @click="loadSoraS3Profiles">
|
||||
{{ loadingSoraProfiles ? t('common.loading') : t('admin.settings.soraS3.reloadProfiles') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[1000px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-gray-400">
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.profile') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.active') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.endpoint') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.bucket') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.quota') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.updatedAt') }}</th>
|
||||
<th class="py-2">{{ t('admin.settings.soraS3.columns.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="profile in soraS3Profiles" :key="profile.profile_id" class="border-b border-gray-100 align-top dark:border-dark-800">
|
||||
<td class="py-3 pr-4">
|
||||
<div class="font-mono text-xs">{{ profile.profile_id }}</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">{{ profile.name }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4">
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs"
|
||||
:class="profile.is_active ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-700 dark:bg-dark-800 dark:text-gray-300'"
|
||||
>
|
||||
{{ profile.is_active ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-xs">
|
||||
<div>{{ profile.endpoint || '-' }}</div>
|
||||
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ profile.region || '-' }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-xs">{{ profile.bucket || '-' }}</td>
|
||||
<td class="py-3 pr-4 text-xs">{{ formatStorageQuotaGB(profile.default_storage_quota_bytes) }}</td>
|
||||
<td class="py-3 pr-4 text-xs">{{ formatDate(profile.updated_at) }}</td>
|
||||
<td class="py-3 text-xs">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-xs" @click="editSoraProfile(profile.profile_id)">
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!profile.is_active"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-xs"
|
||||
:disabled="activatingSoraProfile"
|
||||
@click="activateSoraProfile(profile.profile_id)"
|
||||
>
|
||||
{{ t('admin.settings.soraS3.activateProfile') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger btn-xs"
|
||||
:disabled="deletingSoraProfile"
|
||||
@click="removeSoraProfile(profile.profile_id)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="soraS3Profiles.length === 0">
|
||||
<td colspan="7" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.soraS3.empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="dm-drawer-mask">
|
||||
<div
|
||||
v-if="soraProfileDrawerOpen"
|
||||
class="fixed inset-0 z-[54] bg-black/40 backdrop-blur-sm"
|
||||
@click="closeSoraProfileDrawer"
|
||||
></div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="dm-drawer-panel">
|
||||
<div
|
||||
v-if="soraProfileDrawerOpen"
|
||||
class="fixed inset-y-0 right-0 z-[55] flex h-full w-full max-w-2xl flex-col border-l border-gray-200 bg-white shadow-2xl dark:border-dark-700 dark:bg-dark-900"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ creatingSoraProfile ? t('admin.settings.soraS3.createTitle') : t('admin.settings.soraS3.editTitle') }}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-800 dark:hover:text-gray-200"
|
||||
@click="closeSoraProfileDrawer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<input
|
||||
v-model="soraProfileForm.profile_id"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.settings.soraS3.profileID')"
|
||||
:disabled="!creatingSoraProfile"
|
||||
/>
|
||||
<input
|
||||
v-model="soraProfileForm.name"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.settings.soraS3.profileName')"
|
||||
/>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
|
||||
<input v-model="soraProfileForm.enabled" type="checkbox" />
|
||||
<span>{{ t('admin.settings.soraS3.enabled') }}</span>
|
||||
</label>
|
||||
<input v-model="soraProfileForm.endpoint" class="input w-full" :placeholder="t('admin.settings.soraS3.endpoint')" />
|
||||
<input v-model="soraProfileForm.region" class="input w-full" :placeholder="t('admin.settings.soraS3.region')" />
|
||||
<input v-model="soraProfileForm.bucket" class="input w-full" :placeholder="t('admin.settings.soraS3.bucket')" />
|
||||
<input v-model="soraProfileForm.prefix" class="input w-full" :placeholder="t('admin.settings.soraS3.prefix')" />
|
||||
<input v-model="soraProfileForm.access_key_id" class="input w-full" :placeholder="t('admin.settings.soraS3.accessKeyId')" />
|
||||
<input
|
||||
v-model="soraProfileForm.secret_access_key"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
:placeholder="soraProfileForm.secret_access_key_configured ? t('admin.settings.soraS3.secretConfigured') : t('admin.settings.soraS3.secretAccessKey')"
|
||||
/>
|
||||
<input v-model="soraProfileForm.cdn_url" class="input w-full" :placeholder="t('admin.settings.soraS3.cdnUrl')" />
|
||||
<div>
|
||||
<input
|
||||
v-model.number="soraProfileForm.default_storage_quota_gb"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.settings.soraS3.defaultQuota')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.defaultQuotaHint') }}</p>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="soraProfileForm.force_path_style" type="checkbox" />
|
||||
<span>{{ t('admin.settings.soraS3.forcePathStyle') }}</span>
|
||||
</label>
|
||||
<label v-if="creatingSoraProfile" class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
|
||||
<input v-model="soraProfileForm.set_active" type="checkbox" />
|
||||
<span>{{ t('admin.settings.soraS3.setActive') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2 border-t border-gray-200 p-4 dark:border-dark-700">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="closeSoraProfileDrawer">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="testingSoraProfile || !soraProfileForm.enabled" @click="testSoraProfileConnection">
|
||||
{{ testingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.testConnection') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="savingSoraProfile" @click="saveSoraProfile">
|
||||
{{ savingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.saveProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SoraS3Profile } from '@/api/admin/settings'
|
||||
import { adminAPI } from '@/api'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loadingSoraProfiles = ref(false)
|
||||
const savingSoraProfile = ref(false)
|
||||
const testingSoraProfile = ref(false)
|
||||
const activatingSoraProfile = ref(false)
|
||||
const deletingSoraProfile = ref(false)
|
||||
const creatingSoraProfile = ref(false)
|
||||
const soraProfileDrawerOpen = ref(false)
|
||||
|
||||
const soraS3Profiles = ref<SoraS3Profile[]>([])
|
||||
const selectedSoraProfileID = ref('')
|
||||
|
||||
type SoraS3ProfileForm = {
|
||||
profile_id: string
|
||||
name: string
|
||||
set_active: boolean
|
||||
enabled: boolean
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key: string
|
||||
secret_access_key_configured: boolean
|
||||
prefix: string
|
||||
force_path_style: boolean
|
||||
cdn_url: string
|
||||
default_storage_quota_gb: number
|
||||
}
|
||||
|
||||
const soraProfileForm = ref<SoraS3ProfileForm>(newDefaultSoraS3ProfileForm())
|
||||
|
||||
async function loadSoraS3Profiles() {
|
||||
loadingSoraProfiles.value = true
|
||||
try {
|
||||
const result = await adminAPI.settings.listSoraS3Profiles()
|
||||
soraS3Profiles.value = result.items || []
|
||||
if (!creatingSoraProfile.value) {
|
||||
const stillExists = selectedSoraProfileID.value
|
||||
? soraS3Profiles.value.some((item) => item.profile_id === selectedSoraProfileID.value)
|
||||
: false
|
||||
if (!stillExists) {
|
||||
selectedSoraProfileID.value = pickPreferredSoraProfileID()
|
||||
}
|
||||
syncSoraProfileFormWithSelection()
|
||||
}
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
loadingSoraProfiles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startCreateSoraProfile() {
|
||||
creatingSoraProfile.value = true
|
||||
selectedSoraProfileID.value = ''
|
||||
soraProfileForm.value = newDefaultSoraS3ProfileForm()
|
||||
soraProfileDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function editSoraProfile(profileID: string) {
|
||||
selectedSoraProfileID.value = profileID
|
||||
creatingSoraProfile.value = false
|
||||
syncSoraProfileFormWithSelection()
|
||||
soraProfileDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function closeSoraProfileDrawer() {
|
||||
soraProfileDrawerOpen.value = false
|
||||
if (creatingSoraProfile.value) {
|
||||
creatingSoraProfile.value = false
|
||||
selectedSoraProfileID.value = pickPreferredSoraProfileID()
|
||||
syncSoraProfileFormWithSelection()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSoraProfile() {
|
||||
if (!soraProfileForm.value.name.trim()) {
|
||||
appStore.showError(t('admin.settings.soraS3.profileNameRequired'))
|
||||
return
|
||||
}
|
||||
if (creatingSoraProfile.value && !soraProfileForm.value.profile_id.trim()) {
|
||||
appStore.showError(t('admin.settings.soraS3.profileIDRequired'))
|
||||
return
|
||||
}
|
||||
if (!creatingSoraProfile.value && !selectedSoraProfileID.value) {
|
||||
appStore.showError(t('admin.settings.soraS3.profileSelectRequired'))
|
||||
return
|
||||
}
|
||||
if (soraProfileForm.value.enabled) {
|
||||
if (!soraProfileForm.value.endpoint.trim()) {
|
||||
appStore.showError(t('admin.settings.soraS3.endpointRequired'))
|
||||
return
|
||||
}
|
||||
if (!soraProfileForm.value.bucket.trim()) {
|
||||
appStore.showError(t('admin.settings.soraS3.bucketRequired'))
|
||||
return
|
||||
}
|
||||
if (!soraProfileForm.value.access_key_id.trim()) {
|
||||
appStore.showError(t('admin.settings.soraS3.accessKeyRequired'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
savingSoraProfile.value = true
|
||||
try {
|
||||
if (creatingSoraProfile.value) {
|
||||
const created = await adminAPI.settings.createSoraS3Profile({
|
||||
profile_id: soraProfileForm.value.profile_id.trim(),
|
||||
name: soraProfileForm.value.name.trim(),
|
||||
set_active: soraProfileForm.value.set_active,
|
||||
enabled: soraProfileForm.value.enabled,
|
||||
endpoint: soraProfileForm.value.endpoint,
|
||||
region: soraProfileForm.value.region,
|
||||
bucket: soraProfileForm.value.bucket,
|
||||
access_key_id: soraProfileForm.value.access_key_id,
|
||||
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
|
||||
prefix: soraProfileForm.value.prefix,
|
||||
force_path_style: soraProfileForm.value.force_path_style,
|
||||
cdn_url: soraProfileForm.value.cdn_url,
|
||||
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
|
||||
})
|
||||
selectedSoraProfileID.value = created.profile_id
|
||||
creatingSoraProfile.value = false
|
||||
soraProfileDrawerOpen.value = false
|
||||
appStore.showSuccess(t('admin.settings.soraS3.profileCreated'))
|
||||
} else {
|
||||
await adminAPI.settings.updateSoraS3Profile(selectedSoraProfileID.value, {
|
||||
name: soraProfileForm.value.name.trim(),
|
||||
enabled: soraProfileForm.value.enabled,
|
||||
endpoint: soraProfileForm.value.endpoint,
|
||||
region: soraProfileForm.value.region,
|
||||
bucket: soraProfileForm.value.bucket,
|
||||
access_key_id: soraProfileForm.value.access_key_id,
|
||||
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
|
||||
prefix: soraProfileForm.value.prefix,
|
||||
force_path_style: soraProfileForm.value.force_path_style,
|
||||
cdn_url: soraProfileForm.value.cdn_url,
|
||||
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
|
||||
})
|
||||
soraProfileDrawerOpen.value = false
|
||||
appStore.showSuccess(t('admin.settings.soraS3.profileSaved'))
|
||||
}
|
||||
await loadSoraS3Profiles()
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
savingSoraProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testSoraProfileConnection() {
|
||||
testingSoraProfile.value = true
|
||||
try {
|
||||
const result = await adminAPI.settings.testSoraS3Connection({
|
||||
profile_id: creatingSoraProfile.value ? undefined : selectedSoraProfileID.value,
|
||||
enabled: soraProfileForm.value.enabled,
|
||||
endpoint: soraProfileForm.value.endpoint,
|
||||
region: soraProfileForm.value.region,
|
||||
bucket: soraProfileForm.value.bucket,
|
||||
access_key_id: soraProfileForm.value.access_key_id,
|
||||
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
|
||||
prefix: soraProfileForm.value.prefix,
|
||||
force_path_style: soraProfileForm.value.force_path_style,
|
||||
cdn_url: soraProfileForm.value.cdn_url,
|
||||
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
|
||||
})
|
||||
appStore.showSuccess(result.message || t('admin.settings.soraS3.testSuccess'))
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
testingSoraProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function activateSoraProfile(profileID: string) {
|
||||
activatingSoraProfile.value = true
|
||||
try {
|
||||
await adminAPI.settings.setActiveSoraS3Profile(profileID)
|
||||
appStore.showSuccess(t('admin.settings.soraS3.profileActivated'))
|
||||
await loadSoraS3Profiles()
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
activatingSoraProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSoraProfile(profileID: string) {
|
||||
if (!window.confirm(t('admin.settings.soraS3.deleteConfirm', { profileID }))) {
|
||||
return
|
||||
}
|
||||
deletingSoraProfile.value = true
|
||||
try {
|
||||
await adminAPI.settings.deleteSoraS3Profile(profileID)
|
||||
if (selectedSoraProfileID.value === profileID) {
|
||||
selectedSoraProfileID.value = ''
|
||||
}
|
||||
appStore.showSuccess(t('admin.settings.soraS3.profileDeleted'))
|
||||
await loadSoraS3Profiles()
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
deletingSoraProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function formatStorageQuotaGB(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) {
|
||||
return '0 GB'
|
||||
}
|
||||
const gb = bytes / (1024 * 1024 * 1024)
|
||||
return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`
|
||||
}
|
||||
|
||||
function pickPreferredSoraProfileID(): string {
|
||||
const active = soraS3Profiles.value.find((item) => item.is_active)
|
||||
if (active) {
|
||||
return active.profile_id
|
||||
}
|
||||
return soraS3Profiles.value[0]?.profile_id || ''
|
||||
}
|
||||
|
||||
function syncSoraProfileFormWithSelection() {
|
||||
const profile = soraS3Profiles.value.find((item) => item.profile_id === selectedSoraProfileID.value)
|
||||
soraProfileForm.value = newDefaultSoraS3ProfileForm(profile)
|
||||
}
|
||||
|
||||
function newDefaultSoraS3ProfileForm(profile?: SoraS3Profile): SoraS3ProfileForm {
|
||||
if (!profile) {
|
||||
return {
|
||||
profile_id: '',
|
||||
name: '',
|
||||
set_active: false,
|
||||
enabled: false,
|
||||
endpoint: '',
|
||||
region: '',
|
||||
bucket: '',
|
||||
access_key_id: '',
|
||||
secret_access_key: '',
|
||||
secret_access_key_configured: false,
|
||||
prefix: 'sora/',
|
||||
force_path_style: false,
|
||||
cdn_url: '',
|
||||
default_storage_quota_gb: 0
|
||||
}
|
||||
}
|
||||
|
||||
const quotaBytes = profile.default_storage_quota_bytes || 0
|
||||
|
||||
return {
|
||||
profile_id: profile.profile_id,
|
||||
name: profile.name,
|
||||
set_active: false,
|
||||
enabled: profile.enabled,
|
||||
endpoint: profile.endpoint || '',
|
||||
region: profile.region || '',
|
||||
bucket: profile.bucket || '',
|
||||
access_key_id: profile.access_key_id || '',
|
||||
secret_access_key: '',
|
||||
secret_access_key_configured: Boolean(profile.secret_access_key_configured),
|
||||
prefix: profile.prefix || '',
|
||||
force_path_style: Boolean(profile.force_path_style),
|
||||
cdn_url: profile.cdn_url || '',
|
||||
default_storage_quota_gb: Number((quotaBytes / (1024 * 1024 * 1024)).toFixed(2))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSoraS3Profiles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dm-drawer-mask-enter-active,
|
||||
.dm-drawer-mask-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.dm-drawer-mask-enter-from,
|
||||
.dm-drawer-mask-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dm-drawer-panel-enter-active,
|
||||
.dm-drawer-panel-leave-active {
|
||||
transition:
|
||||
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.dm-drawer-panel-enter-from,
|
||||
.dm-drawer-panel-leave-to {
|
||||
opacity: 0.96;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dm-drawer-mask-enter-active,
|
||||
.dm-drawer-mask-leave-active,
|
||||
.dm-drawer-panel-enter-active,
|
||||
.dm-drawer-panel-leave-active {
|
||||
transition-duration: 0s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -522,80 +522,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sora 按次计费配置 -->
|
||||
<div v-if="createForm.platform === 'sora'" class="border-t pt-4">
|
||||
<label class="block mb-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.soraPricing.title') }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t('admin.groups.soraPricing.description') }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.image360') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.sora_image_price_360"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.05"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.image540') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.sora_image_price_540"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.08"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.video') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.sora_video_price_per_request"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.videoHd') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.sora_video_price_per_request_hd"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.storageQuota') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="createForm.sora_storage_quota_gb"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="10"
|
||||
/>
|
||||
<span class="shrink-0 text-sm text-gray-500">GB</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.groups.soraPricing.storageQuotaHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 支持的模型系列(仅 antigravity 平台) -->
|
||||
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
|
||||
@@ -1312,80 +1239,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sora 按次计费配置 -->
|
||||
<div v-if="editForm.platform === 'sora'" class="border-t pt-4">
|
||||
<label class="block mb-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.soraPricing.title') }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t('admin.groups.soraPricing.description') }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.image360') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.sora_image_price_360"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.05"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.image540') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.sora_image_price_540"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.08"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.video') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.sora_video_price_per_request"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.videoHd') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.sora_video_price_per_request_hd"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.storageQuota') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="editForm.sora_storage_quota_gb"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="10"
|
||||
/>
|
||||
<span class="shrink-0 text-sm text-gray-500">GB</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.groups.soraPricing.storageQuotaHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 支持的模型系列(仅 antigravity 平台) -->
|
||||
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
|
||||
@@ -2001,8 +1855,7 @@ const platformOptions = computed(() => [
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{ value: 'sora', label: 'Sora' }
|
||||
{ value: 'antigravity', label: 'Antigravity' }
|
||||
])
|
||||
|
||||
const platformFilterOptions = computed(() => [
|
||||
@@ -2010,8 +1863,7 @@ const platformFilterOptions = computed(() => [
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{ value: 'sora', label: 'Sora' }
|
||||
{ value: 'antigravity', label: 'Antigravity' }
|
||||
])
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
@@ -2160,12 +2012,6 @@ const createForm = reactive({
|
||||
image_price_1k: null as number | null,
|
||||
image_price_2k: null as number | null,
|
||||
image_price_4k: null as number | null,
|
||||
// Sora 按次计费配置
|
||||
sora_image_price_360: null as number | null,
|
||||
sora_image_price_540: null as number | null,
|
||||
sora_video_price_per_request: null as number | null,
|
||||
sora_video_price_per_request_hd: null as number | null,
|
||||
sora_storage_quota_gb: null as number | null,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null,
|
||||
@@ -2407,12 +2253,6 @@ const editForm = reactive({
|
||||
image_price_1k: null as number | null,
|
||||
image_price_2k: null as number | null,
|
||||
image_price_4k: null as number | null,
|
||||
// Sora 按次计费配置
|
||||
sora_image_price_360: null as number | null,
|
||||
sora_image_price_540: null as number | null,
|
||||
sora_video_price_per_request: null as number | null,
|
||||
sora_video_price_per_request_hd: null as number | null,
|
||||
sora_storage_quota_gb: null as number | null,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null,
|
||||
@@ -2559,11 +2399,6 @@ const closeCreateModal = () => {
|
||||
createForm.image_price_1k = null
|
||||
createForm.image_price_2k = null
|
||||
createForm.image_price_4k = null
|
||||
createForm.sora_image_price_360 = null
|
||||
createForm.sora_image_price_540 = null
|
||||
createForm.sora_video_price_per_request = null
|
||||
createForm.sora_video_price_per_request_hd = null
|
||||
createForm.sora_storage_quota_gb = null
|
||||
createForm.claude_code_only = false
|
||||
createForm.fallback_group_id = null
|
||||
createForm.fallback_group_id_on_invalid_request = null
|
||||
@@ -2602,13 +2437,11 @@ const handleCreateGroup = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
// 构建请求数据,包含模型路由配置
|
||||
const { sora_storage_quota_gb: createQuotaGb, ...createRest } = createForm
|
||||
const requestData = {
|
||||
...createRest,
|
||||
...createForm,
|
||||
daily_limit_usd: normalizeOptionalLimit(createForm.daily_limit_usd as number | string | null),
|
||||
weekly_limit_usd: normalizeOptionalLimit(createForm.weekly_limit_usd as number | string | null),
|
||||
monthly_limit_usd: normalizeOptionalLimit(createForm.monthly_limit_usd as number | string | null),
|
||||
sora_storage_quota_bytes: createQuotaGb ? Math.round(createQuotaGb * 1024 * 1024 * 1024) : 0,
|
||||
model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value)
|
||||
}
|
||||
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
|
||||
@@ -2648,11 +2481,6 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.image_price_1k = group.image_price_1k
|
||||
editForm.image_price_2k = group.image_price_2k
|
||||
editForm.image_price_4k = group.image_price_4k
|
||||
editForm.sora_image_price_360 = group.sora_image_price_360
|
||||
editForm.sora_image_price_540 = group.sora_image_price_540
|
||||
editForm.sora_video_price_per_request = group.sora_video_price_per_request
|
||||
editForm.sora_video_price_per_request_hd = group.sora_video_price_per_request_hd
|
||||
editForm.sora_storage_quota_gb = group.sora_storage_quota_bytes ? Number((group.sora_storage_quota_bytes / (1024 * 1024 * 1024)).toFixed(2)) : null
|
||||
editForm.claude_code_only = group.claude_code_only || false
|
||||
editForm.fallback_group_id = group.fallback_group_id
|
||||
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request
|
||||
@@ -2690,13 +2518,11 @@ const handleUpdateGroup = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
|
||||
const { sora_storage_quota_gb: editQuotaGb, ...editRest } = editForm
|
||||
const payload = {
|
||||
...editRest,
|
||||
...editForm,
|
||||
daily_limit_usd: normalizeOptionalLimit(editForm.daily_limit_usd as number | string | null),
|
||||
weekly_limit_usd: normalizeOptionalLimit(editForm.weekly_limit_usd as number | string | null),
|
||||
monthly_limit_usd: normalizeOptionalLimit(editForm.monthly_limit_usd as number | string | null),
|
||||
sora_storage_quota_bytes: editQuotaGb ? Math.round(editQuotaGb * 1024 * 1024 * 1024) : 0,
|
||||
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
|
||||
fallback_group_id_on_invalid_request:
|
||||
editForm.fallback_group_id_on_invalid_request === null
|
||||
|
||||
@@ -1563,8 +1563,6 @@ const qualityTargetLabel = (target: string) => {
|
||||
return 'Anthropic'
|
||||
case 'gemini':
|
||||
return 'Gemini'
|
||||
case 'sora':
|
||||
return 'Sora'
|
||||
default:
|
||||
return target
|
||||
}
|
||||
|
||||
@@ -965,8 +965,7 @@ const platformFilterOptions = computed(() => [
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{ value: 'sora', label: 'Sora' }
|
||||
{ value: 'antigravity', label: 'Antigravity' }
|
||||
])
|
||||
|
||||
// Group options for assign (only subscription type groups)
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
<template>
|
||||
<div class="sora-root">
|
||||
<!-- Sora 页面内容 -->
|
||||
<div class="sora-page">
|
||||
<!-- 功能未启用提示 -->
|
||||
<div v-if="!soraEnabled" class="sora-not-enabled">
|
||||
<svg class="sora-not-enabled-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
|
||||
</svg>
|
||||
<h2 class="sora-not-enabled-title">{{ t('sora.notEnabled') }}</h2>
|
||||
<p class="sora-not-enabled-desc">{{ t('sora.notEnabledDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sora 主界面 -->
|
||||
<template v-else>
|
||||
<!-- 自定义 Sora 头部 -->
|
||||
<header class="sora-header">
|
||||
<div class="sora-header-left">
|
||||
<!-- 返回主页按钮 -->
|
||||
<router-link :to="dashboardPath" class="sora-back-btn" :title="t('common.back')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</router-link>
|
||||
<nav class="sora-nav-tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:class="['sora-nav-tab', activeTab === tab.key && 'active']"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="sora-header-right">
|
||||
<SoraQuotaBar v-if="quota" :quota="quota" />
|
||||
<div v-if="activeTaskCount > 0" class="sora-queue-indicator">
|
||||
<span class="sora-queue-dot" :class="{ busy: hasGeneratingTask }"></span>
|
||||
<span>{{ activeTaskCount }} {{ t('sora.queueTasks') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<main class="sora-main">
|
||||
<SoraGeneratePage
|
||||
v-show="activeTab === 'generate'"
|
||||
@task-count-change="onTaskCountChange"
|
||||
/>
|
||||
<SoraLibraryPage
|
||||
v-show="activeTab === 'library'"
|
||||
@switch-to-generate="activeTab = 'generate'"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore } from '@/stores'
|
||||
import SoraQuotaBar from '@/components/sora/SoraQuotaBar.vue'
|
||||
import SoraGeneratePage from '@/components/sora/SoraGeneratePage.vue'
|
||||
import SoraLibraryPage from '@/components/sora/SoraLibraryPage.vue'
|
||||
import soraAPI, { type QuotaInfo } from '@/api/sora'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const soraEnabled = computed(() => appStore.cachedPublicSettings?.sora_client_enabled ?? false)
|
||||
|
||||
const activeTab = ref<'generate' | 'library'>('generate')
|
||||
const quota = ref<QuotaInfo | null>(null)
|
||||
const activeTaskCount = ref(0)
|
||||
const hasGeneratingTask = ref(false)
|
||||
const dashboardPath = computed(() => (authStore.isAdmin ? '/admin/dashboard' : '/dashboard'))
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'generate' as const, label: t('sora.tabGenerate') },
|
||||
{ key: 'library' as const, label: t('sora.tabLibrary') }
|
||||
])
|
||||
|
||||
function onTaskCountChange(counts: { active: number; generating: boolean }) {
|
||||
activeTaskCount.value = counts.active
|
||||
hasGeneratingTask.value = counts.generating
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!soraEnabled.value) return
|
||||
try {
|
||||
quota.value = await soraAPI.getQuota()
|
||||
} catch {
|
||||
// 配额查询失败不阻塞页面
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================
|
||||
Sora 主题 CSS 变量 — 亮色模式(跟随应用主题)
|
||||
============================================================ */
|
||||
.sora-root {
|
||||
--sora-bg-primary: #F9FAFB;
|
||||
--sora-bg-secondary: #FFFFFF;
|
||||
--sora-bg-tertiary: #F3F4F6;
|
||||
--sora-bg-elevated: #FFFFFF;
|
||||
--sora-bg-hover: #E5E7EB;
|
||||
--sora-bg-input: #FFFFFF;
|
||||
--sora-text-primary: #111827;
|
||||
--sora-text-secondary: #6B7280;
|
||||
--sora-text-tertiary: #9CA3AF;
|
||||
--sora-text-muted: #D1D5DB;
|
||||
--sora-accent-primary: #14b8a6;
|
||||
--sora-accent-secondary: #0d9488;
|
||||
--sora-accent-gradient: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
|
||||
--sora-accent-gradient-hover: linear-gradient(135deg, #2dd4bf 0%, #14b8a6 100%);
|
||||
--sora-success: #10B981;
|
||||
--sora-warning: #F59E0B;
|
||||
--sora-error: #EF4444;
|
||||
--sora-info: #3B82F6;
|
||||
--sora-border-color: #E5E7EB;
|
||||
--sora-border-subtle: #F3F4F6;
|
||||
--sora-radius-sm: 8px;
|
||||
--sora-radius-md: 12px;
|
||||
--sora-radius-lg: 16px;
|
||||
--sora-radius-xl: 20px;
|
||||
--sora-radius-full: 9999px;
|
||||
--sora-shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
||||
--sora-shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||
--sora-shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
|
||||
--sora-shadow-glow: 0 0 20px rgba(20,184,166,0.25);
|
||||
--sora-transition-fast: 150ms ease;
|
||||
--sora-transition-normal: 250ms ease;
|
||||
--sora-header-height: 56px;
|
||||
--sora-header-bg: rgba(249, 250, 251, 0.85);
|
||||
--sora-placeholder-gradient: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 50%, #cffafe 100%);
|
||||
--sora-modal-backdrop: rgba(0, 0, 0, 0.4);
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--sora-bg-primary);
|
||||
color: var(--sora-text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
页面布局
|
||||
============================================================ */
|
||||
.sora-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
头部导航栏
|
||||
============================================================ */
|
||||
.sora-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
height: var(--sora-header-height);
|
||||
background: var(--sora-header-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--sora-border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.sora-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.sora-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 返回按钮 */
|
||||
.sora-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--sora-radius-sm);
|
||||
color: var(--sora-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all var(--sora-transition-fast);
|
||||
}
|
||||
|
||||
.sora-back-btn:hover {
|
||||
background: var(--sora-bg-tertiary);
|
||||
color: var(--sora-text-primary);
|
||||
}
|
||||
|
||||
/* Tab 导航 */
|
||||
.sora-nav-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--sora-bg-secondary);
|
||||
border-radius: var(--sora-radius-full);
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.sora-nav-tab {
|
||||
padding: 6px 20px;
|
||||
border-radius: var(--sora-radius-full);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--sora-text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--sora-transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sora-nav-tab:hover {
|
||||
color: var(--sora-text-primary);
|
||||
}
|
||||
|
||||
.sora-nav-tab.active {
|
||||
background: var(--sora-bg-tertiary);
|
||||
color: var(--sora-text-primary);
|
||||
}
|
||||
|
||||
/* 队列指示器 */
|
||||
.sora-queue-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--sora-bg-secondary);
|
||||
border-radius: var(--sora-radius-full);
|
||||
font-size: 12px;
|
||||
color: var(--sora-text-secondary);
|
||||
}
|
||||
|
||||
.sora-queue-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--sora-success);
|
||||
animation: sora-pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sora-queue-dot.busy {
|
||||
background: var(--sora-warning);
|
||||
}
|
||||
|
||||
@keyframes sora-pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
主内容区
|
||||
============================================================ */
|
||||
.sora-main {
|
||||
min-height: calc(100vh - var(--sora-header-height));
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
功能未启用
|
||||
============================================================ */
|
||||
.sora-not-enabled {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.sora-not-enabled-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--sora-text-tertiary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sora-not-enabled-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--sora-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sora-not-enabled-desc {
|
||||
font-size: 14px;
|
||||
color: var(--sora-text-tertiary);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 900px) {
|
||||
.sora-header {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.sora-header-left {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.sora-nav-tab {
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
.sora-root ::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.sora-root ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sora-root ::-webkit-scrollbar-thumb {
|
||||
background: var(--sora-bg-hover);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sora-root ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--sora-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 暗色模式:必须明确命中 .sora-root,避免被 scoped 编译后的变量覆盖问题 */
|
||||
html.dark .sora-root {
|
||||
--sora-bg-primary: #020617;
|
||||
--sora-bg-secondary: #0f172a;
|
||||
--sora-bg-tertiary: #1e293b;
|
||||
--sora-bg-elevated: #1e293b;
|
||||
--sora-bg-hover: #334155;
|
||||
--sora-bg-input: #0f172a;
|
||||
--sora-text-primary: #f1f5f9;
|
||||
--sora-text-secondary: #94a3b8;
|
||||
--sora-text-tertiary: #64748b;
|
||||
--sora-text-muted: #475569;
|
||||
--sora-border-color: #334155;
|
||||
--sora-border-subtle: #1e293b;
|
||||
--sora-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--sora-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--sora-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
--sora-shadow-glow: 0 0 20px rgba(20, 184, 166, 0.3);
|
||||
--sora-header-bg: rgba(2, 6, 23, 0.85);
|
||||
--sora-placeholder-gradient: linear-gradient(135deg, #1e293b 0%, #0f172a 50%, #020617 100%);
|
||||
--sora-modal-backdrop: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user