Files
image2api/frontend/src/views/ChannelsView.vue
T
chiyiandDevin 4d31219368 feat(ui): 全站统一首页墨黑中性风格,固定亮色并移除光暗切换
- 全站紫/青/粉写死色值与 CSS 变量改为墨黑中性色板
- 移除 aurora 光斑/星点装饰,登录弹窗改纯白卡片
- theme.js 固定亮色并清除历史 dark 选择,删除前台/后台/画布切换按钮
- 模型页空状态文案对齐「保存渠道即自动同步」

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:17:55 +08:00

332 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted } from 'vue'
import { api, jsonBody } from '../api'
import { fmtTs, fmtDate } from '../utils/format'
import Icon from '../components/Icon.vue'
const KIND_LABEL = { image: '图像', video: '视频', text: '文本' }
const channels = ref([])
const loading = ref(false)
const err = ref('')
const warn = ref('') // 保存后自动同步的非致命告警
const okMsg = ref('') // 同步模型成功提示
// create/edit form
const form = ref(null)
const saving = ref(false)
const formErr = ref('')
// 同步上游模型弹窗:开关选择 + 别名,批量导入(保存渠道已自动全量同步)
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
err.value = ''
const r = await api('/channels')
if (r.ok) channels.value = r.data?.data || []
else err.value = r.data?.detail || '渠道加载失败'
loading.value = false
}
function openAdd() {
formErr.value = ''
form.value = { id: '', name: '', base_url: '', api_key: '', concurrency: 5, weight: 1, enabled: true }
}
function openEdit(c) {
formErr.value = ''
form.value = { id: c.id, name: c.name, base_url: c.base_url, api_key: '', concurrency: c.concurrency || 0, weight: c.weight || 1, enabled: !!c.enabled }
}
async function save() {
const f = form.value
if (!f) return
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
if (!f.id && !f.api_key) { formErr.value = '请填写 API Key'; return }
if (saving.value) return
saving.value = true
formErr.value = ''
okMsg.value = ''
const r = await api('/channels', jsonBody('POST', {
id: f.id, name: f.name, base_url: f.base_url, api_key: f.api_key,
enabled: !!f.enabled, concurrency: f.concurrency || 0, weight: f.weight || 1,
}))
saving.value = false
if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return }
warn.value = r.data?.warning || ''
form.value = null
load()
}
async function toggle(c) {
const prev = !!c.enabled
c.enabled = !prev
const r = await api('/channels', jsonBody('POST', {
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 || '状态更新失败'; return }
warn.value = r.data?.warning || ''
}
async function del(c) {
if (!confirm(`确认删除渠道「${c.name || c.base_url}」?其绑定的模型若没有其他渠道接入,将一并删除。`)) return
const r = await api('/channels/delete', jsonBody('POST', { id: c.id }))
if (!r.ok) { err.value = r.data?.detail || '删除失败'; return }
channels.value = channels.value.filter((x) => x.id !== c.id)
}
// 行内「同步上游模型」走开关 + 别名弹窗(渠道已保存,用库里凭据拉取)。
// 默认全开:点「同步模型」把勾选项批量导入绑定;上游消失的模型由保存时的
// 全量同步负责解绑。
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 }))
fetching.value = false
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: '' }))
picked.value = new Set(specs.value.map((s) => s.id))
}
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 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 {
importing.value = false
}
}
function specText(s) {
const parts = []
if (s.qualities?.length) parts.push(s.qualities.join('/'))
if (s.duration) {
if (s.duration.mode === 'fixed') parts.push(`固定 ${s.duration.seconds}s`)
else if (s.duration.mode === 'range') parts.push(`${s.duration.min}-${s.duration.max}s`)
else if (s.duration.mode === 'options') parts.push((s.duration.options || []).map((d) => `${d}s`).join('/'))
}
const refs = []
if (s.refImages > 0) refs.push(`图片${s.refImages}`)
if (s.refVideos > 0) refs.push(`视频${s.refVideos}${s.refVideoSeconds > 0 ? `≤${s.refVideoSeconds}s` : ''}`)
if (s.refAudios > 0) refs.push(`音频${s.refAudios}${s.refAudioSeconds > 0 ? `≤${s.refAudioSeconds}s` : ''}`)
if (refs.length) parts.push(`参考 ${refs.join('+')}`)
return parts.join(' · ') || '-'
}
onMounted(load)
</script>
<template>
<section class="space-y-4">
<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>
</div>
<button @click="openAdd" class="btn-primary">
<Icon name="plus" class="w-4 h-4" /> 添加渠道
</button>
</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>
<p v-if="okMsg" class="text-xs text-emerald-300">{{ okMsg }}</p>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="tbl">
<thead>
<tr>
<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>状态</th>
<th>最近使用</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="c in channels" :key="c.id">
<td>
<div class="font-medium text-white/85">{{ c.name }}</div>
<div class="text-[11px] text-white/35 font-mono">{{ c.id }}</div>
</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>
<button @click="toggle(c)" class="inline-flex items-center gap-1.5 text-xs"
:class="c.enabled ? 'text-emerald-300' : 'text-white/40'">
<span class="w-1.5 h-1.5 rounded-full" :class="c.enabled ? 'bg-emerald-400' : 'bg-white/25'"></span>
{{ c.enabled ? '启用' : '停用' }}
</button>
</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="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="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>
</div>
<!-- 添加 / 编辑渠道 -->
<div v-if="form" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="form = null">
<div class="card !shadow-2xl my-12 w-full max-w-lg">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">{{ form.id ? '编辑渠道' : '添加渠道' }}</h2>
<button @click="form = null" 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>
<label class="lbl">渠道名称</label>
<input v-model.trim="form.name" type="text" maxlength="64" class="field" placeholder="例如:主站" />
</div>
<div>
<label class="lbl">上游地址 <span class="text-rose-300">*</span></label>
<input v-model.trim="form.base_url" type="text" maxlength="300" class="field" placeholder="https://example.com" />
<p class="mt-1 text-[11px] text-white/35">同构 /v1 协议实例的站点地址,不带路径</p>
</div>
<div>
<label class="lbl">API Key <span v-if="!form.id" class="text-rose-300">*</span></label>
<input v-model.trim="form.api_key" type="text" class="field" :placeholder="form.id ? '留空保持不变' : 'vk_…'" autocomplete="off" />
<p class="mt-1 text-[11px] text-white/35">上游站点的用户 API Key(vk_ 前缀),密文存储,不回显</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="lbl">并发数</label>
<input v-model.number="form.concurrency" type="number" min="0" max="2147483647" class="field" />
<p class="mt-1 text-[11px] text-white/35">0 = 不限</p>
</div>
<div>
<label class="lbl">配比</label>
<input v-model.number="form.weight" type="number" min="1" max="100" class="field" />
<p class="mt-1 text-[11px] text-white/35">同模型多渠道按配比分单</p>
</div>
</div>
<div class="flex items-center gap-3">
<label class="lbl !mb-0">状态</label>
<button type="button" @click="form.enabled = !form.enabled" class="inline-flex items-center gap-1.5 text-xs"
:class="form.enabled ? 'text-emerald-300' : 'text-white/40'">
<span class="w-1.5 h-1.5 rounded-full" :class="form.enabled ? 'bg-emerald-400' : 'bg-white/25'"></span>
{{ form.enabled ? '已启用' : '已停用' }}
</button>
</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">
<button @click="form = null" class="btn-ghost">取消</button>
<button @click="save" :disabled="saving" class="btn-primary">{{ saving ? '保存中…' : '保存' }}</button>
</div>
</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>
<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 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-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-zinc-500'"></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>
</template>
</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>
</section>
</template>
<style scoped>
.tbl { width: 100%; border-collapse: collapse; min-width: 1000px; }
.tbl thead th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600; color: rgb(255 255 255 / 0.45); border-bottom: 1px solid rgb(255 255 255 / 0.06); white-space: nowrap; }
.tbl tbody td { padding: 10px 14px; border-bottom: 1px solid rgb(255 255 255 / 0.04); font-size: 12.5px; color: rgb(255 255 255 / 0.7); vertical-align: middle; }
.tbl tbody tr:last-child td { border-bottom: none; }
.tbl tbody tr:hover { background: rgb(255 255 255 / 0.02); }
.lbl { display: block; font-size: 0.72rem; font-weight: 500; color: rgb(255 255 255 / 0.55); margin-bottom: 0.4rem; }
.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>