feat(models): 视频分辨率档位增加 768p,编辑弹窗支持增删档位

- CustomModelModal:VID_RES/ALL_RES 加入 768p
- ModelFormModal:provider=custom 的模型编辑时渲染 候选档位 ∪ 已存档位
  完整价格网格,填价即开通、留空即下线;PATCH 的 resolutions/durations
  改发"有价键集合"(与新建语义一致),非 custom 模型行为不变
- 摘要面板仍只显示已开通档位,提示文案区分 catalog / custom

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-14 18:24:32 +08:00
co-authored by Devin
parent b17cc3e9c8
commit ecdbc0b281
2 changed files with 50 additions and 34 deletions
+3 -3
View File
@@ -10,8 +10,8 @@ const emit = defineEmits(['close', 'saved'])
// the backend guessRatio() and the docs 对照表. 9:21 removed (no image provider accepts it). // the backend guessRatio() and the docs 对照表. 9:21 removed (no image provider accepts it).
const RATIO_OPTS = ['1:1', '5:4', '4:3', '3:2', '16:9', '2:1', '21:9', '3:1', '4:1', '8:1', '4:5', '3:4', '2:3', '9:16', '1:3', '1:4', '1:8'] const RATIO_OPTS = ['1:1', '5:4', '4:3', '3:2', '16:9', '2:1', '21:9', '3:1', '4:1', '8:1', '4:5', '3:4', '2:3', '9:16', '1:3', '1:4', '1:8']
const IMG_RES = ['1K', '2K', '4K'] const IMG_RES = ['1K', '2K', '4K']
const VID_RES = ['720p', '1080p', '2K', '4K'] const VID_RES = ['720p', '768p', '1080p', '2K', '4K']
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p'] const ALL_RES = ['1K', '2K', '4K', '720p', '768p', '1080p']
const DUR_OPTS = ['5s', '6s', '8s', '10s', '15s'] const DUR_OPTS = ['5s', '6s', '8s', '10s', '15s']
const id = ref('') const id = ref('')
@@ -50,7 +50,7 @@ function removeDur(key) {
} }
const isVideo = computed(() => type.value === 'video') const isVideo = computed(() => type.value === 'video')
// Resolution tiers depend on type: image = 1K/2K/4K, video = 540p/720p/1080p/2K/4K. // Resolution tiers depend on type: image = 1K/2K/4K, video = 720p/768p/1080p/2K/4K.
const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES)) const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES))
function toggleRatio(r) { function toggleRatio(r) {
+47 -31
View File
@@ -15,6 +15,14 @@ const isEdit = computed(() => !!props.model)
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' } const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
// provider=custom 的模型没有目录约束,档位由价格表本身决定——编辑时给出
// 完整候选面板(与 CustomModelModal 的 VID_RES/IMG_RES/DUR_OPTS 保持一致),
// 留空的档位 = 不支持。已存的自定义档(如 "540p"/"12s")也会并入显示。
const CUSTOM_IMG_RES = ['1K', '2K', '4K']
const CUSTOM_VID_RES = ['720p', '768p', '1080p', '2K', '4K']
const CUSTOM_DUR = ['5s', '6s', '8s', '10s', '15s']
const bySeconds = (a, b) => parseFloat(a) - parseFloat(b)
const catalog = ref([]) const catalog = ref([])
const loading = ref(true) const loading = ref(true)
const selectedId = ref(props.model?.id || '') const selectedId = ref(props.model?.id || '')
@@ -37,9 +45,21 @@ const entry = computed(() => {
return catalog.value.find((e) => e.id === selectedId.value) || null return catalog.value.find((e) => e.id === selectedId.value) || null
}) })
const isVideo = computed(() => entry.value?.type === 'video') const isVideo = computed(() => entry.value?.type === 'video')
const isCustom = computed(() => entry.value?.provider === 'custom')
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K) // Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
// regardless of how the catalog/stored record happens to list them. // regardless of how the catalog/stored record happens to list them. For
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || [])) // custom models the stored list is just the currently-priced subset — merge
// in the full palette so the admin can light up a new tier (e.g. 768p) by
// typing a price, or retire one by blanking it.
const resolutions = computed(() => {
const stored = entry.value?.resolutions || []
if (!isCustom.value) return sortResolutions(stored)
const palette = isVideo.value ? CUSTOM_VID_RES : CUSTOM_IMG_RES
return sortResolutions([...new Set([...palette, ...stored])])
})
// Read-only summary shows what's actually supported (stored list), not the
// offerable palette — for custom models the palette is just the input grid.
const storedResolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
// 按秒计价的视频模型(一个 /s 价,实付 = 分辨率价 + 每秒价 × 秒数)。目录用 // 按秒计价的视频模型(一个 /s 价,实付 = 分辨率价 + 每秒价 × 秒数)。目录用
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。 // per_second 声明;已入库的模型则看它是否已存了 per_second 价。
const isPerSecond = computed(() => { const isPerSecond = computed(() => {
@@ -53,7 +73,9 @@ const durationTiers = computed(() => {
if (!e) return [] if (!e) return []
if (isPerSecond.value) return ['per_second'] if (isPerSecond.value) return ['per_second']
const ds = e.durations || [] const ds = e.durations || []
return ds.length ? ds : Object.keys(e.duration_prices || {}) const stored = ds.length ? ds : Object.keys(e.duration_prices || {})
if (e.provider !== 'custom') return stored
return [...new Set([...stored, ...CUSTOM_DUR])].sort(bySeconds)
}) })
// Dropdown options: catalog rows not already in the managed store. // Dropdown options: catalog rows not already in the managed store.
@@ -71,14 +93,9 @@ function resetPrices(e) {
if (!e) return if (!e) return
// Both image and video price per resolution; video ALSO prices per duration // Both image and video price per resolution; video ALSO prices per duration
// (real video price = resolution price + duration price). // (real video price = resolution price + duration price).
for (const r of (e.resolutions || [])) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' } for (const r of resolutions.value) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
if (e.type === 'video') { if (e.type === 'video') {
if (isPerSecond.value) { for (const d of durationTiers.value) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
videoPrices.value['per_second'] = ''
videoPricesAgent.value['per_second'] = ''
} else {
for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
}
} }
} }
@@ -94,20 +111,16 @@ onMounted(async () => {
const m = props.model const m = props.model
// Resolution prices apply to both; video additionally has duration prices. // Resolution prices apply to both; video additionally has duration prices.
// Agent prices are an optional overlay (blank = follows the normal price). // Agent prices are an optional overlay (blank = follows the normal price).
for (const r of (m.resolutions || [])) { // Iterate the DISPLAYED tier lists (for custom models that's the full
// palette ∪ stored), so unpriced-but-offerable tiers get blank inputs.
for (const r of resolutions.value) {
imagePrices.value[r] = m.prices?.[r] ?? '' imagePrices.value[r] = m.prices?.[r] ?? ''
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? '' imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
} }
if (m.type === 'video') { if (m.type === 'video') {
if (m.duration_prices?.per_second != null) { for (const d of durationTiers.value) {
videoPrices.value['per_second'] = m.duration_prices.per_second videoPrices.value[d] = m.duration_prices?.[d] ?? ''
videoPricesAgent.value['per_second'] = m.duration_prices_agent?.per_second ?? '' videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
} else {
const durs = (m.durations && m.durations.length) ? m.durations : Object.keys(m.duration_prices || {})
for (const d of durs) {
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
}
} }
} }
} }
@@ -132,9 +145,9 @@ async function save() {
let payload let payload
if (e.type === 'video') { if (e.type === 'video') {
const prices = collect(imagePrices.value, e.resolutions || []) const prices = collect(imagePrices.value, resolutions.value)
const duration_prices = collect(videoPrices.value, durationTiers.value) const duration_prices = collect(videoPrices.value, durationTiers.value)
const prices_agent = collect(imagePricesAgent.value, e.resolutions || []) const prices_agent = collect(imagePricesAgent.value, resolutions.value)
const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value) const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value)
if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return } if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return }
if (!Object.keys(duration_prices).length) { error.value = '至少填写一个时长价格'; return } if (!Object.keys(duration_prices).length) { error.value = '至少填写一个时长价格'; return }
@@ -142,9 +155,12 @@ async function save() {
type: 'video', type: 'video',
provider: e.provider, provider: e.provider,
ratios: e.ratios || [], ratios: e.ratios || [],
resolutions: e.resolutions || [], // catalog models keep their declared tier list; custom models' supported
// tiers ARE the priced keys — a blanked price retires the tier.
resolutions: isCustom.value ? sortResolutions(Object.keys(prices)) : (e.resolutions || []),
prices, prices,
durations: isPerSecond.value ? (e.durations || []) : Object.keys(duration_prices), durations: isPerSecond.value ? (e.durations || [])
: (isCustom.value ? Object.keys(duration_prices).sort(bySeconds) : Object.keys(duration_prices)),
duration_prices, duration_prices,
prices_agent, prices_agent,
duration_prices_agent, duration_prices_agent,
@@ -154,8 +170,8 @@ async function save() {
weight: Number(weight.value) || 0, weight: Number(weight.value) || 0,
} }
} else { } else {
const prices = collect(imagePrices.value, e.resolutions || []) const prices = collect(imagePrices.value, resolutions.value)
const prices_agent = collect(imagePricesAgent.value, e.resolutions || []) const prices_agent = collect(imagePricesAgent.value, resolutions.value)
if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return } if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return }
payload = { payload = {
type: 'image', type: 'image',
@@ -224,7 +240,7 @@ async function save() {
{{ isVideo ? '生视频' : '生图' }} {{ isVideo ? '生视频' : '生图' }}
</span> </span>
<span class="text-[11px] text-white/45 capitalize">{{ entry.provider }}</span> <span class="text-[11px] text-white/45 capitalize">{{ entry.provider }}</span>
<span class="ml-auto text-[10px] text-white/30">参数自动加载,不可改</span> <span class="ml-auto text-[10px] text-white/30">{{ isCustom ? '档位由价格表决定 · 留空=不支持' : '参数自动加载,不可改' }}</span>
</div> </div>
<div class="grid grid-cols-[3.5rem_1fr] gap-x-3 gap-y-1.5 text-[11px]"> <div class="grid grid-cols-[3.5rem_1fr] gap-x-3 gap-y-1.5 text-[11px]">
@@ -237,8 +253,8 @@ async function save() {
<template v-if="isVideo"> <template v-if="isVideo">
<span class="text-white/40">分辨率</span> <span class="text-white/40">分辨率</span>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span> <span v-for="r in storedResolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!resolutions.length" class="text-white/30">—</span> <span v-if="!storedResolutions.length" class="text-white/30">—</span>
</div> </div>
<span class="text-white/40">时长</span> <span class="text-white/40">时长</span>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
@@ -256,8 +272,8 @@ async function save() {
<template v-else> <template v-else>
<span class="text-white/40">画质</span> <span class="text-white/40">画质</span>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span> <span v-for="r in storedResolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!resolutions.length" class="text-white/30">—</span> <span v-if="!storedResolutions.length" class="text-white/30">—</span>
</div> </div>
<span class="text-white/40">参考图</span> <span class="text-white/40">参考图</span>
<div class="text-white/70">{{ entry.max_reference_images > 0 ? `${entry.max_reference_images} 张` : (entry.image_to_image ? '支持' : '不支持') }}</div> <div class="text-white/70">{{ entry.max_reference_images > 0 ? `${entry.max_reference_images} 张` : (entry.image_to_image ? '支持' : '不支持') }}</div>