fix: restore upstream Sora frontend components and remove leftover i18n affinity keys

This commit is contained in:
erio
2026-04-04 17:08:43 +08:00
parent c790b808dd
commit 53afb8c540
7 changed files with 2146 additions and 8 deletions
@@ -0,0 +1,430 @@
<template>
<div class="sora-generate-page">
<div class="sora-task-area">
<!-- 欢迎区域(无任务时显示) -->
<div v-if="activeGenerations.length === 0" class="sora-welcome-section">
<h1 class="sora-welcome-title">{{ t('sora.welcomeTitle') }}</h1>
<p class="sora-welcome-subtitle">{{ t('sora.welcomeSubtitle') }}</p>
</div>
<!-- 示例提示词(无任务时显示) -->
<div v-if="activeGenerations.length === 0" class="sora-example-prompts">
<button
v-for="(example, idx) in examplePrompts"
:key="idx"
class="sora-example-prompt"
@click="fillPrompt(example)"
>
{{ example }}
</button>
</div>
<!-- 任务卡片列表 -->
<div v-if="activeGenerations.length > 0" class="sora-task-cards">
<SoraProgressCard
v-for="gen in activeGenerations"
:key="gen.id"
:generation="gen"
@cancel="handleCancel"
@delete="handleDelete"
@save="handleSave"
@retry="handleRetry"
/>
</div>
<!-- 无存储提示 Toast -->
<div v-if="showNoStorageToast" class="sora-no-storage-toast">
<span>⚠️</span>
<span>{{ t('sora.noStorageToastMessage') }}</span>
</div>
</div>
<!-- 底部创作栏 -->
<SoraPromptBar
ref="promptBarRef"
:generating="generating"
:active-task-count="activeTaskCount"
:max-concurrent-tasks="3"
@generate="handleGenerate"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import soraAPI, { type SoraGeneration, type GenerateRequest } from '@/api/sora'
import SoraProgressCard from './SoraProgressCard.vue'
import SoraPromptBar from './SoraPromptBar.vue'
const { t } = useI18n()
const emit = defineEmits<{
'task-count-change': [counts: { active: number; generating: boolean }]
}>()
const activeGenerations = ref<SoraGeneration[]>([])
const generating = ref(false)
const showNoStorageToast = ref(false)
let pollTimers: Record<number, ReturnType<typeof setTimeout>> = {}
const promptBarRef = ref<InstanceType<typeof SoraPromptBar> | null>(null)
// 示例提示词
const examplePrompts = [
'一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
'无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
'赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
'水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
]
// 活跃任务统计
const activeTaskCount = computed(() =>
activeGenerations.value.filter(g => g.status === 'pending' || g.status === 'generating').length
)
const hasGeneratingTask = computed(() =>
activeGenerations.value.some(g => g.status === 'generating')
)
// 通知父组件任务数变化
watch([activeTaskCount, hasGeneratingTask], () => {
emit('task-count-change', {
active: activeTaskCount.value,
generating: hasGeneratingTask.value
})
}, { immediate: true })
// ==================== 浏览器通知 ====================
function requestNotificationPermission() {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission()
}
}
function sendNotification(title: string, body: string) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' })
}
}
const originalTitle = document.title
let titleBlinkTimer: ReturnType<typeof setInterval> | null = null
function startTitleBlink(message: string) {
stopTitleBlink()
let show = true
titleBlinkTimer = setInterval(() => {
document.title = show ? message : originalTitle
show = !show
}, 1000)
const onFocus = () => {
stopTitleBlink()
window.removeEventListener('focus', onFocus)
}
window.addEventListener('focus', onFocus)
}
function stopTitleBlink() {
if (titleBlinkTimer) {
clearInterval(titleBlinkTimer)
titleBlinkTimer = null
}
document.title = originalTitle
}
function checkStatusTransition(oldGen: SoraGeneration, newGen: SoraGeneration) {
const wasActive = oldGen.status === 'pending' || oldGen.status === 'generating'
if (!wasActive) return
if (newGen.status === 'completed') {
const title = t('sora.notificationCompleted')
const body = t('sora.notificationCompletedBody', { model: newGen.model })
sendNotification(title, body)
if (document.hidden) startTitleBlink(title)
} else if (newGen.status === 'failed') {
const title = t('sora.notificationFailed')
const body = t('sora.notificationFailedBody', { model: newGen.model })
sendNotification(title, body)
if (document.hidden) startTitleBlink(title)
}
}
// ==================== beforeunload ====================
const hasUpstreamRecords = computed(() =>
activeGenerations.value.some(g => g.status === 'completed' && g.storage_type === 'upstream')
)
function beforeUnloadHandler(e: BeforeUnloadEvent) {
if (hasUpstreamRecords.value) {
e.preventDefault()
e.returnValue = t('sora.beforeUnloadWarning')
return e.returnValue
}
}
// ==================== 轮询 ====================
function getPollingIntervalByRuntime(createdAt: string): number {
const createdAtMs = new Date(createdAt).getTime()
if (Number.isNaN(createdAtMs)) return 3000
const elapsedMs = Date.now() - createdAtMs
if (elapsedMs < 2 * 60 * 1000) return 3000
if (elapsedMs < 10 * 60 * 1000) return 10000
return 30000
}
function schedulePolling(id: number) {
const current = activeGenerations.value.find(g => g.id === id)
const interval = current ? getPollingIntervalByRuntime(current.created_at) : 3000
if (pollTimers[id]) clearTimeout(pollTimers[id])
pollTimers[id] = setTimeout(() => { void pollGeneration(id) }, interval)
}
async function pollGeneration(id: number) {
try {
const gen = await soraAPI.getGeneration(id)
const idx = activeGenerations.value.findIndex(g => g.id === id)
if (idx >= 0) {
checkStatusTransition(activeGenerations.value[idx], gen)
activeGenerations.value[idx] = gen
}
if (gen.status === 'pending' || gen.status === 'generating') {
schedulePolling(id)
} else {
delete pollTimers[id]
}
} catch {
delete pollTimers[id]
}
}
async function loadActiveGenerations() {
try {
const res = await soraAPI.listGenerations({
status: 'pending,generating,completed,failed,cancelled',
page_size: 50
})
const generations = Array.isArray(res.data) ? res.data : []
activeGenerations.value = generations
for (const gen of generations) {
if ((gen.status === 'pending' || gen.status === 'generating') && !pollTimers[gen.id]) {
schedulePolling(gen.id)
}
}
} catch (e) {
console.error('Failed to load generations:', e)
}
}
// ==================== 操作 ====================
async function handleGenerate(req: GenerateRequest) {
generating.value = true
try {
const res = await soraAPI.generate(req)
const gen = await soraAPI.getGeneration(res.generation_id)
activeGenerations.value.unshift(gen)
schedulePolling(gen.id)
} catch (e: any) {
console.error('Generate failed:', e)
alert(e?.response?.data?.message || e?.message || 'Generation failed')
} finally {
generating.value = false
}
}
async function handleCancel(id: number) {
try {
await soraAPI.cancelGeneration(id)
const idx = activeGenerations.value.findIndex(g => g.id === id)
if (idx >= 0) activeGenerations.value[idx].status = 'cancelled'
} catch (e) {
console.error('Cancel failed:', e)
}
}
async function handleDelete(id: number) {
try {
await soraAPI.deleteGeneration(id)
activeGenerations.value = activeGenerations.value.filter(g => g.id !== id)
} catch (e) {
console.error('Delete failed:', e)
}
}
async function handleSave(id: number) {
try {
await soraAPI.saveToStorage(id)
const gen = await soraAPI.getGeneration(id)
const idx = activeGenerations.value.findIndex(g => g.id === id)
if (idx >= 0) activeGenerations.value[idx] = gen
} catch (e) {
console.error('Save failed:', e)
}
}
function handleRetry(gen: SoraGeneration) {
handleGenerate({ model: gen.model, prompt: gen.prompt, media_type: gen.media_type })
}
function fillPrompt(text: string) {
promptBarRef.value?.fillPrompt(text)
}
// ==================== 检查存储状态 ====================
async function checkStorageStatus() {
try {
const status = await soraAPI.getStorageStatus()
if (!status.s3_enabled || !status.s3_healthy) {
showNoStorageToast.value = true
setTimeout(() => { showNoStorageToast.value = false }, 8000)
}
} catch {
// 忽略
}
}
onMounted(() => {
loadActiveGenerations()
requestNotificationPermission()
checkStorageStatus()
window.addEventListener('beforeunload', beforeUnloadHandler)
})
onUnmounted(() => {
Object.values(pollTimers).forEach(clearTimeout)
pollTimers = {}
stopTitleBlink()
window.removeEventListener('beforeunload', beforeUnloadHandler)
})
</script>
<style scoped>
.sora-generate-page {
padding-bottom: 200px;
min-height: calc(100vh - 56px);
display: flex;
flex-direction: column;
}
/* 任务区域 */
.sora-task-area {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
gap: 24px;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
/* 欢迎区域 */
.sora-welcome-section {
text-align: center;
padding: 60px 0 40px;
}
.sora-welcome-title {
font-size: 36px;
font-weight: 700;
letter-spacing: -0.03em;
margin-bottom: 12px;
background: linear-gradient(135deg, var(--sora-text-primary) 0%, var(--sora-text-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sora-welcome-subtitle {
font-size: 16px;
color: var(--sora-text-secondary, #A0A0A0);
max-width: 480px;
margin: 0 auto;
line-height: 1.6;
}
/* 示例提示词 */
.sora-example-prompts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: 100%;
max-width: 640px;
}
.sora-example-prompt {
padding: 16px 20px;
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-md, 12px);
font-size: 13px;
color: var(--sora-text-secondary, #A0A0A0);
cursor: pointer;
transition: all 150ms ease;
text-align: left;
line-height: 1.5;
font-family: inherit;
}
.sora-example-prompt:hover {
background: var(--sora-bg-tertiary, #242424);
border-color: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
transform: translateY(-1px);
}
/* 任务卡片列表 */
.sora-task-cards {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
/* 无存储 Toast */
.sora-no-storage-toast {
position: fixed;
top: 80px;
right: 24px;
background: var(--sora-bg-elevated, #2A2A2A);
border: 1px solid var(--sora-warning, #F59E0B);
border-radius: var(--sora-radius-md, 12px);
padding: 14px 20px;
font-size: 13px;
color: var(--sora-warning, #F59E0B);
z-index: 50;
box-shadow: var(--sora-shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
animation: sora-slide-in-right 0.3s ease;
max-width: 340px;
display: flex;
align-items: center;
gap: 10px;
}
@keyframes sora-slide-in-right {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* 响应式 */
@media (max-width: 900px) {
.sora-example-prompts {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.sora-welcome-title {
font-size: 28px;
}
.sora-task-area {
padding: 24px 16px;
}
}
</style>
@@ -0,0 +1,282 @@
<template>
<Teleport to="body">
<Transition name="sora-modal">
<div
v-if="visible && generation"
class="sora-preview-overlay"
@keydown.esc="emit('close')"
>
<!-- 背景遮罩 -->
<div class="sora-preview-backdrop" @click="emit('close')" />
<!-- 内容区 -->
<div class="sora-preview-modal">
<!-- 顶部栏 -->
<div class="sora-preview-header">
<h3 class="sora-preview-title">{{ t('sora.previewTitle') }}</h3>
<button class="sora-preview-close" @click="emit('close')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 媒体区 -->
<div class="sora-preview-media-area">
<video
v-if="generation.media_type === 'video'"
:src="generation.media_url"
class="sora-preview-media"
controls
autoplay
/>
<img
v-else
:src="generation.media_url"
class="sora-preview-media"
alt=""
/>
</div>
<!-- 详情 + 操作 -->
<div class="sora-preview-footer">
<!-- 模型 + 时间 -->
<div class="sora-preview-meta">
<span class="sora-preview-model-tag">{{ generation.model }}</span>
<span>{{ formatDateTime(generation.created_at) }}</span>
</div>
<!-- 提示词 -->
<p class="sora-preview-prompt">{{ generation.prompt }}</p>
<!-- 操作按钮 -->
<div class="sora-preview-actions">
<button
v-if="generation.storage_type === 'upstream'"
class="sora-preview-btn primary"
@click="emit('save', generation.id)"
>
☁️ {{ t('sora.save') }}
</button>
<a
v-if="generation.media_url"
:href="generation.media_url"
target="_blank"
download
class="sora-preview-btn secondary"
@click="emit('download', generation.media_url)"
>
📥 {{ t('sora.download') }}
</a>
<button class="sora-preview-btn ghost" @click="emit('close')">
{{ t('sora.closePreview') }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SoraGeneration } from '@/api/sora'
defineProps<{
visible: boolean
generation: SoraGeneration | null
}>()
const emit = defineEmits<{
close: []
save: [id: number]
download: [url: string]
}>()
const { t } = useI18n()
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString()
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</script>
<style scoped>
.sora-preview-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
}
.sora-preview-backdrop {
position: absolute;
inset: 0;
background: var(--sora-modal-backdrop, rgba(0, 0, 0, 0.4));
backdrop-filter: blur(4px);
}
.sora-preview-modal {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
max-height: 90vh;
max-width: 90vw;
overflow: hidden;
border-radius: 20px;
background: var(--sora-bg-secondary, #FFF);
border: 1px solid var(--sora-border-color, #E5E7EB);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: sora-modal-in 0.3s ease;
}
@keyframes sora-modal-in {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.sora-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--sora-border-color, #E5E7EB);
}
.sora-preview-title {
font-size: 14px;
font-weight: 500;
color: var(--sora-text-primary, #111827);
}
.sora-preview-close {
padding: 6px;
border-radius: 8px;
color: var(--sora-text-tertiary, #9CA3AF);
background: none;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-preview-close:hover {
background: var(--sora-bg-tertiary, #F3F4F6);
color: var(--sora-text-secondary, #6B7280);
}
.sora-preview-media-area {
flex: 1;
overflow: auto;
background: var(--sora-bg-primary, #F9FAFB);
padding: 8px;
}
.sora-preview-media {
max-height: 70vh;
width: 100%;
border-radius: 8px;
object-fit: contain;
}
.sora-preview-footer {
padding: 16px 20px;
border-top: 1px solid var(--sora-border-color, #E5E7EB);
}
.sora-preview-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--sora-text-tertiary, #9CA3AF);
margin-bottom: 8px;
}
.sora-preview-model-tag {
padding: 2px 8px;
background: var(--sora-bg-tertiary, #F3F4F6);
border-radius: 9999px;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 11px;
color: var(--sora-text-secondary, #6B7280);
}
.sora-preview-prompt {
font-size: 13px;
color: var(--sora-text-secondary, #6B7280);
line-height: 1.5;
margin-bottom: 16px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sora-preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.sora-preview-btn {
padding: 8px 16px;
border-radius: 9999px;
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 150ms ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sora-preview-btn.primary {
background: var(--sora-accent-gradient);
color: white;
}
.sora-preview-btn.primary:hover {
box-shadow: var(--sora-shadow-glow);
}
.sora-preview-btn.secondary {
background: var(--sora-bg-tertiary, #F3F4F6);
color: var(--sora-text-secondary, #6B7280);
}
.sora-preview-btn.secondary:hover {
background: var(--sora-bg-hover, #E5E7EB);
color: var(--sora-text-primary, #111827);
}
.sora-preview-btn.ghost {
background: transparent;
color: var(--sora-text-tertiary, #9CA3AF);
margin-left: auto;
}
.sora-preview-btn.ghost:hover {
color: var(--sora-text-secondary, #6B7280);
}
/* 过渡动画 */
.sora-modal-enter-active,
.sora-modal-leave-active {
transition: opacity 0.2s ease;
}
.sora-modal-enter-from,
.sora-modal-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,609 @@
<template>
<div
class="sora-task-card"
:class="{
cancelled: generation.status === 'cancelled',
'countdown-warning': isUpstream && !isExpired && remainingMs <= 2 * 60 * 1000
}"
>
<!-- 头部:状态 + 模型 + 取消按钮 -->
<div class="sora-task-header">
<div class="sora-task-status">
<span class="sora-status-dot" :class="statusDotClass" />
<span class="sora-status-label" :class="statusLabelClass">{{ statusText }}</span>
</div>
<div class="sora-task-header-right">
<span class="sora-model-tag">{{ generation.model }}</span>
<button
v-if="generation.status === 'pending' || generation.status === 'generating'"
class="sora-cancel-btn"
@click="emit('cancel', generation.id)"
>
✕ {{ t('sora.cancel') }}
</button>
</div>
</div>
<!-- 提示词 -->
<div class="sora-task-prompt" :class="{ 'line-through': generation.status === 'cancelled' }">
{{ generation.prompt }}
</div>
<!-- 错误分类(失败时) -->
<div v-if="generation.status === 'failed' && generation.error_message" class="sora-task-error-category">
⛔ {{ t('sora.errorCategory') }}
</div>
<div v-if="generation.status === 'failed' && generation.error_message" class="sora-task-error-message">
{{ generation.error_message }}
</div>
<!-- 进度条(排队/生成/失败时) -->
<div v-if="showProgress" class="sora-task-progress-wrapper">
<div class="sora-task-progress-bar">
<div
class="sora-task-progress-fill"
:class="progressFillClass"
:style="{ width: progressWidth }"
/>
</div>
<div v-if="generation.status !== 'failed'" class="sora-task-progress-info">
<span>{{ progressInfoText }}</span>
<span>{{ progressInfoRight }}</span>
</div>
</div>
<!-- 完成预览区 -->
<div v-if="generation.status === 'completed' && generation.media_url" class="sora-task-preview">
<video
v-if="generation.media_type === 'video'"
:src="generation.media_url"
class="sora-task-preview-media"
muted
loop
@mouseenter="($event.target as HTMLVideoElement).play()"
@mouseleave="($event.target as HTMLVideoElement).pause()"
/>
<img
v-else
:src="generation.media_url"
class="sora-task-preview-media"
alt=""
/>
</div>
<!-- 完成占位预览(无 media_url 时) -->
<div v-else-if="generation.status === 'completed' && !generation.media_url" class="sora-task-preview">
<div class="sora-task-preview-placeholder">🎨</div>
</div>
<!-- 操作按钮 -->
<div v-if="showActions" class="sora-task-actions">
<!-- 已完成 -->
<template v-if="generation.status === 'completed'">
<!-- 已保存标签 -->
<span v-if="generation.storage_type !== 'upstream'" class="sora-saved-badge">
✓ {{ t('sora.savedToCloud') }}
</span>
<!-- 保存到存储按钮(upstream 时) -->
<button
v-if="generation.storage_type === 'upstream'"
class="sora-action-btn save-storage"
@click="emit('save', generation.id)"
>
☁️ {{ t('sora.save') }}
</button>
<!-- 本地下载 -->
<a
v-if="generation.media_url"
:href="generation.media_url"
target="_blank"
download
class="sora-action-btn primary"
>
📥 {{ t('sora.downloadLocal') }}
</a>
<!-- 倒计时文本(upstream) -->
<span v-if="isUpstream && !isExpired" class="sora-countdown-text">
⏱ {{ t('sora.upstreamCountdown', { time: countdownText }) }} {{ t('sora.canDownload') }}
</span>
<span v-if="isUpstream && isExpired" class="sora-countdown-text expired">
{{ t('sora.upstreamExpired') }}
</span>
</template>
<!-- 失败/取消 -->
<template v-if="generation.status === 'failed' || generation.status === 'cancelled'">
<button class="sora-action-btn primary" @click="emit('retry', generation)">
🔄 {{ generation.status === 'cancelled' ? t('sora.regenrate') : t('sora.retry') }}
</button>
<button class="sora-action-btn secondary" @click="emit('delete', generation.id)">
🗑 {{ t('sora.delete') }}
</button>
</template>
</div>
<!-- 倒计时进度条(upstream 已完成) -->
<div v-if="isUpstream && !isExpired && generation.status === 'completed'" class="sora-countdown-bar-wrapper">
<div class="sora-countdown-bar">
<div class="sora-countdown-bar-fill" :style="{ width: countdownPercent + '%' }" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SoraGeneration } from '@/api/sora'
const props = defineProps<{ generation: SoraGeneration }>()
const emit = defineEmits<{
cancel: [id: number]
delete: [id: number]
save: [id: number]
retry: [gen: SoraGeneration]
}>()
const { t } = useI18n()
// ==================== 状态样式 ====================
const statusDotClass = computed(() => {
const s = props.generation.status
return {
queued: s === 'pending',
generating: s === 'generating',
completed: s === 'completed',
failed: s === 'failed',
cancelled: s === 'cancelled'
}
})
const statusLabelClass = computed(() => statusDotClass.value)
const statusText = computed(() => {
const map: Record<string, string> = {
pending: t('sora.statusPending'),
generating: t('sora.statusGenerating'),
completed: t('sora.statusCompleted'),
failed: t('sora.statusFailed'),
cancelled: t('sora.statusCancelled')
}
return map[props.generation.status] || props.generation.status
})
// ==================== 进度条 ====================
const showProgress = computed(() => {
const s = props.generation.status
return s === 'pending' || s === 'generating' || s === 'failed'
})
const progressFillClass = computed(() => {
const s = props.generation.status
return {
generating: s === 'pending' || s === 'generating',
completed: s === 'completed',
failed: s === 'failed'
}
})
const progressWidth = computed(() => {
const s = props.generation.status
if (s === 'failed') return '100%'
if (s === 'pending') return '0%'
if (s === 'generating') {
// 根据创建时间估算进度
const created = new Date(props.generation.created_at).getTime()
const elapsed = Date.now() - created
// 假设平均 10 分钟完成,最多到 95%
const progress = Math.min(95, (elapsed / (10 * 60 * 1000)) * 100)
return `${Math.round(progress)}%`
}
return '100%'
})
const progressInfoText = computed(() => {
const s = props.generation.status
if (s === 'pending') return t('sora.queueWaiting')
if (s === 'generating') {
const created = new Date(props.generation.created_at).getTime()
const elapsed = Date.now() - created
return `${t('sora.waited')} ${formatElapsed(elapsed)}`
}
return ''
})
const progressInfoRight = computed(() => {
const s = props.generation.status
if (s === 'pending') return t('sora.waiting')
return ''
})
function formatElapsed(ms: number): string {
const s = Math.floor(ms / 1000)
const m = Math.floor(s / 60)
const sec = s % 60
return `${m}:${sec.toString().padStart(2, '0')}`
}
// ==================== 操作按钮 ====================
const showActions = computed(() => {
const s = props.generation.status
return s === 'completed' || s === 'failed' || s === 'cancelled'
})
// ==================== Upstream 倒计时 ====================
const UPSTREAM_TTL = 15 * 60 * 1000
const now = ref(Date.now())
let countdownTimer: ReturnType<typeof setInterval> | null = null
const isUpstream = computed(() =>
props.generation.status === 'completed' && props.generation.storage_type === 'upstream'
)
const expireTime = computed(() => {
if (!props.generation.completed_at) return 0
return new Date(props.generation.completed_at).getTime() + UPSTREAM_TTL
})
const remainingMs = computed(() => Math.max(0, expireTime.value - now.value))
const isExpired = computed(() => remainingMs.value <= 0)
const countdownPercent = computed(() => {
if (isExpired.value) return 0
return Math.round((remainingMs.value / UPSTREAM_TTL) * 100)
})
const countdownText = computed(() => {
const totalSec = Math.ceil(remainingMs.value / 1000)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return `${m}:${s.toString().padStart(2, '0')}`
})
onMounted(() => {
if (isUpstream.value) {
countdownTimer = setInterval(() => {
now.value = Date.now()
if (now.value >= expireTime.value && countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}, 1000)
}
})
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
</script>
<style scoped>
.sora-task-card {
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-lg, 16px);
padding: 24px;
transition: all 250ms ease;
animation: sora-fade-in 0.4s ease;
}
.sora-task-card:hover {
border-color: var(--sora-bg-hover, #333);
}
.sora-task-card.cancelled {
opacity: 0.6;
border-color: var(--sora-border-subtle, #1F1F1F);
}
.sora-task-card.countdown-warning {
border-color: var(--sora-error, #EF4444) !important;
box-shadow: 0 0 12px rgba(239, 68, 68, 0.15);
}
@keyframes sora-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 头部 */
.sora-task-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.sora-task-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.sora-task-header-right {
display: flex;
align-items: center;
gap: 8px;
}
/* 状态指示点 */
.sora-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.sora-status-dot.queued { background: var(--sora-text-tertiary, #666); }
.sora-status-dot.generating {
background: var(--sora-warning, #F59E0B);
animation: sora-pulse-dot 1.5s ease-in-out infinite;
}
.sora-status-dot.completed { background: var(--sora-success, #10B981); }
.sora-status-dot.failed { background: var(--sora-error, #EF4444); }
.sora-status-dot.cancelled { background: var(--sora-text-tertiary, #666); }
@keyframes sora-pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* 状态标签 */
.sora-status-label.queued { color: var(--sora-text-secondary, #A0A0A0); }
.sora-status-label.generating { color: var(--sora-warning, #F59E0B); }
.sora-status-label.completed { color: var(--sora-success, #10B981); }
.sora-status-label.failed { color: var(--sora-error, #EF4444); }
.sora-status-label.cancelled { color: var(--sora-text-tertiary, #666); }
/* 模型标签 */
.sora-model-tag {
font-size: 11px;
padding: 3px 10px;
background: var(--sora-bg-tertiary, #242424);
border-radius: var(--sora-radius-full, 9999px);
color: var(--sora-text-secondary, #A0A0A0);
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
}
/* 取消按钮 */
.sora-cancel-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
background: var(--sora-bg-tertiary, #242424);
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-cancel-btn:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--sora-error, #EF4444);
}
/* 提示词 */
.sora-task-prompt {
font-size: 14px;
color: var(--sora-text-secondary, #A0A0A0);
margin-bottom: 16px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sora-task-prompt.line-through {
text-decoration: line-through;
color: var(--sora-text-tertiary, #666);
}
/* 错误分类 */
.sora-task-error-category {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(239, 68, 68, 0.1);
border-radius: var(--sora-radius-sm, 8px);
font-size: 12px;
color: var(--sora-error, #EF4444);
margin-bottom: 8px;
}
.sora-task-error-message {
font-size: 13px;
color: var(--sora-text-secondary, #A0A0A0);
line-height: 1.5;
margin-bottom: 12px;
}
/* 进度条 */
.sora-task-progress-wrapper {
margin-bottom: 16px;
}
.sora-task-progress-bar {
width: 100%;
height: 4px;
background: var(--sora-bg-hover, #333);
border-radius: 2px;
overflow: hidden;
}
.sora-task-progress-fill {
height: 100%;
border-radius: 2px;
transition: width 400ms ease;
}
.sora-task-progress-fill.generating {
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
animation: sora-progress-shimmer 2s ease-in-out infinite;
}
.sora-task-progress-fill.completed {
background: var(--sora-success, #10B981);
}
.sora-task-progress-fill.failed {
background: var(--sora-error, #EF4444);
}
@keyframes sora-progress-shimmer {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
.sora-task-progress-info {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: var(--sora-text-tertiary, #666);
}
/* 预览 */
.sora-task-preview {
margin-top: 16px;
border-radius: var(--sora-radius-md, 12px);
overflow: hidden;
background: var(--sora-bg-tertiary, #242424);
}
.sora-task-preview-media {
width: 100%;
height: 280px;
object-fit: cover;
display: block;
}
.sora-task-preview-placeholder {
width: 100%;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
background: var(--sora-placeholder-gradient, linear-gradient(135deg, #e0e7ff 0%, #dbeafe 50%, #cffafe 100%));
font-size: 48px;
}
/* 操作按钮 */
.sora-task-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
align-items: center;
}
.sora-action-btn {
padding: 8px 20px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 150ms ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sora-action-btn.primary {
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
color: white;
}
.sora-action-btn.primary:hover {
background: var(--sora-accent-gradient-hover, linear-gradient(135deg, #2dd4bf, #14b8a6));
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
}
.sora-action-btn.secondary {
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-secondary, #A0A0A0);
}
.sora-action-btn.secondary:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
.sora-action-btn.save-storage {
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
color: white;
}
.sora-action-btn.save-storage:hover {
box-shadow: 0 0 16px rgba(16, 185, 129, 0.3);
}
/* 已保存标签 */
.sora-saved-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.25);
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 500;
color: var(--sora-success, #10B981);
}
/* 倒计时文本 */
.sora-countdown-text {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
color: var(--sora-warning, #F59E0B);
}
.sora-countdown-text.expired {
color: var(--sora-error, #EF4444);
}
/* 倒计时进度条 */
.sora-countdown-bar-wrapper {
margin-top: 12px;
}
.sora-countdown-bar {
width: 100%;
height: 3px;
background: var(--sora-bg-hover, #333);
border-radius: 2px;
overflow: hidden;
}
.sora-countdown-bar-fill {
height: 100%;
background: var(--sora-warning, #F59E0B);
border-radius: 2px;
transition: width 1s linear;
}
.countdown-warning .sora-countdown-bar-fill {
background: var(--sora-error, #EF4444);
}
.countdown-warning .sora-countdown-text {
color: var(--sora-error, #EF4444);
}
</style>
@@ -0,0 +1,738 @@
<template>
<div class="sora-creator-bar-wrapper">
<div class="sora-creator-bar">
<div class="sora-creator-bar-inner" :class="{ focused: isFocused }">
<!-- 模型选择行 -->
<div class="sora-creator-model-row">
<div class="sora-model-select-wrapper">
<select
v-model="selectedFamily"
class="sora-model-select"
@change="onFamilyChange"
>
<optgroup v-if="videoFamilies.length" :label="t('sora.videoModels')">
<option v-for="f in videoFamilies" :key="f.id" :value="f.id">{{ f.name }}</option>
</optgroup>
<optgroup v-if="imageFamilies.length" :label="t('sora.imageModels')">
<option v-for="f in imageFamilies" :key="f.id" :value="f.id">{{ f.name }}</option>
</optgroup>
</select>
<span class="sora-model-select-arrow">▼</span>
</div>
<!-- 凭证选择器 -->
<div class="sora-credential-select-wrapper">
<select v-model="selectedCredentialId" class="sora-model-select">
<option :value="0" disabled>{{ t('sora.selectCredential') }}</option>
<optgroup v-if="apiKeyOptions.length" :label="t('sora.apiKeys')">
<option v-for="k in apiKeyOptions" :key="'k'+k.id" :value="k.id">
{{ k.name }}{{ k.group ? ' · ' + k.group.name : '' }}
</option>
</optgroup>
<optgroup v-if="subscriptionOptions.length" :label="t('sora.subscriptions')">
<option v-for="s in subscriptionOptions" :key="'s'+s.id" :value="-s.id">
{{ s.group?.name || t('sora.subscription') }}
</option>
</optgroup>
</select>
<span class="sora-model-select-arrow">▼</span>
</div>
<!-- 无凭证提示 -->
<span v-if="soraCredentialEmpty" class="sora-no-storage-badge">
⚠ {{ t('sora.noCredentialHint') }}
</span>
<!-- 无存储提示 -->
<span v-if="!hasStorage" class="sora-no-storage-badge">
⚠ {{ t('sora.noStorageConfigured') }}
</span>
</div>
<!-- 参考图预览 -->
<div v-if="imagePreview" class="sora-image-preview-row">
<div class="sora-image-preview-thumb">
<img :src="imagePreview" alt="" />
<button class="sora-image-preview-remove" @click="removeImage">✕</button>
</div>
<span class="sora-image-preview-label">{{ t('sora.referenceImage') }}</span>
</div>
<!-- 输入框 -->
<div class="sora-creator-input-wrapper">
<textarea
ref="textareaRef"
v-model="prompt"
class="sora-creator-textarea"
:placeholder="t('sora.creatorPlaceholder')"
rows="1"
@input="autoResize"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.enter.ctrl="submit"
@keydown.enter.meta="submit"
/>
</div>
<!-- 底部工具行 -->
<div class="sora-creator-tools-row">
<div class="sora-creator-tools-left">
<!-- 方向选择(根据所选模型家族支持的方向动态渲染) -->
<template v-if="availableAspects.length > 0">
<button
v-for="a in availableAspects"
:key="a.value"
class="sora-tool-btn"
:class="{ active: currentAspect === a.value }"
@click="currentAspect = a.value"
>
<span class="sora-tool-btn-icon">{{ a.icon }}</span> {{ a.label }}
</button>
<span v-if="availableDurations.length > 0" class="sora-tool-divider" />
</template>
<!-- 时长选择(根据所选模型家族支持的时长动态渲染) -->
<template v-if="availableDurations.length > 0">
<button
v-for="d in availableDurations"
:key="d"
class="sora-tool-btn"
:class="{ active: currentDuration === d }"
@click="currentDuration = d"
>
{{ d }}s
</button>
<span class="sora-tool-divider" />
</template>
<!-- 视频数量(官方 Videos 1/2/3) -->
<template v-if="availableVideoCounts.length > 0">
<button
v-for="count in availableVideoCounts"
:key="count"
class="sora-tool-btn"
:class="{ active: currentVideoCount === count }"
@click="currentVideoCount = count"
>
{{ count }}
</button>
<span class="sora-tool-divider" />
</template>
<!-- 图片上传 -->
<button class="sora-upload-btn" :title="t('sora.uploadReference')" @click="triggerFileInput">
📎
</button>
<input
ref="fileInputRef"
type="file"
accept="image/png,image/jpeg,image/webp"
style="display: none"
@change="onFileChange"
/>
</div>
<!-- 活跃任务计数 -->
<span v-if="activeTaskCount > 0" class="sora-active-tasks-label">
<span class="sora-pulse-indicator" />
<span>{{ t('sora.generatingCount', { current: activeTaskCount, max: maxConcurrentTasks }) }}</span>
</span>
<!-- 生成按钮 -->
<button
class="sora-generate-btn"
:class="{ 'max-reached': isMaxReached }"
:disabled="!canSubmit || generating || isMaxReached"
@click="submit"
>
<span class="sora-generate-btn-icon">✨</span>
<span>{{ generating ? t('sora.generating') : t('sora.generate') }}</span>
</button>
</div>
</div>
</div>
<!-- 文件大小错误 -->
<p v-if="imageError" class="sora-image-error">{{ imageError }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import soraAPI, { type SoraModelFamily, type GenerateRequest } from '@/api/sora'
import keysAPI from '@/api/keys'
import { useSubscriptionStore } from '@/stores/subscriptions'
import type { ApiKey, UserSubscription } from '@/types'
const MAX_IMAGE_SIZE = 20 * 1024 * 1024
/** 方向显示配置 */
const ASPECT_META: Record<string, { icon: string; label: string }> = {
landscape: { icon: '▬', label: '横屏' },
portrait: { icon: '▮', label: '竖屏' },
square: { icon: '◻', label: '方形' }
}
const props = defineProps<{
generating: boolean
activeTaskCount: number
maxConcurrentTasks: number
}>()
const emit = defineEmits<{
generate: [req: GenerateRequest]
fillPrompt: [prompt: string]
}>()
const { t } = useI18n()
const prompt = ref('')
const families = ref<SoraModelFamily[]>([])
const selectedFamily = ref('')
const currentAspect = ref('landscape')
const currentDuration = ref(10)
const currentVideoCount = ref(1)
const isFocused = ref(false)
const imagePreview = ref<string | null>(null)
const imageError = ref('')
const fileInputRef = ref<HTMLInputElement | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const hasStorage = ref(true)
// 凭证相关状态
const apiKeyOptions = ref<ApiKey[]>([])
const subscriptionOptions = ref<UserSubscription[]>([])
const selectedCredentialId = ref<number>(0) // >0 = api_key.id, <0 = -subscription.id
const soraCredentialEmpty = computed(() =>
apiKeyOptions.value.length === 0 && subscriptionOptions.value.length === 0
)
// 按类型分组
const videoFamilies = computed(() => families.value.filter(f => f.type === 'video'))
const imageFamilies = computed(() => families.value.filter(f => f.type === 'image'))
// 当前选中的家族对象
const currentFamily = computed(() => families.value.find(f => f.id === selectedFamily.value))
// 当前家族支持的方向列表
const availableAspects = computed(() => {
const fam = currentFamily.value
if (!fam?.orientations?.length) return []
return fam.orientations
.map(o => ({ value: o, ...(ASPECT_META[o] || { icon: '?', label: o }) }))
})
// 当前家族支持的时长列表
const availableDurations = computed(() => currentFamily.value?.durations ?? [])
const availableVideoCounts = computed(() => (currentFamily.value?.type === 'video' ? [1, 2, 3] : []))
const isMaxReached = computed(() => props.activeTaskCount >= props.maxConcurrentTasks)
const canSubmit = computed(() =>
prompt.value.trim().length > 0 && selectedFamily.value && selectedCredentialId.value !== 0
)
/** 构建最终 model ID(family + orientation + duration) */
function buildModelID(): string {
const fam = currentFamily.value
if (!fam) return selectedFamily.value
if (fam.type === 'image') {
// 图像模型: "gpt-image"(方形)或 "gpt-image-landscape"
return currentAspect.value === 'square'
? fam.id
: `${fam.id}-${currentAspect.value}`
}
// 视频模型: "sora2-landscape-10s"
return `${fam.id}-${currentAspect.value}-${currentDuration.value}s`
}
/** 切换家族时自动调整方向和时长为首个可用值 */
function onFamilyChange() {
const fam = families.value.find(f => f.id === selectedFamily.value)
if (!fam) return
// 若当前方向不在新家族支持列表中,重置为首个
if (fam.orientations?.length && !fam.orientations.includes(currentAspect.value)) {
currentAspect.value = fam.orientations[0]
}
// 若当前时长不在新家族支持列表中,重置为首个
if (fam.durations?.length && !fam.durations.includes(currentDuration.value)) {
currentDuration.value = fam.durations[0]
}
if (fam.type !== 'video') {
currentVideoCount.value = 1
}
}
async function loadModels() {
try {
families.value = await soraAPI.getModels()
if (families.value.length > 0 && !selectedFamily.value) {
selectedFamily.value = families.value[0].id
onFamilyChange()
}
} catch (e) {
console.error('Failed to load models:', e)
}
}
async function loadStorageStatus() {
try {
const status = await soraAPI.getStorageStatus()
hasStorage.value = status.s3_enabled && status.s3_healthy
} catch {
hasStorage.value = false
}
}
async function loadSoraCredentials() {
try {
// 加载 API Keys,筛选 sora 平台 + active 状态
const keysRes = await keysAPI.list(1, 100)
apiKeyOptions.value = (keysRes.items || []).filter(
(k: ApiKey) => k.status === 'active' && k.group?.platform === 'sora'
)
// 加载活跃订阅,筛选 sora 平台
const subStore = useSubscriptionStore()
const subs = await subStore.fetchActiveSubscriptions()
subscriptionOptions.value = subs.filter(
(s: UserSubscription) => s.status === 'active' && s.group?.platform === 'sora'
)
// 自动选择第一个
if (apiKeyOptions.value.length > 0) {
selectedCredentialId.value = apiKeyOptions.value[0].id
} else if (subscriptionOptions.value.length > 0) {
selectedCredentialId.value = -subscriptionOptions.value[0].id
}
} catch (e) {
console.error('Failed to load sora credentials:', e)
}
}
function autoResize() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
imageError.value = ''
if (file.size > MAX_IMAGE_SIZE) {
imageError.value = t('sora.imageTooLarge')
input.value = ''
return
}
const reader = new FileReader()
reader.onload = (e) => {
imagePreview.value = e.target?.result as string
}
reader.readAsDataURL(file)
input.value = ''
}
function removeImage() {
imagePreview.value = null
imageError.value = ''
}
function submit() {
if (!canSubmit.value || props.generating || isMaxReached.value) return
const modelID = buildModelID()
const req: GenerateRequest = {
model: modelID,
prompt: prompt.value.trim(),
media_type: currentFamily.value?.type || 'video'
}
if ((currentFamily.value?.type || 'video') === 'video') {
req.video_count = currentVideoCount.value
}
if (imagePreview.value) {
req.image_input = imagePreview.value
}
if (selectedCredentialId.value > 0) {
req.api_key_id = selectedCredentialId.value
}
emit('generate', req)
prompt.value = ''
imagePreview.value = null
imageError.value = ''
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
}
/** 外部调用:填充提示词 */
function fillPrompt(text: string) {
prompt.value = text
setTimeout(autoResize, 0)
textareaRef.value?.focus()
}
defineExpose({ fillPrompt })
onMounted(() => {
loadModels()
loadStorageStatus()
loadSoraCredentials()
})
</script>
<style scoped>
.sora-creator-bar-wrapper {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
background: linear-gradient(to top, var(--sora-bg-primary, #0D0D0D) 60%, transparent 100%);
padding: 20px 24px 24px;
pointer-events: none;
}
.sora-creator-bar {
max-width: 780px;
margin: 0 auto;
pointer-events: all;
}
.sora-creator-bar-inner {
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-xl, 20px);
padding: 12px 16px;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.sora-creator-bar-inner.focused {
border-color: var(--sora-accent-primary, #14b8a6);
box-shadow: 0 0 0 1px var(--sora-accent-primary, #14b8a6), var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
}
/* 模型选择行 */
.sora-creator-model-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 0 4px;
}
.sora-model-select-wrapper {
position: relative;
}
.sora-model-select {
appearance: none;
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-primary, #FFF);
padding: 5px 28px 5px 10px;
border-radius: var(--sora-radius-sm, 8px);
font-size: 12px;
font-family: "SF Mono", "Fira Code", monospace;
cursor: pointer;
border: 1px solid transparent;
transition: all 150ms ease;
}
.sora-model-select:hover {
border-color: var(--sora-bg-hover, #333);
}
.sora-model-select:focus {
border-color: var(--sora-accent-primary, #14b8a6);
outline: none;
}
.sora-model-select option {
background: var(--sora-bg-secondary, #1A1A1A);
color: var(--sora-text-primary, #FFF);
}
.sora-model-select-arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 10px;
color: var(--sora-text-tertiary, #666);
}
.sora-credential-select-wrapper {
position: relative;
max-width: 200px;
}
/* 无存储提示 */
.sora-no-storage-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: var(--sora-radius-full, 9999px);
font-size: 11px;
color: var(--sora-warning, #F59E0B);
}
/* 参考图预览 */
.sora-image-preview-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
margin-bottom: 8px;
}
.sora-image-preview-thumb {
position: relative;
width: 48px;
height: 48px;
}
.sora-image-preview-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--sora-border-color, #2A2A2A);
}
.sora-image-preview-remove {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--sora-error, #EF4444);
color: white;
font-size: 10px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.sora-image-preview-label {
font-size: 12px;
color: var(--sora-text-tertiary, #666);
}
/* 输入框 */
.sora-creator-input-wrapper {
position: relative;
}
.sora-creator-textarea {
width: 100%;
min-height: 44px;
max-height: 120px;
padding: 10px 4px;
font-size: 14px;
color: var(--sora-text-primary, #FFF);
background: transparent;
resize: none;
line-height: 1.5;
overflow-y: auto;
border: none;
outline: none;
font-family: inherit;
}
.sora-creator-textarea::placeholder {
color: var(--sora-text-muted, #4A4A4A);
}
/* 底部工具行 */
.sora-creator-tools-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 0;
border-top: 1px solid var(--sora-border-subtle, #1F1F1F);
margin-top: 4px;
padding-top: 10px;
gap: 8px;
}
.sora-creator-tools-left {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.sora-tool-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
background: var(--sora-bg-tertiary, #242424);
border: none;
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.sora-tool-btn:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
.sora-tool-btn.active {
background: rgba(20, 184, 166, 0.15);
color: var(--sora-accent-primary, #14b8a6);
border: 1px solid rgba(20, 184, 166, 0.3);
}
.sora-tool-btn-icon {
font-size: 14px;
line-height: 1;
}
.sora-tool-divider {
width: 1px;
height: 20px;
background: var(--sora-border-color, #2A2A2A);
margin: 0 4px;
}
/* 上传按钮 */
.sora-upload-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--sora-radius-sm, 8px);
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-secondary, #A0A0A0);
font-size: 16px;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-upload-btn:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
/* 活跃任务计数 */
.sora-active-tasks-label {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: rgba(20, 184, 166, 0.12);
border: 1px solid rgba(20, 184, 166, 0.25);
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
font-weight: 500;
color: var(--sora-accent-primary, #14b8a6);
white-space: nowrap;
animation: sora-fade-in 0.3s ease;
}
.sora-pulse-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--sora-accent-primary, #14b8a6);
animation: sora-pulse-dot 1.5s ease-in-out infinite;
}
@keyframes sora-pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes sora-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 生成按钮 */
.sora-generate-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 24px;
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 600;
color: white;
border: none;
cursor: pointer;
transition: all 150ms ease;
flex-shrink: 0;
}
.sora-generate-btn:hover:not(:disabled) {
background: var(--sora-accent-gradient-hover, linear-gradient(135deg, #2dd4bf, #14b8a6));
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
transform: translateY(-1px);
}
.sora-generate-btn:active:not(:disabled) {
transform: translateY(0);
}
.sora-generate-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.sora-generate-btn.max-reached {
opacity: 0.4;
cursor: not-allowed;
}
.sora-generate-btn-icon {
font-size: 16px;
}
/* 图片错误 */
.sora-image-error {
text-align: center;
font-size: 12px;
color: var(--sora-error, #EF4444);
margin-top: 8px;
pointer-events: all;
}
/* 响应式 */
@media (max-width: 600px) {
.sora-creator-bar-wrapper {
padding: 12px 12px 16px;
}
.sora-creator-tools-left {
gap: 4px;
}
.sora-tool-btn {
padding: 5px 8px;
font-size: 11px;
}
}
</style>
@@ -0,0 +1,87 @@
<template>
<div v-if="quota && quota.source !== 'none'" class="sora-quota-info">
<div class="sora-quota-bar-wrapper">
<div
class="sora-quota-bar-fill"
:class="{ warning: percentage > 80, danger: percentage > 95 }"
:style="{ width: `${Math.min(percentage, 100)}%` }"
/>
</div>
<span class="sora-quota-text" :class="{ warning: percentage > 80, danger: percentage > 95 }">
{{ formatBytes(quota.used_bytes) }} / {{ quota.quota_bytes === 0 ? '∞' : formatBytes(quota.quota_bytes) }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { QuotaInfo } from '@/api/sora'
const props = defineProps<{ quota: QuotaInfo }>()
const percentage = computed(() => {
if (!props.quota || props.quota.quota_bytes === 0) return 0
return (props.quota.used_bytes / props.quota.quota_bytes) * 100
})
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
</script>
<style scoped>
.sora-quota-info {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
background: var(--sora-bg-secondary);
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
}
.sora-quota-bar-wrapper {
width: 80px;
height: 4px;
background: var(--sora-bg-hover, #333);
border-radius: 2px;
overflow: hidden;
}
.sora-quota-bar-fill {
height: 100%;
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
border-radius: 2px;
transition: width 400ms ease;
}
.sora-quota-bar-fill.warning {
background: var(--sora-warning, #F59E0B) !important;
}
.sora-quota-bar-fill.danger {
background: var(--sora-error, #EF4444) !important;
}
.sora-quota-text {
white-space: nowrap;
}
.sora-quota-text.warning {
color: var(--sora-warning, #F59E0B);
}
.sora-quota-text.danger {
color: var(--sora-error, #EF4444);
}
@media (max-width: 900px) {
.sora-quota-info {
display: none;
}
}
</style>
-4
View File
@@ -2414,10 +2414,6 @@ export default {
hint: 'Forward requests to a custom relay service. Proxy URL will be passed as a query parameter.',
urlHint: 'Relay service URL (e.g., https://relay.example.com)',
},
clientAffinity: {
label: 'Client Affinity Scheduling',
hint: 'When enabled, new sessions prefer accounts previously used by this client to reduce account switching'
}
},
expired: 'Expired',
proxy: 'Proxy',
-4
View File
@@ -2566,10 +2566,6 @@ export default {
hint: '启用后将请求转发到自定义中继服务,代理地址将作为 URL 参数传递给中继服务',
urlHint: '中继服务地址(如 https://relay.example.com)',
},
clientAffinity: {
label: '客户端亲和调度',
hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号'
}
},
expired: '已过期',
proxy: '代理',