fix(frontend): 视频模型参考图/媒体上限按 preset 展示,文档页加定价列

模型管理与文档页改为读 /video-presets 的 max_images/max_videos/max_audios,去掉写死的图片/视频/音频上限与重复的参考资产总数;文档表新增定价列,model/类型/定价 三列不换行。
This commit is contained in:
2026-08-09 21:02:18 +08:00
parent 84a2eac64f
commit cfb7bcebc9
3 changed files with 103 additions and 31 deletions
+27
View File
@@ -0,0 +1,27 @@
// 视频模型的参考资产分类上限(图片 / 视频 / 音频)。
// 单一真源是后端 /video-presets 的 max_images / max_videos / max_audios(服务端
// leonardoVideoSpecs 校验的就是这套数),预设没声明音视频上限的 seedance 系回落
// 到 3 / 3,与画图台 (PlaygroundView) 的判定保持一致。
const SEEDANCE_FALLBACK = { videos: 3, audios: 3 }
function declared(v) {
return v === undefined || v === null ? null : Number(v)
}
// mediaCaps 返回 { images, videos, audios }0 = 不支持该类),不支持图片以外的
// 参考资产时返回 null。model 兼容 /managed-models(type) 与 /models(kind) 两种字段。
export function mediaCaps(model, preset) {
if ((model?.type || model?.kind) !== 'video') return null
const isSeedance = /^seedance/.test(model?.id || '')
const videos = declared(preset?.max_videos) ?? (isSeedance ? SEEDANCE_FALLBACK.videos : 0)
const audios = declared(preset?.max_audios) ?? (isSeedance ? SEEDANCE_FALLBACK.audios : 0)
if (!videos && !audios) return null
return { images: declared(preset?.max_images) ?? 0, videos, audios }
}
// presetMap 把 /video-presets 的列表转成 key → preset,便于按模型 id 取预设。
export function presetMap(list) {
const out = {}
for (const p of list || []) if (p?.key) out[p.key] = p
return out
}
+59 -24
View File
@@ -6,16 +6,26 @@ import { ref, computed, onMounted } from 'vue'
import { auth } from '../auth'
import { api } from '../api'
import Icon from '../components/Icon.vue'
import { points } from '../credits'
import { mediaCaps, presetMap } from '../videoCaps'
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'YOUR_API_KEY')
const models = ref([])
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
onMounted(async () => {
const r = await api('/managed-models')
const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')])
if (r.ok) models.value = (r.data?.data || []).filter((m) => m.enabled !== false)
if (p.ok) presets.value = presetMap(p.data?.data)
})
// 各类参考资产的上限(图片/视频/音频)由后端预设给出,不在这里写死。
function caps(m) { return mediaCaps(m, presets.value[m.id]) }
// 参考图张数:支持音视频参考的模型,max_reference_images 是各类资产合计,图片自己
// 的上限来自预设 max_images。
function refImages(m) { return caps(m)?.images || m.max_reference_images }
const imageModels = computed(() => models.value.filter((m) => m.type === 'image'))
const videoModels = computed(() => models.value.filter((m) => m.type === 'video'))
function pubName(m) {
@@ -27,23 +37,29 @@ const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0]
function modeEmpty(m) {
if (m.type === 'image') return !(m.resolutions || []).length && !m.image_to_image && !(m.ratios || []).length
return !(m.resolutions || []).length && !(m.durations || []).length && !m.max_reference_images && !m.id?.startsWith('seedance') && !(m.ratios || []).length
return !(m.resolutions || []).length && !(m.durations || []).length && !m.max_reference_images && !caps(m) && !(m.ratios || []).length
}
function priceOf(m) {
if (m.type === 'video') {
// Video charge = resolution price + duration price; show the combined range.
const rv = Object.values(m.prices || {}).filter((v) => v != null).map(Number)
const dv = Object.values(m.duration_prices || {}).filter((v) => v != null).map(Number)
if (!rv.length || !dv.length) return '—'
const lo = Math.min(...rv) + Math.min(...dv)
const hi = Math.max(...rv) + Math.max(...dv)
return lo === hi ? `${points(lo)} 积分` : `${points(lo)}${points(hi)} 积分`
// ---- 定价(同模型管理的分档展示):代理账号看代理价,某档没设代理价时回退普通价;
// 普通价为空表示该档不支持,不展示。视频价 = 分辨率价 + 时长价(按秒计价的模型给 /s 价)。
const isAgent = computed(() => auth.user?.role === 'agent')
function tierPrice(normal, agent, key) {
const n = (normal || {})[key]
if (n == null) return null
if (isAgent.value) {
const a = (agent || {})[key]
if (a != null) return Number(a)
}
const vals = Object.values(m.prices || {}).filter((v) => v != null).map(Number)
if (!vals.length) return '—'
const lo = Math.min(...vals), hi = Math.max(...vals)
return lo === hi ? `${points(lo)} 积分` : `${points(lo)}${points(hi)} 积分`
return Number(n)
}
function resPrice(m, r) { return tierPrice(m.prices, m.prices_agent, r) }
function durPrice(m, d) { return tierPrice(m.duration_prices, m.duration_prices_agent, d) }
function perSecondPrice(m) { return tierPrice(m.duration_prices, m.duration_prices_agent, 'per_second') }
function priceEmpty(m) {
if ((m.resolutions || []).some((r) => resPrice(m, r) != null)) return false
if (m.type !== 'video') return true
if (perSecondPrice(m) != null) return false
return !(m.durations || []).some((d) => durPrice(m, d) != null)
}
// ---- request parameter tables ----
@@ -272,15 +288,30 @@ async function copy(text) {
<table class="w-full text-sm">
<thead>
<tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-3 font-medium">model</th>
<th class="px-4 py-3 font-medium">类型</th>
<th class="px-4 py-3 font-medium whitespace-nowrap">model</th>
<th class="px-4 py-3 font-medium whitespace-nowrap">类型</th>
<th class="px-4 py-3 font-medium whitespace-nowrap" title="视频价 = 分辨率价 + 时长价">定价 <span class="normal-case text-white/30">积分</span></th>
<th class="px-4 py-3 font-medium">能力</th>
</tr>
</thead>
<tbody>
<tr v-for="m in models" :key="m.id" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-3 font-mono text-white/90">{{ pubName(m) }}</td>
<td class="px-4 py-3 text-white/60">{{ m.type === 'video' ? '视频' : '图像' }}</td>
<td class="px-4 py-3 font-mono text-white/90 whitespace-nowrap">{{ pubName(m) }}</td>
<td class="px-4 py-3 text-white/60 whitespace-nowrap">{{ m.type === 'video' ? '视频' : '图像' }}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-1 text-[11px]">
<template v-for="r in (m.resolutions || [])" :key="'pr'+r">
<span v-if="resPrice(m, r) != null" class="cap cap-price">{{ r }}<b class="ml-1 font-semibold tabular-nums">{{ points(resPrice(m, r)) }}</b></span>
</template>
<template v-if="m.type === 'video'">
<span v-if="perSecondPrice(m) != null" class="cap cap-price">/s<b class="ml-1 font-semibold tabular-nums">{{ points(perSecondPrice(m)) }}</b></span>
<template v-else v-for="d in (m.durations || [])" :key="'pd'+d">
<span v-if="durPrice(m, d) != null" class="cap cap-price">{{ d }}<b class="ml-1 font-semibold tabular-nums">+{{ points(durPrice(m, d)) }}</b></span>
</template>
</template>
<span v-if="priceEmpty(m)" class="text-white/30"></span>
</div>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-1 text-[11px]">
<template v-if="m.type === 'image'">
@@ -292,17 +323,19 @@ async function copy(text) {
<span v-else-if="(m.durations || []).length === 1" class="cap cap-dur">{{ m.durations[0] }}</span>
</template>
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap cap-ref">参考图 {{ refImages(m) }}</span>
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap cap-ref">参考图 {{ refImages(m) }}</span>
<span v-if="m.type === 'image' && m.image_to_image && (!m.reference_mode || m.reference_mode === 'none')" class="cap cap-ref">参考图 1</span>
<span v-if="m.id?.startsWith('seedance')" class="cap cap-media">视频 3</span>
<span v-if="m.id?.startsWith('seedance')" class="cap cap-media"> 3</span>
<template v-if="caps(m)">
<span v-if="caps(m).videos" class="cap cap-media"> {{ caps(m).videos }}</span>
<span v-if="caps(m).audios" class="cap cap-media">音频 {{ caps(m).audios }}</span>
</template>
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap cap-ratio">{{ r.replace(':', '×') }}</span>
<span v-if="modeEmpty(m)" class="text-white/30"></span>
</div>
</td>
</tr>
<tr v-if="!models.length"><td colspan="3" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
<tr v-if="!models.length"><td colspan="4" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
</tbody>
</table>
</div>
@@ -490,12 +523,14 @@ html.dark .badge-err { background: rgb(244 63 94 / 0.15); color: rgb(253 164 175
.cap-frame { background: rgb(236 72 153 / 0.12); color: rgb(159 18 57); box-shadow: inset 0 0 0 1px rgb(236 72 153 / 0.3); }
.cap-ref { background: rgb(16 185 129 / 0.12); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.3); }
.cap-media { background: rgb(245 158 11 / 0.12); color: rgb(146 64 14); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.3); }
.cap-price { background: rgb(14 165 233 / 0.12); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.3); font-variant-numeric: tabular-nums; }
.cap-ratio { background: rgb(100 116 139 / 0.1); color: rgb(51 65 85); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.25); }
html.dark .cap-k { background: rgb(16 185 129 / 0.18); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.4); }
html.dark .cap-dur { background: rgb(99 102 241 / 0.18); color: rgb(165 180 252); box-shadow: inset 0 0 0 1px rgb(129 140 248 / 0.4); }
html.dark .cap-frame { background: rgb(236 72 153 / 0.18); color: rgb(244 114 182); box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.45); }
html.dark .cap-ref { background: rgb(16 185 129 / 0.18); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.4); }
html.dark .cap-media { background: rgb(245 158 11 / 0.18); color: rgb(252 211 77); box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45); }
html.dark .cap-price { background: rgb(14 165 233 / 0.18); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.45); }
html.dark .cap-ratio { background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.65); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12); }
/* 苹果风代码卡片:自带深色配色,不随页面主题切换,深/浅背景下都可读 */
+17 -7
View File
@@ -6,6 +6,7 @@ import ModelFormModal from '../components/ModelFormModal.vue'
import CustomModelModal from '../components/CustomModelModal.vue'
import TestModal from '../components/TestModal.vue'
import { points } from '../credits'
import { mediaCaps, presetMap } from '../videoCaps'
const models = ref([])
const loading = ref(false)
@@ -19,17 +20,24 @@ const statusFilter = ref('') // '' | 'enabled' | 'disabled'
const search = ref('')
const TYPE_LABEL = { image: '生图', video: '生视频' }
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
function caps(m) { return mediaCaps(m, presets.value[m.id]) }
// 参考图张数:支持音视频参考的模型,max_reference_images 是各类资产合计,图片自己
// 的上限来自预设 max_images。
function refImages(m) { return caps(m)?.images || m.max_reference_images }
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
function capEmpty(m) {
if (m.type === 'image') return !(m.ratios || []).length && !m.image_to_image && (m.reference_mode === 'none' || !m.reference_mode)
return !(m.durations || []).length && !(m.resolutions || []).length && !m.max_reference_images && !m.id?.startsWith('seedance') && !(m.ratios || []).length
return !(m.durations || []).length && !(m.resolutions || []).length && !m.max_reference_images && !caps(m) && !(m.ratios || []).length
}
async function loadModels() {
loading.value = true
const r = await api('/managed-models')
const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')])
models.value = r.data?.data || []
presets.value = presetMap(p.data?.data)
loading.value = false
}
@@ -239,12 +247,14 @@ onMounted(loadModels)
<!-- Frame mode (首尾帧 2) -->
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap-chip cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
<!-- Reference image mode (参考图 9) -->
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap-chip cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap-chip cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap-chip cap-ref">参考图 {{ refImages(m) }}</span>
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap-chip cap-ref">参考图 {{ refImages(m) }}</span>
<span v-else-if="m.type === 'image' && m.image_to_image" class="cap-chip cap-ref">参考图</span>
<!-- Video/Audio for seedance -->
<span v-if="m.id?.startsWith('seedance')" class="cap-chip cap-media">视频 3</span>
<span v-if="m.id?.startsWith('seedance')" class="cap-chip cap-media"> 3</span>
<!-- 视频/音频参考上限,来自 /video-presets -->
<template v-if="caps(m)">
<span v-if="caps(m).videos" class="cap-chip cap-media"> {{ caps(m).videos }}</span>
<span v-if="caps(m).audios" class="cap-chip cap-media">音频 {{ caps(m).audios }}</span>
</template>
<!-- Ratios -->
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap-chip cap-mono">{{ r.replace(':', '×') }}</span>
<span v-if="capEmpty(m)" class="text-white/30 text-xs"></span>