feat(Sora): 完成Sora网关接入与媒体能力

新增 Sora 网关路由、账号调度与同步服务\n补充媒体代理与签名 URL、模型列表动态拉取\n完善计费配置、前端支持与相关测试
This commit is contained in:
yangjianbo
2026-01-31 20:22:22 +08:00
parent 99dc3b59bc
commit 618a614cbf
67 changed files with 4840 additions and 202 deletions

View File

@@ -45,6 +45,19 @@
:placeholder="t('admin.accounts.searchModels')"
@click.stop
/>
<div v-if="props.platform === 'sora'" class="mt-2 flex items-center gap-2 text-xs">
<span v-if="loadingSoraModels" class="text-gray-500">
{{ t('admin.accounts.soraModelsLoading') }}
</span>
<button
v-else-if="soraLoadError"
type="button"
class="text-primary-600 hover:underline dark:text-primary-400"
@click.stop="loadSoraModels"
>
{{ t('admin.accounts.soraModelsRetry') }}
</button>
</div>
</div>
<div class="max-h-52 overflow-auto">
<button
@@ -120,12 +133,13 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import ModelIcon from '@/components/common/ModelIcon.vue'
import Icon from '@/components/icons/Icon.vue'
import { allModels, getModelsByPlatform } from '@/composables/useModelWhitelist'
import { adminAPI } from '@/api/admin'
const { t } = useI18n()
@@ -144,11 +158,24 @@ const showDropdown = ref(false)
const searchQuery = ref('')
const customModel = ref('')
const isComposing = ref(false)
const soraModelOptions = ref<{ value: string; label: string }[]>([])
const loadingSoraModels = ref(false)
const soraLoadError = ref(false)
const availableOptions = computed(() => {
if (props.platform === 'sora') {
if (soraModelOptions.value.length > 0) {
return soraModelOptions.value
}
return getModelsByPlatform('sora').map(m => ({ value: m, label: m }))
}
return allModels
})
const filteredModels = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
if (!query) return allModels
return allModels.filter(
if (!query) return availableOptions.value
return availableOptions.value.filter(
m => m.value.toLowerCase().includes(query) || m.label.toLowerCase().includes(query)
)
})
@@ -186,7 +213,9 @@ const handleEnter = () => {
}
const fillRelated = () => {
const models = getModelsByPlatform(props.platform)
const models = props.platform === 'sora' && soraModelOptions.value.length > 0
? soraModelOptions.value.map(m => m.value)
: getModelsByPlatform(props.platform)
const newModels = [...props.modelValue]
for (const model of models) {
if (!newModels.includes(model)) newModels.push(model)
@@ -197,4 +226,32 @@ const fillRelated = () => {
const clearAll = () => {
emit('update:modelValue', [])
}
const loadSoraModels = async () => {
if (props.platform !== 'sora') {
soraModelOptions.value = []
return
}
if (loadingSoraModels.value) return
soraLoadError.value = false
loadingSoraModels.value = true
try {
const models = await adminAPI.models.getPlatformModels('sora')
soraModelOptions.value = (models || []).map((m) => ({ value: m, label: m }))
} catch (error) {
console.warn('加载 Sora 模型列表失败', error)
soraLoadError.value = true
appStore.showWarning(t('admin.accounts.soraModelsLoadFailed'))
} finally {
loadingSoraModels.value = false
}
}
watch(
() => props.platform,
() => {
loadSoraModels()
},
{ immediate: true }
)
</script>

View File

@@ -19,7 +19,7 @@ const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])
</script>

View File

@@ -97,6 +97,9 @@ const labelClass = computed(() => {
if (props.platform === 'gemini') {
return `${base} bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
}
if (props.platform === 'sora') {
return `${base} bg-rose-200/60 text-rose-800 dark:bg-rose-800/40 dark:text-rose-300`
}
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
})
@@ -118,6 +121,11 @@ const badgeClass = computed(() => {
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
}
if (props.platform === 'sora') {
return isSubscription.value
? 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
: 'bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400'
}
// Fallback: original colors
return isSubscription.value
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'

View File

@@ -19,6 +19,12 @@
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
</svg>
<!-- Sora logo (sparkle) -->
<svg v-else-if="platform === 'sora'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2.5l2.1 4.7 5.1.5-3.9 3.4 1.2 5-4.5-2.6-4.5 2.6 1.2-5-3.9-3.4 5.1-.5L12 2.5z"
/>
</svg>
<!-- Fallback: generic platform icon -->
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
<path

View File

@@ -48,6 +48,7 @@ const platformLabel = computed(() => {
if (props.platform === 'anthropic') return 'Anthropic'
if (props.platform === 'openai') return 'OpenAI'
if (props.platform === 'antigravity') return 'Antigravity'
if (props.platform === 'sora') return 'Sora'
return 'Gemini'
})
@@ -74,6 +75,9 @@ const platformClass = computed(() => {
if (props.platform === 'antigravity') {
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}
if (props.platform === 'sora') {
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
}
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -87,6 +91,9 @@ const typeClass = computed(() => {
if (props.platform === 'antigravity') {
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
}
if (props.platform === 'sora') {
return 'bg-rose-100 text-rose-600 dark:bg-rose-900/30 dark:text-rose-400'
}
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
})
</script>