feat(channel): 对齐 vivid 最新渠道/模型管理设计

渠道管理(对齐 9db9971/9f6eeca/b607a8d/268a8f7/7f8a87e):
- 列表新增「运行中」(Redis 并发槽位实时计数,与调度同源)和「失败数」
  (event_logs 按渠道聚合)两列
- 行内「同步上游模型」恢复弹窗:默认全开、右侧开关、彩点+单行别名,
  底部「同步模型」批量导入;编辑表单去掉获取模型列表按钮(保存已自动
  全量同步);去掉勾选即解绑语义与 unbind-model 端点
- 导入加固:别名与上游同名报错、同批别名复用拒绝、本地类型冲突拒绝、
  单次上限 50

模型管理(对齐 5545195):
- 模型表单/CreateModel 补齐 max_reference_videos/audios 与
  max_ref_video/audio_seconds 透传

产物(对齐 2253e58):
- 下载结果仅信任 image/*/video/* 响应头,text/plain 等乱报一律按魔数
  嗅探定 MIME

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-19 19:14:25 +08:00
co-authored by Devin
parent 72d4eec203
commit 4632b1ab04
9 changed files with 129 additions and 159 deletions
@@ -167,6 +167,10 @@ async function save() {
duration_prices_agent,
alias: alias.value.trim(),
max_reference_images: e.max_reference_images || 0,
max_reference_videos: e.max_reference_videos || 0,
max_reference_audios: e.max_reference_audios || 0,
max_ref_video_seconds: e.max_ref_video_seconds || 0,
max_ref_audio_seconds: e.max_ref_audio_seconds || 0,
reference_mode: e.reference_mode || 'none',
weight: Number(weight.value) || 0,
}
+71 -105
View File
@@ -10,19 +10,20 @@ const channels = ref([])
const loading = ref(false)
const err = ref('')
const warn = ref('') // 保存后自动同步的非致命告警
const okMsg = ref('') // 手动同步成功提示
const syncing = ref(false)
const okMsg = ref('') // 同步模型成功提示
// create/edit form
const form = ref(null)
const saving = ref(false)
const formErr = ref('')
// fetch-models → 勾选即绑定(选中自动导入到模型列表,取消即解绑)
const fetchTarget = ref(null) // channel snapshot being imported into
// 同步上游模型弹窗:开关选择 + 别名,批量导入(保存渠道已自动全量同步)
const fetchTarget = ref(null) // saved channel being synced
const fetching = ref(false)
const fetchErr = ref('')
const specs = ref([])
const picked = ref(new Set())
const importing = ref(false)
async function load() {
loading.value = true
@@ -80,84 +81,44 @@ async function del(c) {
channels.value = channels.value.filter((x) => x.id !== c.id)
}
// 已保存渠道手动「同步上游模型」:直接全量同步(新模型入库绑定,上游消失的
// 解绑/清孤儿),拉取失败直接报错;新渠道则只用表单地址+key 预览不落库。
async function syncModels(c) {
if (syncing.value) return
syncing.value = true
err.value = ''
warn.value = ''
okMsg.value = ''
try {
const r = await api('/channels/sync-models', jsonBody('POST', { id: c.id }))
if (!r.ok) { err.value = r.data?.detail || '同步失败'; return }
const msg = r.data?.message || '已同步上游模型'
if (msg === '已同步上游模型') okMsg.value = msg
else warn.value = msg
load()
} finally {
syncing.value = false
}
}
// 表单内拉模型:编辑态直接全量同步;新渠道用表单里的地址+key 预览(导入时自动保存)。
function fetchFromForm() {
const f = form.value
if (!f) return
if (f.id) { syncModels(f); return }
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
if (!f.api_key) { formErr.value = '请填写 API Key'; return }
openFetch({ id: '', name: f.name || f.base_url, base_url: f.base_url, api_key: f.api_key, concurrency: f.concurrency, weight: f.weight, enabled: !!f.enabled })
}
// 行内「同步上游模型」走开关 + 别名弹窗(渠道已保存,用库里凭据拉取)。
// 默认全开:点「同步模型」把勾选项批量导入绑定;上游消失的模型由保存时的
// 全量同步负责解绑。
async function openFetch(c) {
fetchTarget.value = c
specs.value = []
picked.value = new Set()
fetchErr.value = ''
fetching.value = true
const r = await api('/channels/fetch-models', jsonBody('POST', {
id: c.id || '', base_url: c.base_url || '', api_key: c.api_key || '',
}))
const r = await api('/channels/fetch-models', jsonBody('POST', { id: c.id }))
fetching.value = false
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', busy: false }))
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: '' }))
picked.value = new Set(specs.value.map((s) => s.id))
}
// 勾选 = 立即导入绑定;取消勾选 = 解绑(模型没有其他渠道接入时本地行一并删除)。
// 新渠道尚未入库时,第一次勾选会先自动保存渠道再绑定。
async function toggleBind(s) {
if (s.busy) return
if (!s.bound && s.alias && s.alias === s.id) {
fetchErr.value = `「${s.id}」别名不能与上游模型同名,留空即可`
return
}
s.busy = true
function togglePick(id) {
const next = new Set(picked.value)
next.has(id) ? next.delete(id) : next.add(id)
picked.value = next
}
async function doImport() {
if (importing.value || !picked.value.size) return
const dup = specs.value.find((s) => picked.value.has(s.id) && s.alias && s.alias === s.id)
if (dup) { fetchErr.value = `${dup.id}的别名与上游模型名相同,别名只在需要改名时设置,同名请留空`; return }
const models = specs.value.filter((s) => picked.value.has(s.id))
.map((s) => { const { bound, ...rest } = s; return rest })
importing.value = true
fetchErr.value = ''
try {
const target = fetchTarget.value
if (!s.bound) {
if (!target.id) {
const sr = await api('/channels', jsonBody('POST', {
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
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
}
const { bound, busy, ...rest } = s
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models: [rest] }))
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
s.bound = true
} else {
const r = await api('/channels/unbind-model', jsonBody('POST', { id: target.id, model: s.id }))
if (!r.ok) { fetchErr.value = r.data?.detail || '解绑失败'; return }
s.bound = false
}
const r = await api('/channels/import-models', jsonBody('POST', { id: fetchTarget.value.id, models }))
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
okMsg.value = `已导入 ${models.length} 个模型`
fetchTarget.value = null
load()
} finally {
s.busy = false
importing.value = false
}
}
@@ -204,7 +165,9 @@ onMounted(load)
<th>渠道</th>
<th>并发</th>
<th>配比</th>
<th class="text-right">成功调用</th>
<th class="text-right">运行中</th>
<th class="text-right">成功数</th>
<th class="text-right">失败数</th>
<th class="text-right">累计消耗</th>
<th class="text-right">上游余额</th>
<th>状态</th>
@@ -220,7 +183,9 @@ onMounted(load)
</td>
<td class="tabular-nums">{{ c.concurrency || '不限' }}</td>
<td class="tabular-nums">{{ c.weight || 1 }}</td>
<td class="text-right tabular-nums" :class="c.running_count > 0 ? 'text-emerald-300' : ''">{{ c.running_count || 0 }}</td>
<td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums" :class="c.fail_count > 0 ? 'text-rose-300' : ''">{{ (c.fail_count || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums">{{ c.upstream_balance == null ? '-' : c.upstream_balance.toLocaleString() }}</td>
<td>
@@ -232,13 +197,13 @@ onMounted(load)
</td>
<td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td>
<td class="text-right whitespace-nowrap">
<button @click="syncModels(c)" :disabled="syncing" class="op" title="同步上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
<button @click="openFetch(c)" class="op" title="同步上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
<button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
<button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
</td>
</tr>
<tr v-if="loading"><td colspan="9" class="py-10 text-center text-white/40 text-sm">正在加载渠道…</td></tr>
<tr v-else-if="!channels.length"><td colspan="9" class="py-10 text-center text-white/40 text-sm">没有渠道,点右上角「添加渠道」接入上游实例</td></tr>
<tr v-if="loading"><td colspan="11" class="py-10 text-center text-white/40 text-sm">正在加载渠道…</td></tr>
<tr v-else-if="!channels.length"><td colspan="11" class="py-10 text-center text-white/40 text-sm">没有渠道,点右上角「添加渠道」接入上游实例</td></tr>
</tbody>
</table>
</div>
@@ -286,12 +251,6 @@ onMounted(load)
{{ form.enabled ? '已启用' : '已停用' }}
</button>
</div>
<div class="flex items-center gap-3">
<button type="button" @click="fetchFromForm" :disabled="fetching" class="btn-ghost text-xs">
{{ fetching ? '拉取中…' : '获取模型列表' }}
</button>
<span class="text-[11px] text-white/35">{{ form.id ? '直接全量同步上游模型到模型列表' : '用上方的地址与 API Key 预览;导入时会先自动保存渠道' }}</span>
</div>
<p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p>
</div>
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
@@ -301,42 +260,40 @@ onMounted(load)
</div>
</div>
<!-- 拉取上游模型 → 勾选即导入绑定 -->
<!-- 同步上游模型:开关选择 + 别名,批量导入 -->
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
<div class="card !shadow-2xl my-12 w-full max-w-2xl">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2>
<h2 class="text-sm font-semibold">同步上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2>
<button @click="fetchTarget = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
</div>
<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 leading-relaxed">开启的模型会同步到本站模型列表,参数随上游带入;到「模型管理」配价后上架。别名即本站模型标识,留空 = 与上游同名。</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">
<div v-for="s in specs" :key="s.id" @click="toggleBind(s)"
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors"
:class="[!s.bound && 'opacity-50', s.busy && 'pointer-events-none']">
<span class="mt-1 inline-block h-3.5 w-3.5 shrink-0 rounded-full ring-1 transition-all"
:class="s.bound ? 'bg-sky-400 ring-sky-300' : 'bg-transparent ring-white/25'"></span>
<div class="flex-1 min-w-0 space-y-1.5">
<div class="flex items-center gap-2 flex-wrap">
<code class="font-mono text-xs text-white/85">{{ s.id }}</code>
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold"
:class="s.kind === 'video' ? 'bg-rose-400/15 text-rose-300' : 'bg-sky-400/15 text-sky-300'">
{{ KIND_LABEL[s.kind] || s.kind }}
</span>
<span class="text-[11px] text-white/40">{{ specText(s) }}</span>
<span v-if="s.busy" class="ml-auto text-[10px] text-white/40">处理中…</span>
<span v-else-if="s.bound" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已接入</span>
</div>
<div class="flex items-center gap-2" @click.stop>
<span class="text-[11px] text-white/35 shrink-0">别名</span>
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="s.bound"
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40"
:placeholder="s.bound ? '已绑定,到模型管理改别名' : '留空 = 与上游同名'" />
</div>
<div v-else class="space-y-2 max-h-[400px] overflow-y-auto p-0.5">
<div v-for="s in specs" :key="s.id" @click="togglePick(s.id)"
class="p-3 rounded-xl ring-1 ring-white/10 bg-white/[0.02] hover:bg-white/[0.04] hover:ring-white/20 cursor-pointer transition-all space-y-2"
:class="!picked.has(s.id) && 'opacity-45'">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-2 h-2 rounded-full shrink-0"
:class="s.kind === 'video' ? 'bg-sky-400' : s.kind === 'text' ? 'bg-amber-400' : 'bg-indigo-400'"></span>
<code class="font-mono text-[13px] font-semibold text-white/90 truncate">{{ s.id }}</code>
<span class="text-[11px] font-semibold text-white/40 shrink-0">{{ KIND_LABEL[s.kind] || s.kind }}</span>
<button type="button" class="ml-auto sw" :class="picked.has(s.id) && 'sw-on'"
:aria-pressed="picked.has(s.id)" @click.stop="togglePick(s.id)">
<span class="sw-thumb"></span>
</button>
</div>
<div class="pl-[18px] text-[11px] text-white/50">{{ specText(s) }}</div>
<div class="pl-[18px]" @click.stop>
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)"
class="w-full px-2.5 py-1.5 rounded-lg bg-white/[0.03] border border-dashed border-white/15 text-xs text-white/80
hover:border-solid focus:border-solid focus:border-white/30 focus:bg-white/[0.05] focus:outline-none
disabled:opacity-40 disabled:cursor-not-allowed transition-all"
placeholder="别名(留空 = 与上游同名)" />
</div>
</div>
</div>
@@ -344,6 +301,9 @@ onMounted(load)
</div>
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
<button @click="fetchTarget = null" class="btn-ghost">关闭</button>
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
{{ importing ? '同步中…' : `同步模型(${picked.size})` }}
</button>
</div>
</div>
</div>
@@ -362,4 +322,10 @@ onMounted(load)
.op { display: inline-grid; place-items: center; min-width: 28px; height: 26px; padding: 0 7px; margin-left: 4px; border-radius: 7px; background: rgb(255 255 255 / 0.05); color: rgb(255 255 255 / 0.6); }
.op:hover { background: rgb(255 255 255 / 0.1); color: white; }
.op.danger:hover { background: rgb(244 63 94 / 0.15); color: rgb(251 113 133); }
/* iOS-style switch for 模型开关 in the sync modal */
.sw { position: relative; width: 2rem; height: 1.15rem; 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; flex-shrink: 0; }
.sw-thumb { position: absolute; top: 2px; left: 2px; width: calc(1.15rem - 4px); height: calc(1.15rem - 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(2rem - 1.15rem)); }
</style>