From cfb7bcebc9107d2ed087077820804bb4b189a12f Mon Sep 17 00:00:00 2001 From: chiyi Date: Sun, 9 Aug 2026 21:02:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E8=A7=86=E9=A2=91=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8F=82=E8=80=83=E5=9B=BE/=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E6=8C=89=20preset=20=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E6=96=87=E6=A1=A3=E9=A1=B5=E5=8A=A0=E5=AE=9A=E4=BB=B7?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型管理与文档页改为读 /video-presets 的 max_images/max_videos/max_audios,去掉写死的图片/视频/音频上限与重复的参考资产总数;文档表新增定价列,model/类型/定价 三列不换行。 --- frontend/src/videoCaps.js | 27 ++++++++++ frontend/src/views/DocsView.vue | 83 ++++++++++++++++++++++--------- frontend/src/views/ModelsView.vue | 24 ++++++--- 3 files changed, 103 insertions(+), 31 deletions(-) create mode 100644 frontend/src/videoCaps.js diff --git a/frontend/src/videoCaps.js b/frontend/src/videoCaps.js new file mode 100644 index 0000000..3014109 --- /dev/null +++ b/frontend/src/videoCaps.js @@ -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 +} diff --git a/frontend/src/views/DocsView.vue b/frontend/src/views/DocsView.vue index adbba91..ffff8e3 100644 --- a/frontend/src/views/DocsView.vue +++ b/frontend/src/views/DocsView.vue @@ -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) { - - + + + - - + + + - +
model类型model类型定价 积分 能力
{{ pubName(m) }}{{ m.type === 'video' ? '视频' : '图像' }}{{ pubName(m) }}{{ m.type === 'video' ? '视频' : '图像' }} +
+ + + +
+
首尾帧 {{ Math.min(m.max_reference_images, 2) }} - 参考图 {{ m.max_reference_images }} - 参考图 {{ m.max_reference_images }} + 参考图 {{ refImages(m) }} + 参考图 {{ refImages(m) }} 参考图 1 - 视频 3 - 音频 3 + {{ r.replace(':', '×') }}
暂无可用模型
暂无可用模型
@@ -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); } /* 苹果风代码卡片:自带深色配色,不随页面主题切换,深/浅背景下都可读 */ diff --git a/frontend/src/views/ModelsView.vue b/frontend/src/views/ModelsView.vue index c0078a1..a0bc7e9 100644 --- a/frontend/src/views/ModelsView.vue +++ b/frontend/src/views/ModelsView.vue @@ -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) 首尾帧 {{ Math.min(m.max_reference_images, 2) }} - 参考图 {{ m.max_reference_images }} - 参考图 {{ m.max_reference_images }} + 参考图 {{ refImages(m) }} + 参考图 {{ refImages(m) }} 参考图 - - 视频 3 - 音频 3 + + {{ r.replace(':', '×') }}