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
+402
View File
@@ -0,0 +1,402 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { api, generatedUrl } from '../api'
import { site } from '../site'
import { isAuthed, openLogin } from '../auth'
import Icon from '../components/Icon.vue'
const router = useRouter()
// Navigate to a signed-in page, or pop the login modal (remembering the
// destination) when the visitor isn't logged in yet.
function go(path, query) {
const target = query ? { path, query } : { path }
if (isAuthed()) { router.push(target); return }
openLogin(typeof target === 'string' ? target : router.resolve(target).fullPath)
}
const stats = ref({ generated_count: 0, recent: [] })
const managed = ref([]) // managed model records (provider, type, ...)
const showcase = ref({ hero: [], bento: [] })
// heroDeck holds the top-3 hero cards in a RANDOMIZED order, so a different card
// fronts the deck on each page load. It's reshuffled only when the hero set first
// loads or its members change — not on every 30s poll, so the deck stays put.
const heroDeck = ref([])
function shuffleArr(arr) {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
let timer = null
async function refresh() {
try {
// No /logs here — that endpoint now requires a login and only returns the
// caller's own entries. The avg-latency KPI comes from /stats (aggregate,
// prompt-free) so the public home page exposes nothing per-user.
const [s, m, sc] = await Promise.all([
api('/stats'),
api('/managed-models'),
api('/showcase'),
])
stats.value = s.data || { generated_count: 0, recent: [] }
managed.value = (m.data?.data || []).filter((x) => x.enabled !== false)
showcase.value = sc.data?.data || { hero: [], bento: [] }
// Randomize which hero card fronts the deck: shuffle the top-3 once when the
// set first loads / changes; keep the order stable across refreshes so the
// deck doesn't reshuffle every 30s while the visitor is looking at it.
const top3 = (showcase.value.hero || []).slice(0, 3)
const sameSet = heroDeck.value.length === top3.length
&& top3.every((c) => heroDeck.value.some((d) => d.id === c.id))
if (!sameSet) heroDeck.value = shuffleArr(top3)
} catch {}
}
onMounted(() => { refresh(); timer = setInterval(refresh, 30000) })
onUnmounted(() => clearInterval(timer))
// ---- KPI strip — three signals derived from real data ----
const modelCount = computed(() => managed.value.length)
// Show the 24h average (matches the admin overview); fall back to the all-time
// average on a quiet day so the KPI isn't blank.
const avgElapsed = computed(() => stats.value?.avg_elapsed_ms_24h ?? stats.value?.avg_elapsed_ms ?? null)
const avgLabel = computed(() => {
if (avgElapsed.value == null) return '—'
if (avgElapsed.value < 1000) return avgElapsed.value + 'ms'
return (avgElapsed.value / 1000).toFixed(1) + 's'
})
// ---- “已接入” provider strip, derived from managed models ----
const PROVIDER_TINT = {
adobe: 'from-rose-400 to-orange-400',
chatgpt: 'from-violet-500 to-fuchsia-500',
google: 'from-sky-400 to-indigo-500',
default: 'from-emerald-400 to-teal-500',
}
const providerGroups = computed(() => {
const map = new Map()
for (const m of managed.value) {
const key = (m.provider || 'unknown').toLowerCase()
const g = map.get(key) || { name: m.provider || 'unknown', image: 0, video: 0 }
if (m.type === 'video') g.video++
else g.image++
map.set(key, g)
}
return [...map.entries()].map(([key, g]) => ({
...g,
grad: PROVIDER_TINT[key] || PROVIDER_TINT.default,
})).sort((a, b) => (b.image + b.video) - (a.image + a.video))
})
// Admin-curated "我们的作品" entries from /admin/api/showcase (kind=work),
// already sorted by weight on the server.
const works = computed(() => showcase.value.work || [])
// Hero deck = a random ordering of the top-3 hero entries (see refresh).
const bento = computed(() => showcase.value.bento || [])
// Resolve an image reference: external URLs pass through, relative paths
// (like "user/abc.png") are served from /generated by the backend.
function imgSrc(image) {
if (!image) return ''
return /^https?:\/\//i.test(image) ? image : generatedUrl(image)
}
// Background style for a showcase card. Prefers a real image (the new shape);
// falls back to the legacy CSS gradient so seed entries still render.
function cardBg(card) {
if (card.image) {
return {
backgroundImage: `url("${imgSrc(card.image)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
}
return { background: card.gradient }
}
function useExample(ex) {
go('/user', { prompt: ex.prompt })
}
</script>
<template>
<div class="space-y-28">
<!-- ============ HERO ============ -->
<section class="relative pt-8 md:pt-16 grid lg:grid-cols-[1.15fr_1fr] gap-10 lg:gap-16 items-center min-h-[640px]">
<!-- LEFT: copy -->
<div>
<h1 class="font-bold tracking-tight leading-[0.9] text-[clamp(2.5rem,6.5vw,6.5rem)] text-[color:var(--fg)]">
<span class="block text-[color:var(--fg-3)] font-light italic">Imagine</span>
<span class="block">it,
<span class="bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent italic">type</span>
it,
</span>
<span class="block">own it.</span>
</h1>
<p class="mt-8 text-base md:text-lg text-[color:var(--fg-2)] max-w-md leading-relaxed">
把脑海里的画面写成一句话,GPTGeminiFireflyFlux 等顶级模型替你变成图像与视频
</p>
<div class="mt-10 flex items-center gap-4">
<button @click="go('/user')"
class="group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
开始画图
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">
</span>
</button>
<a href="#bento" class="text-sm text-[color:var(--fg-3)] hover:text-[color:var(--fg)] transition-colors">浏览灵感 </a>
</div>
<!-- counter strip three real signals: models we support, total
outputs ever generated, average wall-clock to produce one. -->
<div class="mt-14 grid grid-cols-3 gap-px rounded-2xl overflow-hidden ring-1 ring-[color:var(--hairline)] max-w-xl" style="background: var(--hairline)">
<div class="bg-[var(--surface)] px-5 py-4">
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ modelCount }}</div>
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已接入模型</div>
</div>
<div class="bg-[var(--surface)] px-5 py-4">
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ stats.generated_count || 0 }}</div>
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已生成作品</div>
</div>
<div class="bg-[var(--surface)] px-5 py-4">
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ avgLabel }}</div>
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">平均出片</div>
</div>
</div>
</div>
<!-- RIGHT: stacked card deck driven by /admin/api/showcase (kind=hero),
top 3 by weight. Position classes are picked by index so existing CSS
transforms in <style> still apply. -->
<div class="relative h-[480px] lg:h-[560px] hero-deck">
<template v-for="(card, i) in heroDeck" :key="card.id">
<!-- back -->
<div v-if="i === 2"
class="deck-card deck-card-3 absolute inset-y-8 right-12 lg:right-20 w-[68%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl"
:style="cardBg(card)">
<div class="absolute inset-0 mix-blend-overlay opacity-25"
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%222%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
<div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div>
<div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div>
</div>
</div>
<!-- middle -->
<div v-if="i === 1"
class="deck-card deck-card-2 absolute inset-y-4 right-4 lg:right-8 w-[72%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl"
:style="cardBg(card)">
<div class="absolute inset-0 mix-blend-overlay opacity-25"
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%223%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
<div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div>
<div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div>
</div>
</div>
<!-- front -->
<div v-if="i === 0"
class="deck-card deck-card-1 absolute inset-y-0 right-0 w-[78%] rounded-3xl overflow-hidden ring-1 ring-white/15 shadow-[0_30px_80px_-20px_rgba(168,85,247,0.45)]"
:style="cardBg(card)">
<div class="absolute inset-0 mix-blend-overlay opacity-30"
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
<div class="absolute top-0 inset-x-0 flex items-center justify-between px-5 py-4">
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
</div>
<div class="text-[10px] uppercase tracking-[0.25em] text-white/60 font-mono">live</div>
</div>
<div class="absolute inset-x-0 bottom-0 p-6 bg-gradient-to-t from-black/90 via-black/40 to-transparent">
<div class="text-[10px] uppercase tracking-[0.3em] text-white/65">{{ card.subtitle }}</div>
<div class="text-2xl font-bold text-white mt-2">{{ card.title }}</div>
<p class="text-xs text-white/70 mt-2 line-clamp-2 leading-relaxed">{{ card.prompt }}</p>
</div>
</div>
</template>
</div>
</section>
<!-- ============ WORKS STRIP ============ -->
<section v-if="works.length" class="-mx-8 md:-mx-14">
<div class="px-8 md:px-14 mb-6">
<h2 class="text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">我们的作品</h2>
</div>
<div class="marquee-wrap">
<div class="marquee-track">
<div v-for="(w, i) in [...works, ...works]" :key="w.id + '-' + i"
class="shrink-0 w-56 h-56 rounded-2xl overflow-hidden ring-1 ring-white/[0.08] hover:ring-white/30 hover:scale-[1.02] transition-all cursor-pointer relative"
@click="go('/logs')">
<img :src="imgSrc(w.image)" loading="lazy"
class="w-full h-full object-cover" />
<div v-if="w.title" class="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
<div class="text-xs font-medium text-white line-clamp-1">{{ w.title }}</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============ BENTO EXAMPLES ============ -->
<section id="bento">
<div class="flex items-end justify-between flex-wrap gap-3 mb-8">
<div>
<div class="text-[10px] uppercase tracking-[0.3em] text-violet-300/70 font-medium">灵感</div>
<h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">从一个起点开始</h2>
<p class="text-[color:var(--fg-3)] mt-2 max-w-md">点任意一张,自动进入画图工作台并预填提示词</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 grid-flow-row-dense gap-4 auto-rows-[200px]">
<button v-for="ex in bento" :key="ex.id" @click="useExample(ex)"
class="group relative text-left overflow-hidden rounded-3xl ring-1 ring-white/[0.06] hover:ring-white/20 transition-all"
:class="ex.span"
:style="cardBg(ex)">
<!-- grain -->
<div class="absolute inset-0 mix-blend-overlay opacity-30"
style="background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
<!-- veil -->
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
<div class="absolute top-5 right-5 w-9 h-9 rounded-xl bg-white/15 backdrop-blur ring-1 ring-white/20 grid place-items-center opacity-0 group-hover:opacity-100 group-hover:scale-100 scale-95 transition-all">
<Icon name="open" class="w-3.5 h-3.5" />
</div>
<div class="absolute inset-x-0 bottom-0 p-5 md:p-6">
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ ex.subtitle }}</div>
<div class="text-xl md:text-2xl font-bold mt-2 text-white">{{ ex.title }}</div>
<div class="text-xs text-white/65 mt-2 line-clamp-2 leading-relaxed">{{ ex.prompt }}</div>
</div>
</button>
</div>
</section>
<!-- ============ MODELS GRID ============ -->
<section>
<div class="flex items-end justify-between flex-wrap gap-3 mb-8">
<div>
<div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">模型</div>
<h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">已接入</h2>
<p class="text-[color:var(--fg-3)] mt-2 max-w-md">由管理员在后台注册;以下为已对接的上游 family</p>
</div>
</div>
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-3">
<div v-for="p in providerGroups" :key="p.name"
class="group relative overflow-hidden rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
<div class="absolute -right-8 -top-8 w-32 h-32 rounded-full bg-gradient-to-br opacity-30 blur-xl group-hover:opacity-50 transition-opacity"
:class="p.grad"></div>
<div class="relative flex items-center justify-between">
<div>
<div class="text-base font-semibold capitalize text-[color:var(--fg)]">{{ p.name }}</div>
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)] mt-1 flex gap-2">
<span v-if="p.image">{{ p.image }} 图像</span>
<span v-if="p.video">{{ p.video }} 视频</span>
</div>
</div>
<span class="w-2.5 h-2.5 rounded-full bg-gradient-to-br" :class="p.grad"></span>
</div>
</div>
<div v-if="!providerGroups.length" class="text-sm text-[color:var(--fg-3)] col-span-full text-center py-10">
管理员尚未接入任何模型
</div>
</div>
</section>
<!-- ============ FOOTER CTA ============ -->
<section class="cta-band relative overflow-hidden rounded-[2rem] ring-1 ring-[color:var(--hairline)] p-12 md:p-20">
<div class="relative max-w-2xl">
<h3 class="text-4xl md:text-6xl font-bold tracking-tight leading-[1.05] text-[color:var(--fg)]">
停下来思考,<br />
<span class="italic font-light text-[color:var(--fg-3)]">不如</span> 直接开始
</h3>
<p class="text-base md:text-lg text-[color:var(--fg-2)] mt-6 max-w-lg leading-relaxed">
登录即可开始打开画图工作台,写一句话, AI 替你完成
</p>
<button @click="go('/user')"
class="mt-10 group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
开始画图
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">
</span>
</button>
</div>
</section>
<!-- thin footer line -->
<footer class="pt-12 border-t border-[color:var(--hairline)] flex flex-wrap items-center justify-between gap-4 text-xs text-[color:var(--fg-3)]">
<div class="flex items-center gap-3">
<span class="font-mono">{{ site.title }}</span>
<span>·</span>
<span>AI 生图与生视频平台</span>
</div>
</footer>
</div>
</template>
<style scoped>
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* ----- Hero stacked card deck ----- */
.hero-deck { perspective: 1200px; }
.deck-card { transform-origin: center bottom; will-change: transform, opacity; }
/* staggered fade-in-up on load, then a livelier continuous float with a gentle
rotation wobble. The :hover rules below still take over to fan the deck out. */
.deck-card-1 { animation: deckIn1 0.8s cubic-bezier(0.2,0.7,0.2,1) backwards, deckFloat1 7s ease-in-out 0.8s infinite; }
.deck-card-2 { animation: deckIn2 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.12s backwards, deckFloat2 9s ease-in-out 0.92s infinite; }
.deck-card-3 { animation: deckIn3 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.24s backwards, deckFloat3 11s ease-in-out 1.04s infinite; opacity: 0.85; }
@keyframes deckFloat1 {
0%,100% { transform: rotate(2deg) translateY(0); }
35% { transform: rotate(0.5deg) translateY(-16px); }
70% { transform: rotate(3deg) translateY(-7px); }
}
@keyframes deckFloat2 {
0%,100% { transform: rotate(-3deg) translateY(0); }
50% { transform: rotate(-1.5deg) translateY(-13px); }
}
@keyframes deckFloat3 {
0%,100% { transform: rotate(5deg) translateY(0); }
50% { transform: rotate(3.5deg) translateY(-10px); }
}
@keyframes deckIn1 {
from { opacity: 0; transform: rotate(2deg) translateY(48px) scale(0.92); }
to { opacity: 1; transform: rotate(2deg) translateY(0) scale(1); }
}
@keyframes deckIn2 {
from { opacity: 0; transform: rotate(-3deg) translateY(48px) scale(0.92); }
to { opacity: 1; transform: rotate(-3deg) translateY(0) scale(1); }
}
@keyframes deckIn3 {
from { opacity: 0; transform: rotate(5deg) translateY(48px) scale(0.92); }
to { opacity: 0.85; transform: rotate(5deg) translateY(0) scale(1); }
}
.hero-deck:hover .deck-card-1 { transform: rotate(0deg) translateY(-12px); transition: transform 0.4s ease; animation: none; }
.hero-deck:hover .deck-card-2 { transform: rotate(-6deg) translate(-30px, 10px); transition: transform 0.4s ease; animation: none; }
.hero-deck:hover .deck-card-3 { transform: rotate(8deg) translate(-50px, 20px); transition: transform 0.4s ease; animation: none; opacity: 0.7; }
.marquee-wrap {
position: relative;
overflow: hidden;
mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%);
}
.marquee-track {
display: flex;
gap: 1rem;
width: max-content;
padding: 0 2rem;
animation: marquee 55s linear infinite;
}
.marquee-wrap:hover .marquee-track { animation-play-state: paused; }
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
</style>