Files
image2api/frontend/src/components/ModelFormModal.vue
T
2026-07-04 14:49:52 +08:00

352 lines
16 KiB
Vue

<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { api, jsonBody } from '../api'
import { sortResolutions } from '../utils/format'
import Icon from './Icon.vue'
import SelectMenu from './SelectMenu.vue'
const props = defineProps({
// null → add mode; an object (managed-model record) → edit mode
model: { type: Object, default: null },
})
const emit = defineEmits(['close', 'saved'])
const isEdit = computed(() => !!props.model)
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
const catalog = ref([])
const loading = ref(true)
const selectedId = ref(props.model?.id || '')
const alias = ref(props.model?.alias || '')
const imagePrices = ref({}) // 普通价 { '1K': '', '2K': '', ... } keyed by resolutions
const videoPrices = ref({}) // 普通价 { '5s': '', '10s': '', ... } keyed by durations
const imagePricesAgent = ref({}) // 代理价(留空 = 跟随普通价)
const videoPricesAgent = ref({}) // 代理价(留空 = 跟随普通价)
// Display weight — admin-set (NOT a catalog param): higher = higher up the
// dropdown / model list. Defaults to the stored value in edit mode, else 0.
const weight = ref(Number(props.model?.weight) || 0)
const error = ref('')
const saving = ref(false)
// The entry whose params drive the form: in edit mode it's the stored record,
// in add mode it's the catalog row for the picked id. All generation params are
// read straight off it — the admin never types them, only the price.
const entry = computed(() => {
if (isEdit.value) return props.model
return catalog.value.find((e) => e.id === selectedId.value) || null
})
const isVideo = computed(() => entry.value?.type === 'video')
// 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 || []))
// Duration tiers for the price inputs. Prefer the declared `durations`; fall
// back to the keys of any stored duration_prices so a model saved without a
// `durations` array (the legacy sync bug) is still viewable/editable.
const durationTiers = computed(() => {
const e = entry.value
if (!e) return []
const ds = e.durations || []
return ds.length ? ds : Object.keys(e.duration_prices || {})
})
// Dropdown options: catalog rows not already in the managed store.
const addOptions = computed(() =>
catalog.value
.filter((e) => !e.added)
.map((e) => ({ value: e.id, label: `${e.id} · ${e.type === 'video' ? '视频' : '图像'}` }))
)
function resetPrices(e) {
imagePrices.value = {}
videoPrices.value = {}
imagePricesAgent.value = {}
videoPricesAgent.value = {}
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] = '' }
if (e.type === 'video') {
for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
}
}
// In add mode, switching the selected model rebuilds the price inputs to match
// that model's resolution / duration tiers.
watch(selectedId, () => { if (!isEdit.value) resetPrices(entry.value) })
onMounted(async () => {
const r = await api('/catalog')
catalog.value = r.data?.data || []
loading.value = false
if (isEdit.value) {
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 || [])) {
imagePrices.value[r] = m.prices?.[r] ?? ''
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
}
if (m.type === 'video') {
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] ?? ''
}
}
}
})
async function save() {
const e = entry.value
if (!e) { error.value = '请选择模型'; return }
error.value = ''
// Collect valid (>=0) numeric prices from a {key: value} ref into a plain map.
const collect = (src, keys) => {
const out = {}
for (const k of keys) {
const raw = String(src[k] ?? '').trim()
if (raw === '') continue
const n = Number(raw)
if (!isNaN(n) && n >= 0) out[k] = n
}
return out
}
let payload
if (e.type === 'video') {
// Real video price = resolution price + duration price. Both tiers are
// priced independently; a blank tier on either axis = unsupported. Charge
// happens server-side as prices[res] + duration_prices[dur].
const prices = collect(imagePrices.value, e.resolutions || [])
const duration_prices = collect(videoPrices.value, durationTiers.value)
// 代理价:可选覆盖,留空的档跟随普通价。
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
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 }
payload = {
type: 'video',
provider: e.provider,
ratios: e.ratios || [],
resolutions: e.resolutions || [],
prices,
// durations MUST track duration_prices — the model list / docs iterate
// `durations` to render the per-second price chips. Persisting prices
// without the matching durations array hides them. Keep them in sync.
durations: Object.keys(duration_prices),
duration_prices,
prices_agent,
duration_prices_agent,
alias: alias.value.trim(),
max_reference_images: e.max_reference_images || 0,
reference_mode: e.reference_mode || 'none',
weight: Number(weight.value) || 0,
}
} else {
const prices = collect(imagePrices.value, e.resolutions || [])
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return }
payload = {
type: 'image',
provider: e.provider,
ratios: e.ratios || [],
prices,
prices_agent,
image_to_image: !!e.image_to_image,
alias: alias.value.trim(),
// 多参考图:把目录定义的张数(gpt=3/seedream=6/flux=4 …)写进模型,
// 否则后端仍按旧值(默认 1)限制。
max_reference_images: e.max_reference_images || 0,
reference_mode: e.reference_mode || 'none',
weight: Number(weight.value) || 0,
}
}
saving.value = true
const r = isEdit.value
? await api(`/managed-models/${encodeURIComponent(e.id)}`, jsonBody('PATCH', payload))
: await api('/managed-models', jsonBody('POST', { id: e.id, ...payload }))
saving.value = false
if (r.ok) emit('saved')
else error.value = r.data?.detail || `保存失败 (${r.status})`
}
</script>
<template>
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
@click.self="emit('close')">
<div class="card !shadow-2xl my-12 w-full max-w-md">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">{{ isEdit ? '编辑价格' : '新增模型' }}</h2>
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
<Icon name="close" class="w-5 h-5" />
</button>
</div>
<div class="p-5 space-y-4">
<div v-if="loading" class="text-center text-sm text-white/40 py-8">加载支持的模型</div>
<template v-else>
<!-- model id: dropdown when adding, fixed label when editing -->
<div>
<label class="lbl">模型</label>
<SelectMenu v-if="!isEdit" v-model="selectedId" :options="addOptions"
placeholder="选择一个支持的模型" />
<div v-else class="field font-mono !cursor-default opacity-90">{{ entry?.id }}</div>
<p v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-300/80 mt-1.5">
所有支持的模型都已添加
</p>
</div>
<div>
<label class="lbl">别名</label>
<input v-model="alias" class="field font-mono" placeholder="可选,对外名" />
<p class="text-[11px] text-white/40 mt-1.5">设置后原模型名将不可调用,画图台 / API / 文档都改用别名</p>
</div>
<!-- read-only param summary, straight from the loaded catalog -->
<div v-if="entry" class="rounded-xl bg-white/[0.03] ring-1 ring-white/[0.06] p-3.5 space-y-2.5">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="isVideo ? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30'
: 'bg-indigo-500/10 text-indigo-300 ring-indigo-400/30'">
{{ isVideo ? '生视频' : '生图' }}
</span>
<span class="text-[11px] text-white/45 capitalize">{{ entry.provider }}</span>
<span class="ml-auto text-[10px] text-white/30">参数自动加载,不可改</span>
</div>
<div class="grid grid-cols-[3.5rem_1fr] gap-x-3 gap-y-1.5 text-[11px]">
<span class="text-white/40">比例</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in (entry.ratios || [])" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!(entry.ratios || []).length" class="text-white/30"></span>
</div>
<template v-if="isVideo">
<span class="text-white/40">分辨率</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!resolutions.length" class="text-white/30"></span>
</div>
<span class="text-white/40">时长</span>
<div class="flex flex-wrap gap-1">
<span v-for="d in (entry.durations || [])" :key="d" class="ro-chip">{{ d }}</span>
<span v-if="!(entry.durations || []).length" class="text-white/30"></span>
</div>
<span class="text-white/40">参考图</span>
<div class="text-white/70">
{{ entry.max_reference_images > 0
? `${entry.max_reference_images} 张 · ${REF_MODE_LABEL[entry.reference_mode] || entry.reference_mode}`
: '不支持' }}
</div>
</template>
<template v-else>
<span class="text-white/40">画质</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!resolutions.length" class="text-white/30"></span>
</div>
<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>
</template>
</div>
</div>
<!-- ===== PRICE: image = per-quality; video = per-quality + per-duration (additive) ===== -->
<template v-if="entry">
<div class="space-y-4">
<!-- resolution prices (both image & video): 普通价 + 代理价 -->
<div>
<label class="lbl">{{ isVideo ? '分辨率价格' : '画质价格' }} <span class="text-white/35">(普通价留空 = 不支持该档;代理价留空 = 跟随普通价)</span></label>
<div class="space-y-2">
<div v-if="resolutions.length" class="flex items-center gap-2 text-[10px] text-white/35 pl-14">
<span class="flex-1">普通价</span>
<span class="flex-1">代理价</span>
</div>
<div v-for="r in resolutions" :key="r" class="flex items-center gap-2">
<div class="w-12 shrink-0 text-sm text-white/85 font-mono">{{ r }}</div>
<div class="relative flex-1">
<input v-model="imagePrices[r]" type="number" min="0" step="1" class="field !pr-10" placeholder="普通价" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/30 text-[10px]">积分</span>
</div>
<div class="relative flex-1">
<input v-model="imagePricesAgent[r]" type="number" min="0" step="1" class="field !pr-10" placeholder="跟随普通" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-300/40 text-[10px]">代理</span>
</div>
</div>
<p v-if="!resolutions.length" class="text-xs text-white/35">该模型未声明分辨率档位</p>
</div>
</div>
<!-- duration prices (video only): 普通价 + 代理价 -->
<div v-if="isVideo">
<label class="lbl">时长价格 <span class="text-white/35">(代理价留空 = 跟随普通价)</span></label>
<div v-if="durationTiers.length" class="space-y-2">
<div class="flex items-center gap-2 text-[10px] text-white/35 pl-14">
<span class="flex-1">普通价</span>
<span class="flex-1">代理价</span>
</div>
<div v-for="d in durationTiers" :key="d" class="flex items-center gap-2">
<div class="w-12 shrink-0 text-sm text-white/85 font-mono">{{ d }}</div>
<div class="relative flex-1">
<input v-model="videoPrices[d]" type="number" min="0" step="1" class="field !pr-10" placeholder="普通价" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/30 text-[10px]">积分</span>
</div>
<div class="relative flex-1">
<input v-model="videoPricesAgent[d]" type="number" min="0" step="1" class="field !pr-10" placeholder="跟随普通" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-300/40 text-[10px]">代理</span>
</div>
</div>
</div>
<p v-else class="text-xs text-white/35">该模型未声明时长档位</p>
</div>
<p v-if="isVideo" class="text-[11px] text-white/40">实付 = 分辨率价 + 时长价(:720p 50 + 5s 30 = 80 积分)</p>
<!-- display weight: admin-set ordering (not a catalog param) -->
<div>
<label class="lbl">展示权重 <span class="text-white/35">(数值越大,在下拉 / 列表中越靠前;相同权重按新建时间)</span></label>
<input v-model="weight" type="number" step="1" class="field" placeholder="0" />
</div>
</div>
</template>
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
<div class="flex justify-end gap-2 pt-1">
<button @click="emit('close')" class="btn-soft">取消</button>
<button @click="save" :disabled="saving || !entry" class="btn-primary">{{ saving ? '保存中…' : '保存' }}</button>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.lbl {
display: block;
font-size: 0.72rem;
font-weight: 500;
color: rgb(255 255 255 / 0.55);
margin-bottom: 0.4rem;
}
/* read-only param chip */
.ro-chip {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.45rem;
font-size: 0.68rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
border-radius: 0.4rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.05);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
}
</style>