refactor: 彻底移除 Sora 视频生成模块(全栈清理)
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

## 后端变更
- 删除 21 个 sora_*.go 服务文件(service/handler/repository/routes)
- 删除 Sora 相关 migration 文件(046/047/063/090)
- 清理 config 中的 sora_* 配置项和平台常量
- 清理 wire 依赖注入中的 Sora 组件
- 修复 wire_gen.go 语法错误(缺少逗号和闭合括号)
- 移除 go.mod 中的 go-sora2api 依赖
- 更新 ent schema usage_log.go 注释

## 前端变更
- 删除 SoraView、SoraAdminView 及 8 个 Sora 子组件
- 删除 sora API 层和路由配置
- 清理 UserEditModal 中的 Sora 存储配额 UI
- 清理 types/index.ts 中 Sora 相关类型定义
- 清理 stores/app.ts 默认配置
- 清理 i18n 翻译文件 en.ts/zh.ts (~110 行)
- 更新相关测试文件

## 文档更新
- README.md / README_CN.md / README_JA.md: 移除 Sora 状态说明和配置段落
- PROJECT_DIFF.md: 移除 Sora 相关差异描述

## 验证结果
-  Go 编译通过 (go build ./...)
-  TypeScript 类型检查通过 (vue-tsc --noEmit)
-  后端测试全通过 (0 failures)
-  前端测试全通过 (59 files, 329 tests, 0 failures)
-  前端生产构建成功 (23.81s)
This commit is contained in:
2026-05-10 14:15:45 +08:00
parent 1da074cfd6
commit 0e057904e6
96 changed files with 726 additions and 20525 deletions

View File

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

View File

@@ -26,7 +26,6 @@ import backupAPI from './backup'
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
import channelsAPI from './channels'
import adminPaymentAPI from './payment'
import soraAdminAPI from './sora'
/**
* Unified admin API object for convenient access
@@ -54,8 +53,7 @@ export const adminAPI = {
backup: backupAPI,
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
channels: channelsAPI,
payment: adminPaymentAPI,
sora: soraAdminAPI
payment: adminPaymentAPI
}
export {
@@ -81,8 +79,7 @@ export {
backupAPI,
tlsFingerprintProfileAPI,
channelsAPI,
adminPaymentAPI,
soraAdminAPI
adminPaymentAPI
}
export default adminAPI

View File

@@ -1,78 +0,0 @@
/**
* Admin Sora API
*/
import { apiClient } from '../client'
import type { BasePaginationResponse } from '@/types'
export interface SoraUserStats {
user_id: number
username: string
email: string
quota_bytes: number
used_bytes: number
available_bytes: number
quota_source: string
generations_count: number
active_count: number
total_file_size_bytes: number
}
export interface SoraSystemStats {
total_users: number
total_generations: number
total_storage_bytes: number
active_generations: number
by_status: Record<string, number>
by_model: Record<string, number>
}
export interface SoraGenerationAdmin {
id: number
user_id: number
username: string
email: string
model: string
prompt: string
media_type: string
status: string
storage_type: string
media_url: string
file_size_bytes: number
error_message: string
created_at: string
completed_at: string | null
}
const soraAdminAPI = {
async getSystemStats(): Promise<SoraSystemStats> {
const { data } = await apiClient.get<{ data: SoraSystemStats }>('/admin/sora/stats')
return data.data
},
async listUserStats(params?: {
page?: number
page_size?: number
search?: string
sort_by?: string
sort_order?: string
}): Promise<BasePaginationResponse<SoraUserStats>> {
const { data } = await apiClient.get<BasePaginationResponse<SoraUserStats>>('/admin/sora/users', { params })
return data
},
async listGenerations(params?: {
page?: number
page_size?: number
user_id?: number
status?: string
model?: string
sort_by?: string
sort_order?: string
}): Promise<BasePaginationResponse<SoraGenerationAdmin>> {
const { data } = await apiClient.get<BasePaginationResponse<SoraGenerationAdmin>>('/admin/sora/generations', { params })
return data
}
}
export default soraAdminAPI

View File

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

View File

@@ -37,11 +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>
<input v-model.number="form.soraStorageQuotaGB" type="number" min="0" step="0.1" class="input" />
<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>
@@ -71,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, soraStorageQuotaGB: 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, soraStorageQuotaGB: Math.round((u.sora_storage_quota_bytes || 0) / (1024 * 1024 * 1024) * 10) / 10, customAttributes: {} })
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
passwordCopied.value = false
}
}, { immediate: true })
@@ -102,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.soraStorageQuotaGB * 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)

View File

@@ -57,8 +57,6 @@ function createMockUser(overrides: Partial<AdminUser> = {}): AdminUser {
is_superuser: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
sora_storage_quota_bytes: 10 * 1024 * 1024 * 1024,
sora_storage_used_bytes: 0,
...overrides
}
}
@@ -95,8 +93,7 @@ describe('UserEditModal', () => {
const user = createMockUser({
email: 'user@example.com',
username: 'myuser',
concurrency: 5,
sora_storage_quota_bytes: 20 * 1024 * 1024 * 1024
concurrency: 5
})
const wrapper = mount(UserEditModal, {
@@ -127,10 +124,6 @@ describe('UserEditModal', () => {
// 验证 concurrency
const concurrencyInput = wrapper.find('input[type="number"]')
expect((concurrencyInput.element as HTMLInputElement).value).toBe('5')
// Sora quota 应该转换为 GB
const soraQuotaInput = wrapper.findAll('input[type="number"]')[1]
expect((soraQuotaInput.element as HTMLInputElement).value).toBe('20')
})
it('关闭后重新打开时重置表单', async () => {
@@ -244,34 +237,6 @@ describe('UserEditModal', () => {
}))
})
it('更新 Sora 存储配额', async () => {
const user = createMockUser()
const wrapper = mount(UserEditModal, {
props: { show: true, user },
global: {
stubs: {
BaseDialog: BaseDialogStub,
Icon: IconStub,
UserAttributeForm: UserAttributeFormStub
}
}
})
await flushPromises()
// 修改 Sora 配额GB 单位)
const soraQuotaInput = wrapper.findAll('input[type="number"]')[1]
await soraQuotaInput.setValue(50)
await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
expect(updateMock).toHaveBeenCalledWith(1, expect.objectContaining({
sora_storage_quota_bytes: 50 * 1024 * 1024 * 1024
}))
})
it('更新失败时不会崩溃', async () => {
updateMock.mockRejectedValue(new Error('Update failed'))
@@ -321,53 +286,6 @@ describe('UserEditModal', () => {
})
})
describe('Sora 配额转换', () => {
it('正确转换字节到 GB', async () => {
const user = createMockUser({
sora_storage_quota_bytes: 15 * 1024 * 1024 * 1024 // 15 GB
})
const wrapper = mount(UserEditModal, {
props: { show: true, user },
global: {
stubs: {
BaseDialog: BaseDialogStub,
Icon: IconStub,
UserAttributeForm: UserAttributeFormStub
}
}
})
await flushPromises()
const soraQuotaInput = wrapper.findAll('input[type="number"]')[1]
expect((soraQuotaInput.element as HTMLInputElement).value).toBe('15')
})
it('正确处理小数 GB', async () => {
const user = createMockUser({
sora_storage_quota_bytes: 5.5 * 1024 * 1024 * 1024 // 5.5 GB
})
const wrapper = mount(UserEditModal, {
props: { show: true, user },
global: {
stubs: {
BaseDialog: BaseDialogStub,
Icon: IconStub,
UserAttributeForm: UserAttributeFormStub
}
}
})
await flushPromises()
const soraQuotaInput = wrapper.findAll('input[type="number"]')[1]
const value = parseFloat((soraQuotaInput.element as HTMLInputElement).value)
expect(value).toBeCloseTo(5.5, 1)
})
})
describe('关闭对话框', () => {
it('点击取消按钮触发 close 事件', async () => {
const user = createMockUser()

View File

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

View File

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

View File

@@ -1,606 +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="requestDelete(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"
/>
<ConfirmDialog
:show="showDeleteConfirmDialog"
:title="t('common.delete')"
:message="t('sora.confirmDelete')"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
danger
@confirm="confirmDelete"
@cancel="closeDeleteConfirmDialog"
/>
</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 ConfirmDialog from '@/components/common/ConfirmDialog.vue'
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 showDeleteConfirmDialog = ref(false)
const pendingDeleteId = ref<number | 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
}
function closeDeleteConfirmDialog() {
showDeleteConfirmDialog.value = false
pendingDeleteId.value = null
}
function requestDelete(id: number) {
pendingDeleteId.value = id
showDeleteConfirmDialog.value = true
}
async function handleDelete(id: number) {
try {
await soraAPI.deleteGeneration(id)
items.value = items.value.filter(i => i.id !== id)
} catch (e) {
console.error('Delete failed:', e)
}
}
async function confirmDelete() {
const id = pendingDeleteId.value
closeDeleteConfirmDialog()
if (id == null) return
await handleDelete(id)
}
function handleDownload(item: SoraGeneration) {
if (item.media_url) {
window.open(item.media_url, '_blank')
}
}
function handleDownloadUrl(url: string) {
window.open(url, '_blank')
}
async function handleSaveFromPreview(id: number) {
try {
await soraAPI.saveToStorage(id)
const gen = await soraAPI.getGeneration(id)
const idx = items.value.findIndex(i => i.id === id)
if (idx >= 0) items.value[idx] = gen
} catch (e) {
console.error('Save failed:', e)
}
}
onMounted(() => loadItems(1))
</script>
<style scoped>
.sora-gallery-page {
padding: 24px;
padding-bottom: 40px;
}
/* 筛选栏 */
.sora-gallery-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.sora-gallery-filters {
display: flex;
gap: 4px;
background: var(--sora-bg-secondary, #1A1A1A);
border-radius: var(--sora-radius-full, 9999px);
padding: 3px;
}
.sora-gallery-filter {
padding: 6px 18px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 500;
color: var(--sora-text-secondary, #A0A0A0);
background: none;
border: none;
cursor: pointer;
transition: all 150ms ease;
user-select: none;
}
.sora-gallery-filter:hover {
color: var(--sora-text-primary, #FFF);
}
.sora-gallery-filter.active {
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-primary, #FFF);
}
.sora-gallery-count {
font-size: 13px;
color: var(--sora-text-tertiary, #666);
}
/* 网格 */
.sora-gallery-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
/* 卡片 */
.sora-gallery-card {
position: relative;
border-radius: var(--sora-radius-md, 12px);
overflow: hidden;
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
cursor: pointer;
transition: all 250ms ease;
}
.sora-gallery-card:hover {
border-color: var(--sora-bg-hover, #333);
transform: translateY(-2px);
box-shadow: var(--sora-shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
}
.sora-gallery-card-thumb {
position: relative;
width: 100%;
aspect-ratio: 16/9;
overflow: hidden;
}
.sora-gallery-card-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 400ms ease;
}
.sora-gallery-card:hover .sora-gallery-card-image {
transform: scale(1.05);
}
.sora-gallery-card-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
}
/* 渐变背景 */
.gradient-bg-1 { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.gradient-bg-2 { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
.gradient-bg-3 { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
.gradient-bg-4 { background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); }
.gradient-bg-5 { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
.gradient-bg-6 { background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); }
.gradient-bg-7 { background: linear-gradient(135deg, #fccb90 0%, #d57eeb 100%); }
.gradient-bg-8 { background: linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%); }
/* 类型角标 */
.sora-gallery-card-badge {
position: absolute;
top: 8px;
left: 8px;
padding: 3px 8px;
border-radius: var(--sora-radius-sm, 8px);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
backdrop-filter: blur(8px);
}
.sora-gallery-card-badge.video {
background: rgba(20, 184, 166, 0.8);
color: white;
}
.sora-gallery-card-badge.image {
background: rgba(16, 185, 129, 0.8);
color: white;
}
/* Hover 操作层 */
.sora-gallery-card-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
opacity: 0;
transition: opacity 150ms ease;
}
.sora-gallery-card:hover .sora-gallery-card-overlay {
opacity: 1;
}
.sora-gallery-card-action {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: white;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-gallery-card-action:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.1);
}
/* 播放指示 */
.sora-gallery-card-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
opacity: 0;
transition: all 150ms ease;
pointer-events: none;
}
.sora-gallery-card:hover .sora-gallery-card-play {
opacity: 1;
}
/* 视频时长 */
.sora-gallery-card-duration {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.7);
font-size: 11px;
font-family: "SF Mono", "Fira Code", monospace;
color: white;
}
/* 卡片信息 */
.sora-gallery-card-info {
padding: 12px;
}
.sora-gallery-card-model {
font-size: 11px;
font-family: "SF Mono", "Fira Code", monospace;
color: var(--sora-text-tertiary, #666);
margin-bottom: 4px;
}
.sora-gallery-card-time {
font-size: 12px;
color: var(--sora-text-muted, #4A4A4A);
}
/* 空状态 */
.sora-gallery-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120px 40px;
text-align: center;
}
.sora-gallery-empty-icon {
font-size: 64px;
margin-bottom: 24px;
opacity: 0.3;
}
.sora-gallery-empty-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
color: var(--sora-text-secondary, #A0A0A0);
}
.sora-gallery-empty-desc {
font-size: 14px;
color: var(--sora-text-tertiary, #666);
max-width: 360px;
line-height: 1.6;
}
.sora-gallery-empty-btn {
margin-top: 24px;
padding: 10px 28px;
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
border-radius: var(--sora-radius-full, 9999px);
font-size: 14px;
font-weight: 500;
color: white;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-gallery-empty-btn:hover {
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
}
/* 加载更多 */
.sora-gallery-load-more {
display: flex;
justify-content: center;
margin-top: 24px;
}
.sora-gallery-load-more-btn {
padding: 10px 28px;
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
color: var(--sora-text-secondary, #A0A0A0);
cursor: pointer;
transition: all 150ms ease;
}
.sora-gallery-load-more-btn:hover {
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-primary, #FFF);
}
.sora-gallery-load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 响应式 */
@media (max-width: 1200px) {
.sora-gallery-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 900px) {
.sora-gallery-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.sora-gallery-page {
padding: 16px;
}
.sora-gallery-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

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

View File

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

View File

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

View File

@@ -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 IDfamily + orientation + duration */
function buildModelID(): string {
const fam = currentFamily.value
if (!fam) return selectedFamily.value
if (fam.type === 'image') {
// 图像模型: "gpt-image"(方形)或 "gpt-image-landscape"
return currentAspect.value === 'square'
? fam.id
: `${fam.id}-${currentAspect.value}`
}
// 视频模型: "sora2-landscape-10s"
return `${fam.id}-${currentAspect.value}-${currentDuration.value}s`
}
/** 切换家族时自动调整方向和时长为首个可用值 */
function onFamilyChange() {
const fam = families.value.find(f => f.id === selectedFamily.value)
if (!fam) return
// 若当前方向不在新家族支持列表中,重置为首个
if (fam.orientations?.length && !fam.orientations.includes(currentAspect.value)) {
currentAspect.value = fam.orientations[0]
}
// 若当前时长不在新家族支持列表中,重置为首个
if (fam.durations?.length && !fam.durations.includes(currentDuration.value)) {
currentDuration.value = fam.durations[0]
}
if (fam.type !== 'video') {
currentVideoCount.value = 1
}
}
async function loadModels() {
try {
families.value = await soraAPI.getModels()
if (families.value.length > 0 && !selectedFamily.value) {
selectedFamily.value = families.value[0].id
onFamilyChange()
}
} catch (e) {
console.error('Failed to load models:', e)
}
}
async function loadStorageStatus() {
try {
const status = await soraAPI.getStorageStatus()
hasStorage.value = status.s3_enabled && status.s3_healthy
} catch {
hasStorage.value = false
}
}
async function loadSoraCredentials() {
try {
// 加载 API Keys筛选 sora 平台 + active 状态
const keysRes = await keysAPI.list(1, 100)
apiKeyOptions.value = (keysRes.items || []).filter(
(k: ApiKey) => k.status === 'active' && k.group?.platform === 'sora'
)
// 加载活跃订阅,筛选 sora 平台
const subStore = useSubscriptionStore()
const subs = await subStore.fetchActiveSubscriptions()
subscriptionOptions.value = subs.filter(
(s: UserSubscription) => s.status === 'active' && s.group?.platform === 'sora'
)
// 自动选择第一个
if (apiKeyOptions.value.length > 0) {
selectedCredentialId.value = apiKeyOptions.value[0].id
} else if (subscriptionOptions.value.length > 0) {
selectedCredentialId.value = -subscriptionOptions.value[0].id
}
} catch (e) {
console.error('Failed to load sora credentials:', e)
}
}
function autoResize() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
imageError.value = ''
if (file.size > MAX_IMAGE_SIZE) {
imageError.value = t('sora.imageTooLarge')
input.value = ''
return
}
const reader = new FileReader()
reader.onload = (e) => {
imagePreview.value = e.target?.result as string
}
reader.readAsDataURL(file)
input.value = ''
}
function removeImage() {
imagePreview.value = null
imageError.value = ''
}
function submit() {
if (!canSubmit.value || props.generating || isMaxReached.value) return
const modelID = buildModelID()
const req: GenerateRequest = {
model: modelID,
prompt: prompt.value.trim(),
media_type: currentFamily.value?.type || 'video'
}
if ((currentFamily.value?.type || 'video') === 'video') {
req.video_count = currentVideoCount.value
}
if (imagePreview.value) {
req.image_input = imagePreview.value
}
if (selectedCredentialId.value > 0) {
req.api_key_id = selectedCredentialId.value
}
emit('generate', req)
prompt.value = ''
imagePreview.value = null
imageError.value = ''
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
}
/** 外部调用:填充提示词 */
function fillPrompt(text: string) {
prompt.value = text
setTimeout(autoResize, 0)
textareaRef.value?.focus()
}
defineExpose({ fillPrompt })
onMounted(() => {
loadModels()
loadStorageStatus()
loadSoraCredentials()
})
</script>
<style scoped>
.sora-creator-bar-wrapper {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
background: linear-gradient(to top, var(--sora-bg-primary, #0D0D0D) 60%, transparent 100%);
padding: 20px 24px 24px;
pointer-events: none;
}
.sora-creator-bar {
max-width: 780px;
margin: 0 auto;
pointer-events: all;
}
.sora-creator-bar-inner {
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-xl, 20px);
padding: 12px 16px;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.sora-creator-bar-inner.focused {
border-color: var(--sora-accent-primary, #14b8a6);
box-shadow: 0 0 0 1px var(--sora-accent-primary, #14b8a6), var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
}
/* 模型选择行 */
.sora-creator-model-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 0 4px;
}
.sora-model-select-wrapper {
position: relative;
}
.sora-model-select {
appearance: none;
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-primary, #FFF);
padding: 5px 28px 5px 10px;
border-radius: var(--sora-radius-sm, 8px);
font-size: 12px;
font-family: "SF Mono", "Fira Code", monospace;
cursor: pointer;
border: 1px solid transparent;
transition: all 150ms ease;
}
.sora-model-select:hover {
border-color: var(--sora-bg-hover, #333);
}
.sora-model-select:focus {
border-color: var(--sora-accent-primary, #14b8a6);
outline: none;
}
.sora-model-select option {
background: var(--sora-bg-secondary, #1A1A1A);
color: var(--sora-text-primary, #FFF);
}
.sora-model-select-arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 10px;
color: var(--sora-text-tertiary, #666);
}
.sora-credential-select-wrapper {
position: relative;
max-width: 200px;
}
/* 无存储提示 */
.sora-no-storage-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: var(--sora-radius-full, 9999px);
font-size: 11px;
color: var(--sora-warning, #F59E0B);
}
/* 参考图预览 */
.sora-image-preview-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
margin-bottom: 8px;
}
.sora-image-preview-thumb {
position: relative;
width: 48px;
height: 48px;
}
.sora-image-preview-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--sora-border-color, #2A2A2A);
}
.sora-image-preview-remove {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--sora-error, #EF4444);
color: white;
font-size: 10px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.sora-image-preview-label {
font-size: 12px;
color: var(--sora-text-tertiary, #666);
}
/* 输入框 */
.sora-creator-input-wrapper {
position: relative;
}
.sora-creator-textarea {
width: 100%;
min-height: 44px;
max-height: 120px;
padding: 10px 4px;
font-size: 14px;
color: var(--sora-text-primary, #FFF);
background: transparent;
resize: none;
line-height: 1.5;
overflow-y: auto;
border: none;
outline: none;
font-family: inherit;
}
.sora-creator-textarea::placeholder {
color: var(--sora-text-muted, #4A4A4A);
}
/* 底部工具行 */
.sora-creator-tools-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 0;
border-top: 1px solid var(--sora-border-subtle, #1F1F1F);
margin-top: 4px;
padding-top: 10px;
gap: 8px;
}
.sora-creator-tools-left {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.sora-tool-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
background: var(--sora-bg-tertiary, #242424);
border: none;
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.sora-tool-btn:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
.sora-tool-btn.active {
background: rgba(20, 184, 166, 0.15);
color: var(--sora-accent-primary, #14b8a6);
border: 1px solid rgba(20, 184, 166, 0.3);
}
.sora-tool-btn-icon {
font-size: 14px;
line-height: 1;
}
.sora-tool-divider {
width: 1px;
height: 20px;
background: var(--sora-border-color, #2A2A2A);
margin: 0 4px;
}
/* 上传按钮 */
.sora-upload-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--sora-radius-sm, 8px);
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-secondary, #A0A0A0);
font-size: 16px;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-upload-btn:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
/* 活跃任务计数 */
.sora-active-tasks-label {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: rgba(20, 184, 166, 0.12);
border: 1px solid rgba(20, 184, 166, 0.25);
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
font-weight: 500;
color: var(--sora-accent-primary, #14b8a6);
white-space: nowrap;
animation: sora-fade-in 0.3s ease;
}
.sora-pulse-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--sora-accent-primary, #14b8a6);
animation: sora-pulse-dot 1.5s ease-in-out infinite;
}
@keyframes sora-pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes sora-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 生成按钮 */
.sora-generate-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 24px;
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 600;
color: white;
border: none;
cursor: pointer;
transition: all 150ms ease;
flex-shrink: 0;
}
.sora-generate-btn:hover:not(:disabled) {
background: var(--sora-accent-gradient-hover, linear-gradient(135deg, #2dd4bf, #14b8a6));
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
transform: translateY(-1px);
}
.sora-generate-btn:active:not(:disabled) {
transform: translateY(0);
}
.sora-generate-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.sora-generate-btn.max-reached {
opacity: 0.4;
cursor: not-allowed;
}
.sora-generate-btn-icon {
font-size: 16px;
}
/* 图片错误 */
.sora-image-error {
text-align: center;
font-size: 12px;
color: var(--sora-error, #EF4444);
margin-top: 8px;
pointer-events: all;
}
/* 响应式 */
@media (max-width: 600px) {
.sora-creator-bar-wrapper {
padding: 12px 12px 16px;
}
.sora-creator-tools-left {
gap: 4px;
}
.sora-tool-btn {
padding: 5px 8px;
font-size: 11px;
}
}
</style>

View File

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

View File

@@ -1,382 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, ref } from 'vue'
import SoraGeneratePage from '../SoraGeneratePage.vue'
import type { SoraGeneration, QuotaInfo, GenerateRequest } from '@/api/sora'
// 使用 vi.hoisted 确保 mock 函数在 mock 工厂函数执行前定义
const {
mockGenerate,
mockGetGeneration,
mockCancelGeneration,
mockDeleteGeneration,
mockSaveToStorage,
mockGetQuota,
mockGetModels,
mockListGenerations
} = vi.hoisted(() => ({
mockGenerate: vi.fn(),
mockGetGeneration: vi.fn(),
mockCancelGeneration: vi.fn(),
mockDeleteGeneration: vi.fn(),
mockSaveToStorage: vi.fn(),
mockGetQuota: vi.fn(),
mockGetModels: vi.fn(),
mockListGenerations: vi.fn()
}))
// Mock SoraProgressCard component
vi.mock('../SoraProgressCard.vue', () => ({
default: defineComponent({
name: 'SoraProgressCard',
props: ['generation'],
emits: ['cancel', 'delete', 'save', 'retry'],
template: '<div class="sora-progress-card" :data-id="generation.id">{{ generation.status }}</div>'
})
}))
// Mock SoraPromptBar component - 必须暴露 fillPrompt 和 reset 方法
vi.mock('../SoraPromptBar.vue', () => ({
default: defineComponent({
name: 'SoraPromptBar',
props: ['generating', 'activeTaskCount', 'maxConcurrentTasks'],
emits: ['generate'],
setup(_, { expose }) {
const promptText = ref('')
const fillPrompt = (text: string) => {
promptText.value = text
}
const reset = () => {
promptText.value = ''
}
expose({ fillPrompt, reset })
return { promptText, fillPrompt, reset }
},
template: '<div class="sora-prompt-bar"><slot /></div>'
})
}))
// Mock API
vi.mock('@/api/sora', () => ({
default: {
generate: mockGenerate,
getGeneration: mockGetGeneration,
cancelGeneration: mockCancelGeneration,
deleteGeneration: mockDeleteGeneration,
saveToStorage: mockSaveToStorage,
getQuota: mockGetQuota,
getModels: mockGetModels,
listGenerations: mockListGenerations,
getStorageStatus: vi.fn().mockResolvedValue({ s3_enabled: true, s3_healthy: true })
}
}))
// Mock vue-i18n
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
// Mock window.Notification
vi.stubGlobal('Notification', {
permission: 'default',
requestPermission: vi.fn().mockResolvedValue('granted')
})
function createMockGeneration(overrides: Partial<SoraGeneration> = {}): SoraGeneration {
return {
id: 1,
user_id: 1,
model: 'sora2',
prompt: 'Test prompt',
media_type: 'video',
status: 'completed',
storage_type: 's3',
media_url: 'https://example.com/video.mp4',
media_urls: [],
s3_object_keys: [],
file_size_bytes: 1024 * 1024,
error_message: '',
created_at: '2024-01-01T00:00:00Z',
...overrides
}
}
function createMockQuota(overrides: Partial<QuotaInfo> = {}): QuotaInfo {
return {
quota_bytes: 10 * 1024 * 1024 * 1024,
used_bytes: 1 * 1024 * 1024 * 1024,
available_bytes: 9 * 1024 * 1024 * 1024,
quota_source: 'user',
...overrides
}
}
describe('SoraGeneratePage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockGetQuota.mockResolvedValue(createMockQuota())
mockGetModels.mockResolvedValue([
{ id: 'sora2', name: 'Sora 2', type: 'video', orientations: ['landscape', 'portrait'], durations: [10, 15, 25] }
])
mockListGenerations.mockResolvedValue({ data: [], total: 0, page: 1 })
})
afterEach(() => {
vi.useRealTimers()
})
describe('初始渲染', () => {
it('无活跃任务时显示欢迎区域', async () => {
const wrapper = mount(SoraGeneratePage)
await flushPromises()
expect(wrapper.find('.sora-welcome-section').exists()).toBe(true)
expect(wrapper.find('.sora-welcome-title').text()).toBe('sora.welcomeTitle')
})
it('无活跃任务时显示示例提示词', async () => {
const wrapper = mount(SoraGeneratePage)
await flushPromises()
const examplePrompts = wrapper.findAll('.sora-example-prompt')
expect(examplePrompts.length).toBeGreaterThan(0)
})
it('有活跃任务时隐藏欢迎区域', async () => {
mockListGenerations.mockResolvedValue({
data: [createMockGeneration({ status: 'generating' })],
total: 1,
page: 1
})
const wrapper = mount(SoraGeneratePage)
await flushPromises()
expect(wrapper.find('.sora-welcome-section').exists()).toBe(false)
expect(wrapper.find('.sora-task-cards').exists()).toBe(true)
})
})
describe('生成流程', () => {
it('点击示例提示词填充输入框', async () => {
const wrapper = mount(SoraGeneratePage)
await flushPromises()
const firstExample = wrapper.find('.sora-example-prompt')
// 点击不应抛出错误fillPrompt 方法已被 mock
await firstExample.trigger('click')
await flushPromises()
expect(wrapper.exists()).toBe(true)
})
it('成功提交生成请求', async () => {
mockGenerate.mockResolvedValue({ generation_id: 123 })
mockGetGeneration.mockResolvedValue(createMockGeneration({ id: 123, status: 'pending' }))
const wrapper = mount(SoraGeneratePage)
await flushPromises()
// 直接通过 findComponent 获取 SoraPromptBar 并触发 generate 事件
const promptBar = wrapper.findComponent({ name: 'SoraPromptBar' })
const generateReq: GenerateRequest = { model: 'sora2', prompt: 'Test prompt', media_type: 'video' }
await promptBar.vm.$emit('generate', generateReq)
await flushPromises()
expect(mockGenerate).toHaveBeenCalledWith(generateReq)
})
it('生成失败时显示错误提示', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockGenerate.mockRejectedValue(new Error('Generation failed'))
// Mock alert
vi.stubGlobal('alert', vi.fn())
const wrapper = mount(SoraGeneratePage)
await flushPromises()
const promptBar = wrapper.findComponent({ name: 'SoraPromptBar' })
const generateReq: GenerateRequest = { model: 'sora2', prompt: 'Test', media_type: 'video' }
await promptBar.vm.$emit('generate', generateReq)
await flushPromises()
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
})
describe('任务管理', () => {
it('取消任务', async () => {
mockCancelGeneration.mockResolvedValue({})
mockListGenerations.mockResolvedValue({
data: [createMockGeneration({ id: 1, status: 'generating' })],
total: 1,
page: 1
})
const wrapper = mount(SoraGeneratePage)
await flushPromises()
const progressCard = wrapper.findComponent({ name: 'SoraProgressCard' })
await progressCard.vm.$emit('cancel', 1)
await flushPromises()
expect(mockCancelGeneration).toHaveBeenCalledWith(1)
})
it('删除任务', async () => {
mockDeleteGeneration.mockResolvedValue({})
mockListGenerations.mockResolvedValue({
data: [createMockGeneration({ id: 1 })],
total: 1,
page: 1
})
const wrapper = mount(SoraGeneratePage)
await flushPromises()
const progressCard = wrapper.findComponent({ name: 'SoraProgressCard' })
await progressCard.vm.$emit('delete', 1)
await flushPromises()
expect(mockDeleteGeneration).toHaveBeenCalledWith(1)
})
it('保存到存储', async () => {
mockSaveToStorage.mockResolvedValue({ message: 'Saved' })
mockGetGeneration.mockResolvedValue(createMockGeneration({ id: 1, storage_type: 's3' }))
mockListGenerations.mockResolvedValue({
data: [createMockGeneration({ id: 1 })],
total: 1,
page: 1
})
const wrapper = mount(SoraGeneratePage)
await flushPromises()
const progressCard = wrapper.findComponent({ name: 'SoraProgressCard' })
await progressCard.vm.$emit('save', 1)
await flushPromises()
expect(mockSaveToStorage).toHaveBeenCalledWith(1)
})
})
describe('任务计数', () => {
it('计算活跃任务数量', async () => {
mockListGenerations.mockResolvedValue({
data: [
createMockGeneration({ id: 1, status: 'pending' }),
createMockGeneration({ id: 2, status: 'generating' }),
createMockGeneration({ id: 3, status: 'completed' })
],
total: 3,
page: 1
})
const wrapper = mount(SoraGeneratePage)
await flushPromises()
// activeTaskCount 应该只计算 pending 和 generating
const promptBar = wrapper.findComponent({ name: 'SoraPromptBar' })
expect(promptBar.props('activeTaskCount')).toBe(2)
})
it('触发 task-count-change 事件', async () => {
mockListGenerations.mockResolvedValue({
data: [createMockGeneration({ status: 'generating' })],
total: 1,
page: 1
})
const wrapper = mount(SoraGeneratePage)
await flushPromises()
// 事件应该在 watch 中触发
expect(wrapper.emitted('task-count-change')).toBeTruthy()
})
})
describe('轮询机制', () => {
it('启动轮询检查任务状态', async () => {
mockGenerate.mockResolvedValue({ generation_id: 123 })
// 使用接近当前时间的时间戳,确保轮询间隔为 3 秒
const now = new Date().toISOString()
mockGetGeneration.mockResolvedValue(createMockGeneration({ id: 123, status: 'generating', created_at: now }))
const wrapper = mount(SoraGeneratePage)
await flushPromises()
const promptBar = wrapper.findComponent({ name: 'SoraPromptBar' })
const generateReq: GenerateRequest = { model: 'sora2', prompt: 'Test', media_type: 'video' }
await promptBar.vm.$emit('generate', generateReq)
await flushPromises()
// 第一次 getGeneration 在 handleGenerate 中
expect(mockGetGeneration).toHaveBeenCalledTimes(1)
// 快进轮询定时器 - 对于刚创建的任务,轮询间隔为 3 秒
vi.advanceTimersByTime(3000)
await flushPromises()
// 轮询应该再次调用 getGeneration
expect(mockGetGeneration).toHaveBeenCalledTimes(2)
})
})
describe('边界条件', () => {
it('API 错误时不会崩溃', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockGetQuota.mockRejectedValue(new Error('Network error'))
const wrapper = mount(SoraGeneratePage)
await flushPromises()
expect(wrapper.exists()).toBe(true)
consoleSpy.mockRestore()
})
it('组件卸载时清理定时器', async () => {
mockGenerate.mockResolvedValue({ generation_id: 123 })
mockGetGeneration.mockResolvedValue(createMockGeneration({ id: 123, status: 'generating' }))
const wrapper = mount(SoraGeneratePage)
await flushPromises()
const promptBar = wrapper.findComponent({ name: 'SoraPromptBar' })
const generateReq: GenerateRequest = { model: 'sora2', prompt: 'Test', media_type: 'video' }
await promptBar.vm.$emit('generate', generateReq)
await flushPromises()
// 卸载组件
wrapper.unmount()
// 清理后不应该有内存泄漏
vi.advanceTimersByTime(10000)
expect(true).toBe(true)
})
})
})

View File

@@ -1413,8 +1413,6 @@ export default {
failedToLoadApiKeys: 'Failed to load user API keys',
emailRequired: 'Please enter email',
concurrencyMin: 'Concurrency must be at least 1',
soraStorageQuota: 'Sora Storage Quota',
soraStorageQuotaHint: 'In GB, 0 means use group or system default quota',
amountRequired: 'Please enter a valid amount',
insufficientBalance: 'Insufficient balance',
deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.",
@@ -3145,29 +3143,6 @@ export default {
"Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed."
},
// Sora Management
sora: {
title: 'Sora Management',
description: 'Manage Sora video generation service and user quotas',
overview: 'Overview',
userStats: 'User Stats',
generations: 'Generations',
totalUsers: 'Total Users',
totalGenerations: 'Total Generations',
activeGenerations: 'Active Generations',
totalStorage: 'Total Storage',
byStatus: 'By Status',
byModel: 'By Model',
quota: 'Quota',
used: 'Used',
model: 'Model',
status: 'Status',
size: 'Size',
createdAt: 'Created At',
clearStorage: 'Clear Storage',
confirmClearStorage: 'Are you sure you want to clear this user\'s Sora storage? This action cannot be undone.'
},
// Redeem Codes
redeem: {
title: 'Redeem Code Management',
@@ -4523,12 +4498,6 @@ export default {
integrationDoc: 'Payment Integration Docs',
integrationDocHint: 'Covers endpoint specs, idempotency semantics, and code samples'
},
soraClient: {
title: 'Sora Client',
description: 'Control whether to show the Sora client entry in the sidebar',
enabled: 'Enable Sora Client',
enabledHint: 'When enabled, the Sora entry will be shown in the sidebar for users to access Sora features'
},
customMenu: {
title: 'Custom Menu Pages',
description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.',
@@ -4717,98 +4686,6 @@ export default {
securityWarning: 'Warning: This key provides full admin access. Keep it secure.',
usage: 'Usage: Add to request header - x-api-key: <your-admin-api-key>'
},
soraS3: {
title: 'Sora Storage',
description: 'Manage Sora media storage profiles with S3 and Google Drive support',
newProfile: 'New Profile',
reloadProfiles: 'Reload Profiles',
empty: 'No storage profiles yet, create one first',
createTitle: 'Create Storage Profile',
editTitle: 'Edit Storage Profile',
selectProvider: 'Select Storage Type',
providerS3Desc: 'S3-compatible object storage',
providerGDriveDesc: 'Google Drive cloud storage',
profileID: 'Profile ID',
profileName: 'Profile Name',
setActive: 'Set as active after creation',
saveProfile: 'Save Profile',
activateProfile: 'Activate',
profileCreated: 'Storage profile created',
profileSaved: 'Storage profile saved',
profileDeleted: 'Storage profile deleted',
profileActivated: 'Active storage profile switched',
profileIDRequired: 'Profile ID is required',
profileNameRequired: 'Profile name is required',
profileSelectRequired: 'Please select a profile first',
endpointRequired: 'S3 endpoint is required when enabled',
bucketRequired: 'Bucket is required when enabled',
accessKeyRequired: 'Access Key ID is required when enabled',
deleteConfirm: 'Delete storage profile {profileID}?',
columns: {
profile: 'Profile',
profileId: 'Profile ID',
name: 'Name',
provider: 'Type',
active: 'Active',
endpoint: 'Endpoint',
bucket: 'Bucket',
storagePath: 'Storage Path',
capacityUsage: 'Capacity / Used',
capacityUnlimited: 'Unlimited',
videoCount: 'Videos',
videoCompleted: 'completed',
videoInProgress: 'in progress',
quota: 'Default Quota',
updatedAt: 'Updated At',
actions: 'Actions',
rootFolder: 'Root folder',
testInTable: 'Test',
testingInTable: 'Testing...',
testTimeout: 'Test timed out (15s)'
},
enabled: 'Enable Storage',
enabledHint: 'When enabled, Sora generated media files will be automatically uploaded',
endpoint: 'S3 Endpoint',
region: 'Region',
bucket: 'Bucket',
prefix: 'Object Prefix',
accessKeyId: 'Access Key ID',
secretAccessKey: 'Secret Access Key',
secretConfigured: '(Configured, leave blank to keep)',
cdnUrl: 'CDN URL',
cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL',
forcePathStyle: 'Force Path Style',
defaultQuota: 'Default Storage Quota',
defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited',
testConnection: 'Test Connection',
testing: 'Testing...',
testSuccess: 'Connection test successful',
testFailed: 'Connection test failed',
saved: 'Storage settings saved successfully',
saveFailed: 'Failed to save storage settings',
gdrive: {
authType: 'Authentication Method',
serviceAccount: 'Service Account',
clientId: 'Client ID',
clientSecret: 'Client Secret',
clientSecretConfigured: '(Configured, leave blank to keep)',
refreshToken: 'Refresh Token',
refreshTokenConfigured: '(Configured, leave blank to keep)',
serviceAccountJson: 'Service Account JSON',
serviceAccountConfigured: '(Configured, leave blank to keep)',
folderId: 'Folder ID (optional)',
authorize: 'Authorize Google Drive',
authorizeHint: 'Get Refresh Token via OAuth2',
oauthFieldsRequired: 'Please fill in Client ID and Client Secret first',
oauthSuccess: 'Google Drive authorization successful',
oauthFailed: 'Google Drive authorization failed',
closeWindow: 'This window will close automatically',
processing: 'Processing authorization...',
testStorage: 'Test Storage',
testSuccess: 'Google Drive storage test passed (upload, access, delete all OK)',
testFailed: 'Google Drive storage test failed'
}
},
overloadCooldown: {
title: '529 Overload Cooldown',
description: 'Configure account scheduling pause strategy when upstream returns 529 (overloaded)',

View File

@@ -1477,8 +1477,6 @@ export default {
failedToAdjust: '调整失败',
emailRequired: '请输入邮箱',
concurrencyMin: '并发数不能小于1',
soraStorageQuota: 'Sora 存储配额',
soraStorageQuotaHint: '单位 GB0 表示使用分组或系统默认配额',
amountRequired: '请输入有效金额',
insufficientBalance: '余额不足',
setAllowedGroups: '设置允许分组',
@@ -3271,29 +3269,6 @@ export default {
deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。"
},
// Sora Management
sora: {
title: 'Sora 管理',
description: '管理 Sora 视频生成服务和用户配额',
overview: '概览',
userStats: '用户统计',
generations: '生成记录',
totalUsers: '总用户数',
totalGenerations: '总生成数',
activeGenerations: '活跃生成',
totalStorage: '总存储',
byStatus: '按状态分布',
byModel: '按模型分布',
quota: '配额',
used: '已用',
model: '模型',
status: '状态',
size: '大小',
createdAt: '创建时间',
clearStorage: '清除存储',
confirmClearStorage: '确定要清除该用户的 Sora 存储吗?此操作不可撤销。'
},
// Redeem Codes Management
redeem: {
title: '兑换码管理',
@@ -4687,12 +4662,6 @@ export default {
integrationDoc: '支付集成文档',
integrationDocHint: '包含接口说明、幂等语义及示例代码'
},
soraClient: {
title: 'Sora 客户端',
description: '控制是否在侧边栏展示 Sora 客户端入口',
enabled: '启用 Sora 客户端',
enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能'
},
customMenu: {
title: '自定义菜单页面',
description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。',
@@ -4880,98 +4849,8 @@ export default {
securityWarning: '警告:此密钥拥有完整的管理员权限,请妥善保管。',
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-api-key>'
},
soraS3: {
title: 'Sora 存储配置',
description: '以多配置列表管理 Sora 媒体存储,支持 S3 和 Google Drive',
newProfile: '新建配置',
reloadProfiles: '刷新列表',
empty: '暂无存储配置,请先创建',
createTitle: '新建存储配置',
editTitle: '编辑存储配置',
selectProvider: '选择存储类型',
providerS3Desc: 'S3 兼容对象存储',
providerGDriveDesc: 'Google Drive 云盘',
profileID: '配置 ID',
profileName: '配置名称',
setActive: '创建后设为生效',
saveProfile: '保存配置',
activateProfile: '设为生效',
profileCreated: '存储配置创建成功',
profileSaved: '存储配置保存成功',
profileDeleted: '存储配置删除成功',
profileActivated: '生效配置已切换',
profileIDRequired: '请填写配置 ID',
profileNameRequired: '请填写配置名称',
profileSelectRequired: '请先选择配置',
endpointRequired: '启用时必须填写 S3 端点',
bucketRequired: '启用时必须填写存储桶',
accessKeyRequired: '启用时必须填写 Access Key ID',
deleteConfirm: '确定删除存储配置 {profileID} 吗?',
columns: {
profile: '配置',
profileId: 'Profile ID',
name: '名称',
provider: '存储类型',
active: '生效状态',
endpoint: '端点',
bucket: '存储桶',
storagePath: '存储路径',
capacityUsage: '容量 / 已用',
capacityUnlimited: '无限制',
videoCount: '视频数',
videoCompleted: '完成',
videoInProgress: '进行中',
quota: '默认配额',
updatedAt: '更新时间',
actions: '操作',
rootFolder: '根目录',
testInTable: '测试',
testingInTable: '测试中...',
testTimeout: '测试超时15秒'
},
enabled: '启用存储',
enabledHint: '启用后Sora 生成的媒体文件将自动上传到存储',
endpoint: 'S3 端点',
region: '区域',
bucket: '存储桶',
prefix: '对象前缀',
accessKeyId: 'Access Key ID',
secretAccessKey: 'Secret Access Key',
secretConfigured: '(已配置,留空保持不变)',
cdnUrl: 'CDN URL',
cdnUrlHint: '可选,配置后使用 CDN URL 访问文件',
forcePathStyle: '强制路径风格Path Style',
defaultQuota: '默认存储配额',
defaultQuotaHint: '未在用户或分组级别指定配额时的默认值0 表示无限制',
testConnection: '测试连接',
testing: '测试中...',
testSuccess: '连接测试成功',
testFailed: '连接测试失败',
saved: '存储设置保存成功',
saveFailed: '保存存储设置失败',
gdrive: {
authType: '认证方式',
serviceAccount: '服务账号',
clientId: 'Client ID',
clientSecret: 'Client Secret',
clientSecretConfigured: '(已配置,留空保持不变)',
refreshToken: 'Refresh Token',
refreshTokenConfigured: '(已配置,留空保持不变)',
serviceAccountJson: '服务账号 JSON',
serviceAccountConfigured: '(已配置,留空保持不变)',
folderId: 'Folder ID可选',
authorize: '授权 Google Drive',
authorizeHint: '通过 OAuth2 获取 Refresh Token',
oauthFieldsRequired: '请先填写 Client ID 和 Client Secret',
oauthSuccess: 'Google Drive 授权成功',
oauthFailed: 'Google Drive 授权失败',
closeWindow: '此窗口将自动关闭',
processing: '正在处理授权...',
testStorage: '测试存储',
testSuccess: 'Google Drive 存储测试成功(上传、访问、删除均正常)',
testFailed: 'Google Drive 存储测试失败'
}
},
overloadCooldown: {
title: '529 过载冷却',
description: '配置上游返回 529过载时的账号调度暂停策略',

View File

@@ -207,18 +207,6 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'userSubscriptions.description'
}
},
{
path: '/sora',
name: 'Sora',
component: () => import('@/views/user/SoraView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Sora',
titleKey: 'sora.title',
descriptionKey: 'sora.description'
}
},
{
path: '/purchase',
name: 'PurchaseSubscription',
@@ -416,18 +404,6 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.proxies.description'
}
},
{
path: '/admin/sora',
name: 'AdminSora',
component: () => import('@/views/admin/SoraAdminView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Sora Management',
titleKey: 'admin.sora.title',
descriptionKey: 'admin.sora.description'
}
},
{
path: '/admin/redeem',
name: 'AdminRedeem',

View File

@@ -338,7 +338,6 @@ export const useAppStore = defineStore('app', () => {
linuxdo_oauth_enabled: false,
oidc_oauth_enabled: false,
oidc_oauth_provider_name: 'OIDC',
sora_client_enabled: false, // 从本地版本合并
backend_mode_enabled: false,
version: siteVersion.value
}

View File

@@ -45,10 +45,6 @@ export interface AdminUser extends User {
group_rates?: Record<number, number>
// 当前并发数(仅管理员列表接口返回)
current_concurrency?: number
// Sora 存储配额单位字节0 表示使用分组或系统默认配额)
sora_storage_quota_bytes: number
// Sora 存储已用字节
sora_storage_used_bytes: number
}
export interface LoginRequest {
@@ -116,7 +112,6 @@ export interface PublicSettings {
linuxdo_oauth_enabled: boolean
oidc_oauth_enabled: boolean
oidc_oauth_provider_name: string
sora_client_enabled: boolean // 从本地版本合并
backend_mode_enabled: boolean
version: string
}
@@ -370,7 +365,7 @@ export interface PaginationConfig {
// ==================== API Key & Group Types ====================
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora' // 从本地版本合并添加sora平台
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type SubscriptionType = 'standard' | 'subscription'
@@ -547,7 +542,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora' // 从本地版本合并添加sora平台
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'

View File

@@ -1,417 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import soraAdminAPI, { type SoraSystemStats, type SoraUserStats, type SoraGenerationAdmin } from '@/api/admin/sora'
import AppLayout from '@/components/layout/AppLayout.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
// State
const loading = ref(true)
const systemStats = ref<SoraSystemStats | null>(null)
const userStats = ref<SoraUserStats[]>([])
const generations = ref<SoraGenerationAdmin[]>([])
const activeTab = ref<'overview' | 'users' | 'generations'>('overview')
// Pagination
const userPage = ref(1)
const userPageSize = ref(20)
const userTotal = ref(0)
const genPage = ref(1)
const genPageSize = ref(20)
const genTotal = ref(0)
// Filters
const userSearch = ref('')
const genStatusFilter = ref('')
const genModelFilter = ref('')
// Computed
const userTotalPages = computed(() => Math.ceil(userTotal.value / userPageSize.value))
const genTotalPages = computed(() => Math.ceil(genTotal.value / genPageSize.value))
// Format helpers
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
function getStatusColor(status: string): string {
switch (status) {
case 'completed': return 'text-green-600 dark:text-green-400'
case 'failed': return 'text-red-600 dark:text-red-400'
case 'cancelled': return 'text-gray-500 dark:text-gray-400'
case 'pending': return 'text-yellow-600 dark:text-yellow-400'
case 'generating': return 'text-blue-600 dark:text-blue-400'
default: return 'text-gray-600 dark:text-gray-400'
}
}
// API calls
async function fetchSystemStats() {
try {
systemStats.value = await soraAdminAPI.getSystemStats()
} catch (err) {
console.error('Failed to fetch system stats:', err)
}
}
async function fetchUserStats() {
try {
const res = await soraAdminAPI.listUserStats({
page: userPage.value,
page_size: userPageSize.value,
search: userSearch.value || undefined
})
userStats.value = res.items
userTotal.value = res.total
} catch (err) {
console.error('Failed to fetch user stats:', err)
}
}
async function fetchGenerations() {
try {
const res = await soraAdminAPI.listGenerations({
page: genPage.value,
page_size: genPageSize.value,
status: genStatusFilter.value || undefined,
model: genModelFilter.value || undefined
})
generations.value = res.items
genTotal.value = res.total
} catch (err) {
console.error('Failed to fetch generations:', err)
}
}
async function loadAll() {
loading.value = true
await Promise.all([
fetchSystemStats(),
fetchUserStats(),
fetchGenerations()
])
loading.value = false
}
// Event handlers
function onUserPageChange(page: number) {
userPage.value = page
fetchUserStats()
}
function onGenPageChange(page: number) {
genPage.value = page
fetchGenerations()
}
function onTabChange(tab: 'overview' | 'users' | 'generations') {
activeTab.value = tab
}
onMounted(loadAll)
</script>
<template>
<AppLayout>
<div class="space-y-6">
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
<template v-else>
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('admin.sora.title') }}
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.sora.description') }}
</p>
</div>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex space-x-8">
<button
@click="onTabChange('overview')"
:class="[
activeTab === 'overview'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
]"
>
{{ t('admin.sora.overview') }}
</button>
<button
@click="onTabChange('users')"
:class="[
activeTab === 'users'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
]"
>
{{ t('admin.sora.userStats') }}
</button>
<button
@click="onTabChange('generations')"
:class="[
activeTab === 'generations'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
]"
>
{{ t('admin.sora.generations') }}
</button>
</nav>
</div>
<!-- Overview Tab -->
<div v-if="activeTab === 'overview' && systemStats" class="space-y-6">
<!-- Stats Cards -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
<Icon name="users" size="md" class="text-purple-600 dark:text-purple-400" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.sora.totalUsers') }}
</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">
{{ systemStats.total_users }}
</p>
</div>
</div>
</div>
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<Icon name="chartBar" size="md" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.sora.totalGenerations') }}
</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">
{{ systemStats.total_generations }}
</p>
</div>
</div>
</div>
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<Icon name="refresh" size="md" class="text-green-600 dark:text-green-400" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.sora.activeGenerations') }}
</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">
{{ systemStats.active_generations }}
</p>
</div>
</div>
</div>
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/30">
<Icon name="database" size="md" class="text-orange-600 dark:text-orange-400" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.sora.totalStorage') }}
</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">
{{ formatBytes(systemStats.total_storage_bytes) }}
</p>
</div>
</div>
</div>
</div>
<!-- By Status -->
<div class="card p-4">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.sora.byStatus') }}
</h3>
<div class="grid grid-cols-2 gap-4 md:grid-cols-5">
<div v-for="(count, status) in systemStats.by_status" :key="status" class="text-center">
<p class="text-2xl font-bold" :class="getStatusColor(status)">{{ count }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ status }}</p>
</div>
</div>
</div>
<!-- By Model -->
<div class="card p-4">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.sora.byModel') }}
</h3>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div v-for="(count, model) in systemStats.by_model" :key="model" class="text-center">
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ count }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ model }}</p>
</div>
</div>
</div>
</div>
<!-- Users Tab -->
<div v-if="activeTab === 'users'" class="space-y-4">
<!-- Search -->
<div class="flex items-center gap-4">
<input
v-model="userSearch"
type="text"
:placeholder="t('common.search')"
class="input flex-1"
@keyup.enter="fetchUserStats"
/>
<button class="btn btn-primary" @click="fetchUserStats">{{ t('common.search') }}</button>
</div>
<!-- Table -->
<div class="card overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('admin.users.username') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('admin.users.email') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('admin.sora.quota') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('admin.sora.used') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('admin.sora.generations') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('common.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="user in userStats" :key="user.user_id">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ user.username }}</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{{ user.email }}</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ formatBytes(user.quota_bytes) }}</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ formatBytes(user.used_bytes) }}</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ user.generations_count }}</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">-</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="userTotalPages > 1" class="flex items-center justify-center gap-2">
<button
v-for="p in userTotalPages"
:key="p"
:class="['btn btn-sm', p === userPage ? 'btn-primary' : 'btn-secondary']"
@click="onUserPageChange(p)"
>
{{ p }}
</button>
</div>
</div>
<!-- Generations Tab -->
<div v-if="activeTab === 'generations'" class="space-y-4">
<!-- Filters -->
<div class="flex items-center gap-4">
<select v-model="genStatusFilter" class="input" @change="fetchGenerations">
<option value="">{{ t('common.allStatus') }}</option>
<option value="pending">Pending</option>
<option value="generating">Generating</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<select v-model="genModelFilter" class="input" @change="fetchGenerations">
<option value="">{{ t('common.allModels') }}</option>
<option value="sora2">Sora 2</option>
</select>
</div>
<!-- Table -->
<div class="card overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">ID</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('admin.users.username') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('admin.sora.model') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('admin.sora.status') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('admin.sora.size') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{{ t('admin.sora.createdAt') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="gen in generations" :key="gen.id">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ gen.id }}</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{{ gen.username }}</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ gen.model }}</td>
<td class="px-4 py-3 text-sm" :class="getStatusColor(gen.status)">{{ gen.status }}</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ formatBytes(gen.file_size_bytes) }}</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{{ formatDate(gen.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="genTotalPages > 1" class="flex items-center justify-center gap-2">
<button
v-for="p in genTotalPages"
:key="p"
:class="['btn btn-sm', p === genPage ? 'btn-primary' : 'btn-secondary']"
@click="onGenPageChange(p)"
>
{{ p }}
</button>
</div>
</div>
</template>
</div>
</AppLayout>
</template>

View File

@@ -1,262 +0,0 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import SoraAdminView from '../SoraAdminView.vue'
import type { SoraSystemStats, SoraUserStats, SoraGenerationAdmin } from '@/api/admin/sora'
// 使用 vi.hoisted 确保 mock 函数在 mock 工厂函数执行前定义
const {
mockGetSystemStats,
mockListUserStats,
mockListGenerations
} = vi.hoisted(() => ({
mockGetSystemStats: vi.fn(),
mockListUserStats: vi.fn(),
mockListGenerations: vi.fn()
}))
vi.mock('@/api/admin/sora', () => ({
default: {
getSystemStats: mockGetSystemStats,
listUserStats: mockListUserStats,
listGenerations: mockListGenerations
}
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
// Stub child components
const AppLayoutStub = defineComponent({
name: 'AppLayout',
template: '<div class="app-layout"><slot /></div>'
})
const LoadingSpinnerStub = defineComponent({
name: 'LoadingSpinner',
template: '<div class="loading-spinner" />'
})
const IconStub = defineComponent({
name: 'Icon',
props: ['name', 'size'],
template: '<span class="icon" />'
})
function createMockSystemStats(overrides: Partial<SoraSystemStats> = {}): SoraSystemStats {
return {
total_users: 10,
total_generations: 100,
total_storage_bytes: 5 * 1024 * 1024 * 1024,
active_generations: 3,
by_status: { completed: 80, failed: 15, pending: 5 },
by_model: { sora2: 60, sora1: 40 },
...overrides
}
}
function createMockUserStats(): SoraUserStats[] {
return [
{
user_id: 1,
username: 'user1',
email: 'user1@example.com',
quota_bytes: 10 * 1024 * 1024 * 1024,
used_bytes: 2 * 1024 * 1024 * 1024,
available_bytes: 8 * 1024 * 1024 * 1024,
quota_source: 'user',
generations_count: 20,
active_count: 1,
total_file_size_bytes: 1 * 1024 * 1024 * 1024
}
]
}
function createMockGenerations(): SoraGenerationAdmin[] {
return [
{
id: 1,
user_id: 1,
username: 'user1',
email: 'user1@example.com',
model: 'sora2',
prompt: 'A beautiful sunset',
media_type: 'video',
status: 'completed',
storage_type: 's3',
media_url: 'https://example.com/video.mp4',
file_size_bytes: 10 * 1024 * 1024,
error_message: '',
created_at: '2024-01-01T10:00:00Z',
completed_at: '2024-01-01T10:05:00Z'
}
]
}
describe('SoraAdminView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSystemStats.mockResolvedValue(createMockSystemStats())
mockListUserStats.mockResolvedValue({
items: createMockUserStats(),
total: 1,
page: 1,
page_size: 20,
pages: 1
})
mockListGenerations.mockResolvedValue({
items: createMockGenerations(),
total: 1,
page: 1,
page_size: 20,
pages: 1
})
})
function mountComponent() {
return mount(SoraAdminView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
LoadingSpinner: LoadingSpinnerStub,
Icon: IconStub
}
}
})
}
describe('初始加载', () => {
it('加载时显示 loading spinner', () => {
const wrapper = mountComponent()
expect(wrapper.find('.loading-spinner').exists()).toBe(true)
})
it('加载完成后显示页面内容', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('.loading-spinner').exists()).toBe(false)
expect(wrapper.find('.app-layout').exists()).toBe(true)
})
it('加载完成后调用所有 API', async () => {
mountComponent()
await flushPromises()
expect(mockGetSystemStats).toHaveBeenCalled()
expect(mockListUserStats).toHaveBeenCalled()
expect(mockListGenerations).toHaveBeenCalled()
})
})
describe('概览标签页', () => {
it('默认显示概览标签页', async () => {
const wrapper = mountComponent()
await flushPromises()
// 概览标签页应显示统计卡片
const cards = wrapper.findAll('.card')
expect(cards.length).toBeGreaterThan(0)
})
it('显示系统统计数据', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('10') // total_users
expect(wrapper.text()).toContain('100') // total_generations
expect(wrapper.text()).toContain('3') // active_generations
})
it('显示按状态分布', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('completed')
expect(wrapper.text()).toContain('80')
})
it('显示按模型分布', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('sora2')
expect(wrapper.text()).toContain('60')
})
})
describe('用户统计标签页', () => {
it('点击用户统计标签切换到用户列表', async () => {
const wrapper = mountComponent()
await flushPromises()
const tabs = wrapper.findAll('button')
const userTab = tabs.find(b => b.text().includes('admin.sora.userStats'))
await userTab?.trigger('click')
await flushPromises()
expect(wrapper.find('table').exists()).toBe(true)
expect(wrapper.text()).toContain('user1')
expect(wrapper.text()).toContain('user1@example.com')
})
it('does not render the deprecated clear storage action', async () => {
const wrapper = mountComponent()
await flushPromises()
const tabs = wrapper.findAll('button')
const userTab = tabs.find(b => b.text().includes('admin.sora.userStats'))
await userTab?.trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain('admin.sora.clearStorage')
})
})
describe('生成记录标签页', () => {
it('点击生成记录标签切换到记录列表', async () => {
const wrapper = mountComponent()
await flushPromises()
const tabs = wrapper.findAll('button')
const genTab = tabs.find(b => b.text().includes('admin.sora.generations'))
await genTab?.trigger('click')
await flushPromises()
expect(wrapper.find('table').exists()).toBe(true)
expect(wrapper.text()).toContain('user1')
expect(wrapper.text()).toContain('sora2')
expect(wrapper.text()).toContain('completed')
})
})
describe('API 错误处理', () => {
it('API 错误时不会崩溃', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockGetSystemStats.mockRejectedValue(new Error('Network error'))
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.exists()).toBe(true)
consoleSpy.mockRestore()
})
})
describe('格式化函数', () => {
it('正确格式化字节数', async () => {
const wrapper = mountComponent()
await flushPromises()
// 5 GB 应该显示为 "5 GB"
expect(wrapper.text()).toContain('5 GB')
})
})
})

View File

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