Initial open-source release (MIT): image2api AI gateway

Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider
account pools, billing/admin, Docker one-command deploy with auto HTTPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 22:59:04 +08:00
co-authored by Claude Opus 4.8
commit 606caaf047
142 changed files with 33648 additions and 0 deletions
+399
View File
@@ -0,0 +1,399 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { api } from '../api'
import Icon from '../components/Icon.vue'
import ModelFormModal from '../components/ModelFormModal.vue'
import TestModal from '../components/TestModal.vue'
import { points } from '../credits'
const models = ref([])
const loading = ref(false)
const showForm = ref(false)
const editing = ref(null) // null = add, object = edit
const testing = ref(null) // model being tested, or null
const kindFilter = ref('') // '' | 'image' | 'video'
const statusFilter = ref('') // '' | 'enabled' | 'disabled'
const search = ref('')
const TYPE_LABEL = { image: '生图', video: '生视频' }
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
async function loadModels() {
loading.value = true
const r = await api('/managed-models')
models.value = r.data?.data || []
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() }
async function toggleEnabled(m) {
// Optimistic: flip the switch instantly, persist in the background, revert on
// failure. Avoids the lag of awaiting the PATCH + a full table reload before
// the toggle visibly moves.
const cur = m.enabled !== false
const next = !cur
m.enabled = next
const r = await api(`/managed-models/${encodeURIComponent(m.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: next }),
})
if (!r.ok) m.enabled = cur
}
async function remove(m) {
if (!confirm(`确认删除模型 ${m.id}?`)) return
await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' })
loadModels()
}
const stats = computed(() => {
const total = models.value.length
const image = models.value.filter((m) => m.type === 'image').length
const video = models.value.filter((m) => m.type === 'video').length
const enabled = models.value.filter((m) => m.enabled !== false).length
return { total, image, video, enabled, disabled: total - enabled }
})
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
return models.value.filter((m) => {
if (kindFilter.value && m.type !== kindFilter.value) return false
if (statusFilter.value === 'enabled' && m.enabled === false) return false
if (statusFilter.value === 'disabled' && m.enabled !== false) return false
if (q && !(m.id.toLowerCase().includes(q) || (m.provider || '').toLowerCase().includes(q))) return false
return true
})
})
onMounted(loadModels)
</script>
<template>
<section class="space-y-4">
<!-- KPI strip — same shape as LogsView / InvitesAdminView -->
<div class="grid grid-cols-2 md:grid-cols-4 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>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-indigo-300/80">图像</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-indigo-300">{{ stats.image }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">视频</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.video }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">启用</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.enabled }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
</div>
</div>
<!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1">
<button @click="kindFilter = ''" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button>
<button @click="kindFilter = 'image'" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button>
<button @click="kindFilter = 'video'" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="statusFilter = ''" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
<button @click="statusFilter = 'enabled'" class="fp" :class="statusFilter === 'enabled' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>启用
</button>
<button @click="statusFilter = 'disabled'" class="fp" :class="statusFilter === 'disabled' && 'fp-white'">
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>停用
</button>
</div>
<div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / Provider…" />
</div>
<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>
</div>
<!-- Table -->
<div class="card overflow-hidden">
<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>
</div>
<table v-else class="w-full text-sm table-fixed">
<colgroup>
<col /> <!-- model id + provider -->
<col class="w-20" /> <!-- type -->
<col /> <!-- pricing -->
<col /> <!-- capability -->
<col class="w-20" /> <!-- weight -->
<col class="w-24" /> <!-- generation count -->
<col class="w-20" /> <!-- status switch -->
<col class="w-36" /> <!-- actions -->
</colgroup>
<thead>
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
<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-left 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>
<th class="text-right px-3 py-3 font-medium">生图次数</th>
<th class="text-left px-3 py-3 font-medium">状态</th>
<th class="text-right px-5 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="m in filtered" :key="m.id"
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
<!-- Model id + provider underneath -->
<td class="px-5 py-3.5 align-middle min-w-0">
<div class="font-mono text-xs text-white/90 truncate" :title="m.id">{{ m.id }}</div>
<div class="mt-1 text-[10px] text-white/45 capitalize truncate">{{ m.provider || '—' }}</div>
</td>
<!-- Type chip with the same shape as Logs/Provider 健康 -->
<td class="px-3 py-3.5 align-middle">
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="m.type === 'video'
? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30'
: 'bg-indigo-500/10 text-indigo-300 ring-indigo-400/30'">
{{ TYPE_LABEL[m.type] || m.type }}
</span>
</td>
<!-- Pricing block — image shows 1K/2K/4K + price, video shows
duration + price; each as a small price-chip. -->
<td class="px-3 py-3.5 align-middle">
<!-- Each tier shows a pair: 普通价 (emerald) + 代理价 (amber).
代理价未设置时回退普通价数值。 -->
<div v-if="m.type === 'image'" class="flex flex-wrap gap-1">
<span v-for="r in (m.resolutions || [])" :key="r" class="price-chip">
<span class="text-white/85">{{ r }}</span>
<span class="text-white/30 mx-1">普通</span>
<span class="text-emerald-300 tabular-nums">{{ points(m.prices?.[r]) }}</span>
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span>
</span>
<span v-if="!(m.resolutions || []).length" class="text-white/30 text-xs">—</span>
</div>
<div v-else class="flex flex-wrap gap-1">
<!-- video charge = resolution price + duration price; both show 普通/代理 -->
<span v-for="r in (m.resolutions || [])" :key="'r'+r" class="price-chip">
<span class="text-white/85">{{ r }}</span>
<span class="text-white/30 mx-1">普通</span>
<span class="text-emerald-300 tabular-nums">{{ points(m.prices?.[r]) }}</span>
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span>
</span>
<span v-for="d in (m.durations || [])" :key="'d'+d" class="price-chip">
<span class="text-white/85">{{ d }}</span>
<span class="text-white/30 mx-1">普通</span>
<span class="text-sky-300 tabular-nums">+{{ points(m.duration_prices?.[d]) }}</span>
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">+{{ points(m.duration_prices_agent?.[d] ?? m.duration_prices?.[d]) }}</span>
</span>
<span v-if="!(m.resolutions || []).length && !(m.durations || []).length" class="text-white/30 text-xs">—</span>
</div>
</td>
<!-- Capability — extras that aren't a price: image-to-image,
video frame mode, supported resolutions for video. -->
<td class="px-3 py-3.5 align-middle">
<div class="flex flex-wrap items-center gap-1 text-[11px]">
<span v-if="m.type === 'image' && m.image_to_image"
class="cap-chip cap-emerald">图生图</span>
<!-- 参考图 is a capability like 图生图 — same emerald style, shown first -->
<span v-if="m.type === 'video' && m.max_reference_images > 0"
class="cap-chip cap-emerald"
:title="REF_MODE_LABEL[m.reference_mode]">参考图 {{ m.max_reference_images }}</span>
<span v-if="m.type === 'video'" v-for="r in (m.resolutions || [])" :key="'vr'+r"
class="cap-chip cap-slate">{{ r }}</span>
<span v-if="(m.type === 'image' && !(m.ratios || []).length && !m.image_to_image) ||
(m.type === 'video' && !(m.resolutions || []).length && !m.max_reference_images)"
class="text-white/30 text-xs">—</span>
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap-chip cap-mono">{{ r }}</span>
</div>
</td>
<!-- Display weight — higher floats to the top of the dropdown/list -->
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
:class="(m.weight || 0) !== 0 ? 'text-white/85' : 'text-white/30'"
title="展示权重(越大越靠前)">
{{ m.weight || 0 }}
</td>
<!-- Successful generations to date, from event_log via /managed-models -->
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
:class="m.generation_count > 0 ? 'text-white/85' : 'text-white/25'">
{{ (m.generation_count || 0).toLocaleString('en-US') }}
</td>
<!-- Status as a real toggle so admins read it as on/off, not a
button to delete or whatever. -->
<td class="px-3 py-3.5 align-middle">
<button class="sw" :class="m.enabled !== false && 'sw-on'"
:aria-pressed="m.enabled !== false" @click="toggleEnabled(m)">
<span class="sw-thumb"></span>
</button>
</td>
<!-- Actions — small soft buttons, danger variant for delete -->
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
<div class="inline-flex items-center gap-1">
<button @click="testing = m" class="act" title="测试生成">
<Icon name="test" class="w-3.5 h-3.5" />
</button>
<button @click="openEdit(m)" class="act" title="编辑">
<Icon name="config" class="w-3.5 h-3.5" />
</button>
<button @click="remove(m)" class="act danger" title="删除">
<Icon name="trash" class="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<ModelFormModal v-if="showForm" :model="editing" @close="showForm = false" @saved="onSaved" />
<TestModal v-if="testing" :model="testing" @close="testing = null" />
</section>
</template>
<style scoped>
/* --- filter pills (mirrors LogsView so the admin shell stays consistent) */
.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-white {
background: rgb(255 255 255 / 0.18);
color: white;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.35);
}
/* --- price chips: tight pill showing a key (resolution or duration) plus
its emerald-tinted price. */
.price-chip {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem;
font-size: 0.7rem;
border-radius: 9999px;
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
white-space: nowrap;
}
/* --- capability chips: smaller, monochrome variants */
.cap-chip {
display: inline-flex; align-items: center;
padding: 0.15rem 0.5rem;
font-size: 0.68rem; font-weight: 500;
border-radius: 9999px;
white-space: nowrap;
}
.cap-emerald {
background: rgb(16 185 129 / 0.12);
color: rgb(110 231 183);
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.3);
}
.cap-amber {
background: rgb(245 158 11 / 0.12);
color: rgb(252 211 77);
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.3);
}
.cap-slate {
background: rgb(255 255 255 / 0.05);
color: rgb(255 255 255 / 0.7);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
}
.cap-mono {
background: transparent;
color: rgb(255 255 255 / 0.5);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
}
/* --- iOS-style toggle for 启用/停用 */
.sw {
position: relative;
width: 2.25rem; height: 1.3rem;
border-radius: 9999px;
background: rgb(255 255 255 / 0.12);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.18s ease;
}
.sw-thumb {
position: absolute;
top: 2px; left: 2px;
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
border-radius: 9999px;
background: white;
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
transition: transform 0.18s ease;
}
.sw-on { background: rgb(16 185 129 / 0.7); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5); }
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
/* --- danger variant for 删除 (kept for any other .btn-soft callers) */
.btn-soft.danger {
color: rgb(253 164 175);
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);
}
/* --- compact square icon button for the actions column. Predictable
width (1.9rem × 3 + gaps) keeps the row inside its 9rem slot, so
the last button never gets clipped. */
.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, box-shadow 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);
}
</style>