| <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> |
| |
| |
| <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) |
| |
| 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 |
| ) |
| |
| |
| function buildModelID(): string { |
| const fam = currentFamily.value |
| if (!fam) return selectedFamily.value |
| |
| if (fam.type === 'image') { |
| |
| return currentAspect.value === 'square' |
| ? fam.id |
| : `${fam.id}-${currentAspect.value}` |
| } |
| |
| 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 { |
| |
| const keysRes = await keysAPI.list(1, 100) |
| apiKeyOptions.value = (keysRes.items || []).filter( |
| (k: ApiKey) => k.status === 'active' && k.group?.platform === '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> |
| |