352 lines
16 KiB
Vue
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>
|