feat!: 账号池改渠道网关,对外协议换成 vivid /v1

- 新增 channels/channel_models:上游 URL + AES-GCM 加密 key +
  并发/权重/本地统计,模型经渠道 /v1/models 导入绑定
- internal/upstream:vivid 协议客户端(NDJSON 流 + jobId 断流重连 +
  uploads 预签名 + models + balance + 错误分类)
- service/v1 重写:权重分组调度、Redis 并发槽 conc:c:、故障转移,
  站内/测试/对外统一走渠道执行器
- 对外端点换成 vivid 四件套:POST /v1/generate(NDJSON+jobId 重连)、
  POST /v1/uploads(SigV4 预签名 PUT)、GET /v1/models、GET /v1/balance,
  错误包 {"error":{"code","message"}}
- API key 新发改 vk_+48hex;删除全部内置 provider 与 token 池
- 管理端:渠道 CRUD/拉模型/导入/余额端点,前端账号页换渠道页,
  概览/文档/测试弹窗适配

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-16 21:14:18 +08:00
co-authored by Devin
parent ecdbc0b281
commit b4efe6b192
75 changed files with 3598 additions and 23848 deletions
-759
View File
@@ -1,759 +0,0 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { api, jsonBody } from '../api'
import { fmtTs, fmtIso, fmtDate, fmtClock } from '../utils/format'
import ImportModal from '../components/ImportModal.vue'
import UpstreamModal from '../components/UpstreamModal.vue'
import AccountEditModal from '../components/AccountEditModal.vue'
import AccountTestModal from '../components/AccountTestModal.vue'
import Icon from '../components/Icon.vue'
const rows = ref([])
const loading = ref(false)
const quotaStatus = ref('')
const showImport = ref(false)
const showUpstream = ref(false)
const editingUpstream = ref(null)
function editUpstream(a) { editingUpstream.value = a; showUpstream.value = true }
const editingAccount = ref(null)
function editAccount(a) { editingAccount.value = a }
const testingAccount = ref(null)
function testAccount(a) { testingAccount.value = a }
// 预加载模型列表,让「生图测试」弹窗即开即用(不显示加载中)。
const allModels = ref([])
async function loadModelList() {
const r = await api('/managed-models')
allModels.value = r.data?.data || []
}
// Reflect the saved values in the table without a full reload.
function applyEdit(payload) {
const row = editingAccount.value
if (!row) return
if (payload.weight != null) row.weight = payload.weight
if (payload.concurrency != null) row.concurrency = payload.concurrency
}
const typeFilter = ref('') // '' | 'openai' | 'adobe' | 'runway' | 'leonardo'
const statusFilter = ref('') // '' | 'active' | 'quota' | 'disabled'
const planFilter = ref('') // '' | 'vip' | 'free' | 'sub' | 'master'
const search = ref('')
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
// Typing a search term must jump back to page 1 and re-query the server —
// search is cross-page now (server-side).
let searchTimer = null
watch(search, () => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => { resetAndLoad() }, 300)
})
const EMPTY_TYPE = { n: 0, ok: 0, dead: 0, quota: 0 }
// 每个类型的 成功/失败/限额 三个数 — 由后端对全量账号统计(与筛选/分页无关)。
const stats = ref({
total: 0, dead_total: 0,
openai: { ...EMPTY_TYPE }, adobe: { ...EMPTY_TYPE }, runway: { ...EMPTY_TYPE },
leonardo: { ...EMPTY_TYPE }, krea: { ...EMPTY_TYPE }, imagine: { ...EMPTY_TYPE },
grok: { ...EMPTY_TYPE }, creativefabrica: { ...EMPTY_TYPE },
})
// 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。
const deadCount = computed(() => stats.value.dead_total || 0)
function typePill(t) {
return {
adobe: 'bg-rose-500/10 text-rose-300 ring-rose-400/30',
openai: 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30',
runway: 'bg-orange-500/10 text-orange-300 ring-orange-400/30',
leonardo: 'bg-amber-500/10 text-amber-300 ring-amber-400/30',
krea: 'bg-sky-500/10 text-sky-300 ring-sky-400/30',
imagine: 'bg-teal-500/10 text-teal-300 ring-teal-400/30',
creativefabrica: 'bg-rose-500/10 text-rose-300 ring-rose-400/30',
}[t] || 'bg-white/[0.06] text-white/70 ring-white/15'
}
// 与后端 accountConcurrency 保持一致:adobe 普号固定 1 并发,会员号取配置值
// (未配置时默认 5);custom 上游取配置值 (默认 1);grok 固定 10,其余 1。
function acctConcurrency(a) {
if (a.type === 'adobe') return a.plan === 'free' ? 1 : (Number(a.concurrency) || 5)
if (a.type === 'custom') return Number(a.concurrency) || 1
return a.type === 'grok' ? 10 : 1
}
const STATUS_LABEL = { active: '正常', quota: '额度耗尽', disabled: '已禁用', pending: '检测中' }
// Server-side pagination: rows IS the current page, already filtered/sorted
// by the backend. total = server-side filtered count.
const filtered = computed(() => rows.value)
const pagedItems = computed(() => rows.value)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
function goPage(n) {
const target = Math.max(1, Math.min(totalPages.value, n))
if (target !== page.value) page.value = target
}
function setFilter(fn) { fn(); resetAndLoad() }
function resetAndLoad() {
if (page.value !== 1) page.value = 1 // the page watcher triggers the load
else loadAccounts()
}
const pageNumbers = computed(() => {
const n = totalPages.value
const cur = page.value
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
const want = new Set([1, n, cur - 1, cur, cur + 1])
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
const out = []
for (let i = 0; i < list.length; i++) {
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
out.push(list[i])
}
return out
})
let pendingTimer = null
function buildQs() {
const qs = new URLSearchParams({
limit: String(pageSize.value),
offset: String((page.value - 1) * pageSize.value),
})
if (typeFilter.value) qs.set('type', typeFilter.value)
if (statusFilter.value) qs.set('status', statusFilter.value)
if (planFilter.value && typeFilter.value === 'adobe') qs.set('plan', planFilter.value)
if (search.value.trim()) qs.set('q', search.value.trim())
return qs.toString()
}
async function fetchAccounts() {
const r = await api('/accounts?' + buildQs())
rows.value = r.data?.data || []
total.value = Number(r.data?.total ?? rows.value.length)
if (r.data?.stats) stats.value = r.data.stats
}
async function loadAccounts() {
loading.value = true
quotaStatus.value = ''
await fetchAccounts()
loading.value = false
if (rows.value.length) reconcile()
schedulePendingPoll()
}
// While any imported account is still being checked server-side, re-fetch the
// list so it flips pending → active/dead on its own (no manual refresh).
function schedulePendingPoll() {
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
if (!rows.value.some((r) => r.pending)) return
pendingTimer = setTimeout(async () => {
await fetchAccounts()
schedulePendingPoll()
}, 2000)
}
// Background reconciliation: openai → live quota; adobe → reset_after;
// adobe without email → fetch email. Only ACTIVE accounts — pending ones are
// handled by the import worker, dead/disabled ones need no re-check.
//
// Scope: ONLY the accounts visible on the current page. Probing all 100+ rows on
// every open floods the backend; the user only ever sees ~20 at a time, so we
// re-check just those and re-run when the page (or filters) change. New imports
// are hydrated server-side by the import worker and surfaced via the pending
// poll reading the store — they don't need a frontend probe.
let reconcileToken = 0
async function reconcile() {
const myToken = ++reconcileToken // supersede any in-flight run (fast page flips)
const visible = pagedItems.value
// NEW accounts (still pending the import worker's server-side check) are never
// probed here — the pending poll just reads the store until the worker writes
// their quota/email. OLD accounts (active) get a real live /quota probe for
// up-to-date remaining + refresh time.
const quotaRows = visible.filter((r) => !r.pending && r.status === 'active' && (r.type === 'openai' || r.type === 'adobe' || r.type === 'runway' || r.type === 'leonardo' || r.type === 'krea' || r.type === 'imagine' || r.type === 'grok'))
const adobeNeedEmail = visible.filter((r) => !r.pending && r.type === 'adobe' && !r.email)
const total = quotaRows.length + adobeNeedEmail.length
if (total === 0) { quotaStatus.value = ''; return }
let done = 0, updates = 0
quotaStatus.value = `后台校对… 0/${total}`
const bump = () => {
done++
if (myToken !== reconcileToken) return // a newer page-flip superseded us
quotaStatus.value = `后台校对… ${done}/${total}${updates ? ` · 更新 ${updates}` : ''}`
}
// Build thunks (NOT immediately-invoked) so the pool controls how many run at
// once. Each /accounts/.../quota probe is a *synchronous* backend call to
// OpenAI/Adobe. We only probe the visible page (≤ pageSize rows), so the
// limit below is effectively bounded by that — no full-list flood.
const jobs = []
for (const row of quotaRows) {
jobs.push(async () => {
const result = await fetchOneQuota(row.pool, row.id)
if (result && result.auth_failed) {
// backend auto-disabled this dead (401) token — reflect it immediately
row.status = result.status || 'disabled'
row.dead = true
row.remaining = null
row._unknown = true
updates++
} else if (result && result.unchanged === false) {
if (row.type === 'adobe') {
row.reset_after = result.reset_after
applyQuota(row, result)
} else applyQuota(row, result)
updates++
}
bump()
})
}
for (const row of adobeNeedEmail) {
jobs.push(async () => {
const result = await fetchOneEmail(row.pool, row.id)
if (result && result.email && (!row.email || row.email === '—')) {
row.email = result.email
updates++
}
bump()
})
}
await runWithLimit(jobs, Infinity) // no JS-side cap — fire all visible-page probes at once (browser still limits ~6 conns/origin)
// clear the indicator when done — but only if we're still the current run
// (a page flip mid-reconcile starts a fresh one that owns the indicator).
if (myToken === reconcileToken) quotaStatus.value = ''
}
// Flipping pages re-queries the server for the new page; loadAccounts() then
// reconciles just the freshly visible rows. Filter buttons go through
// setFilter → resetAndLoad, so everything funnels into loadAccounts.
watch(page, () => { loadAccounts() })
// Bounded-concurrency runner: keeps at most `limit` thunks in flight at once.
async function runWithLimit(thunks, limit) {
let next = 0
const workers = Array.from({ length: Math.min(limit, thunks.length) }, async () => {
while (next < thunks.length) {
const idx = next++
await thunks[idx]()
}
})
await Promise.all(workers)
}
async function fetchOneQuota(pool, id) {
try { return (await api(`/accounts/${pool}/${id}/quota`)).data || {} }
catch (e) { return { error: String(e) } }
}
async function fetchOneEmail(pool, id) {
try { return (await api(`/accounts/${pool}/${id}/email`)).data || {} }
catch (e) { return { error: String(e) } }
}
function applyQuota(row, result) {
// A transient probe error (e.g. connection reset when OpenAI is unreachable
// without a proxy) must NOT blank the cached number — keep the last-known
// value so a network blip doesn't turn the whole column into "—".
if (result.error) { row._quotaError = result.error; return }
row._quotaError = null
if (result.unknown && result.remaining === null) { row.remaining = null; row._unknown = true; return }
row._unknown = false
row.remaining = result.remaining
if (result.image_remaining !== undefined) row.image_remaining = result.image_remaining
if (result.video_remaining !== undefined) row.video_remaining = result.video_remaining
row.reset_after = result.reset_after
// 会员身份随额度一起回写:改过会员/掉了会员的号,查一次额度徽章就跟着更新。
if (result.plan !== undefined) row.plan = result.plan
if (result.sub_account !== undefined) row.sub_account = result.sub_account
}
async function toggleAccountStatus(pool, id, current) {
const row = rows.value.find((r) => r.pool === pool && r.id === id)
const next = current === 'active' ? 'disabled' : 'active'
// Optimistic: flip the switch instantly so the UI never waits on the network.
// The PATCH itself is a cheap in-memory update server-side; the old 5s lag came
// from the follow-up loadAccounts() → reconcile() probing every account's quota.
if (row) row.status = next
try {
const r = await api(`/tokens/${pool}/${id}`, jsonBody('PATCH', { status: next }))
if (!r.ok && row) row.status = current // revert on server rejection
} catch (e) {
if (row) row.status = current // revert on network error
}
}
async function deleteAccount(pool, id) {
if (!confirm(`确认删除 ${pool} / ${id}?`)) return
await api(`/tokens/${pool}/${id}`, { method: 'DELETE' })
loadAccounts()
}
// 一键删除全部异常(已失效/红色锁定)账号。先向服务端要全量 dead 列表(跨页),再逐个删除。
async function deleteDeadAccounts() {
if (!deadCount.value) return
if (!confirm(`确认删除全部 ${deadCount.value} 个异常(已失效)账号?此操作不可撤销。`)) return
const r = await api('/accounts?dead=1&limit=0')
const dead = r.data?.data || []
if (!dead.length) return
await Promise.all(dead.map((a) => api(`/tokens/${a.pool}/${a.id}`, { method: 'DELETE' })))
loadAccounts()
}
// ===== 多选删除 =====
const selected = ref(new Set())
function toggleSelect(id) {
const s = new Set(selected.value)
s.has(id) ? s.delete(id) : s.add(id)
selected.value = s
}
// Header checkbox selects/deselects the CURRENT PAGE only.
const allSelected = computed(() =>
pagedItems.value.length > 0 && pagedItems.value.every((a) => selected.value.has(a.id)))
function toggleSelectAll() {
const s = new Set(selected.value)
if (allSelected.value) pagedItems.value.forEach((a) => s.delete(a.id))
else pagedItems.value.forEach((a) => s.add(a.id))
selected.value = s
}
async function deleteSelected() {
const ids = [...selected.value]
if (!ids.length) return
if (!confirm(`确认删除选中的 ${ids.length} 个账号?此操作不可撤销。`)) return
const r = await api('/tokens/delete-bulk', jsonBody('POST', { ids }))
if (r.ok) {
selected.value = new Set()
loadAccounts()
}
}
onMounted(() => { loadAccounts(); loadModelList() })
</script>
<template>
<section class="space-y-4">
<!-- KPI strip — 每个类型显示 成功/失败/限额 三个数(绿/红/琥珀) -->
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-7 gap-3">
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-white/45">账号总数</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
<div class="text-[10px] text-white/35 mt-0.5">成功/失败/限额</div>
</div>
<div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-orange-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80'],['grok','Grok','text-slate-300/80'],['creativefabrica','Creative Fabrica','text-rose-300/80']]"
:key="t[0]" class="card p-4">
<div class="text-[11px] uppercase tracking-wider" :class="t[2]">{{ t[1] }}</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">
<span class="text-emerald-300">{{ stats[t[0]].ok }}</span><span class="text-white/30">/</span><span class="text-rose-300">{{ stats[t[0]].dead }}</span><span class="text-white/30">/</span><span class="text-amber-300">{{ stats[t[0]].quota }}</span>
</div>
<div class="text-[10px] text-white/35 mt-0.5">共 {{ stats[t[0]].n }}</div>
</div>
</div>
<!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1">
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button>
<button @click="setFilter(() => typeFilter = 'openai')" class="fp" :class="typeFilter === 'openai' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>OpenAI
</button>
<button @click="setFilter(() => typeFilter = 'adobe')" class="fp" :class="typeFilter === 'adobe' && 'fp-rose'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>Adobe
</button>
<button @click="setFilter(() => typeFilter = 'runway')" class="fp" :class="typeFilter === 'runway' && 'fp-orange'">
<span class="w-1.5 h-1.5 rounded-full bg-orange-400"></span>Runway
</button>
<button @click="setFilter(() => typeFilter = 'leonardo')" class="fp" :class="typeFilter === 'leonardo' && 'fp-amber'">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>Leonardo
</button>
<button @click="setFilter(() => typeFilter = 'krea')" class="fp" :class="typeFilter === 'krea' && 'fp-sky'">
<span class="w-1.5 h-1.5 rounded-full bg-sky-400"></span>Krea
</button>
<button @click="setFilter(() => typeFilter = 'imagine')" class="fp" :class="typeFilter === 'imagine' && 'fp-teal'">
<span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>Imagine
</button>
<button @click="setFilter(() => typeFilter = 'grok')" class="fp" :class="typeFilter === 'grok' && 'fp-on'">
<span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>Grok
</button>
<button @click="setFilter(() => typeFilter = 'creativefabrica')" class="fp" :class="typeFilter === 'creativefabrica' && 'fp-on'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>Creative Fabrica
</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常
</button>
<button @click="setFilter(() => statusFilter = 'quota')" class="fp" :class="statusFilter === 'quota' && 'fp-amber'">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>额度耗尽
</button>
<button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>已禁用
</button>
</div>
<template v-if="typeFilter === 'adobe'">
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setFilter(() => planFilter = '')" class="fp" :class="planFilter === '' && 'fp-on'">全部账号</button>
<button @click="setFilter(() => planFilter = 'free')" class="fp" :class="planFilter === 'free' && 'fp-slate'">普号</button>
<button @click="setFilter(() => planFilter = 'sub')" class="fp" :class="planFilter === 'sub' && 'fp-sky'">子号</button>
<button @click="setFilter(() => planFilter = 'master')" class="fp" :class="planFilter === 'master' && 'fp-pink'">会员号</button>
</div>
</template>
<div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / ID / 类型…" />
</div>
<button v-if="selected.size" @click="deleteSelected" class="btn-soft danger" title="删除选中的账号">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
</button>
<button v-if="deadCount" @click="deleteDeadAccounts" class="btn-soft danger" title="删除全部已失效(401)账号">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除异常账号 ({{ deadCount }})
</button>
<button @click="loadAccounts" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
<button @click="showImport = true" class="btn-primary">
<Icon name="plus" class="w-3.5 h-3.5" /> 导入账号
</button>
<button @click="editingUpstream = null; showUpstream = true" class="btn-soft">
<Icon name="plus" class="w-3.5 h-3.5" /> 添加上游
</button>
</div>
<!-- Table -->
<div class="card overflow-x-auto">
<div v-if="loading && !rows.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
<Icon name="accounts" class="w-6 h-6" />
</span>
<span class="text-sm">{{ stats.total ? '没有匹配的账号' : '还没有账号' }}</span>
<button v-if="!stats.total" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
</div>
<table v-else class="w-full text-sm table-fixed min-w-[1080px]">
<colgroup>
<col class="w-9" /> <!-- select -->
<col /> <!-- identity (flex) -->
<col class="w-20" /> <!-- type -->
<col class="w-24" /> <!-- remaining -->
<col class="w-16" /> <!-- weight -->
<col class="w-16" /> <!-- concurrency -->
<col class="w-32" /> <!-- reset -->
<col class="w-28" /> <!-- created -->
<col class="w-28" /> <!-- last used -->
<col class="w-40" /> <!-- inflight/success/fail -->
<col class="w-16" /> <!-- status switch -->
<col class="w-32" /> <!-- actions -->
</colgroup>
<thead>
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
<th class="text-center px-3 py-3 font-medium">
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
class="chk" title="全选" />
</th>
<th class="text-left px-5 py-3 font-medium">账户</th>
<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-center px-3 py-3 font-medium">权重</th>
<th class="text-center 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-left px-3 py-3 font-medium">最后使用</th>
<th class="text-center px-3 py-3 font-medium">在途 / 成功 / 失败</th>
<th class="text-left px-3 py-3 font-medium">状态</th>
<th class="text-right px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="a in pagedItems" :key="a.pool + '/' + a.id"
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"
:class="a.dead && 'dead-row'">
<!-- select -->
<td class="px-3 py-3.5 align-middle text-center">
<input type="checkbox" :checked="selected.has(a.id)" @change="toggleSelect(a.id)" @click.stop
class="chk" />
</td>
<!-- identity -->
<td class="px-5 py-3.5 align-middle">
<!-- email + per-kind quota markers on one line. Both-limited shows as
额度耗尽 in the status column, so here we only surface the single
case. -->
<div class="flex items-center gap-2 min-w-0">
<span class="text-sm text-white/90 truncate" :title="a.email || '-'">{{ a.email || '-' }}</span>
<!-- 身份互斥:子号必然是会员号,所以 子号 > 母号 > 普号 只取一个。
plan 未知(还没查过额度)时不猜,先不显示。 -->
<span v-if="a.sub_account" class="acct-tag acct-tag-sub"
:title="'子号 · 会员 (剩余 ' + (a.remaining ?? '—') + ' 积分)'">子号</span>
<span v-else-if="a.plan && a.plan !== 'free'" class="acct-tag acct-tag-master"
:title="'会员号 (' + a.plan + ')'">会员号</span>
<span v-else-if="a.plan === 'free'" class="acct-tag acct-tag-free"
title="普号 (无会员)">普号</span>
<span v-if="a.image_limited && a.status !== 'quota' && a.plan !== 'free'" class="acct-tag acct-tag-limit"
title="图片额度耗尽,仅视频可用">图片限额</span>
<span v-if="a.video_limited && a.status !== 'quota' && a.plan !== 'free'" class="acct-tag acct-tag-limit"
title="视频额度耗尽,仅图片可用">视频限额</span>
</div>
</td>
<!-- type -->
<td class="px-3 py-3.5 align-middle">
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
:class="typePill(a.type)">{{ a.type }}</span>
</td>
<!-- remaining -->
<td class="px-3 py-3.5 align-middle text-right text-sm tabular-nums whitespace-nowrap">
<!-- quota column: 数字 / — (never "未知"/"失败"/"检测中") -->
<!-- remaining === -1 is the provider "unlimited" sentinel → show — not a scary red -1 -->
<!-- grok 额度是本地写死的两份计数:图 / 视频,用完即判死,没有恢复时间 -->
<span v-if="a.type === 'grok' && a.image_remaining != null"
class="font-mono font-semibold" title="图片剩余 / 视频剩余">
<span :class="a.image_remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.image_remaining }}</span>
<span class="text-white/20">/</span>
<span :class="a.video_remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.video_remaining }}</span>
</span>
<span v-else-if="(a.type === 'openai' || a.type === 'adobe' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine' || a.type === 'creativefabrica') && a.remaining != null && a.remaining !== -1"
class="font-mono font-semibold"
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}</span>
<span v-else class="text-white/25" :title="a._quotaError || ''">—</span>
</td>
<!-- weight (edit via modal) -->
<td class="px-3 py-3.5 align-middle text-center whitespace-nowrap text-xs tabular-nums text-white/70">
{{ a.weight ?? 0 }}
</td>
<!-- concurrency (custom = configured value; others = system fixed) -->
<td class="px-3 py-3.5 align-middle text-center whitespace-nowrap text-xs tabular-nums">
<span v-if="a.type === 'custom'" class="text-white/70">{{ acctConcurrency(a) }}</span>
<span v-else class="text-white/25" title="系统固定">{{ acctConcurrency(a) }}</span>
</td>
<!-- reset_after -->
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
<div v-if="a.reset_after" class="leading-tight" :title="fmtIso(a.reset_after)">
<div class="text-white/65 tabular-nums">{{ fmtDate(a.reset_after) }}</div>
<div class="text-white/35 tabular-nums">{{ fmtClock(a.reset_after) }}</div>
</div>
<span v-else class="text-white/25">—</span>
</td>
<!-- created_at -->
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
<div class="leading-tight" :title="fmtTs(a.created_at)">
<div class="text-white/65 tabular-nums">{{ fmtDate(a.created_at) }}</div>
<div class="text-white/35 tabular-nums">{{ fmtClock(a.created_at) }}</div>
</div>
</td>
<!-- last_used_at -->
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
<div v-if="a.last_used_at" class="leading-tight" :title="fmtTs(a.last_used_at)">
<div class="text-white/65 tabular-nums">{{ fmtDate(a.last_used_at) }}</div>
<div class="text-white/35 tabular-nums">{{ fmtClock(a.last_used_at) }}</div>
</div>
<span v-else class="text-white/25">从未</span>
</td>
<!-- inflight / success / fail -->
<td class="px-3 py-3.5 align-middle">
<div class="flex items-center justify-center gap-1.5 text-xs tabular-nums">
<span class="px-1.5 py-0.5 rounded"
:class="a.in_flight ? 'bg-sky-500/15 text-sky-300 font-semibold' : 'text-white/25'"
title="在途">{{ a.in_flight || 0 }}</span>
<span class="text-white/20">/</span>
<span class="px-1.5 py-0.5 rounded text-emerald-300 font-medium" title="成功">{{ a.success_total || 0 }}</span>
<span class="text-white/20">/</span>
<span class="px-1.5 py-0.5 rounded"
:class="a.fail_total ? 'bg-rose-500/15 text-rose-300 font-medium' : 'text-white/25'"
title="失败">{{ a.fail_total || 0 }}</span>
</div>
</td>
<!-- status (switch) -->
<td class="px-3 py-3.5 align-middle">
<button class="sw"
:class="{ 'sw-on': a.status === 'active', 'sw-dead': a.dead, 'sw-pending': a.status === 'pending', 'sw-quota': a.status === 'quota', 'sw-locked': a.dead || a.status === 'pending' || a.status === 'quota' }"
:disabled="a.dead || a.status === 'pending' || a.status === 'quota'"
:aria-pressed="a.status === 'active'"
:title="a.status === 'pending' ? '正在检测额度…(暂不调度)' : (a.dead ? '号已失效(401) · 已锁定(删除后重新导入有效令牌)' : (a.status === 'quota' ? '额度耗尽 · 已锁定,到恢复时间自动解开' : (a.status === 'active' ? '点击禁用' : '点击启用')))"
@click="!(a.dead || a.status === 'pending' || a.status === 'quota') && toggleAccountStatus(a.pool, a.id, a.status)">
<span class="sw-thumb"></span>
</button>
</td>
<!-- actions -->
<td class="px-3 py-3.5 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<button @click="testAccount(a)" class="act" title="生图测试">
<Icon name="spark" class="w-3.5 h-3.5" />
</button>
<button v-if="a.type !== 'custom'" @click="editAccount(a)" class="act" title="编辑">
<Icon name="config" class="w-3.5 h-3.5" />
</button>
<button v-if="a.type === 'custom'" @click="editUpstream(a)" class="act" title="编辑上游">
<Icon name="config" class="w-3.5 h-3.5" />
</button>
<button @click="deleteAccount(a.pool, a.id)" class="act danger" title="删除">
<Icon name="trash" class="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
<!-- pagination -->
<div v-if="!loading && totalPages > 1"
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
<div>
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
<span class="ml-1">/ {{ total }} 条</span>
</div>
<div class="flex items-center gap-1">
<template v-for="(n, i) in pageNumbers" :key="i">
<span v-if="n === null" class="px-1 text-white/35">…</span>
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
</template>
</div>
</div>
</div>
<ImportModal v-if="showImport" @close="showImport = false" @imported="loadAccounts" />
<UpstreamModal v-if="showUpstream" :account="editingUpstream" @close="showUpstream = false; editingUpstream = null" @imported="loadAccounts" />
<AccountEditModal v-if="editingAccount" :account="editingAccount" @saved="applyEdit" @close="editingAccount = null" />
<AccountTestModal v-if="testingAccount" :account="testingAccount" :all-models="allModels" @close="testingAccount = null" />
</section>
</template>
<style scoped>
/* --- filter pills (mirrors LogsView/UsersView/ModelsView) */
.fp {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.35rem 0.7rem; font-size: 0.72rem;
border-radius: 0.55rem;
color: rgb(255 255 255 / 0.65);
background: rgb(255 255 255 / 0.05);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
.fp-emerald {
background: rgb(16 185 129 / 0.22);
color: rgb(110 231 183);
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
}
.fp-rose {
background: rgb(244 63 94 / 0.22);
color: rgb(253 164 175);
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
}
.fp-amber {
background: rgb(245 158 11 / 0.22);
color: rgb(253 224 71);
box-shadow: inset 0 0 0 1px rgb(253 224 71 / 0.4);
}
.fp-orange {
background: rgb(249 115 22 / 0.22);
color: rgb(253 186 116);
box-shadow: inset 0 0 0 1px rgb(253 186 116 / 0.45);
}
.fp-sky {
background: rgb(56 189 248 / 0.22);
color: rgb(125 211 252);
box-shadow: inset 0 0 0 1px rgb(125 211 252 / 0.45);
}
.fp-teal {
background: rgb(20 184 166 / 0.22);
color: rgb(94 234 212);
box-shadow: inset 0 0 0 1px rgb(94 234 212 / 0.45);
}
.fp-pink {
background: rgb(244 114 182 / 0.22);
color: rgb(249 168 212);
box-shadow: inset 0 0 0 1px rgb(249 168 212 / 0.45);
}
.fp-slate {
background: rgb(148 163 184 / 0.22);
color: rgb(203 213 225);
box-shadow: inset 0 0 0 1px rgb(203 213 225 / 0.4);
}
/* --- icon-only action buttons */
.act {
display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem;
border-radius: 0.5rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s, color 0.15s;
}
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
.act.danger {
color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
}
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
/* toolbar 「删除异常账号」按钮 — rose 变体 */
.btn-soft.danger {
color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
}
.btn-soft.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
/* iOS-style switch (mirrors UsersView/ModelsView) */
.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)); }
/* dead account (401) — red, thumb stays left */
.sw-dead {
background: rgb(244 63 94 / 0.8);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.6);
}
/* pending (import quota probe in flight) — neutral sky, thumb stays left */
.sw-pending {
background: rgb(14 165 233 / 0.45);
box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.4);
}
/* quota exhausted — amber (NOT red/dead), thumb stays left, locked until reset */
.sw-quota {
background: rgb(245 158 11 / 0.5);
box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.45);
}
/* dead / pending toggle is locked — can't be flipped */
.sw-locked { cursor: not-allowed; }
/* tint the whole row so a dead account is obvious at a glance */
.dead-row { background: rgb(244 63 94 / 0.07); }
.dead-row:hover { background: rgb(244 63 94 / 0.12); }
/* --- numbered pagination buttons */
.pg {
min-width: 1.75rem;
padding: 0.3rem 0.55rem;
font-size: 0.72rem;
font-weight: 500;
text-align: center;
border-radius: 0.45rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s, color 0.15s;
}
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
</style>
+362
View File
@@ -0,0 +1,362 @@
<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('')
// create/edit form
const form = ref(null)
const saving = ref(false)
const formErr = ref('')
// fetch-models → pick → import
const fetchTarget = ref(null) // channel snapshot being imported into
const fetching = ref(false)
const fetchErr = ref('')
const specs = ref([])
const picked = ref(new Set())
const importing = ref(false)
const managedIds = ref(new Set())
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
}
async function loadManaged() {
const r = await api('/managed-models')
managedIds.value = new Set((r.data?.data || []).map((m) => m.id))
}
function modelSummary(c) {
const names = c.models || []
if (!names.length) return ''
return names.length <= 2 ? names.join('、') : `${names[0]}、${names[1]} 等`
}
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 = ''
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 }
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 || '状态更新失败' }
}
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)
}
// 表单内拉模型:编辑态走库内渠道(密钥不用重填);新渠道用表单里的地址+key 预览。
function fetchFromForm() {
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 }
openFetch({ id: f.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 || '',
}))
fetching.value = false
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', imported: managedIds.value.has(s.id) }))
picked.value = new Set(specs.value.filter((s) => !s.imported).map((s) => s.id))
if (!picked.value.size) 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
}
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(' · ') || '-'
}
async function doImport() {
if (importing.value || !picked.value.size) return
const models = specs.value
.filter((s) => picked.value.has(s.id))
.map((s) => {
const { imported, ...rest } = s
return rest
})
const target = fetchTarget.value
importing.value = true
try {
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 }
target.id = sr.data?.id
form.value = null
}
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models }))
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
fetchTarget.value = null
load()
loadManaged()
} finally {
importing.value = false
}
}
onMounted(() => { load(); loadManaged() })
</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>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="tbl">
<thead>
<tr>
<th>渠道</th>
<th>上游地址</th>
<th>模型</th>
<th>并发</th>
<th>配比</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>
<div class="max-w-[240px] truncate font-mono text-xs text-white/55" :title="c.base_url">{{ c.base_url }}</div>
</td>
<td>
<div class="tabular-nums text-white/80">{{ (c.models || []).length }}</div>
<div class="text-[11px] text-white/35 max-w-[180px] truncate" :title="(c.models || []).join('、')">{{ modelSummary(c) }}</div>
</td>
<td class="tabular-nums">{{ c.concurrency || '默认' }}</td>
<td class="tabular-nums">{{ c.weight || 1 }}</td>
<td class="text-right tabular-nums">{{ (c.success_total || 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="100" class="field" />
<p class="mt-1 text-[11px] text-white/35">0 = 默认 5</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>
<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">
<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>
<div v-else-if="fetchErr" class="py-10 text-center text-rose-300 text-sm">{{ fetchErr }}</div>
<template v-else>
<p class="text-xs text-white/45 mb-3">勾选要导入的模型:能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名);已导入的模型自动标记(重复导入会刷新参数)。</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="togglePick(s.id)"
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="!picked.has(s.id) && 'opacity-50'">
<input type="checkbox" :checked="picked.has(s.id)" @click.stop @change="togglePick(s.id)" class="mt-1 accent-sky-400" />
<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.imported" 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="!picked.has(s.id)"
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="留空 = 与上游同名" />
</div>
</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 ? '导入中…' : (fetchTarget?.id ? `导入选中(${picked.size})` : `保存渠道并导入(${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); }
</style>
+107 -252
View File
@@ -1,7 +1,6 @@
<script setup>
// API 对接文档 — OpenAI-compatible. Lists live models and shows ready-to-run
// curl / Python(openai SDK) examples for image + video, wired to this
// deployment's base URL and the caller's model ids.
// API 对接文档 — Vivid 协议(/v1/generate NDJSON 长连接)。列出在售模型
// 与计费档位,示例代码直接可跑。
import { ref, computed, onMounted } from 'vue'
import { auth } from '../auth'
import { api } from '../api'
@@ -10,7 +9,7 @@ import { points } from '../credits'
import { mediaCaps, presetMap } from '../videoCaps'
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'YOUR_API_KEY')
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'vk_xxxx')
const models = ref([])
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
@@ -31,8 +30,10 @@ const videoModels = computed(() => models.value.filter((m) => m.type === 'video'
function pubName(m) {
return m?.alias || m?.id || ''
}
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'firefly-image-4')
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'firefly-kling3')
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'gpt-image-2')
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'video-model')
const sampleQuality = computed(() => (imageModels.value[0]?.resolutions || [])[0] || '1K')
const sampleVQuality = computed(() => (videoModels.value[0]?.resolutions || [])[0] || '720p')
const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, ''))
function modeEmpty(m) {
@@ -61,183 +62,118 @@ function priceEmpty(m) {
if (perSecondPrice(m) != null) return false
return !(m.durations || []).some((d) => durPrice(m, d) != null)
}
// 视频时长展示:固定/离散档列出来;区间档(duration_min/max)显示 min–max s。
function durText(m) {
const ds = m.durations || []
if (ds.length) return ds.join('/')
if ((m.duration_max || 0) > 0) return `${m.duration_min}–${m.duration_max}s`
return ''
}
// ---- request parameter tables ----
const imageParams = [
['model', 'string', '必填', '模型名(别名优先),见上表(图像)'],
['prompt', 'string', '必填', '文字描述'],
['size', 'string', '可选', '宽x高,如 "1024x1024"。同时决定「比例」+「分辨率档」(按长边)。具体怎么填见下方对照表;留空 = 1:1 · 2K'],
]
const editParams = [
['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'],
['prompt', 'string', '必填', '编辑/参考描述'],
['model', 'string', '必填', '模型名(别名优先,需支持图生图)'],
['size', 'string', '可选', '同图像:决定比例 + 分辨率档(见下方对照表)'],
]
const videoParams = [
['model', 'string', '必填', '模型名(别名优先),见上表(视频)'],
['prompt', 'string', '必填', '文字描述'],
['seconds', 'string|int', '必填', '时长秒数,如 "5" "8"(取决于模型支持)'],
['size', 'string', '可选', '如 "1280x720" / "720x1280" → 决定比例与分辨率'],
['input_reference', 'file', '可选', '参考图/首尾帧(multipart 文件上传;多张时重复 input_reference[] 字段)。runway 图生视频必填 1 张,张数上限见上方模型表'],
['reference_mode', 'string', '可选', '参考图用途:"frame"=首尾帧(最多 2 张,第 1 张为首帧、第 2 张为尾帧,只传 1 张即仅首帧),"asset"=普通参考图。留空用模型默认;仅支持单一模式的模型不可传'],
const generateParams = [
['model', 'string', '必填', '模型名(别名优先),见上表 /v1/models 的 id'],
['prompt', 'string', '必填', '文本描述'],
['quality', 'string', '可选', '清晰度档位,取该模型 qualities 之一(图像如 1K/2K/4K,视频如 720p/1080p);不传 = 该模型最低档'],
['ratio', 'string', '可选', '宽高比,如 "16:9";不传 = 类型默认'],
['duration', 'int64', '可选', '视频时长(秒);range/options 模型必填,图片忽略'],
['refs', 'array<string>', '可选', '参考素材 key 数组:先 POST /v1/uploads 拿预签名直传,PUT 上传后把返回的 key 放这里。30 分钟内有效,张数上限见模型表'],
['jobId', 'string', '可选', '重连模式:流意外断开后只带 jobId 原样重发,不创建新任务、不重复扣费;已完成直接回终态帧'],
]
// ---- size → 比例 × 分辨率档 对照表(用 size 该传的值)----
// size 的长边映射档位:<1800→1K · 1800–3499→2K · ≥3500→4K;宽高比映射比例。
const sizeTable = [
{ ratio: '1:1 · 方', k1: '1024x1024', k2: '2048x2048', k4: '4096x4096' },
{ ratio: '5:4 · 横', k1: '1280x1024', k2: '2560x2048', k4: '3840x3072' },
{ ratio: '4:3 · 横', k1: '1024x768', k2: '2048x1536', k4: '4096x3072' },
{ ratio: '3:2 · 横', k1: '1200x800', k2: '2400x1600', k4: '3600x2400' },
{ ratio: '16:9 · 横', k1: '1280x720', k2: '2048x1152', k4: '4096x2304' },
{ ratio: '2:1 · 横', k1: '1440x720', k2: '2880x1440', k4: '4096x2048' },
{ ratio: '21:9 · 超宽', k1: '1680x720', k2: '2520x1080', k4: '5040x2160' },
{ ratio: '3:1 · 超宽', k1: '1536x512', k2: '2304x768', k4: '3840x1280' },
{ ratio: '4:1 · 超宽', k1: '1728x432', k2: '2880x720', k4: '4096x1024' },
{ ratio: '8:1 · 超宽', k1: '1728x216', k2: '2880x360', k4: '4096x512' },
{ ratio: '4:5 · 竖', k1: '1024x1280', k2: '2048x2560', k4: '3072x3840' },
{ ratio: '3:4 · 竖', k1: '768x1024', k2: '1536x2048', k4: '3072x4096' },
{ ratio: '2:3 · 竖', k1: '800x1200', k2: '1600x2400', k4: '2400x3600' },
{ ratio: '9:16 · 竖', k1: '720x1280', k2: '1152x2048', k4: '2304x4096' },
{ ratio: '1:3 · 竖', k1: '512x1536', k2: '768x2304', k4: '1280x3840' },
{ ratio: '1:4 · 竖', k1: '432x1728', k2: '720x2880', k4: '1024x4096' },
{ ratio: '1:8 · 竖', k1: '216x1728', k2: '360x2880', k4: '512x4096' },
]
// ---- 视频 size → 比例 × 分辨率(720p / 1080p)----
// 视频按「短边」判档:短边 <1080 → 720p,≥1080 → 1080p;宽高比映射比例。
const videoSizeTable = [
{ ratio: '16:9 · 横', p720: '1280x720', p1080: '1920x1080' },
{ ratio: '9:16 · 竖', p720: '720x1280', p1080: '1080x1920' },
{ ratio: '1:1 · 方', p720: '720x720', p1080: '1080x1080' },
{ ratio: '4:3 · 横', p720: '960x720', p1080: '1440x1080' },
{ ratio: '3:4 · 竖', p720: '720x960', p1080: '1080x1440' },
{ ratio: '3:2 · 横', p720: '1080x720', p1080: '1620x1080' },
{ ratio: '2:3 · 竖', p720: '720x1080', p1080: '1080x1620' },
const uploadParams = [
['kind', 'string', '必填', '素材大类:image / video / audio(与文件实际类型一致)'],
['mime', 'string', '必填', '文件 MIME(如 image/png),须与 kind 自洽;PUT 上传的 Content-Type 必须与它一字不差(已签进 URL)'],
]
// ---- examples (built in script so refs resolve correctly) ----
const examples = computed(() => [
{
title: '文生图 · curl',
title: '文生图 · curl (NDJSON 流)',
code:
`curl ${base.value}/v1/images/generations \\
`curl -N ${base.value}/v1/generate \\
-H "Authorization: Bearer ${keyHint.value}" \\
-H "Content-Type: application/json" \\
-d '{
"model": "${sampleImage.value}",
"prompt": "a corgi running in a golden wheat field, cinematic",
"size": "2048x2048"
}'`,
"quality": "${sampleQuality.value}",
"ratio": "1:1"
}'
# 响应是 NDJSON:首行 {"jobId":"...","status":"running"},空行为心跳(忽略),
# 末行终态 {"status":"succeeded","result":"<预签名URL>","creditsCharged":10}`,
},
{
title: '文生图 · Python (openai SDK)',
title: '生图 · Python (流式读终态)',
code:
`import urllib.request
from openai import OpenAI
`import json, requests
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
r = requests.post("${base.value}/v1/generate", stream=True, timeout=600,
headers={"Authorization": "Bearer ${keyHint.value}"},
json={"model": "${sampleImage.value}", "prompt": "a cute cat", "quality": "${sampleQuality.value}"})
resp = client.images.generate(
model="${sampleImage.value}",
prompt="a corgi running in a golden wheat field, cinematic",
size="2048x2048", # 2K · 1:1,见下方对照表
)
# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
job_id, final = None, None
for line in r.iter_lines():
if not line: # 空行 = 心跳,忽略
continue
frame = json.loads(line)
job_id = frame.get("jobId") or job_id
if frame.get("status") in ("succeeded", "failed"):
final = frame
break
# 断流重连:只带 jobId 重发即继续等同一任务(不重复扣费)
# requests.post(..., json={"jobId": job_id})
print(final)`,
},
{
title: '图生图 / 参考图 · curl (multipart)',
title: '参考素材上传 · curl (预签名直传)',
code:
`curl ${base.value}/v1/images/edits \\
`# 1) 申请上传会话 → 拿 {key, url}
resp=$(curl -s ${base.value}/v1/uploads \\
-H "Authorization: Bearer ${keyHint.value}" \\
-F model="${sampleImage.value}" \\
-F prompt="把这张图改成赛博朋克风格" \\
-F size="2048x2048" \\
-F image=@input.png
# 多张参考图:重复 -F image=@a.png -F image=@b.png`,
-H "Content-Type: application/json" \\
-d '{"kind":"image","mime":"image/png"}')
# 2) PUT 直传(Content-Type 必须与申请的 mime 完全一致)
curl -X PUT -H "Content-Type: image/png" --data-binary @input.png "<url>"
# 3) key 放进 /v1/generate 的 refs
curl -N ${base.value}/v1/generate \\
-H "Authorization: Bearer ${keyHint.value}" \\
-H "Content-Type: application/json" \\
-d '{"model":"${sampleImage.value}","prompt":"改成赛博朋克风格","refs":["<key>"]}'`,
},
{
title: '图生图 · Python (openai SDK)',
title: '视频 · curl',
code:
`import urllib.request
from openai import OpenAI
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
resp = client.images.edit(
model="${sampleImage.value}",
image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")]
prompt="把这张图改成赛博朋克风格",
)
# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
},
{
title: '视频 · curl(创建 → 轮询 → 下载)',
code:
`# 1) 创建任务 → 立即返回 {"id": "...", "status": "queued"}
curl ${base.value}/v1/videos \\
`curl -N ${base.value}/v1/generate \\
-H "Authorization: Bearer ${keyHint.value}" \\
-H "Content-Type: application/json" \\
-d '{
"model": "${sampleVideo.value}",
"prompt": "a paper boat sailing down a rainy street, cinematic",
"seconds": "${sampleSeconds.value}",
"size": "1280x720"
"quality": "${sampleVQuality.value}",
"ratio": "16:9",
"duration": ${sampleSeconds.value}
}'
# 2) 轮询状态,直到 status=completed
curl ${base.value}/v1/videos/<VIDEO_ID> \\
-H "Authorization: Bearer ${keyHint.value}"
# 3) 下载 mp4(完成后)
curl ${base.value}/v1/videos/<VIDEO_ID>/content \\
-H "Authorization: Bearer ${keyHint.value}" -o out.mp4`,
},
{
title: '视频 · Python (requests, 轮询)',
code:
`import time, requests
base = "${base.value}/v1"
h = {"Authorization": "Bearer ${keyHint.value}"}
# 1) 创建
job = requests.post(f"{base}/videos", headers=h, json={
"model": "${sampleVideo.value}",
"prompt": "a paper boat sailing down a rainy street",
"seconds": "${sampleSeconds.value}",
"size": "1280x720",
}).json()
vid = job["id"]
# 2) 轮询
while True:
s = requests.get(f"{base}/videos/{vid}", headers=h).json()
if s["status"] in ("completed", "failed"):
break
time.sleep(5)
# 3) 下载
if s["status"] == "completed":
mp4 = requests.get(f"{base}/videos/{vid}/content", headers=h).content
open("out.mp4", "wb").write(mp4)`,
# 与图像同一端点:quality 传分辨率档,duration 传秒数`,
},
{
title: '列出模型 · curl',
code:
`curl ${base.value}/v1/models \\
-H "Authorization: Bearer ${keyHint.value}"`,
-H "Authorization: Bearer ${keyHint.value}"
# => [{"id":"...","kind":"image","qualities":["1K","2K"],"refImages":4}, ...]`,
},
{
title: '查询余额 · curl',
code:
`curl ${base.value}/v1/user/balance \\
`curl ${base.value}/v1/balance \\
-H "Authorization: Bearer ${keyHint.value}"
# => {"object":"user.balance","balance":12000,"used":680,"total":12680}`,
# => {"balance":980}`,
},
])
@@ -255,7 +191,7 @@ async function copy(text) {
<header>
<span class="sticker">✦ 开发者</span>
<h1 class="mt-3 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1>
<p class="text-white/45 mt-2">完全兼容 OpenAI 接口规范 — 改个 <code class="text-white/70">base_url</code> 和 <code class="text-white/70">api_key</code> 即可直接调用。图像 / 视频 / 图生图全支持。</p>
<p class="text-white/45 mt-2">Vivid 协议 — 创建任务后连接保持到任务完成(NDJSON 流),一次性返回终态结果,无需轮询。图像 / 视频 / 参考素材全支持。</p>
</header>
<!-- quickstart -->
@@ -264,10 +200,10 @@ async function copy(text) {
<h2 class="text-sm font-semibold text-white/80">基础信息</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex items-center justify-between gap-3">
<dt class="text-white/45">Base URL</dt><dd class="font-mono text-white/90">{{ base }}/v1</dd>
<dt class="text-white/45">Base URL</dt><dd class="font-mono text-white/90">{{ base }}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-white/45">鉴权</dt><dd class="font-mono text-white/90">Authorization: Bearer &lt;key&gt;</dd>
<dt class="text-white/45">鉴权</dt><dd class="font-mono text-white/90">Authorization: Bearer vk_…</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-white/45">你的 Key</dt><dd class="font-mono text-white/70">{{ keyHint }}</dd>
@@ -279,13 +215,10 @@ async function copy(text) {
<div class="card p-6">
<h2 class="text-sm font-semibold text-white/80">端点</h2>
<ul class="mt-4 space-y-2.5 text-sm font-mono">
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span></li>
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/user/balance</span><span class="text-white/35 font-sans text-xs">查余额</span></li>
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/generations</span><span class="text-white/35 font-sans text-xs">文生图</span></li>
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/edits</span><span class="text-white/35 font-sans text-xs">图生图(multipart)</span></li>
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/videos</span><span class="text-white/35 font-sans text-xs">建视频任务</span></li>
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}</span><span class="text-white/35 font-sans text-xs">查状态</span></li>
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}/content</span><span class="text-white/35 font-sans text-xs">下载 mp4</span></li>
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/generate</span><span class="text-white/35 font-sans text-xs">创建任务(NDJSON 流)</span></li>
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/uploads</span><span class="text-white/35 font-sans text-xs">参考素材上传会话</span></li>
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span><span class="text-white/35 font-sans text-xs">可售模型 + 能力规格</span></li>
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/balance</span><span class="text-white/35 font-sans text-xs">查余额</span></li>
</ul>
</div>
</section>
@@ -323,13 +256,11 @@ async function copy(text) {
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-1 text-[11px]">
<template v-if="m.type === 'image'">
<span v-for="r in (m.resolutions || [])" :key="r" class="cap cap-k">{{ r }}</span>
<template v-for="r in (m.resolutions || [])" :key="r">
<span class="cap cap-k">{{ r }}</span>
</template>
<template v-if="m.type === 'video'">
<span v-for="r in (m.resolutions || [])" :key="r" class="cap cap-k">{{ r }}</span>
<span v-if="(m.durations || []).length >= 2" class="cap cap-dur">{{ m.durations[0] }}–{{ m.durations[m.durations.length - 1] }}</span>
<span v-else-if="(m.durations || []).length === 1" class="cap cap-dur">{{ m.durations[0] }}</span>
<span v-if="durText(m)" class="cap cap-dur">{{ durText(m) }}</span>
</template>
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap cap-ref">参考图 {{ refImages(m) }}</span>
@@ -353,14 +284,14 @@ async function copy(text) {
<!-- parameters -->
<section class="grid lg:grid-cols-2 gap-6">
<div>
<h2 class="text-lg font-semibold mb-3">文生图参数 <span class="text-xs font-normal text-white/40">/v1/images/generations</span></h2>
<h2 class="text-lg font-semibold mb-3">生成参数 <span class="text-xs font-normal text-white/40">POST /v1/generate</span></h2>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
</tr></thead>
<tbody>
<tr v-for="p in imageParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
<tr v-for="p in generateParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
@@ -372,14 +303,14 @@ async function copy(text) {
</div>
<div>
<h2 class="text-lg font-semibold mb-3">图生图参数 <span class="text-xs font-normal text-white/40">/v1/images/edits · multipart</span></h2>
<h2 class="text-lg font-semibold mb-3">上传参数 <span class="text-xs font-normal text-white/40">POST /v1/uploads</span></h2>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
</tr></thead>
<tbody>
<tr v-for="p in editParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
<tr v-for="p in uploadParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
@@ -388,85 +319,13 @@ async function copy(text) {
</tbody>
</table>
</div>
<p class="text-xs text-white/40 mt-3">
用法:POST 拿 <code class="text-white/70">url</code> → PUT 直传 → 返回的 <code class="text-white/70">key</code> 放进
<code class="text-white/70">/v1/generate</code> 的 <code class="text-white/70">refs</code>。
预签名 URL 仅用于 PUT(不可 GET 读回),30 分钟内有效。
支持:图 png/jpeg/webp,视频 mp4/webm/mov,音频 mp3/wav/m4a/ogg。
</p>
</div>
<div class="lg:col-span-2">
<h2 class="text-lg font-semibold mb-3">视频参数 <span class="text-xs font-normal text-white/40">/v1/videos · 异步</span></h2>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
</tr></thead>
<tbody>
<tr v-for="p in videoParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- size 对照表(课时表)—— 解决"传错分辨率" -->
<section>
<h2 class="text-lg font-semibold mb-1">图像分辨率对照表 · <code class="text-white/70 text-sm">size</code> 该传什么</h2>
<p class="text-xs text-white/45 mb-3">
左边选比例,上面选分辨率档,交叉格里就是 <code class="text-white/70">size</code> 要传的值(直接复制)。
没有 <code class="text-white/70">quality</code> 参数,图像分辨率只看 <code class="text-white/70">size</code> 的<strong class="text-white/70">长边</strong>。
档位必须是该模型支持的(见上方「可用模型」的分辨率列),不支持会自动回退到该模型最低档。
</p>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">比例</th>
<th class="px-4 py-2.5 font-medium">1K</th>
<th class="px-4 py-2.5 font-medium">2K</th>
<th class="px-4 py-2.5 font-medium">4K</th>
</tr></thead>
<tbody>
<tr v-for="row in sizeTable" :key="row.ratio" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-2.5 text-white/75">{{ row.ratio }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k1 }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k2 }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k4 }}</td>
</tr>
</tbody>
</table>
</div>
<p class="text-xs text-white/40 mt-2">
例:想要 <strong class="text-white/70">2K 的 16:9 横图</strong> → <code class="text-white/70">"size": "2048x1152"</code>。
留空 size = 默认 <strong class="text-white/70">1:1 · 2K</strong>。
</p>
<!-- 视频分辨率(720p / 1080p,按短边判) -->
<h2 class="text-lg font-semibold mb-1 mt-8">视频分辨率对照表 · <code class="text-white/70 text-sm">size</code> 该传什么</h2>
<p class="text-xs text-white/45 mb-3">
视频用 <code class="text-white/70">720p</code> / <code class="text-white/70">1080p</code> 两档,只看 <code class="text-white/70">size</code> 的<strong class="text-white/70">短边</strong>(短边 ≥1080 = 1080p,否则 720p)。
档位必须是该视频模型支持的(如 grok-video 仅 720p),不支持会被拒。
</p>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">比例</th>
<th class="px-4 py-2.5 font-medium">720p</th>
<th class="px-4 py-2.5 font-medium">1080p</th>
</tr></thead>
<tbody>
<tr v-for="row in videoSizeTable" :key="row.ratio" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-2.5 text-white/75">{{ row.ratio }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.p720 }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.p1080 }}</td>
</tr>
</tbody>
</table>
</div>
<p class="text-xs text-white/40 mt-2">
例:想要 <strong class="text-white/70">720p 的 16:9 横版视频</strong> → <code class="text-white/70">"size": "1280x720"</code>;
竖版 9:16 → <code class="text-white/70">"720x1280"</code>。
</p>
</section>
<!-- examples -->
@@ -488,23 +347,19 @@ async function copy(text) {
<section>
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
<div class="card p-6 space-y-3 text-sm text-white/70">
<p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "url": "..." }] }' }}</code> —— <code class="text-white/85 font-mono">data[0].url</code> 是产物 URL,服务端不留存(<strong class="text-white/90">不返回 base64</strong>)。多数模型返回上游<strong class="text-white/90">原始直链</strong>;少数上游需鉴权的(如 gpt-image)会返回一个本站转发链 <code class="text-white/85 font-mono">/v1/images/{id}/content</code>,由服务端带账号凭据取回。<strong class="text-white/90">两种链接都会过期</strong>,请<strong class="text-white/90">尽快下载或转存到你自己的存储</strong>。</p>
<p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1">
<li><code class="text-white/85 font-mono">POST /v1/videos</code> 立即返回任务对象 <code class="text-white/85 font-mono">{{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}</code></li>
<li>轮询 <code class="text-white/85 font-mono">GET /v1/videos/{id}</code>,<code class="text-white/70">status</code> 从 <code class="text-white/70">queued → in_progress → completed</code>(或 <code class="text-white/70">failed</code>)</li>
<li>完成后 <code class="text-white/85 font-mono">GET /v1/videos/{id}/content</code> 返回 <strong class="text-white/90">mp4 原始二进制</strong>(非 base64、非 URL)</li>
</ol>
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;图像或视频上游失败会自动退回 —— 失败不扣费。</p>
<p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>(宽x高)同时决定<strong class="text-white/90">比例 + 分辨率档</strong>(长边:&lt;1800→1K · 1800–3499→2K · ≥3500→4K),<code class="text-white/70">seconds</code>→视频时长。<strong class="text-white/90">没有 quality 参数</strong>,分辨率只看 size。档位须是该模型支持的(不支持会回退到该模型最低档);参数须落在定价表内否则 400,余额不足 402。</p>
<p><strong class="text-white/90">NDJSON 流</strong>(<code class="text-white/85 font-mono">application/x-ndjson</code>):连接建立即回执首行
<code class="text-white/85 font-mono">{{ '{"jobId":"…","status":"running"}' }}</code>;
等待期间每 25 秒一个空行心跳(请忽略);末行为终态
<code class="text-white/85 font-mono">succeeded</code> / <code class="text-white/85 font-mono">failed</code> —— 读最后一行 JSON 即结果。</p>
<p><strong class="text-white/90">断流重连</strong>:流意外断开时,只带 <code class="text-white/85 font-mono">jobId</code> 重发同一请求即可重连续等 —— 不会重复创建任务、不重复扣费;已完成直接回终态帧。</p>
<p><strong class="text-white/90">产物 URL</strong>:<code class="text-white/85 font-mono">result</code> 是只读预签名 URL(30 分钟),过期即焚,请及时下载转存。任务类型即文件类型。</p>
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;终态 <code class="text-white/85 font-mono">failed</code> 时 <code class="text-white/85 font-mono">refunded:true</code>,预扣积分自动退回 —— 失败不扣费。</p>
<div class="pt-2 grid sm:grid-cols-2 gap-2 text-xs">
<div class="flex items-center gap-2"><span class="badge-err">401</span> Key 无效 / 上游需重新授权</div>
<div class="flex items-center gap-2"><span class="badge-err">404</span> 未知 model / 视频任务不存在</div>
<div class="flex items-center gap-2"><span class="badge-err">400</span> 参数缺失 / 不支持或未定价</div>
<div class="flex items-center gap-2"><span class="badge-err">402</span> 积分不足</div>
<div class="flex items-center gap-2"><span class="badge-err">409</span> 视频尚未完成(content 未就绪)</div>
<div class="flex items-center gap-2"><span class="badge-err">429</span> 账号并发已满,请重试</div>
<div class="flex items-center gap-2"><span class="badge-err">503</span> 上游繁忙,请重试</div>
<div class="flex items-center gap-2"><span class="badge-err">4001</span> 参数/业务拒绝:模型不可用、quality 不在白名单、refs 超限等</div>
<div class="flex items-center gap-2"><span class="badge-err">4002</span> 积分不足,请先充值</div>
<div class="flex items-center gap-2"><span class="badge-err">4011</span> API Key 无效或未授权</div>
<div class="flex items-center gap-2"><span class="badge-err">4293</span> 并发任务数已达上限 / 上游繁忙,请重试</div>
<div class="flex items-center gap-2"><span class="badge-err">5001</span> 服务异常,请稍后重试</div>
</div>
</div>
</section>
+1 -1
View File
@@ -241,7 +241,7 @@ const sourcePill = (s) => ({
<th class="text-center px-4 py-3 font-medium">预览</th>
<th class="text-left px-4 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-left 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-left px-3 py-3 font-medium">参数</th>
+6 -13
View File
@@ -3,7 +3,6 @@ import { ref, computed, onMounted } from 'vue'
import { api } from '../api'
import Icon from '../components/Icon.vue'
import ModelFormModal from '../components/ModelFormModal.vue'
import CustomModelModal from '../components/CustomModelModal.vue'
import TestModal from '../components/TestModal.vue'
import { points } from '../credits'
import { mediaCaps, presetMap } from '../videoCaps'
@@ -11,7 +10,6 @@ import { mediaCaps, presetMap } from '../videoCaps'
const models = ref([])
const loading = ref(false)
const showForm = ref(false)
const showCustom = ref(false)
const editing = ref(null) // null = add, object = edit
const testing = ref(null) // model being tested, or null
@@ -41,7 +39,6 @@ async function loadModels() {
loading.value = false
}
function openAdd() { editing.value = null; showForm.value = true }
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
function onSaved() { showForm.value = false; loadModels() }
@@ -79,7 +76,7 @@ const filtered = computed(() => {
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 (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.provider || '').toLowerCase().includes(q))) return false
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q))) return false
return true
})
})
@@ -132,12 +129,9 @@ onMounted(loadModels)
<button @click="loadModels" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
<button @click="openAdd" class="btn-primary">
<Icon name="plus" class="w-3.5 h-3.5" /> 新增模型
</button>
<button @click="showCustom = true" class="btn-soft">
<Icon name="plus" class="w-3.5 h-3.5" /> 自定义模型
</button>
<router-link to="/admin/channels" class="btn-primary inline-flex items-center gap-1">
<Icon name="plug" class="w-3.5 h-3.5" /> 渠道导入模型
</router-link>
</div>
<!-- Table -->
@@ -145,7 +139,7 @@ onMounted(loadModels)
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span>
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,点右上角「新增模型」' }}</span>
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,先到「渠道管理」拉取上游模型导入' }}</span>
</div>
<table v-else class="w-full text-sm table-fixed">
@@ -180,7 +174,7 @@ onMounted(loadModels)
<div class="font-mono text-xs text-white/90 truncate" :title="m.id">{{ m.id }}</div>
<span v-if="m.alias" class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/20 shrink-0">{{ m.alias }}</span>
</div>
<div class="mt-1 text-[10px] text-white/45 capitalize truncate">{{ m.provider || '—' }}</div>
<div class="mt-1 text-[10px] text-white/45 truncate">{{ m.provider === 'channel' ? '渠道导入' : (m.provider || '—') }}</div>
</td>
<!-- Type chip with the same shape as Logs/Provider 健康 -->
@@ -303,7 +297,6 @@ onMounted(loadModels)
</div>
<ModelFormModal v-if="showForm" :model="editing" @close="showForm = false" @saved="onSaved" />
<CustomModelModal v-if="showCustom" @close="showCustom = false" @saved="() => { showCustom = false; loadModels() }" />
<TestModal v-if="testing" :model="testing" @close="testing = null" />
</section>
</template>
+19 -21
View File
@@ -75,12 +75,12 @@ const cdk = computed(() => dash.value?.cdk || {})
const invites = computed(() => dash.value?.invites || {})
const checkin = computed(() => dash.value?.checkin || {})
// ---- token / model / provider summaries (from /providers + /managed-models) ----
// ---- channel / model summaries (from /providers — now a channel summary — + /managed-models) ----
const tokens = computed(() => {
let active = 0, total = 0
for (const p of providers.value) {
active += p.tokens_active || 0
total += (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
total += 1
if (p.enabled) active += 1
}
return { active, total }
})
@@ -93,20 +93,19 @@ const modelTypes = computed(() => {
const providerHealth = computed(() =>
providers.value.map((p) => {
const total = (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
let status = 'down'
if ((p.tokens_active || 0) > 0) status = 'healthy'
else if (total > 0) status = 'warning'
return { ...p, status, total }
if (p.enabled && (p.model_count || 0) > 0) status = 'healthy'
else if (p.enabled) status = 'warning'
return { ...p, status }
})
)
// One-glance system health badge derived from provider token availability.
// One-glance system health badge derived from channel availability.
const overallHealth = computed(() => {
const list = providerHealth.value
if (!list.length) return { label: '未配置 Provider', tone: 'down' }
if (list.some((p) => p.status === 'down')) return { label: 'Provider 异常', tone: 'down' }
if (list.some((p) => p.status === 'warning')) return { label: 'Provider 告警', tone: 'warning' }
if (!list.length) return { label: '未配置渠道', tone: 'down' }
if (list.some((p) => p.status === 'down')) return { label: '渠道停用', tone: 'down' }
if (list.some((p) => p.status === 'warning')) return { label: '渠道未绑定模型', tone: 'warning' }
return { label: '系统健康', tone: 'healthy' }
})
@@ -272,13 +271,13 @@ onUnmounted(() => clearInterval(timer))
</div>
</div>
<div class="card p-4">
<div class="text-xs text-white/55">Token</div>
<div class="text-xs text-white/55">渠道</div>
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
<span class="text-emerald-300">{{ tokens.active }}</span>
<span class="text-white/30">/</span>
<span>{{ tokens.total }}</span>
</div>
<div class="text-[11px] text-white/45 mt-1">活跃 / 已配置</div>
<div class="text-[11px] text-white/45 mt-1">启用 / 已配置</div>
</div>
<div class="card p-4">
<div class="text-xs text-white/55">模型</div>
@@ -353,7 +352,7 @@ onUnmounted(() => clearInterval(timer))
<div class="grid lg:grid-cols-2 gap-3">
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">Provider 健康</h2>
<h2 class="text-sm font-semibold">渠道健康</h2>
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="statusPill(overallHealth.tone)">
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
@@ -361,16 +360,15 @@ onUnmounted(() => clearInterval(timer))
</span>
</div>
<div class="p-3">
<div v-if="!providerHealth.length" class="text-center text-xs text-white/40 py-8">未注册 Provider</div>
<div v-if="!providerHealth.length" class="text-center text-xs text-white/40 py-8">未配置渠道</div>
<div v-else class="space-y-0.5">
<div v-for="p in providerHealth" :key="p.name"
<div v-for="p in providerHealth" :key="p.id"
class="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04] transition-colors">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium capitalize truncate">{{ p.name }}</div>
<div class="text-[11px] text-white/45 mt-0.5">
{{ p.model_count }} 模型 · token {{ p.tokens_active }}/{{ p.total || 0 }} 活跃
<span v-if="p.tokens_quota"> · {{ p.tokens_quota }} 限额</span>
<span v-if="p.tokens_disabled"> · {{ p.tokens_disabled }} 停用</span>
<div class="text-sm font-medium truncate">{{ p.name }}</div>
<div class="text-[11px] text-white/45 mt-0.5 truncate">
{{ p.model_count }} 模型 · 成功 {{ p.success_total || 0 }}
<span v-if="p.upstream_balance != null"> · 余额 {{ p.upstream_balance }}</span>
</div>
</div>
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 tabular-nums"
+11 -14
View File
@@ -143,11 +143,8 @@ const maxAudiosRaw = computed(() => {
const m = Number(model.value?.max_reference_audios || 0)
return m > 0 ? m : null
})
// creativefabrica 上游只收图片参考,没有首尾帧/视频/音频,虽叫 seedance 也要排除。
// custom 上游模型 id 也可以 seedance 开头(管理员自定义命名),它们的上限
// 由模型字段显式声明,不走 seedance 默认 3/3。
const isSeedanceModel = computed(() =>
/^seedance/.test(model.value?.id || '') && !['creativefabrica', 'custom'].includes(model.value?.provider || ''))
// seedance 系兜底 3/3 参考上限(模型字段显式声明的上限优先,见 maxVideosRaw)。
const isSeedanceModel = computed(() => /^seedance/.test(model.value?.id || ''))
// 支持图片以外的参考资产(视频/音频)的模型:seedance 系 + 预设声明了音视频上限的
const supportsMediaRefs = computed(() =>
isSeedanceModel.value || maxVideosRaw.value > 0 || maxAudiosRaw.value > 0)
@@ -260,17 +257,17 @@ const refKindsLabel = computed(() => {
// 模型没填 = 0,不再兜底成 3。
const maxVideos = computed(() => (maxVideosRaw.value === null ? (isSeedanceModel.value ? 3 : 0) : maxVideosRaw.value))
const maxAudios = computed(() => (maxAudiosRaw.value === null ? (isSeedanceModel.value ? 3 : 0) : maxAudiosRaw.value))
// Leonardo 私有 seedance:单个视频参考 3-10 秒,总时长须短于 15 秒。
// 服务端同样校验,这里只是提交前先拦一下,避免白跑一次生成。
// 参考视频/音频累计秒数上限(渠道导入的能力规格 max_video_seconds /
// max_audio_seconds)。服务端同样校验,这里只是提交前先拦一下。
function videoRefDurationError() {
if (mode.value !== 'video' || familyPreset.value?.provider !== 'leonardo') return ''
const vids = refImages.value.filter((r) => r.fileType === 'video')
for (const v of vids) {
if (v.duration && (v.duration < 3 || v.duration > 10))
return `单个视频参考需 3-10 秒:${v.name} 为 ${v.duration.toFixed(1)} 秒`
if (mode.value !== 'video') return ''
const videoLimit = Number(familyPreset.value?.max_video_seconds) || 0
if (videoLimit > 0) {
const vTotal = refImages.value
.filter((r) => r.fileType === 'video')
.reduce((n, v) => n + (v.duration || 0), 0)
if (vTotal > videoLimit) return `视频参考总时长 ${vTotal.toFixed(1)} 秒,最多 ${videoLimit} 秒`
}
const vTotal = vids.reduce((n, v) => n + (v.duration || 0), 0)
if (vids.length && vTotal >= 15) return `视频参考总时长 ${vTotal.toFixed(1)} 秒,需短于 15 秒`
const audioLimit = Number(familyPreset.value?.max_audio_seconds) || 0
if (audioLimit > 0) {
const aTotal = refImages.value
+1 -1
View File
@@ -41,7 +41,7 @@ async function loadKey() {
onMounted(loadKey)
// Uniform mask for the hidden / persisted state — always "***" so the just-minted
// preview and the server-stored preview read the same (no sk-xxx…yyyy mismatch).
// preview and the server-stored preview read the same (no vk_xxx…yyyy mismatch).
const maskedKey = computed(() => (apiKey.value || keyPreview.value) ? '***' : '')
async function generateKey() {