Commit 62e80c60 authored by erio's avatar erio
Browse files

revert: completely remove all Sora functionality

parent dbb248df
<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>
<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>
<template>
<div v-if="quota && quota.source !== 'none'" class="sora-quota-info">
<div class="sora-quota-bar-wrapper">
<div
class="sora-quota-bar-fill"
:class="{ warning: percentage > 80, danger: percentage > 95 }"
:style="{ width: `${Math.min(percentage, 100)}%` }"
/>
</div>
<span class="sora-quota-text" :class="{ warning: percentage > 80, danger: percentage > 95 }">
{{ formatBytes(quota.used_bytes) }} / {{ quota.quota_bytes === 0 ? '' : formatBytes(quota.quota_bytes) }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { QuotaInfo } from '@/api/sora'
const props = defineProps<{ quota: QuotaInfo }>()
const percentage = computed(() => {
if (!props.quota || props.quota.quota_bytes === 0) return 0
return (props.quota.used_bytes / props.quota.quota_bytes) * 100
})
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
</script>
<style scoped>
.sora-quota-info {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
background: var(--sora-bg-secondary);
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
}
.sora-quota-bar-wrapper {
width: 80px;
height: 4px;
background: var(--sora-bg-hover, #333);
border-radius: 2px;
overflow: hidden;
}
.sora-quota-bar-fill {
height: 100%;
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
border-radius: 2px;
transition: width 400ms ease;
}
.sora-quota-bar-fill.warning {
background: var(--sora-warning, #F59E0B) !important;
}
.sora-quota-bar-fill.danger {
background: var(--sora-error, #EF4444) !important;
}
.sora-quota-text {
white-space: nowrap;
}
.sora-quota-text.warning {
color: var(--sora-warning, #F59E0B);
}
.sora-quota-text.danger {
color: var(--sora-error, #EF4444);
}
@media (max-width: 900px) {
.sora-quota-info {
display: none;
}
}
</style>
......@@ -11,8 +11,7 @@ vi.mock('@/api/admin', () => ({
accounts: {
generateAuthUrl: vi.fn(),
exchangeCode: vi.fn(),
refreshOpenAIToken: vi.fn(),
validateSoraSessionToken: vi.fn()
refreshOpenAIToken: vi.fn()
}
}
}))
......@@ -21,15 +20,15 @@ import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
describe('useOpenAIOAuth.buildCredentials', () => {
it('should keep client_id when token response contains it', () => {
const oauth = useOpenAIOAuth({ platform: 'sora' })
const oauth = useOpenAIOAuth({ platform: 'openai' })
const creds = oauth.buildCredentials({
access_token: 'at',
refresh_token: 'rt',
client_id: 'app_sora_client',
client_id: 'app_test_client',
expires_at: 1700000000
})
expect(creds.client_id).toBe('app_sora_client')
expect(creds.client_id).toBe('app_test_client')
expect(creds.access_token).toBe('at')
expect(creds.refresh_token).toBe('rt')
})
......
......@@ -60,22 +60,6 @@ const geminiModels = [
'gemini-3-pro-preview'
]
// Sora
const soraModels = [
'gpt-image', 'gpt-image-landscape', 'gpt-image-portrait',
'sora2-landscape-10s', 'sora2-portrait-10s',
'sora2-landscape-15s', 'sora2-portrait-15s',
'sora2-landscape-25s', 'sora2-portrait-25s',
'sora2pro-landscape-10s', 'sora2pro-portrait-10s',
'sora2pro-landscape-15s', 'sora2pro-portrait-15s',
'sora2pro-landscape-25s', 'sora2pro-portrait-25s',
'sora2pro-hd-landscape-10s', 'sora2pro-hd-portrait-10s',
'sora2pro-hd-landscape-15s', 'sora2pro-hd-portrait-15s',
'prompt-enhance-short-10s', 'prompt-enhance-short-15s', 'prompt-enhance-short-20s',
'prompt-enhance-medium-10s', 'prompt-enhance-medium-15s', 'prompt-enhance-medium-20s',
'prompt-enhance-long-10s', 'prompt-enhance-long-15s', 'prompt-enhance-long-20s'
]
// Antigravity 官方支持的模型(精确匹配)
// 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+
const antigravityModels = [
......@@ -236,7 +220,6 @@ const allModelsList: string[] = [
...openaiModels,
...claudeModels,
...geminiModels,
...soraModels,
...zhipuModels,
...qwenModels,
...deepseekModels,
......@@ -289,8 +272,6 @@ const openaiPresetMappings = [
{ label: 'Sonnet→5.4', from: 'claude-sonnet-4-6', to: 'gpt-5.4', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }
]
const soraPresetMappings: { label: string; from: string; to: string; color: string }[] = []
const geminiPresetMappings = [
{ label: 'Flash 2.0', from: 'gemini-2.0-flash', to: 'gemini-2.0-flash', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: '2.5 Flash', from: 'gemini-2.5-flash', to: 'gemini-2.5-flash', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
......@@ -385,7 +366,6 @@ export function getModelsByPlatform(platform: string): string[] {
case 'anthropic':
case 'claude': return claudeModels
case 'gemini': return geminiModels
case 'sora': return soraModels
case 'antigravity': return antigravityModels
case 'zhipu': return zhipuModels
case 'qwen': return qwenModels
......@@ -410,7 +390,6 @@ export function getModelsByPlatform(platform: string): string[] {
export function getPresetMappingsByPlatform(platform: string) {
if (platform === 'openai') return openaiPresetMappings
if (platform === 'gemini') return geminiPresetMappings
if (platform === 'sora') return soraPresetMappings
if (platform === 'antigravity') return antigravityPresetMappings
if (platform === 'bedrock') return bedrockPresetMappings
return anthropicPresetMappings
......
......@@ -22,7 +22,7 @@ export interface OpenAITokenInfo {
[key: string]: unknown
}
export type OpenAIOAuthPlatform = 'openai' | 'sora'
export type OpenAIOAuthPlatform = 'openai'
interface UseOpenAIOAuthOptions {
platform?: OpenAIOAuthPlatform
......@@ -31,7 +31,7 @@ interface UseOpenAIOAuthOptions {
export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
const appStore = useAppStore()
const oauthPlatform = options?.platform ?? 'openai'
const endpointPrefix = oauthPlatform === 'sora' ? '/admin/sora' : '/admin/openai'
const endpointPrefix = '/admin/openai'
// State
const authUrl = ref('')
......@@ -160,33 +160,6 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
}
}
// Validate Sora session token and get access token
const validateSessionToken = async (
sessionToken: string,
proxyId?: number | null
): Promise<OpenAITokenInfo | null> => {
if (!sessionToken.trim()) {
error.value = 'Missing session token'
return null
}
loading.value = true
error.value = ''
try {
const tokenInfo = await adminAPI.accounts.validateSoraSessionToken(
sessionToken.trim(),
proxyId,
`${endpointPrefix}/st2at`
)
return tokenInfo as OpenAITokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to validate session token'
appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
// Build credentials for OpenAI OAuth account (aligned with backend BuildAccountCredentials)
const buildCredentials = (tokenInfo: OpenAITokenInfo): Record<string, unknown> => {
const creds: Record<string, unknown> = {
......@@ -250,7 +223,6 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
generateAuthUrl,
exchangeAuthCode,
validateRefreshToken,
validateSessionToken,
buildCredentials,
buildExtraInfo
}
......
......@@ -1611,7 +1611,6 @@ export default {
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
sora: 'Sora'
},
deleteConfirm:
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
......@@ -1636,16 +1635,6 @@ export default {
title: 'Image Generation Pricing',
description: 'Configure pricing for image generation models. Leave empty to use default prices.'
},
soraPricing: {
title: 'Sora Per-Request Pricing',
description: 'Configure per-request pricing for Sora image/video generation. Leave empty to disable billing.',
image360: 'Image 360px ($)',
image540: 'Image 540px ($)',
video: 'Video (standard) ($)',
videoHd: 'Video (Pro-HD) ($)',
storageQuota: 'Storage Quota',
storageQuotaHint: 'In GB, set the Sora storage quota for users in this group. 0 means use system default'
},
claudeCode: {
title: 'Claude Code Client Restriction',
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
......@@ -2025,7 +2014,6 @@ export default {
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
sora: 'Sora'
},
types: {
oauth: 'OAuth',
......@@ -2035,10 +2023,6 @@ export default {
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth',
antigravityApikey: 'Connect via Base URL + API Key',
soraApiKey: 'API Key / Upstream',
soraApiKeyHint: 'Connect to another Sub2API or compatible API',
soraBaseUrlRequired: 'Sora API Key account requires a Base URL',
soraBaseUrlInvalidScheme: 'Base URL must start with http:// or https://',
upstream: 'Upstream',
upstreamDesc: 'Connect via Base URL + API Key'
},
......@@ -2301,8 +2285,6 @@ export default {
codexCLIOnlyDesc:
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.',
enableSora: 'Enable Sora simultaneously',
enableSoraHint: 'Sora uses the same OpenAI account. Enable to create Sora account simultaneously.'
},
anthropic: {
apiKeyPassthrough: 'Auto passthrough (auth only)',
......@@ -2317,9 +2299,6 @@ export default {
'Map request models to actual models. Left is the requested model, right is the actual model sent to API.',
selectedModels: 'Selected {count} model(s)',
supportsAllModels: '(supports all models)',
soraModelsLoadFailed: 'Failed to load Sora models, fallback to default list',
soraModelsLoading: 'Loading Sora models...',
soraModelsRetry: 'Load failed, click to retry',
requestModel: 'Request model',
actualModel: 'Actual model',
addMapping: 'Add Mapping',
......@@ -2469,8 +2448,6 @@ export default {
creating: 'Creating...',
updating: 'Updating...',
accountCreated: 'Account created successfully',
soraAccountCreated: 'Sora account created simultaneously',
soraAccountFailed: 'Failed to create Sora account, please add manually later',
accountUpdated: 'Account updated successfully',
failedToCreate: 'Failed to create account',
failedToUpdate: 'Failed to update account',
......@@ -2584,8 +2561,8 @@ export default {
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
sessionTokenAuth: 'Manual ST Input',
sessionTokenDesc: 'Enter your existing Sora Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
sessionTokenPlaceholder: 'Paste your Sora Session Token...\nSupports multiple, one per line',
sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line',
sessionTokenRawLabel: 'Raw Input',
sessionTokenRawPlaceholder: 'Paste /api/auth/session raw payload or Session Token...',
sessionTokenRawHint: 'You can paste full JSON. The system will auto-parse ST and AT.',
......@@ -2819,7 +2796,6 @@ export default {
reAuthorizeAccount: 'Re-Authorize Account',
claudeCodeAccount: 'Claude Code Account',
openaiAccount: 'OpenAI Account',
soraAccount: 'Sora Account',
geminiAccount: 'Gemini Account',
antigravityAccount: 'Antigravity Account',
inputMethod: 'Input Method',
......@@ -2853,11 +2829,6 @@ export default {
geminiImageTestMode: 'Mode: Gemini image generation test',
geminiImagePreview: 'Generated images:',
geminiImageReceived: 'Received test image #{count}',
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another Sub2API instance or compatible API)',
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
soraTestTarget: 'Target: Sora account capability',
soraTestMode: 'Mode: Connectivity + Capability checks',
soraTestingFlow: 'Running Sora connectivity and capability checks...',
// Stats Modal
viewStats: 'View Stats',
usageStatistics: 'Usage Statistics',
......@@ -5023,99 +4994,4 @@ export default {
}
},
// Sora Studio
sora: {
title: 'Sora Studio',
description: 'Generate videos and images with Sora AI',
notEnabled: 'Feature Not Available',
notEnabledDesc: 'The Sora Studio feature has not been enabled by the administrator. Please contact your admin.',
tabGenerate: 'Generate',
tabLibrary: 'Library',
noActiveGenerations: 'No active generations',
startGenerating: 'Enter a prompt below to start creating',
storage: 'Storage',
promptPlaceholder: 'Describe what you want to create...',
generate: 'Generate',
generating: 'Generating...',
selectModel: 'Select Model',
statusPending: 'Pending',
statusGenerating: 'Generating',
statusCompleted: 'Completed',
statusFailed: 'Failed',
statusCancelled: 'Cancelled',
cancel: 'Cancel',
delete: 'Delete',
save: 'Save to Cloud',
saved: 'Saved',
retry: 'Retry',
download: 'Download',
justNow: 'Just now',
minutesAgo: '{n} min ago',
hoursAgo: '{n} hr ago',
noSavedWorks: 'No saved works',
saveWorksHint: 'Save your completed generations to the library',
filterAll: 'All',
filterVideo: 'Video',
filterImage: 'Image',
confirmDelete: 'Are you sure you want to delete this work?',
loading: 'Loading...',
loadMore: 'Load More',
noStorageWarningTitle: 'No Storage Configured',
noStorageWarningDesc: 'Generated content is only available via temporary upstream links that expire in ~15 minutes. Consider configuring S3 storage.',
mediaTypeVideo: 'Video',
mediaTypeImage: 'Image',
notificationCompleted: 'Generation Complete',
notificationFailed: 'Generation Failed',
notificationCompletedBody: 'Your {model} task has completed',
notificationFailedBody: 'Your {model} task has failed',
upstreamExpiresSoon: 'Expiring soon',
upstreamExpired: 'Link expired',
upstreamCountdown: '{time} remaining',
previewTitle: 'Preview',
closePreview: 'Close',
beforeUnloadWarning: 'You have unsaved generated content. Are you sure you want to leave?',
downloadTitle: 'Download Generated Content',
downloadExpirationWarning: 'This link expires in approximately 15 minutes. Please download and save promptly.',
downloadNow: 'Download Now',
referenceImage: 'Reference Image',
removeImage: 'Remove',
imageTooLarge: 'Image size cannot exceed 20MB',
// Sora dark theme additions
welcomeTitle: 'Turn your imagination into video',
welcomeSubtitle: 'Enter a description and Sora will create realistic videos or images for you. Try the examples below to get started.',
queueTasks: 'tasks',
queueWaiting: 'Queued',
waiting: 'Waiting',
waited: 'Waited',
errorCategory: 'Content Policy Violation',
savedToCloud: 'Saved to Cloud',
downloadLocal: 'Download',
canDownload: 'to download',
regenrate: 'Regenerate',
regenerate: 'Regenerate',
creatorPlaceholder: 'Describe the video or image you want to create...',
videoModels: 'Video Models',
imageModels: 'Image Models',
noStorageConfigured: 'No Storage',
selectCredential: 'Select Credential',
apiKeys: 'API Keys',
subscriptions: 'Subscriptions',
subscription: 'Subscription',
noCredentialHint: 'Please create an API Key or contact admin for subscription',
uploadReference: 'Upload reference image',
generatingCount: 'Generating {current}/{max}',
noStorageToastMessage: 'Cloud storage is not configured. Please use "Download" to save files after generation, otherwise they will be lost.',
galleryCount: '{count} works',
galleryEmptyTitle: 'No works yet',
galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.',
startCreating: 'Start Creating',
yesterday: 'Yesterday',
landscape: 'Landscape',
portrait: 'Portrait',
square: 'Square',
examplePrompt1: 'A golden Shiba Inu walking through the streets of Shibuya, Tokyo, camera following, cinematic shot, 4K',
examplePrompt2: 'Drone aerial view, green aurora reflecting on a glacial lake in Iceland, slow push-in',
examplePrompt3: 'Cyberpunk futuristic city, neon lights reflected in rain puddles, nightscape, cinematic colors',
examplePrompt4: 'Chinese ink painting style, a small boat drifting among misty mountains and rivers, classical atmosphere'
}
}
......@@ -1648,7 +1648,6 @@ export default {
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
sora: 'Sora'
},
saving: '保存中...',
noGroups: '暂无分组',
......@@ -1722,16 +1721,6 @@ export default {
title: '图片生成计费',
description: '配置图片生成模型的图片生成价格,留空则使用默认价格'
},
soraPricing: {
title: 'Sora 按次计费',
description: '配置 Sora 图片/视频按次收费价格,留空则默认不计费',
image360: '图片 360px ($)',
image540: '图片 540px ($)',
video: '视频(标准)($)',
videoHd: '视频(Pro-HD)($)',
storageQuota: '存储配额',
storageQuotaHint: '单位 GB,设置该分组用户的 Sora 存储配额上限,0 表示使用系统默认'
},
claudeCode: {
title: 'Claude Code 客户端限制',
tooltip:
......@@ -2207,7 +2196,6 @@ export default {
anthropic: 'Anthropic',
gemini: 'Gemini',
antigravity: 'Antigravity',
sora: 'Sora'
},
types: {
oauth: 'OAuth',
......@@ -2217,10 +2205,6 @@ export default {
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth',
antigravityApikey: '通过 Base URL + API Key 连接',
soraApiKey: 'API Key / 上游透传',
soraApiKeyHint: '连接另一个 Sub2API 或兼容 API',
soraBaseUrlRequired: 'Sora apikey 账号必须设置上游地址(Base URL)',
soraBaseUrlInvalidScheme: 'Base URL 必须以 http:// 或 https:// 开头',
upstream: '对接上游',
upstreamDesc: '通过 Base URL + API Key 连接上游',
api_key: 'API Key',
......@@ -2449,8 +2433,6 @@ export default {
codexCLIOnly: '仅允许 Codex 官方客户端',
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
enableSora: '同时启用 Sora',
enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
},
anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)',
......@@ -2464,9 +2446,6 @@ export default {
mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。',
selectedModels: '已选择 {count} 个模型',
supportsAllModels: '(支持所有模型)',
soraModelsLoadFailed: '加载 Sora 模型列表失败,已回退到默认列表',
soraModelsLoading: '正在加载 Sora 模型...',
soraModelsRetry: '加载失败,点击重试',
requestModel: '请求模型',
actualModel: '实际模型',
addMapping: '添加映射',
......@@ -2613,8 +2592,6 @@ export default {
creating: '创建中...',
updating: '更新中...',
accountCreated: '账号创建成功',
soraAccountCreated: 'Sora 账号已同时创建',
soraAccountFailed: 'Sora 账号创建失败,请稍后手动添加',
accountUpdated: '账号更新成功',
failedToCreate: '创建账号失败',
failedToUpdate: '更新账号失败',
......@@ -2722,8 +2699,8 @@ export default {
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
sessionTokenAuth: '手动输入 ST',
sessionTokenDesc: '输入您已有的 Sora Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
sessionTokenPlaceholder: '粘贴您的 Sora Session Token...\n支持多个,每行一个',
sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个',
sessionTokenRawLabel: '原始字符串',
sessionTokenRawPlaceholder: '粘贴 /api/auth/session 原始数据或 Session Token...',
sessionTokenRawHint: '支持粘贴完整 JSON,系统会自动解析 ST 和 AT。',
......@@ -2952,7 +2929,6 @@ export default {
reAuthorizeAccount: '重新授权账号',
claudeCodeAccount: 'Claude Code 账号',
openaiAccount: 'OpenAI 账号',
soraAccount: 'Sora 账号',
geminiAccount: 'Gemini 账号',
antigravityAccount: 'Antigravity 账号',
inputMethod: '输入方式',
......@@ -2984,11 +2960,6 @@ export default {
geminiImageTestMode: '模式:Gemini 生图测试',
geminiImagePreview: '生成结果:',
geminiImageReceived: '已收到第 {count} 张测试图片',
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 Sub2API 实例或兼容 API)',
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
soraTestTarget: '检测目标:Sora 账号能力',
soraTestMode: '模式:连通性 + 能力探测',
soraTestingFlow: '执行 Sora 连通性与能力检测...',
// Stats Modal
viewStats: '查看统计',
usageStatistics: '使用统计',
......@@ -5212,99 +5183,4 @@ export default {
}
},
// Sora 创作
sora: {
title: 'Sora 创作',
description: '使用 Sora AI 生成视频与图片',
notEnabled: '功能未开放',
notEnabledDesc: '管理员尚未启用 Sora 创作功能,请联系管理员开通。',
tabGenerate: '生成',
tabLibrary: '作品库',
noActiveGenerations: '暂无生成任务',
startGenerating: '在下方输入提示词,开始创作',
storage: '存储',
promptPlaceholder: '描述你想创作的内容...',
generate: '生成',
generating: '生成中...',
selectModel: '选择模型',
statusPending: '等待中',
statusGenerating: '生成中',
statusCompleted: '已完成',
statusFailed: '失败',
statusCancelled: '已取消',
cancel: '取消',
delete: '删除',
save: '保存到云端',
saved: '已保存',
retry: '重试',
download: '下载',
justNow: '刚刚',
minutesAgo: '{n} 分钟前',
hoursAgo: '{n} 小时前',
noSavedWorks: '暂无保存的作品',
saveWorksHint: '生成完成后,将作品保存到作品库',
filterAll: '全部',
filterVideo: '视频',
filterImage: '图片',
confirmDelete: '确定删除此作品?',
loading: '加载中...',
loadMore: '加载更多',
noStorageWarningTitle: '未配置存储',
noStorageWarningDesc: '生成的内容仅通过上游临时链接提供,约 15 分钟后过期。建议管理员配置 S3 存储。',
mediaTypeVideo: '视频',
mediaTypeImage: '图片',
notificationCompleted: '生成完成',
notificationFailed: '生成失败',
notificationCompletedBody: '您的 {model} 任务已完成',
notificationFailedBody: '您的 {model} 任务失败了',
upstreamExpiresSoon: '即将过期',
upstreamExpired: '链接已过期',
upstreamCountdown: '剩余 {time}',
previewTitle: '作品预览',
closePreview: '关闭',
beforeUnloadWarning: '您有未保存的生成内容,确定要离开吗?',
downloadTitle: '下载生成内容',
downloadExpirationWarning: '此链接约 15 分钟后过期,请尽快下载保存。',
downloadNow: '立即下载',
referenceImage: '参考图',
removeImage: '移除',
imageTooLarge: '图片大小不能超过 20MB',
// Sora 暗色主题新增
welcomeTitle: '将你的想象力变成视频',
welcomeSubtitle: '输入一段描述,Sora 将为你创作逼真的视频或图片。尝试以下示例开始创作。',
queueTasks: '个任务',
queueWaiting: '队列中等待',
waiting: '等待中',
waited: '已等待',
errorCategory: '内容策略限制',
savedToCloud: '已保存到云端',
downloadLocal: '本地下载',
canDownload: '可下载',
regenrate: '重新生成',
regenerate: '重新生成',
creatorPlaceholder: '描述你想要生成的视频或图片...',
videoModels: '视频模型',
imageModels: '图片模型',
noStorageConfigured: '存储未配置',
selectCredential: '选择凭证',
apiKeys: 'API 密钥',
subscriptions: '订阅',
subscription: '订阅',
noCredentialHint: '请先创建 API Key 或联系管理员分配订阅',
uploadReference: '上传参考图片',
generatingCount: '正在生成 {current}/{max}',
noStorageToastMessage: '管理员未开通云存储,生成完成后请使用"本地下载"保存文件,否则将会丢失。',
galleryCount: '共 {count} 个作品',
galleryEmptyTitle: '还没有任何作品',
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
startCreating: '开始创作',
yesterday: '昨天',
landscape: '横屏',
portrait: '竖屏',
square: '方形',
examplePrompt1: '一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
}
}
import { describe, expect, it } from 'vitest'
import { parseSoraRawTokens } from '@/utils/soraTokenParser'
describe('parseSoraRawTokens', () => {
it('parses sessionToken and accessToken from JSON payload', () => {
const payload = JSON.stringify({
user: { id: 'u1' },
accessToken: 'at-json-1',
sessionToken: 'st-json-1'
})
const result = parseSoraRawTokens(payload)
expect(result.sessionTokens).toEqual(['st-json-1'])
expect(result.accessTokens).toEqual(['at-json-1'])
})
it('supports plain session tokens (one per line)', () => {
const result = parseSoraRawTokens('st-1\nst-2')
expect(result.sessionTokens).toEqual(['st-1', 'st-2'])
expect(result.accessTokens).toEqual([])
})
it('supports non-standard object snippets via regex', () => {
const raw = "sessionToken: 'st-snippet', access_token: \"at-snippet\""
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['st-snippet'])
expect(result.accessTokens).toEqual(['at-snippet'])
})
it('keeps unique tokens and extracts JWT-like plain line as AT too', () => {
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature'
const raw = `st-dup\nst-dup\n${jwt}\n${JSON.stringify({ sessionToken: 'st-json', accessToken: jwt })}`
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['st-json', 'st-dup'])
expect(result.accessTokens).toEqual([jwt])
})
it('parses session token from Set-Cookie line and strips cookie attributes', () => {
const raw =
'__Secure-next-auth.session-token.0=st-cookie-part-0; Domain=.chatgpt.com; Path=/; Expires=Thu, 28 May 2026 11:43:36 GMT; HttpOnly; Secure; SameSite=Lax'
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['st-cookie-part-0'])
expect(result.accessTokens).toEqual([])
})
it('merges chunked session-token cookies by numeric suffix order', () => {
const raw = [
'Set-Cookie: __Secure-next-auth.session-token.1=part-1; Path=/; HttpOnly',
'Set-Cookie: __Secure-next-auth.session-token.0=part-0; Path=/; HttpOnly'
].join('\n')
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['part-0part-1'])
expect(result.accessTokens).toEqual([])
})
it('prefers latest duplicate chunk values when multiple cookie groups exist', () => {
const raw = [
'Set-Cookie: __Secure-next-auth.session-token.0=old-0; Path=/; HttpOnly',
'Set-Cookie: __Secure-next-auth.session-token.1=old-1; Path=/; HttpOnly',
'Set-Cookie: __Secure-next-auth.session-token.0=new-0; Path=/; HttpOnly',
'Set-Cookie: __Secure-next-auth.session-token.1=new-1; Path=/; HttpOnly'
].join('\n')
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['new-0new-1'])
expect(result.accessTokens).toEqual([])
})
it('uses latest complete chunk group and ignores incomplete latest group', () => {
const raw = [
'set-cookie',
'__Secure-next-auth.session-token.0=ok-0; Domain=.chatgpt.com; Path=/',
'set-cookie',
'__Secure-next-auth.session-token.1=ok-1; Domain=.chatgpt.com; Path=/',
'set-cookie',
'__Secure-next-auth.session-token.0=partial-0; Domain=.chatgpt.com; Path=/'
].join('\n')
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['ok-0ok-1'])
expect(result.accessTokens).toEqual([])
})
})
export interface ParsedSoraTokens {
sessionTokens: string[]
accessTokens: string[]
}
const sessionKeyNames = new Set(['sessiontoken', 'session_token', 'st'])
const accessKeyNames = new Set(['accesstoken', 'access_token', 'at'])
const sessionRegexes = [
/\bsessionToken\b\s*:\s*["']([^"']+)["']/gi,
/\bsession_token\b\s*:\s*["']([^"']+)["']/gi
]
const accessRegexes = [
/\baccessToken\b\s*:\s*["']([^"']+)["']/gi,
/\baccess_token\b\s*:\s*["']([^"']+)["']/gi
]
const sessionCookieRegex =
/(?:^|[\n\r;])\s*(?:(?:set-cookie|cookie)\s*:\s*)?__Secure-(?:next-auth|authjs)\.session-token(?:\.(\d+))?=([^;\r\n]+)/gi
interface SessionCookieChunk {
index: number
value: string
}
const ignoredPlainLines = new Set([
'set-cookie',
'cookie',
'strict-transport-security',
'vary',
'x-content-type-options',
'x-openai-proxy-wasm'
])
function sanitizeToken(raw: string): string {
return raw.trim().replace(/^["'`]+|["'`,;]+$/g, '')
}
function addUnique(list: string[], seen: Set<string>, rawValue: string): void {
const token = sanitizeToken(rawValue)
if (!token || seen.has(token)) {
return
}
seen.add(token)
list.push(token)
}
function isLikelyJWT(token: string): boolean {
if (!token.startsWith('eyJ')) {
return false
}
return token.split('.').length === 3
}
function collectFromObject(
value: unknown,
sessionTokens: string[],
sessionSeen: Set<string>,
accessTokens: string[],
accessSeen: Set<string>
): void {
if (Array.isArray(value)) {
for (const item of value) {
collectFromObject(item, sessionTokens, sessionSeen, accessTokens, accessSeen)
}
return
}
if (!value || typeof value !== 'object') {
return
}
for (const [key, fieldValue] of Object.entries(value as Record<string, unknown>)) {
if (typeof fieldValue === 'string') {
const normalizedKey = key.toLowerCase()
if (sessionKeyNames.has(normalizedKey)) {
addUnique(sessionTokens, sessionSeen, fieldValue)
}
if (accessKeyNames.has(normalizedKey)) {
addUnique(accessTokens, accessSeen, fieldValue)
}
continue
}
collectFromObject(fieldValue, sessionTokens, sessionSeen, accessTokens, accessSeen)
}
}
function collectFromJSONString(
raw: string,
sessionTokens: string[],
sessionSeen: Set<string>,
accessTokens: string[],
accessSeen: Set<string>
): void {
const trimmed = raw.trim()
if (!trimmed) {
return
}
const candidates = [trimmed]
const firstBrace = trimmed.indexOf('{')
const lastBrace = trimmed.lastIndexOf('}')
if (firstBrace >= 0 && lastBrace > firstBrace) {
candidates.push(trimmed.slice(firstBrace, lastBrace + 1))
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate)
collectFromObject(parsed, sessionTokens, sessionSeen, accessTokens, accessSeen)
return
} catch {
// ignore and keep trying other candidates
}
}
}
function collectByRegex(
raw: string,
regexes: RegExp[],
tokens: string[],
seen: Set<string>
): void {
for (const regex of regexes) {
regex.lastIndex = 0
let match: RegExpExecArray | null
match = regex.exec(raw)
while (match) {
if (match[1]) {
addUnique(tokens, seen, match[1])
}
match = regex.exec(raw)
}
}
}
function collectFromSessionCookies(
raw: string,
sessionTokens: string[],
sessionSeen: Set<string>
): void {
const chunkMatches: SessionCookieChunk[] = []
const singleValues: string[] = []
sessionCookieRegex.lastIndex = 0
let match: RegExpExecArray | null
match = sessionCookieRegex.exec(raw)
while (match) {
const chunkIndex = match[1]
const rawValue = match[2]
const value = sanitizeToken(rawValue || '')
if (value) {
if (chunkIndex !== undefined && chunkIndex !== '') {
const idx = Number.parseInt(chunkIndex, 10)
if (Number.isInteger(idx) && idx >= 0) {
chunkMatches.push({ index: idx, value })
}
} else {
singleValues.push(value)
}
}
match = sessionCookieRegex.exec(raw)
}
const mergedChunkToken = mergeLatestChunkedSessionToken(chunkMatches)
if (mergedChunkToken) {
addUnique(sessionTokens, sessionSeen, mergedChunkToken)
}
for (const value of singleValues) {
addUnique(sessionTokens, sessionSeen, value)
}
}
function mergeChunkSegment(
chunks: SessionCookieChunk[],
requiredMaxIndex: number,
requireComplete: boolean
): string {
if (chunks.length === 0) {
return ''
}
const byIndex = new Map<number, string>()
for (const chunk of chunks) {
byIndex.set(chunk.index, chunk.value)
}
if (!byIndex.has(0)) {
return ''
}
if (requireComplete) {
for (let i = 0; i <= requiredMaxIndex; i++) {
if (!byIndex.has(i)) {
return ''
}
}
}
const orderedIndexes = Array.from(byIndex.keys()).sort((a, b) => a - b)
return orderedIndexes.map((idx) => byIndex.get(idx) || '').join('')
}
function mergeLatestChunkedSessionToken(chunks: SessionCookieChunk[]): string {
if (chunks.length === 0) {
return ''
}
const requiredMaxIndex = chunks.reduce((max, chunk) => Math.max(max, chunk.index), 0)
const groupStarts: number[] = []
chunks.forEach((chunk, idx) => {
if (chunk.index === 0) {
groupStarts.push(idx)
}
})
if (groupStarts.length === 0) {
return mergeChunkSegment(chunks, requiredMaxIndex, false)
}
for (let i = groupStarts.length - 1; i >= 0; i--) {
const start = groupStarts[i]
const end = i + 1 < groupStarts.length ? groupStarts[i + 1] : chunks.length
const merged = mergeChunkSegment(chunks.slice(start, end), requiredMaxIndex, true)
if (merged) {
return merged
}
}
return mergeChunkSegment(chunks, requiredMaxIndex, false)
}
function collectPlainLines(
raw: string,
sessionTokens: string[],
sessionSeen: Set<string>,
accessTokens: string[],
accessSeen: Set<string>
): void {
const lines = raw
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
for (const line of lines) {
const normalized = line.toLowerCase()
if (ignoredPlainLines.has(normalized)) {
continue
}
if (/^__secure-(next-auth|authjs)\.session-token(\.\d+)?=/i.test(line)) {
continue
}
if (line.includes(';')) {
continue
}
if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(line)) {
const parts = line.split('=', 2)
const key = parts[0]?.trim().toLowerCase()
const value = parts[1]?.trim() || ''
if (key && sessionKeyNames.has(key)) {
addUnique(sessionTokens, sessionSeen, value)
continue
}
if (key && accessKeyNames.has(key)) {
addUnique(accessTokens, accessSeen, value)
continue
}
}
if (line.includes('{') || line.includes('}') || line.includes(':') || /\s/.test(line)) {
continue
}
if (isLikelyJWT(line)) {
addUnique(accessTokens, accessSeen, line)
continue
}
addUnique(sessionTokens, sessionSeen, line)
}
}
export function parseSoraRawTokens(rawInput: string): ParsedSoraTokens {
const raw = rawInput.trim()
if (!raw) {
return {
sessionTokens: [],
accessTokens: []
}
}
const sessionTokens: string[] = []
const accessTokens: string[] = []
const sessionSeen = new Set<string>()
const accessSeen = new Set<string>()
collectFromJSONString(raw, sessionTokens, sessionSeen, accessTokens, accessSeen)
collectByRegex(raw, sessionRegexes, sessionTokens, sessionSeen)
collectByRegex(raw, accessRegexes, accessTokens, accessSeen)
collectFromSessionCookies(raw, sessionTokens, sessionSeen)
collectPlainLines(raw, sessionTokens, sessionSeen, accessTokens, accessSeen)
return {
sessionTokens,
accessTokens
}
}
......@@ -523,7 +523,6 @@ function getPlatformTextColor(platform: string): string {
case 'openai': return 'text-emerald-600 dark:text-emerald-400'
case 'gemini': return 'text-blue-600 dark:text-blue-400'
case 'antigravity': return 'text-purple-600 dark:text-purple-400'
case 'sora': return 'text-rose-600 dark:text-rose-400'
default: return 'text-gray-600 dark:text-gray-400'
}
}
......@@ -534,7 +533,6 @@ function getRateBadgeClass(platform: string): string {
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
case 'sora': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
}
}
......
<template>
<div class="space-y-6">
<div class="card p-6">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.soraS3.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.soraS3.description') }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary btn-sm" @click="startCreateSoraProfile">
{{ t('admin.settings.soraS3.newProfile') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="loadingSoraProfiles" @click="loadSoraS3Profiles">
{{ loadingSoraProfiles ? t('common.loading') : t('admin.settings.soraS3.reloadProfiles') }}
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[1000px] text-sm">
<thead>
<tr class="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-gray-400">
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.profile') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.active') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.endpoint') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.bucket') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.quota') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.updatedAt') }}</th>
<th class="py-2">{{ t('admin.settings.soraS3.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="profile in soraS3Profiles" :key="profile.profile_id" class="border-b border-gray-100 align-top dark:border-dark-800">
<td class="py-3 pr-4">
<div class="font-mono text-xs">{{ profile.profile_id }}</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">{{ profile.name }}</div>
</td>
<td class="py-3 pr-4">
<span
class="rounded px-2 py-0.5 text-xs"
:class="profile.is_active ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-700 dark:bg-dark-800 dark:text-gray-300'"
>
{{ profile.is_active ? t('common.enabled') : t('common.disabled') }}
</span>
</td>
<td class="py-3 pr-4 text-xs">
<div>{{ profile.endpoint || '-' }}</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ profile.region || '-' }}</div>
</td>
<td class="py-3 pr-4 text-xs">{{ profile.bucket || '-' }}</td>
<td class="py-3 pr-4 text-xs">{{ formatStorageQuotaGB(profile.default_storage_quota_bytes) }}</td>
<td class="py-3 pr-4 text-xs">{{ formatDate(profile.updated_at) }}</td>
<td class="py-3 text-xs">
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary btn-xs" @click="editSoraProfile(profile.profile_id)">
{{ t('common.edit') }}
</button>
<button
v-if="!profile.is_active"
type="button"
class="btn btn-secondary btn-xs"
:disabled="activatingSoraProfile"
@click="activateSoraProfile(profile.profile_id)"
>
{{ t('admin.settings.soraS3.activateProfile') }}
</button>
<button
type="button"
class="btn btn-danger btn-xs"
:disabled="deletingSoraProfile"
@click="removeSoraProfile(profile.profile_id)"
>
{{ t('common.delete') }}
</button>
</div>
</td>
</tr>
<tr v-if="soraS3Profiles.length === 0">
<td colspan="7" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.soraS3.empty') }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<Teleport to="body">
<Transition name="dm-drawer-mask">
<div
v-if="soraProfileDrawerOpen"
class="fixed inset-0 z-[54] bg-black/40 backdrop-blur-sm"
@click="closeSoraProfileDrawer"
></div>
</Transition>
<Transition name="dm-drawer-panel">
<div
v-if="soraProfileDrawerOpen"
class="fixed inset-y-0 right-0 z-[55] flex h-full w-full max-w-2xl flex-col border-l border-gray-200 bg-white shadow-2xl dark:border-dark-700 dark:bg-dark-900"
>
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-dark-700">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ creatingSoraProfile ? t('admin.settings.soraS3.createTitle') : t('admin.settings.soraS3.editTitle') }}
</h4>
<button
type="button"
class="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-800 dark:hover:text-gray-200"
@click="closeSoraProfileDrawer"
>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<input
v-model="soraProfileForm.profile_id"
class="input w-full"
:placeholder="t('admin.settings.soraS3.profileID')"
:disabled="!creatingSoraProfile"
/>
<input
v-model="soraProfileForm.name"
class="input w-full"
:placeholder="t('admin.settings.soraS3.profileName')"
/>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
<input v-model="soraProfileForm.enabled" type="checkbox" />
<span>{{ t('admin.settings.soraS3.enabled') }}</span>
</label>
<input v-model="soraProfileForm.endpoint" class="input w-full" :placeholder="t('admin.settings.soraS3.endpoint')" />
<input v-model="soraProfileForm.region" class="input w-full" :placeholder="t('admin.settings.soraS3.region')" />
<input v-model="soraProfileForm.bucket" class="input w-full" :placeholder="t('admin.settings.soraS3.bucket')" />
<input v-model="soraProfileForm.prefix" class="input w-full" :placeholder="t('admin.settings.soraS3.prefix')" />
<input v-model="soraProfileForm.access_key_id" class="input w-full" :placeholder="t('admin.settings.soraS3.accessKeyId')" />
<input
v-model="soraProfileForm.secret_access_key"
type="password"
class="input w-full"
:placeholder="soraProfileForm.secret_access_key_configured ? t('admin.settings.soraS3.secretConfigured') : t('admin.settings.soraS3.secretAccessKey')"
/>
<input v-model="soraProfileForm.cdn_url" class="input w-full" :placeholder="t('admin.settings.soraS3.cdnUrl')" />
<div>
<input
v-model.number="soraProfileForm.default_storage_quota_gb"
type="number"
min="0"
step="0.1"
class="input w-full"
:placeholder="t('admin.settings.soraS3.defaultQuota')"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.defaultQuotaHint') }}</p>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="soraProfileForm.force_path_style" type="checkbox" />
<span>{{ t('admin.settings.soraS3.forcePathStyle') }}</span>
</label>
<label v-if="creatingSoraProfile" class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
<input v-model="soraProfileForm.set_active" type="checkbox" />
<span>{{ t('admin.settings.soraS3.setActive') }}</span>
</label>
</div>
</div>
<div class="flex flex-wrap justify-end gap-2 border-t border-gray-200 p-4 dark:border-dark-700">
<button type="button" class="btn btn-secondary btn-sm" @click="closeSoraProfileDrawer">
{{ t('common.cancel') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="testingSoraProfile || !soraProfileForm.enabled" @click="testSoraProfileConnection">
{{ testingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.testConnection') }}
</button>
<button type="button" class="btn btn-primary btn-sm" :disabled="savingSoraProfile" @click="saveSoraProfile">
{{ savingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.saveProfile') }}
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SoraS3Profile } from '@/api/admin/settings'
import { adminAPI } from '@/api'
import { useAppStore } from '@/stores'
const { t } = useI18n()
const appStore = useAppStore()
const loadingSoraProfiles = ref(false)
const savingSoraProfile = ref(false)
const testingSoraProfile = ref(false)
const activatingSoraProfile = ref(false)
const deletingSoraProfile = ref(false)
const creatingSoraProfile = ref(false)
const soraProfileDrawerOpen = ref(false)
const soraS3Profiles = ref<SoraS3Profile[]>([])
const selectedSoraProfileID = ref('')
type SoraS3ProfileForm = {
profile_id: string
name: string
set_active: boolean
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key: string
secret_access_key_configured: boolean
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_gb: number
}
const soraProfileForm = ref<SoraS3ProfileForm>(newDefaultSoraS3ProfileForm())
async function loadSoraS3Profiles() {
loadingSoraProfiles.value = true
try {
const result = await adminAPI.settings.listSoraS3Profiles()
soraS3Profiles.value = result.items || []
if (!creatingSoraProfile.value) {
const stillExists = selectedSoraProfileID.value
? soraS3Profiles.value.some((item) => item.profile_id === selectedSoraProfileID.value)
: false
if (!stillExists) {
selectedSoraProfileID.value = pickPreferredSoraProfileID()
}
syncSoraProfileFormWithSelection()
}
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
loadingSoraProfiles.value = false
}
}
function startCreateSoraProfile() {
creatingSoraProfile.value = true
selectedSoraProfileID.value = ''
soraProfileForm.value = newDefaultSoraS3ProfileForm()
soraProfileDrawerOpen.value = true
}
function editSoraProfile(profileID: string) {
selectedSoraProfileID.value = profileID
creatingSoraProfile.value = false
syncSoraProfileFormWithSelection()
soraProfileDrawerOpen.value = true
}
function closeSoraProfileDrawer() {
soraProfileDrawerOpen.value = false
if (creatingSoraProfile.value) {
creatingSoraProfile.value = false
selectedSoraProfileID.value = pickPreferredSoraProfileID()
syncSoraProfileFormWithSelection()
}
}
async function saveSoraProfile() {
if (!soraProfileForm.value.name.trim()) {
appStore.showError(t('admin.settings.soraS3.profileNameRequired'))
return
}
if (creatingSoraProfile.value && !soraProfileForm.value.profile_id.trim()) {
appStore.showError(t('admin.settings.soraS3.profileIDRequired'))
return
}
if (!creatingSoraProfile.value && !selectedSoraProfileID.value) {
appStore.showError(t('admin.settings.soraS3.profileSelectRequired'))
return
}
if (soraProfileForm.value.enabled) {
if (!soraProfileForm.value.endpoint.trim()) {
appStore.showError(t('admin.settings.soraS3.endpointRequired'))
return
}
if (!soraProfileForm.value.bucket.trim()) {
appStore.showError(t('admin.settings.soraS3.bucketRequired'))
return
}
if (!soraProfileForm.value.access_key_id.trim()) {
appStore.showError(t('admin.settings.soraS3.accessKeyRequired'))
return
}
}
savingSoraProfile.value = true
try {
if (creatingSoraProfile.value) {
const created = await adminAPI.settings.createSoraS3Profile({
profile_id: soraProfileForm.value.profile_id.trim(),
name: soraProfileForm.value.name.trim(),
set_active: soraProfileForm.value.set_active,
enabled: soraProfileForm.value.enabled,
endpoint: soraProfileForm.value.endpoint,
region: soraProfileForm.value.region,
bucket: soraProfileForm.value.bucket,
access_key_id: soraProfileForm.value.access_key_id,
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
prefix: soraProfileForm.value.prefix,
force_path_style: soraProfileForm.value.force_path_style,
cdn_url: soraProfileForm.value.cdn_url,
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
})
selectedSoraProfileID.value = created.profile_id
creatingSoraProfile.value = false
soraProfileDrawerOpen.value = false
appStore.showSuccess(t('admin.settings.soraS3.profileCreated'))
} else {
await adminAPI.settings.updateSoraS3Profile(selectedSoraProfileID.value, {
name: soraProfileForm.value.name.trim(),
enabled: soraProfileForm.value.enabled,
endpoint: soraProfileForm.value.endpoint,
region: soraProfileForm.value.region,
bucket: soraProfileForm.value.bucket,
access_key_id: soraProfileForm.value.access_key_id,
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
prefix: soraProfileForm.value.prefix,
force_path_style: soraProfileForm.value.force_path_style,
cdn_url: soraProfileForm.value.cdn_url,
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
})
soraProfileDrawerOpen.value = false
appStore.showSuccess(t('admin.settings.soraS3.profileSaved'))
}
await loadSoraS3Profiles()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
savingSoraProfile.value = false
}
}
async function testSoraProfileConnection() {
testingSoraProfile.value = true
try {
const result = await adminAPI.settings.testSoraS3Connection({
profile_id: creatingSoraProfile.value ? undefined : selectedSoraProfileID.value,
enabled: soraProfileForm.value.enabled,
endpoint: soraProfileForm.value.endpoint,
region: soraProfileForm.value.region,
bucket: soraProfileForm.value.bucket,
access_key_id: soraProfileForm.value.access_key_id,
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
prefix: soraProfileForm.value.prefix,
force_path_style: soraProfileForm.value.force_path_style,
cdn_url: soraProfileForm.value.cdn_url,
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
})
appStore.showSuccess(result.message || t('admin.settings.soraS3.testSuccess'))
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
testingSoraProfile.value = false
}
}
async function activateSoraProfile(profileID: string) {
activatingSoraProfile.value = true
try {
await adminAPI.settings.setActiveSoraS3Profile(profileID)
appStore.showSuccess(t('admin.settings.soraS3.profileActivated'))
await loadSoraS3Profiles()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
activatingSoraProfile.value = false
}
}
async function removeSoraProfile(profileID: string) {
if (!window.confirm(t('admin.settings.soraS3.deleteConfirm', { profileID }))) {
return
}
deletingSoraProfile.value = true
try {
await adminAPI.settings.deleteSoraS3Profile(profileID)
if (selectedSoraProfileID.value === profileID) {
selectedSoraProfileID.value = ''
}
appStore.showSuccess(t('admin.settings.soraS3.profileDeleted'))
await loadSoraS3Profiles()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
deletingSoraProfile.value = false
}
}
function formatDate(value?: string): string {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleString()
}
function formatStorageQuotaGB(bytes: number): string {
if (!bytes || bytes <= 0) {
return '0 GB'
}
const gb = bytes / (1024 * 1024 * 1024)
return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`
}
function pickPreferredSoraProfileID(): string {
const active = soraS3Profiles.value.find((item) => item.is_active)
if (active) {
return active.profile_id
}
return soraS3Profiles.value[0]?.profile_id || ''
}
function syncSoraProfileFormWithSelection() {
const profile = soraS3Profiles.value.find((item) => item.profile_id === selectedSoraProfileID.value)
soraProfileForm.value = newDefaultSoraS3ProfileForm(profile)
}
function newDefaultSoraS3ProfileForm(profile?: SoraS3Profile): SoraS3ProfileForm {
if (!profile) {
return {
profile_id: '',
name: '',
set_active: false,
enabled: false,
endpoint: '',
region: '',
bucket: '',
access_key_id: '',
secret_access_key: '',
secret_access_key_configured: false,
prefix: 'sora/',
force_path_style: false,
cdn_url: '',
default_storage_quota_gb: 0
}
}
const quotaBytes = profile.default_storage_quota_bytes || 0
return {
profile_id: profile.profile_id,
name: profile.name,
set_active: false,
enabled: profile.enabled,
endpoint: profile.endpoint || '',
region: profile.region || '',
bucket: profile.bucket || '',
access_key_id: profile.access_key_id || '',
secret_access_key: '',
secret_access_key_configured: Boolean(profile.secret_access_key_configured),
prefix: profile.prefix || '',
force_path_style: Boolean(profile.force_path_style),
cdn_url: profile.cdn_url || '',
default_storage_quota_gb: Number((quotaBytes / (1024 * 1024 * 1024)).toFixed(2))
}
}
onMounted(async () => {
await loadSoraS3Profiles()
})
</script>
<style scoped>
.dm-drawer-mask-enter-active,
.dm-drawer-mask-leave-active {
transition: opacity 0.2s ease;
}
.dm-drawer-mask-enter-from,
.dm-drawer-mask-leave-to {
opacity: 0;
}
.dm-drawer-panel-enter-active,
.dm-drawer-panel-leave-active {
transition:
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.2s ease;
}
.dm-drawer-panel-enter-from,
.dm-drawer-panel-leave-to {
opacity: 0.96;
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.dm-drawer-mask-enter-active,
.dm-drawer-mask-leave-active,
.dm-drawer-panel-enter-active,
.dm-drawer-panel-leave-active {
transition-duration: 0s;
}
}
</style>
......@@ -522,80 +522,7 @@
</div>
</div>
<!-- Sora 按次计费配置 -->
<div v-if="createForm.platform === 'sora'" class="border-t pt-4">
<label class="block mb-2 font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.soraPricing.title') }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.soraPricing.description') }}
</p>
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<label class="input-label">{{ t('admin.groups.soraPricing.image360') }}</label>
<input
v-model.number="createForm.sora_image_price_360"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.05"
/>
</div>
<div>
<label class="input-label">{{ t('admin.groups.soraPricing.image540') }}</label>
<input
v-model.number="createForm.sora_image_price_540"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.08"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="input-label">{{ t('admin.groups.soraPricing.video') }}</label>
<input
v-model.number="createForm.sora_video_price_per_request"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.5"
/>
</div>
<div>
<label class="input-label">{{ t('admin.groups.soraPricing.videoHd') }}</label>
<input
v-model.number="createForm.sora_video_price_per_request_hd"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.8"
/>
</div>
</div>
<div class="mt-3">
<label class="input-label">{{ t('admin.groups.soraPricing.storageQuota') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="createForm.sora_storage_quota_gb"
type="number"
step="0.1"
min="0"
class="input"
placeholder="10"
/>
<span class="shrink-0 text-sm text-gray-500">GB</span>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.groups.soraPricing.storageQuotaHint') }}
</p>
</div>
</div>
<!-- 支持的模型系列(仅 antigravity 平台) -->
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
......@@ -1312,80 +1239,7 @@
</div>
</div>
<!-- Sora 按次计费配置 -->
<div v-if="editForm.platform === 'sora'" class="border-t pt-4">
<label class="block mb-2 font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.soraPricing.title') }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.soraPricing.description') }}
</p>
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<label class="input-label">{{ t('admin.groups.soraPricing.image360') }}</label>
<input
v-model.number="editForm.sora_image_price_360"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.05"
/>
</div>
<div>
<label class="input-label">{{ t('admin.groups.soraPricing.image540') }}</label>
<input
v-model.number="editForm.sora_image_price_540"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.08"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="input-label">{{ t('admin.groups.soraPricing.video') }}</label>
<input
v-model.number="editForm.sora_video_price_per_request"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.5"
/>
</div>
<div>
<label class="input-label">{{ t('admin.groups.soraPricing.videoHd') }}</label>
<input
v-model.number="editForm.sora_video_price_per_request_hd"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.8"
/>
</div>
</div>
<div class="mt-3">
<label class="input-label">{{ t('admin.groups.soraPricing.storageQuota') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="editForm.sora_storage_quota_gb"
type="number"
step="0.1"
min="0"
class="input"
placeholder="10"
/>
<span class="shrink-0 text-sm text-gray-500">GB</span>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.groups.soraPricing.storageQuotaHint') }}
</p>
</div>
</div>
<!-- 支持的模型系列(仅 antigravity 平台) -->
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
......@@ -2001,8 +1855,7 @@ const platformOptions = computed(() => [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' },
{ value: 'sora', label: 'Sora' }
{ value: 'antigravity', label: 'Antigravity' }
])
const platformFilterOptions = computed(() => [
......@@ -2010,8 +1863,7 @@ const platformFilterOptions = computed(() => [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' },
{ value: 'sora', label: 'Sora' }
{ value: 'antigravity', label: 'Antigravity' }
])
const editStatusOptions = computed(() => [
......@@ -2160,12 +2012,6 @@ const createForm = reactive({
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null,
// Sora 按次计费配置
sora_image_price_360: null as number | null,
sora_image_price_540: null as number | null,
sora_video_price_per_request: null as number | null,
sora_video_price_per_request_hd: null as number | null,
sora_storage_quota_gb: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
......@@ -2407,12 +2253,6 @@ const editForm = reactive({
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null,
// Sora 按次计费配置
sora_image_price_360: null as number | null,
sora_image_price_540: null as number | null,
sora_video_price_per_request: null as number | null,
sora_video_price_per_request_hd: null as number | null,
sora_storage_quota_gb: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
......@@ -2559,11 +2399,6 @@ const closeCreateModal = () => {
createForm.image_price_1k = null
createForm.image_price_2k = null
createForm.image_price_4k = null
createForm.sora_image_price_360 = null
createForm.sora_image_price_540 = null
createForm.sora_video_price_per_request = null
createForm.sora_video_price_per_request_hd = null
createForm.sora_storage_quota_gb = null
createForm.claude_code_only = false
createForm.fallback_group_id = null
createForm.fallback_group_id_on_invalid_request = null
......@@ -2602,13 +2437,11 @@ const handleCreateGroup = async () => {
submitting.value = true
try {
// 构建请求数据,包含模型路由配置
const { sora_storage_quota_gb: createQuotaGb, ...createRest } = createForm
const requestData = {
...createRest,
...createForm,
daily_limit_usd: normalizeOptionalLimit(createForm.daily_limit_usd as number | string | null),
weekly_limit_usd: normalizeOptionalLimit(createForm.weekly_limit_usd as number | string | null),
monthly_limit_usd: normalizeOptionalLimit(createForm.monthly_limit_usd as number | string | null),
sora_storage_quota_bytes: createQuotaGb ? Math.round(createQuotaGb * 1024 * 1024 * 1024) : 0,
model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value)
}
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
......@@ -2648,11 +2481,6 @@ const handleEdit = async (group: AdminGroup) => {
editForm.image_price_1k = group.image_price_1k
editForm.image_price_2k = group.image_price_2k
editForm.image_price_4k = group.image_price_4k
editForm.sora_image_price_360 = group.sora_image_price_360
editForm.sora_image_price_540 = group.sora_image_price_540
editForm.sora_video_price_per_request = group.sora_video_price_per_request
editForm.sora_video_price_per_request_hd = group.sora_video_price_per_request_hd
editForm.sora_storage_quota_gb = group.sora_storage_quota_bytes ? Number((group.sora_storage_quota_bytes / (1024 * 1024 * 1024)).toFixed(2)) : null
editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request
......@@ -2690,13 +2518,11 @@ const handleUpdateGroup = async () => {
submitting.value = true
try {
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const { sora_storage_quota_gb: editQuotaGb, ...editRest } = editForm
const payload = {
...editRest,
...editForm,
daily_limit_usd: normalizeOptionalLimit(editForm.daily_limit_usd as number | string | null),
weekly_limit_usd: normalizeOptionalLimit(editForm.weekly_limit_usd as number | string | null),
monthly_limit_usd: normalizeOptionalLimit(editForm.monthly_limit_usd as number | string | null),
sora_storage_quota_bytes: editQuotaGb ? Math.round(editQuotaGb * 1024 * 1024 * 1024) : 0,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
fallback_group_id_on_invalid_request:
editForm.fallback_group_id_on_invalid_request === null
......
......@@ -1563,8 +1563,6 @@ const qualityTargetLabel = (target: string) => {
return 'Anthropic'
case 'gemini':
return 'Gemini'
case 'sora':
return 'Sora'
default:
return target
}
......
......@@ -965,8 +965,7 @@ const platformFilterOptions = computed(() => [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' },
{ value: 'sora', label: 'Sora' }
{ value: 'antigravity', label: 'Antigravity' }
])
// Group options for assign (only subscription type groups)
......
<template>
<div class="sora-root">
<!-- Sora 页面内容 -->
<div class="sora-page">
<!-- 功能未启用提示 -->
<div v-if="!soraEnabled" class="sora-not-enabled">
<svg class="sora-not-enabled-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>
<h2 class="sora-not-enabled-title">{{ t('sora.notEnabled') }}</h2>
<p class="sora-not-enabled-desc">{{ t('sora.notEnabledDesc') }}</p>
</div>
<!-- Sora 主界面 -->
<template v-else>
<!-- 自定义 Sora 头部 -->
<header class="sora-header">
<div class="sora-header-left">
<!-- 返回主页按钮 -->
<router-link :to="dashboardPath" class="sora-back-btn" :title="t('common.back')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 19l-7-7 7-7" />
</svg>
</router-link>
<nav class="sora-nav-tabs">
<button
v-for="tab in tabs"
:key="tab.key"
:class="['sora-nav-tab', activeTab === tab.key && 'active']"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</nav>
</div>
<div class="sora-header-right">
<SoraQuotaBar v-if="quota" :quota="quota" />
<div v-if="activeTaskCount > 0" class="sora-queue-indicator">
<span class="sora-queue-dot" :class="{ busy: hasGeneratingTask }"></span>
<span>{{ activeTaskCount }} {{ t('sora.queueTasks') }}</span>
</div>
</div>
</header>
<!-- 内容区域 -->
<main class="sora-main">
<SoraGeneratePage
v-show="activeTab === 'generate'"
@task-count-change="onTaskCountChange"
/>
<SoraLibraryPage
v-show="activeTab === 'library'"
@switch-to-generate="activeTab = 'generate'"
/>
</main>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore, useAuthStore } from '@/stores'
import SoraQuotaBar from '@/components/sora/SoraQuotaBar.vue'
import SoraGeneratePage from '@/components/sora/SoraGeneratePage.vue'
import SoraLibraryPage from '@/components/sora/SoraLibraryPage.vue'
import soraAPI, { type QuotaInfo } from '@/api/sora'
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const soraEnabled = computed(() => appStore.cachedPublicSettings?.sora_client_enabled ?? false)
const activeTab = ref<'generate' | 'library'>('generate')
const quota = ref<QuotaInfo | null>(null)
const activeTaskCount = ref(0)
const hasGeneratingTask = ref(false)
const dashboardPath = computed(() => (authStore.isAdmin ? '/admin/dashboard' : '/dashboard'))
const tabs = computed(() => [
{ key: 'generate' as const, label: t('sora.tabGenerate') },
{ key: 'library' as const, label: t('sora.tabLibrary') }
])
function onTaskCountChange(counts: { active: number; generating: boolean }) {
activeTaskCount.value = counts.active
hasGeneratingTask.value = counts.generating
}
onMounted(async () => {
if (!soraEnabled.value) return
try {
quota.value = await soraAPI.getQuota()
} catch {
// 配额查询失败不阻塞页面
}
})
</script>
<style scoped>
/* ============================================================
Sora 主题 CSS 变量 — 亮色模式(跟随应用主题)
============================================================ */
.sora-root {
--sora-bg-primary: #F9FAFB;
--sora-bg-secondary: #FFFFFF;
--sora-bg-tertiary: #F3F4F6;
--sora-bg-elevated: #FFFFFF;
--sora-bg-hover: #E5E7EB;
--sora-bg-input: #FFFFFF;
--sora-text-primary: #111827;
--sora-text-secondary: #6B7280;
--sora-text-tertiary: #9CA3AF;
--sora-text-muted: #D1D5DB;
--sora-accent-primary: #14b8a6;
--sora-accent-secondary: #0d9488;
--sora-accent-gradient: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
--sora-accent-gradient-hover: linear-gradient(135deg, #2dd4bf 0%, #14b8a6 100%);
--sora-success: #10B981;
--sora-warning: #F59E0B;
--sora-error: #EF4444;
--sora-info: #3B82F6;
--sora-border-color: #E5E7EB;
--sora-border-subtle: #F3F4F6;
--sora-radius-sm: 8px;
--sora-radius-md: 12px;
--sora-radius-lg: 16px;
--sora-radius-xl: 20px;
--sora-radius-full: 9999px;
--sora-shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--sora-shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--sora-shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
--sora-shadow-glow: 0 0 20px rgba(20,184,166,0.25);
--sora-transition-fast: 150ms ease;
--sora-transition-normal: 250ms ease;
--sora-header-height: 56px;
--sora-header-bg: rgba(249, 250, 251, 0.85);
--sora-placeholder-gradient: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 50%, #cffafe 100%);
--sora-modal-backdrop: rgba(0, 0, 0, 0.4);
min-height: 100vh;
background: var(--sora-bg-primary);
color: var(--sora-text-primary);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ============================================================
页面布局
============================================================ */
.sora-page {
width: 100%;
}
/* ============================================================
头部导航栏
============================================================ */
.sora-header {
position: sticky;
top: 0;
z-index: 30;
height: var(--sora-header-height);
background: var(--sora-header-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--sora-border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
}
.sora-header-left {
display: flex;
align-items: center;
gap: 24px;
}
.sora-header-right {
display: flex;
align-items: center;
gap: 16px;
}
/* 返回按钮 */
.sora-back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--sora-radius-sm);
color: var(--sora-text-secondary);
text-decoration: none;
transition: all var(--sora-transition-fast);
}
.sora-back-btn:hover {
background: var(--sora-bg-tertiary);
color: var(--sora-text-primary);
}
/* Tab 导航 */
.sora-nav-tabs {
display: flex;
gap: 4px;
background: var(--sora-bg-secondary);
border-radius: var(--sora-radius-full);
padding: 3px;
}
.sora-nav-tab {
padding: 6px 20px;
border-radius: var(--sora-radius-full);
font-size: 13px;
font-weight: 500;
color: var(--sora-text-secondary);
background: none;
border: none;
cursor: pointer;
transition: all var(--sora-transition-fast);
user-select: none;
}
.sora-nav-tab:hover {
color: var(--sora-text-primary);
}
.sora-nav-tab.active {
background: var(--sora-bg-tertiary);
color: var(--sora-text-primary);
}
/* 队列指示器 */
.sora-queue-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--sora-bg-secondary);
border-radius: var(--sora-radius-full);
font-size: 12px;
color: var(--sora-text-secondary);
}
.sora-queue-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--sora-success);
animation: sora-pulse-dot 2s ease-in-out infinite;
}
.sora-queue-dot.busy {
background: var(--sora-warning);
}
@keyframes sora-pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ============================================================
主内容区
============================================================ */
.sora-main {
min-height: calc(100vh - var(--sora-header-height));
}
/* ============================================================
功能未启用
============================================================ */
.sora-not-enabled {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
padding: 40px;
}
.sora-not-enabled-icon {
width: 64px;
height: 64px;
color: var(--sora-text-tertiary);
margin-bottom: 16px;
}
.sora-not-enabled-title {
font-size: 20px;
font-weight: 600;
color: var(--sora-text-secondary);
margin-bottom: 8px;
}
.sora-not-enabled-desc {
font-size: 14px;
color: var(--sora-text-tertiary);
max-width: 400px;
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 900px) {
.sora-header {
padding: 0 16px;
}
.sora-header-left {
gap: 12px;
}
}
@media (max-width: 600px) {
.sora-nav-tab {
padding: 5px 14px;
font-size: 12px;
}
}
/* 滚动条 */
.sora-root ::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.sora-root ::-webkit-scrollbar-track {
background: transparent;
}
.sora-root ::-webkit-scrollbar-thumb {
background: var(--sora-bg-hover);
border-radius: 3px;
}
.sora-root ::-webkit-scrollbar-thumb:hover {
background: var(--sora-text-tertiary);
}
</style>
<style>
/* 暗色模式:必须明确命中 .sora-root,避免被 scoped 编译后的变量覆盖问题 */
html.dark .sora-root {
--sora-bg-primary: #020617;
--sora-bg-secondary: #0f172a;
--sora-bg-tertiary: #1e293b;
--sora-bg-elevated: #1e293b;
--sora-bg-hover: #334155;
--sora-bg-input: #0f172a;
--sora-text-primary: #f1f5f9;
--sora-text-secondary: #94a3b8;
--sora-text-tertiary: #64748b;
--sora-text-muted: #475569;
--sora-border-color: #334155;
--sora-border-subtle: #1e293b;
--sora-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--sora-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--sora-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
--sora-shadow-glow: 0 0 20px rgba(20, 184, 166, 0.3);
--sora-header-bg: rgba(2, 6, 23, 0.85);
--sora-placeholder-gradient: linear-gradient(135deg, #1e293b 0%, #0f172a 50%, #020617 100%);
--sora-modal-backdrop: rgba(0, 0, 0, 0.7);
}
</style>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment