diff --git a/frontend/src/components/CustomModelModal.vue b/frontend/src/components/CustomModelModal.vue index 2f99bbc..3e6dd06 100644 --- a/frontend/src/components/CustomModelModal.vue +++ b/frontend/src/components/CustomModelModal.vue @@ -10,8 +10,8 @@ const emit = defineEmits(['close', 'saved']) // 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 IMG_RES = ['1K', '2K', '4K'] -const VID_RES = ['720p', '1080p', '2K', '4K'] -const ALL_RES = ['1K', '2K', '4K', '720p', '1080p'] +const VID_RES = ['720p', '768p', '1080p', '2K', '4K'] +const ALL_RES = ['1K', '2K', '4K', '720p', '768p', '1080p'] const DUR_OPTS = ['5s', '6s', '8s', '10s', '15s'] const id = ref('') @@ -50,7 +50,7 @@ function removeDur(key) { } 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)) function toggleRatio(r) { diff --git a/frontend/src/components/ModelFormModal.vue b/frontend/src/components/ModelFormModal.vue index f654137..09a8109 100644 --- a/frontend/src/components/ModelFormModal.vue +++ b/frontend/src/components/ModelFormModal.vue @@ -15,6 +15,14 @@ const isEdit = computed(() => !!props.model) 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 loading = ref(true) const selectedId = ref(props.model?.id || '') @@ -37,9 +45,21 @@ const entry = computed(() => { return catalog.value.find((e) => e.id === selectedId.value) || null }) 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) -// regardless of how the catalog/stored record happens to list them. -const resolutions = computed(() => sortResolutions(entry.value?.resolutions || [])) +// regardless of how the catalog/stored record happens to list them. For +// 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 价,实付 = 分辨率价 + 每秒价 × 秒数)。目录用 // per_second 声明;已入库的模型则看它是否已存了 per_second 价。 const isPerSecond = computed(() => { @@ -53,7 +73,9 @@ const durationTiers = computed(() => { if (!e) return [] if (isPerSecond.value) return ['per_second'] 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. @@ -71,14 +93,9 @@ function resetPrices(e) { if (!e) return // Both image and video price per resolution; video ALSO prices per duration // (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 (isPerSecond.value) { - videoPrices.value['per_second'] = '' - videoPricesAgent.value['per_second'] = '' - } else { - for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' } - } + for (const d of durationTiers.value) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' } } } @@ -94,20 +111,16 @@ onMounted(async () => { const m = props.model // Resolution prices apply to both; video additionally has duration prices. // 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] ?? '' imagePricesAgent.value[r] = m.prices_agent?.[r] ?? '' } if (m.type === 'video') { - if (m.duration_prices?.per_second != null) { - videoPrices.value['per_second'] = m.duration_prices.per_second - videoPricesAgent.value['per_second'] = m.duration_prices_agent?.per_second ?? '' - } 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] ?? '' - } + for (const d of durationTiers.value) { + videoPrices.value[d] = m.duration_prices?.[d] ?? '' + videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? '' } } } @@ -132,9 +145,9 @@ async function save() { let payload 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 prices_agent = collect(imagePricesAgent.value, e.resolutions || []) + const prices_agent = collect(imagePricesAgent.value, resolutions.value) const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value) if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return } if (!Object.keys(duration_prices).length) { error.value = '至少填写一个时长价格'; return } @@ -142,9 +155,12 @@ async function save() { type: 'video', provider: e.provider, 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, - 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, prices_agent, duration_prices_agent, @@ -154,8 +170,8 @@ async function save() { weight: Number(weight.value) || 0, } } else { - const prices = collect(imagePrices.value, e.resolutions || []) - const prices_agent = collect(imagePricesAgent.value, e.resolutions || []) + const prices = collect(imagePrices.value, resolutions.value) + const prices_agent = collect(imagePricesAgent.value, resolutions.value) if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return } payload = { type: 'image', @@ -224,7 +240,7 @@ async function save() { {{ isVideo ? '生视频' : '生图' }} {{ entry.provider }} - 参数自动加载,不可改 + {{ isCustom ? '档位由价格表决定 · 留空=不支持' : '参数自动加载,不可改' }}