feat(model): 同步 vivid 最新变更 — 去模型开关,配价即上架
对齐 vivid a338edb / 7fb5c57 / 4f454b6: - model_configs.enabled 列删除:上架唯一门槛 = 绑定了启用渠道 + 全部档位 有价(视频另需时长有价)。/v1/models、/api/models、video-presets、 managed-models 全部改按 sellable 透出 - 保存渠道即全量同步上游模型(消失解绑/孤儿删除/失败告警不删库) - v1 job 持久化 upstream jobId,重启后维护循环按 jobId 续等不丢单 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -50,7 +50,7 @@ watch(genMsg, (v) => {
|
||||
if (v) genMsgTimer = setTimeout(() => { genMsg.value = '' }, 3500)
|
||||
})
|
||||
const kindModels = computed(() =>
|
||||
(allModels.value || []).filter((m) => m.enabled !== false && m.type === gen.kind),
|
||||
(allModels.value || []).filter((m) => m.sellable && m.type === gen.kind),
|
||||
)
|
||||
const model = computed(() => kindModels.value.find((m) => m.id === gen.modelId) || null)
|
||||
const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1'])
|
||||
|
||||
@@ -9,6 +9,7 @@ const KIND_LABEL = { image: '图像', video: '视频', text: '文本' }
|
||||
const channels = ref([])
|
||||
const loading = ref(false)
|
||||
const err = ref('')
|
||||
const warn = ref('') // 保存后自动同步的非致命告警
|
||||
|
||||
// create/edit form
|
||||
const form = ref(null)
|
||||
@@ -59,6 +60,7 @@ async function save() {
|
||||
}))
|
||||
saving.value = false
|
||||
if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return }
|
||||
warn.value = r.data?.warning || ''
|
||||
form.value = null
|
||||
load()
|
||||
}
|
||||
@@ -70,7 +72,8 @@ async function toggle(c) {
|
||||
id: c.id, name: c.name, base_url: c.base_url, api_key: '',
|
||||
enabled: c.enabled, concurrency: c.concurrency || 0, weight: c.weight || 1,
|
||||
}))
|
||||
if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败' }
|
||||
if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败'; return }
|
||||
warn.value = r.data?.warning || ''
|
||||
}
|
||||
|
||||
async function del(c) {
|
||||
@@ -117,6 +120,7 @@ async function toggleBind(s) {
|
||||
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
|
||||
}))
|
||||
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
|
||||
if (sr.data?.warning) warn.value = sr.data.warning
|
||||
target.id = sr.data?.id
|
||||
form.value = null
|
||||
}
|
||||
@@ -159,7 +163,7 @@ onMounted(load)
|
||||
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="text-sm font-medium">渠道</div>
|
||||
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 模型经「拉取上游模型」导入 · 余额实时探测</p>
|
||||
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 保存时自动同步上游模型 · 余额实时探测</p>
|
||||
</div>
|
||||
<button @click="openAdd" class="btn-primary">
|
||||
<Icon name="plus" class="w-4 h-4" /> 添加渠道
|
||||
@@ -167,6 +171,7 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<p v-if="err" class="text-xs text-rose-300">{{ err }}</p>
|
||||
<p v-if="warn" class="text-xs text-amber-300">{{ warn }}</p>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
@@ -292,7 +297,7 @@ onMounted(load)
|
||||
<div class="p-5">
|
||||
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
|
||||
<template v-else>
|
||||
<p class="text-xs text-white/45 mb-3">勾选即导入并绑定到本渠道,取消勾选即解绑(模型没有其他渠道接入时本地行一并删除)。能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名,绑定时生效)。</p>
|
||||
<p class="text-xs text-white/45 mb-3">勾选即导入并绑定到本渠道,取消勾选即解绑(模型没有其他渠道接入时本地行一并删除)。能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名,绑定时生效)。注意:保存渠道会按上游清单全量重同步,手动解绑的模型若仍在上游列表中会被重新接入。</p>
|
||||
<p v-if="fetchErr" class="text-xs text-rose-300 mb-3">{{ fetchErr }}</p>
|
||||
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
|
||||
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto">
|
||||
|
||||
@@ -15,7 +15,7 @@ const models = ref([])
|
||||
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
||||
onMounted(async () => {
|
||||
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 (r.ok) models.value = (r.data?.data || []).filter((m) => m.sellable)
|
||||
if (p.ok) presets.value = presetMap(p.data?.data)
|
||||
})
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ async function refresh() {
|
||||
api('/showcase'),
|
||||
])
|
||||
stats.value = s.data || { generated_count: 0, recent: [] }
|
||||
managed.value = (m.data?.data || []).filter((x) => x.enabled !== false)
|
||||
managed.value = (m.data?.data || []).filter((x) => x.sellable)
|
||||
showcase.value = sc.data?.data || { hero: [], bento: [], work: [] }
|
||||
const top3 = (showcase.value.hero || []).slice(0, 3)
|
||||
const sameSet = heroDeck.value.length === top3.length
|
||||
@@ -92,7 +92,7 @@ const avgLabel = computed(() => {
|
||||
return Math.round(avgElapsed.value / 1000) + 's'
|
||||
})
|
||||
|
||||
// ---- capability cards, derived from the enabled models ----
|
||||
// ---- capability cards, derived from the sellable models ----
|
||||
function refs(m) { return m.image_to_image === true || Number(m.max_reference_images || 0) > 0 }
|
||||
const durationRange = computed(() => {
|
||||
const secs = []
|
||||
|
||||
@@ -14,7 +14,7 @@ const editing = ref(null) // null = add, object = edit
|
||||
const testing = ref(null) // model being tested, or null
|
||||
|
||||
const kindFilter = ref('') // '' | 'image' | 'video'
|
||||
const statusFilter = ref('') // '' | 'enabled' | 'disabled'
|
||||
const saleFilter = ref('') // '' | 'sellable' | 'unsellable'
|
||||
const search = ref('')
|
||||
|
||||
const TYPE_LABEL = { image: '生图', video: '生视频' }
|
||||
@@ -42,20 +42,6 @@ async function loadModels() {
|
||||
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
||||
function onSaved() { showForm.value = false; loadModels() }
|
||||
|
||||
async function toggleEnabled(m) {
|
||||
// Optimistic: flip the switch instantly, persist in the background, revert on
|
||||
// failure. Avoids the lag of awaiting the PATCH + a full table reload before
|
||||
// the toggle visibly moves.
|
||||
const cur = m.enabled !== false
|
||||
const next = !cur
|
||||
m.enabled = next
|
||||
const r = await api(`/managed-models/${encodeURIComponent(m.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
})
|
||||
if (!r.ok) m.enabled = cur
|
||||
}
|
||||
|
||||
async function remove(m) {
|
||||
if (!confirm(`确认删除模型 ${m.id}?`)) return
|
||||
await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' })
|
||||
@@ -66,16 +52,16 @@ const stats = computed(() => {
|
||||
const total = models.value.length
|
||||
const image = models.value.filter((m) => m.type === 'image').length
|
||||
const video = models.value.filter((m) => m.type === 'video').length
|
||||
const enabled = models.value.filter((m) => m.enabled !== false).length
|
||||
return { total, image, video, enabled, disabled: total - enabled }
|
||||
const sellable = models.value.filter((m) => m.sellable).length
|
||||
return { total, image, video, sellable }
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
return models.value.filter((m) => {
|
||||
if (kindFilter.value && m.type !== kindFilter.value) return false
|
||||
if (statusFilter.value === 'enabled' && m.enabled === false) return false
|
||||
if (statusFilter.value === 'disabled' && m.enabled !== false) return false
|
||||
if (saleFilter.value === 'sellable' && !m.sellable) return false
|
||||
if (saleFilter.value === 'unsellable' && m.sellable) return false
|
||||
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q))) return false
|
||||
return true
|
||||
})
|
||||
@@ -101,8 +87,8 @@ onMounted(loadModels)
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">启用</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.enabled }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">上架</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.sellable }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,12 +101,12 @@ onMounted(loadModels)
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="statusFilter = ''" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="statusFilter = 'enabled'" class="fp" :class="statusFilter === 'enabled' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>启用
|
||||
<button @click="saleFilter = ''" class="fp" :class="saleFilter === '' && 'fp-on'">全部</button>
|
||||
<button @click="saleFilter = 'sellable'" class="fp" :class="saleFilter === 'sellable' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>上架
|
||||
</button>
|
||||
<button @click="statusFilter = 'disabled'" class="fp" :class="statusFilter === 'disabled' && 'fp-white'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>停用
|
||||
<button @click="saleFilter = 'unsellable'" class="fp" :class="saleFilter === 'unsellable' && 'fp-white'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>未上架
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
@@ -148,7 +134,7 @@ onMounted(loadModels)
|
||||
<col /> <!-- capability -->
|
||||
<col class="w-20" /> <!-- weight -->
|
||||
<col class="w-24" /> <!-- generation count -->
|
||||
<col class="w-20" /> <!-- status switch -->
|
||||
<col class="w-20" /> <!-- sellable badge -->
|
||||
<col class="w-36" /> <!-- actions -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
@@ -159,7 +145,7 @@ onMounted(loadModels)
|
||||
<th class="text-left px-3 py-3 font-medium">能力</th>
|
||||
<th class="text-right px-3 py-3 font-medium">权重</th>
|
||||
<th class="text-right px-3 py-3 font-medium">生图次数</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-left px-3 py-3 font-medium">上架</th>
|
||||
<th class="text-right px-5 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -266,13 +252,15 @@ onMounted(loadModels)
|
||||
{{ (m.generation_count || 0).toLocaleString('en-US') }}
|
||||
</td>
|
||||
|
||||
<!-- Status as a real toggle so admins read it as on/off, not a
|
||||
button to delete or whatever. -->
|
||||
<!-- 配价即上架(无开关):全档位有价 + 绑定了启用渠道才显示「已上架」 -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<button class="sw" :class="m.enabled !== false && 'sw-on'"
|
||||
:aria-pressed="m.enabled !== false" @click="toggleEnabled(m)">
|
||||
<span class="sw-thumb"></span>
|
||||
</button>
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="m.sellable
|
||||
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||
: 'bg-white/[0.05] text-white/40 ring-white/10'"
|
||||
:title="m.sellable ? '全部档位有价且有启用渠道,对前台可见' : '配齐全部档位价格并绑定启用渠道后自动上架'">
|
||||
{{ m.sellable ? '已上架' : '未上架' }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions — small soft buttons, danger variant for delete -->
|
||||
@@ -376,27 +364,6 @@ onMounted(loadModels)
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
/* --- iOS-style toggle for 启用/停用 */
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 2.25rem; height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.sw-thumb {
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw-on { background: rgb(16 185 129 / 0.7); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5); }
|
||||
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
|
||||
/* --- danger variant for 删除 (kept for any other .btn-soft callers) */
|
||||
.btn-soft.danger {
|
||||
color: rgb(253 164 175);
|
||||
|
||||
@@ -68,7 +68,7 @@ const displayItems = computed(() => [...tasks.value, ...history.value].slice(0,
|
||||
|
||||
// ---- derived ----
|
||||
const models = computed(() =>
|
||||
allModels.value.filter((m) => m.enabled !== false && m.type === mode.value),
|
||||
allModels.value.filter((m) => m.sellable && m.type === mode.value),
|
||||
)
|
||||
const modelOptions = computed(() =>
|
||||
models.value.map((m) => ({ value: m.id, label: m.alias || m.name || m.id })),
|
||||
@@ -231,7 +231,7 @@ function setMode(m) {
|
||||
mode.value = m
|
||||
clearRefs() // 切换生图/生视频清空参考文件
|
||||
// pick a default model of the new kind, if any
|
||||
const first = allModels.value.find((x) => x.enabled !== false && x.type === m)
|
||||
const first = allModels.value.find((x) => x.sellable && x.type === m)
|
||||
modelId.value = first?.id || ''
|
||||
applyModelDefaults()
|
||||
}
|
||||
@@ -789,17 +789,17 @@ onMounted(async () => {
|
||||
if (qPrompt) prompt.value = qPrompt
|
||||
let selected = null
|
||||
if (qModel) {
|
||||
selected = allModels.value.find((m) => m.id === qModel && m.enabled !== false)
|
||||
selected = allModels.value.find((m) => m.id === qModel && m.sellable)
|
||||
}
|
||||
// If the draft already points at a still-available model AND no fresher
|
||||
// intent came in from the URL, keep the draft as-is. Otherwise fall back to
|
||||
// the first usable model.
|
||||
const draftModel = !qModel && modelId.value
|
||||
? allModels.value.find((m) => m.id === modelId.value && m.enabled !== false)
|
||||
? allModels.value.find((m) => m.id === modelId.value && m.sellable)
|
||||
: null
|
||||
if (!selected && !draftModel) {
|
||||
selected = allModels.value.find((m) => m.enabled !== false && m.type === 'image')
|
||||
|| allModels.value.find((m) => m.enabled !== false)
|
||||
selected = allModels.value.find((m) => m.sellable && m.type === 'image')
|
||||
|| allModels.value.find((m) => m.sellable)
|
||||
}
|
||||
// Always re-apply defaults — even when restoring the persisted draft model —
|
||||
// so a stale ratio/resolution that the model no longer supports (e.g. a saved
|
||||
|
||||
Reference in New Issue
Block a user