mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix: restore upstream Sora frontend components and remove leftover i18n affinity keys
This commit is contained in:
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -2566,10 +2566,6 @@ export default {
|
||||
hint: '启用后将请求转发到自定义中继服务,代理地址将作为 URL 参数传递给中继服务',
|
||||
urlHint: '中继服务地址(如 https://relay.example.com)',
|
||||
},
|
||||
clientAffinity: {
|
||||
label: '客户端亲和调度',
|
||||
hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号'
|
||||
}
|
||||
},
|
||||
expired: '已过期',
|
||||
proxy: '代理',
|
||||
|
||||
Reference in New Issue
Block a user