feat: 二次元前端改版 + 后端账号plan探测/调度修复

This commit is contained in:
2026-08-06 10:11:05 +08:00
parent 7f4701d362
commit f76ecd5769
37 changed files with 2484 additions and 813 deletions
+569 -270
View File
@@ -4,7 +4,7 @@ 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'
import Mascot from '../components/Mascot.vue'
const router = useRouter()
@@ -13,12 +13,12 @@ const router = useRouter()
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)
openLogin(router.resolve(target).fullPath)
}
const stats = ref({ generated_count: 0, recent: [] })
const managed = ref([]) // managed model records (provider, type, ...)
const showcase = ref({ hero: [], bento: [] })
const managed = ref([]) // managed model records (type, resolutions, ...)
const showcase = ref({ hero: [], bento: [], work: [] })
// 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.
@@ -35,8 +35,8 @@ 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,
// No /logs here — that endpoint requires a login and only returns the
// caller's own entries. The 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'),
@@ -45,10 +45,7 @@ async function refresh() {
])
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.
showcase.value = sc.data?.data || { hero: [], bento: [], work: [] }
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))
@@ -58,55 +55,127 @@ async function refresh() {
onMounted(() => { refresh(); timer = setInterval(refresh, 30000) })
onUnmounted(() => clearInterval(timer))
// ---- KPI strip — three signals derived from real data ----
// ---- showcase groups: all three kinds are used ----
const bento = computed(() => showcase.value.bento || [])
const works = computed(() => showcase.value.work || [])
// hero + bento imagery, flattened into one strip for the scrolling gallery
// marquee under the hero. Duplicated inline in the template so the loop reads
// seamlessly when it wraps around.
const gallery = computed(() => {
const seen = new Set()
const out = []
for (const c of [...(showcase.value.hero || []), ...bento.value]) {
if (c && !seen.has(c.id)) { seen.add(c.id); out.push(c) }
}
return out
})
// ---- KPI strip — real signals only ----
const imageModels = computed(() => managed.value.filter((m) => m.type !== 'video'))
const videoModels = computed(() => managed.value.filter((m) => m.type === 'video'))
const modelCount = computed(() => managed.value.length)
// Resolution range across image models, e.g. "1K4K" (or a single tier).
const RES_ORDER = ['1K', '2K', '4K']
const resRange = computed(() => {
const seen = new Set()
for (const m of imageModels.value) for (const r of m.resolutions || []) seen.add(r)
const tiers = RES_ORDER.filter((r) => seen.has(r))
if (!tiers.length) return '—'
return tiers.length === 1 ? tiers[0] : `${tiers[0]}${tiers[tiers.length - 1]}`
})
// 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'
return Math.round(avgElapsed.value / 1000) + '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)
// ---- capability cards, derived from the enabled models ----
function refs(m) { return m.image_to_image === true || Number(m.max_reference_images || 0) > 0 }
const durationRange = computed(() => {
const secs = []
for (const m of videoModels.value) {
for (const d of m.durations || []) {
const n = parseInt(String(d), 10)
if (Number.isFinite(n)) secs.push(n)
}
}
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))
if (!secs.length) return ''
const lo = Math.min(...secs), hi = Math.max(...secs)
return lo === hi ? `${lo}s` : `${lo}${hi}s`
})
const videoRes = computed(() => {
const seen = new Set()
for (const m of videoModels.value) for (const r of m.resolutions || []) seen.add(r)
return [...seen].sort().join(' / ')
})
// Each card deep-links into the workbench with ?model=<id> (the only intent
// PlaygroundView reads besides ?prompt), picking the first model that actually
// offers that capability — a card with no model behind it is dropped.
const caps = computed(() => {
const i2iModels = imageModels.value.filter(refs)
const i2vModels = videoModels.value.filter(refs)
return [
{
key: 't2i', title: '文字生图', model: imageModels.value[0]?.id,
desc: '一句话出图,画幅与分辨率按所选模型的可用范围给出。',
tint: 'rgb(52 211 153 / 0.14)', color: 'rgb(52 211 153)',
metas: [resRange.value, `${imageModels.value.length} 个模型`].filter(Boolean),
},
{
key: 'i2i', title: '参考图生图', model: i2iModels[0]?.id,
desc: '上传参考图换风格、改构图、做变体,支持多张参考。',
tint: 'rgb(217 70 239 / 0.14)', color: 'rgb(232 121 249)',
metas: ['多图参考', `${i2iModels.length} 个模型`],
},
{
key: 't2v', title: '文字生视频', model: videoModels.value[0]?.id,
desc: '一段描述生成短片,时长与分辨率按模型能力可选。',
tint: 'rgb(14 165 233 / 0.14)', color: 'rgb(56 189 248)',
metas: [durationRange.value, videoRes.value, `${videoModels.value.length} 个模型`].filter(Boolean),
},
{
key: 'i2v', title: '图生视频', model: i2vModels[0]?.id,
desc: '给参考图或首帧,让静态画面动起来。',
tint: 'rgb(245 158 11 / 0.14)', color: 'rgb(251 191 36)',
metas: ['参考图', `${i2vModels.length} 个模型`],
},
].filter((c) => c.model)
})
// 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 || [])
// ---- model cards: the admin-set alias wins, so no upstream naming leaks ----
const MODEL_TINT = ['#a78bfa', '#f0abfc', '#fb7185', '#fbbf24', '#38bdf8', '#34d399', '#94a3b8']
const modelCards = computed(() => managed.value.map((m, i) => {
const bits = []
if (m.resolutions?.length) bits.push(m.resolutions.join(' / '))
if (m.type === 'video' && m.durations?.length) {
const lo = parseInt(m.durations[0], 10)
const hi = parseInt(m.durations[m.durations.length - 1], 10)
bits.push(lo === hi ? `${lo}s` : `${lo}${hi}s`)
}
if (m.type !== 'video') bits.push(refs(m) ? '支持参考图' : '文字生图')
return {
id: m.id,
name: m.alias || m.name || m.id,
spec: bits.join(' · '),
video: m.type === 'video',
glow: MODEL_TINT[i % MODEL_TINT.length],
}
}))
// Resolve an image reference: external URLs pass through, relative paths
// (like "user/abc.png") are served from /generated by the backend.
// (like "user/abc.png") are served from /images 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.
// background-image (not <img>) keeps Edge's 视觉搜索 overlay off the artwork.
function cardBg(card) {
if (card.image) {
return {
@@ -118,287 +187,517 @@ function cardBg(card) {
return { background: card.gradient }
}
const prompt = ref('')
function submitPrompt() {
const p = prompt.value.trim()
go('/user', p ? { prompt: p } : null)
}
function useExample(ex) {
go('/user', { prompt: ex.prompt })
go('/user', ex.prompt ? { prompt: ex.prompt } : null)
}
</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>
<div class="px-4 md:px-8">
<!-- ============ HERO (centered, mascot-forward) ============ -->
<section class="hero-band relative overflow-hidden pt-16 md:pt-24 pb-10">
<div class="anime-stars"></div>
<div class="relative flex flex-col items-center text-center">
<!-- mascot centerpiece with soft aura -->
<div class="relative mb-6">
<span class="mascot-aura"></span>
<Mascot :size="184" class="relative" />
</div>
<span class="sticker">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_rgb(52_211_153)]"></span>
{{ modelCount }} 个模型在线 · 图像与视频一站生成
</span>
<h1 class="mt-5 font-extrabold tracking-tight leading-[1.05] text-[clamp(2.6rem,6vw,4.6rem)] text-[color:var(--fg)]">
把想象 <span class="anime-grad-text">说给 AI </span>
</h1>
<p class="mt-8 text-base md:text-lg text-[color:var(--fg-2)] max-w-md leading-relaxed">
{{ site.subtitle || '把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。' }}
<p class="mt-5 text-[15px] md:text-base text-[color:var(--fg-2)] max-w-xl leading-relaxed">
写一句话剩下的交给我们自动选模型自动排队图像与视频都在同一个工作台里出片
</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>
<!-- prompt composer the shortest path into the workbench -->
<form class="composer mt-8 !max-w-2xl w-full" @submit.prevent="submitPrompt">
<input v-model="prompt" class="composer-input" type="text"
placeholder="描述你想要的画面,比如:雨夜的东京小巷,霓虹倒映在湿滑路面…" />
<button type="submit" class="composer-go"> 生成</button>
</form>
<!-- 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')">
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
<div :style="{ backgroundImage: `url(${imgSrc(w.image)})` }"
class="w-full h-full bg-cover bg-center"></div>
<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>
<!-- quick starters come from the curated bento set -->
<div v-if="bento.length" class="mt-4 flex flex-wrap justify-center gap-2 max-w-2xl">
<button v-for="ex in bento.slice(0, 5)" :key="ex.id" type="button" class="chip"
@click="useExample(ex)">{{ ex.title }}</button>
</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>
<!-- ============ GALLERY MARQUEE (hero + bento imagery) ============ -->
<section v-if="gallery.length" class="mt-2 -mx-4 md:-mx-8 overflow-hidden marquee-mask">
<div class="marquee flex gap-4 px-4 md:px-8 w-max">
<button v-for="(card, i) in [...gallery, ...gallery]" :key="card.id + '-' + i" type="button"
class="gcard" :style="cardBg(card)" :aria-label="card.title" @click="useExample(card)">
<span class="tile-veil"></span>
<span class="tile-cap">
<i>{{ card.subtitle }}</i>
<b>{{ card.title }}</b>
</span>
</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>
<!-- ============ KPI STRIP ============ -->
<section class="mt-12 grid grid-cols-2 md:grid-cols-4 gap-3">
<div v-for="k in [
{ v: modelCount, l: '在线模型', e: '🧩' },
{ v: caps.length, l: '创作方式', e: '🎨' },
{ v: resRange, l: '输出分辨率', e: '🖼️' },
{ v: avgLabel, l: '平均出片 · 近 24h', e: '⚡' },
]" :key="k.l"
class="anime-card px-5 py-4">
<div class="flex items-center gap-2">
<span class="text-lg">{{ k.e }}</span>
<span class="text-[26px] font-bold tabular-nums text-[color:var(--fg)]">{{ k.v }}</span>
</div>
<div class="mt-1.5 text-[10px] uppercase tracking-[0.18em] text-[color:var(--fg-3)]">{{ k.l }}</div>
</div>
</section>
<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>
<!-- ============ CAPABILITIES ============ -->
<section class="mt-24">
<div class="anime-eyebrow text-violet-500 dark:text-violet-300/80">能力</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">
{{ caps.length }} 种方式开始创作
</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
分辨率与时长直接读取模型配置,页面上写的就是你实际能选的。
</p>
<div class="mt-8 grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
<button v-for="c in caps" :key="c.key" type="button" class="cap-card"
@click="go('/user', { model: c.model })">
<span class="cap-arrow">↗</span>
<span class="cap-icon" :style="{ background: c.tint, color: c.color }">
<svg v-if="c.key === 't2i'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5h16v14H4z"/><path d="M4 15l4.5-4.5 3 3L15 10l5 5"/><circle cx="9" cy="9" r="1.3"/></svg>
<svg v-else-if="c.key === 'i2i'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 7h11v11H3z"/><path d="M10 4h11v11"/></svg>
<svg v-else-if="c.key === 't2v'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h13v12H3z"/><path d="M16 10l5-3v10l-5-3"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5h16v14H4z"/><path d="M9 9l6 3-6 3z"/></svg>
</span>
<span class="block text-[15.5px] font-bold text-[color:var(--fg)]">{{ c.title }}</span>
<span class="mt-2 block text-[12.5px] leading-relaxed text-[color:var(--fg-3)]">{{ c.desc }}</span>
<span class="mt-3.5 flex flex-wrap gap-1.5">
<i v-for="mt in c.metas" :key="mt" class="cap-meta">{{ mt }}</i>
</span>
</button>
</div>
</section>
<!-- ============ INSPIRATION (kind=bento) ============ -->
<section v-if="bento.length" id="bento" class="mt-24">
<div class="anime-eyebrow text-fuchsia-500 dark:text-fuchsia-300/80">灵感</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">点一下,套用同款提示词</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
任选一张,自动进入画图工作台并预填提示词。
</p>
<div class="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 grid-flow-row-dense gap-3 auto-rows-[170px]">
<button v-for="ex in bento" :key="ex.id" type="button" class="tile" :class="ex.span"
:style="cardBg(ex)" @click="useExample(ex)">
<span class="tile-veil"></span>
<span class="tile-cap">
<i>{{ ex.subtitle }}</i>
<b>{{ ex.title }}</b>
<em>{{ ex.prompt }}</em>
</span>
</button>
</div>
</section>
<!-- ============ WORKS (kind=work) ============ -->
<section v-if="works.length" class="mt-24">
<div class="anime-eyebrow text-sky-500 dark:text-sky-300/80">作品</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">大家都在生成什么</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
精选公开作品,点开即用同一条提示词继续创作。
</p>
<div class="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<button v-for="w in works" :key="w.id" type="button" class="work" @click="useExample(w)">
<span class="work-img" :style="cardBg(w)"></span>
<span class="work-overlay">
<b>{{ w.title }}</b>
<em>{{ w.prompt }}</em>
<i class="work-reuse">套用提示词</i>
</span>
</button>
</div>
</section>
<!-- ============ MODELS ============ -->
<section v-if="modelCards.length" class="mt-24">
<div class="anime-eyebrow text-emerald-500 dark:text-emerald-300/80">模型</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">在线模型</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
每个模型的分辨率、时长与参考图能力都写在卡片上,选之前就知道能做什么。
</p>
<div class="mt-8 flex gap-3 overflow-x-auto pb-2 model-row">
<div v-for="m in modelCards" :key="m.id" class="model-card">
<span class="model-glow" :style="{ background: m.glow }"></span>
<div class="relative text-[15px] font-bold text-[color:var(--fg)]">{{ m.name }}</div>
<div class="relative mt-1.5 text-[11px] text-[color:var(--fg-3)]">{{ m.spec }}</div>
<div class="relative mt-3.5">
<span :class="['model-tag', m.video ? 'is-video' : 'is-image']">{{ m.video ? '视频' : '图像' }}</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> 直接开始
<!-- ============ CTA ============ -->
<section class="mt-24 cta-band relative overflow-hidden rounded-[2rem] ring-1 ring-[color:var(--hairline)] p-10 md:p-16">
<div class="relative max-w-xl">
<h3 class="text-[clamp(1.7rem,3.4vw,2.8rem)] font-extrabold tracking-tight leading-[1.2] text-[color:var(--fg)]">
与其继续想,<br />不如现在写一句。
</h3>
<p class="text-base md:text-lg text-[color:var(--fg-2)] mt-6 max-w-lg leading-relaxed">
登录即可开始打开画图工作台,写一句话, AI 替你完成
<p class="mt-4 text-[15px] text-[color:var(--fg-2)] max-w-md leading-relaxed">
注册即可开始,图像与视频都能试;不满意就换个模型,额度按次计费。
</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 type="button" @click="go('/user')"
class="mt-7 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)]">
<footer class="mt-16 pt-10 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 class="font-semibold text-[color:var(--fg-2)]">{{ site.title }}</span>
<span>·</span>
<span>AI 生图与生视频平台</span>
</div>
<a href="https://github.com/cyi-cc/image2api" target="_blank" rel="noopener" class="font-mono hover:text-[color:var(--fg)] transition-colors">image2api</a>
<div class="flex items-center gap-4">
<router-link to="/docs" class="hover:text-[color:var(--fg)] transition-colors">文档</router-link>
<router-link to="/about" class="hover:text-[color:var(--fg)] transition-colors">关于</router-link>
</div>
</footer>
</div>
</template>
<style scoped>
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* ---------- composer ---------- */
.composer {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 36rem;
padding: 0.5rem;
border-radius: 1.25rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline), 0 20px 50px -22px rgb(139 92 246 / 0.3);
transition: box-shadow 0.25s ease;
}
.composer:focus-within {
box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.55), 0 20px 60px -20px rgb(139 92 246 / 0.45);
}
.composer-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: var(--fg);
font-size: 15px;
padding: 0.7rem 0.85rem;
}
.composer-input::placeholder { color: var(--fg-faint); }
.composer-go {
flex: none;
border: none;
cursor: pointer;
color: #fff;
font-size: 14px;
font-weight: 600;
padding: 0.7rem 1.25rem;
border-radius: 0.9rem;
background: linear-gradient(135deg, #e879f9, #8b5cf6);
transition: transform 0.15s ease, filter 0.15s ease;
}
.composer-go:hover { transform: translateY(-1px); filter: brightness(1.1); }
/* ----- 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); }
.chip {
font-size: 12.5px;
color: var(--fg-2);
cursor: pointer;
border: none;
padding: 0.42rem 0.8rem;
border-radius: 999px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: color 0.15s ease, box-shadow 0.15s ease;
}
@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; }
.chip:hover { color: var(--fg); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.5); }
.marquee-wrap {
/* ---------- hero cards (kind=hero) ---------- */
.fcard {
position: absolute;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.4rem;
overflow: hidden;
box-shadow: 0 30px 70px -24px rgb(0 0 0 / 0.55), inset 0 0 0 1px rgb(255 255 255 / 0.14);
transition: transform 0.45s cubic-bezier(0.2, 0.7, 0.2, 1);
animation: fcardIn 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) backwards;
}
.fcard-1 { width: 58%; height: 72%; top: 4%; right: 0; z-index: 3; transform: rotate(1.5deg); }
.fcard-2 { width: 46%; height: 44%; bottom: 2%; left: 4%; z-index: 2; transform: rotate(-2.5deg); animation-delay: 0.1s; }
.fcard-3 { width: 34%; height: 36%; top: 0; left: 0; z-index: 1; transform: rotate(4deg); opacity: 0.92; animation-delay: 0.2s; }
/* the back card is partly covered — keep only its title legible */
.fcard-3 .fcard-cap em { display: none; }
.fcard:hover { transform: translateY(-8px) rotate(0deg) scale(1.02); z-index: 4; }
@keyframes fcardIn {
from { opacity: 0; transform: translateY(28px) scale(0.95); }
}
.fcard-veil {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.82), transparent 58%);
}
.fcard-cap {
position: absolute;
left: 1rem;
right: 1rem;
bottom: 0.9rem;
text-align: left;
color: rgb(255 255 255 / 0.82);
}
.fcard-cap i { display: block; font-style: normal; font-size: 9.5px; letter-spacing: 0.18em; color: rgb(255 255 255 / 0.6); }
.fcard-cap b { display: block; font-size: 14px; margin-top: 0.2rem; color: #fff; }
.fcard-cap em {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-style: normal;
font-size: 11.5px;
line-height: 1.5;
margin-top: 0.25rem;
}
/* ---------- capability cards ---------- */
.cap-card {
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%);
text-align: left;
cursor: pointer;
border: none;
padding: 1.35rem;
border-radius: 1.25rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.marquee-track {
.cap-card:hover {
transform: translateY(-4px);
box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.45), 0 20px 40px -24px rgb(139 92 246 / 0.45);
}
.cap-arrow { position: absolute; top: 1.1rem; right: 1.1rem; color: var(--fg-3); transition: color 0.15s ease; }
.cap-card:hover .cap-arrow { color: var(--fg); }
.cap-icon {
width: 2.6rem;
height: 2.6rem;
border-radius: 0.85rem;
display: grid;
place-items: center;
margin-bottom: 0.9rem;
}
.cap-icon svg { width: 1.25rem; height: 1.25rem; }
.cap-meta {
font-style: normal;
font-size: 10px;
color: var(--fg-2);
padding: 0.18rem 0.5rem;
border-radius: 0.4rem;
background: var(--surface-2);
}
/* ---------- bento tiles (kind=bento) ---------- */
.tile {
position: relative;
overflow: hidden;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.25rem;
box-shadow: inset 0 0 0 1px var(--hairline);
transition: transform 0.35s ease;
}
.tile:hover { transform: translateY(-3px); }
.tile-veil {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.82) 0%, rgb(0 0 0 / 0.28) 45%, transparent 72%);
}
.tile-cap { position: absolute; left: 1rem; right: 1rem; bottom: 0.9rem; text-align: left; color: #fff; }
.tile-cap i { display: block; font-style: normal; font-size: 9.5px; letter-spacing: 0.2em; color: rgb(255 255 255 / 0.66); }
.tile-cap b { display: block; font-size: 15px; margin-top: 0.2rem; }
.tile-cap em {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-style: normal;
font-size: 11.5px;
line-height: 1.55;
margin-top: 0.3rem;
color: rgb(255 255 255 / 0.72);
}
/* ---------- works (kind=work) ---------- */
.work {
position: relative;
display: block;
width: 100%;
overflow: hidden;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.1rem;
box-shadow: inset 0 0 0 1px var(--hairline);
}
.work-img { display: block; width: 100%; aspect-ratio: 4 / 5; transition: transform 0.5s ease; }
.work:hover .work-img { transform: scale(1.04); }
.work-overlay {
position: absolute;
inset: 0;
display: flex;
gap: 1rem;
width: max-content;
padding: 0 2rem;
animation: marquee 55s linear infinite;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
text-align: left;
padding: 1rem;
background: linear-gradient(to top, rgb(0 0 0 / 0.86) 0%, rgb(0 0 0 / 0.3) 50%, transparent 78%);
opacity: 0;
transition: opacity 0.25s ease;
}
.marquee-wrap:hover .marquee-track { animation-play-state: paused; }
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
.work:hover .work-overlay, .work:focus-visible .work-overlay { opacity: 1; }
/* touch devices have no hover — keep the caption visible there */
@media (hover: none) { .work-overlay { opacity: 1; } }
.work-overlay b { color: #fff; font-size: 14px; }
.work-overlay em {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-style: normal;
font-size: 11.5px;
line-height: 1.6;
margin-top: 0.3rem;
color: rgb(255 255 255 / 0.8);
}
.work-reuse {
margin-top: 0.7rem;
font-style: normal;
font-size: 12px;
font-weight: 600;
color: rgb(11 13 19);
background: #fff;
border-radius: 999px;
padding: 0.4rem 0.8rem;
}
/* ---------- model row ---------- */
.model-row::-webkit-scrollbar { height: 6px; }
.model-row::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 3px; }
.model-card {
position: relative;
flex: none;
width: 14.5rem;
overflow: hidden;
padding: 1.25rem;
border-radius: 1.25rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: box-shadow 0.2s ease;
}
.model-card:hover { box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.42); }
.model-glow {
position: absolute;
top: -2.5rem;
right: -2.5rem;
width: 8rem;
height: 8rem;
border-radius: 50%;
filter: blur(40px);
opacity: 0.3;
}
.model-tag { font-size: 10.5px; padding: 0.18rem 0.55rem; border-radius: 0.4rem; }
.model-tag.is-image { background: rgb(52 211 153 / 0.14); color: rgb(52 211 153); }
.model-tag.is-video { background: rgb(125 211 252 / 0.14); color: rgb(56 189 248); }
/* ---------- hero band + mascot aura ---------- */
.hero-band {
border-radius: 2rem;
}
.mascot-aura {
position: absolute;
left: 50%;
top: 50%;
width: 260px;
height: 260px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: radial-gradient(circle, rgb(217 70 239 / 0.35), rgb(56 189 248 / 0.12) 55%, transparent 72%);
filter: blur(14px);
animation: auraPulse 5s ease-in-out infinite;
pointer-events: none;
}
@keyframes auraPulse {
0%, 100% { opacity: 0.8; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
}
/* ---------- gallery marquee ---------- */
.marquee-mask {
-webkit-mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
}
.marquee {
animation: marqueeScroll 42s linear infinite;
}
.marquee:hover { animation-play-state: paused; }
@keyframes marqueeScroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.gcard {
position: relative;
flex: none;
width: 15rem;
height: 10rem;
overflow: hidden;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.4rem;
background-size: cover;
background-position: center;
box-shadow: inset 0 0 0 1px var(--hairline);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.gcard:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.5), 0 22px 44px -26px rgb(168 85 247 / 0.6);
}
@media (prefers-reduced-motion: reduce) {
.marquee { animation: none; }
.mascot-aura { animation: none; }
}
</style>