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
+65 -31
View File
@@ -1,67 +1,101 @@
<script setup>
import { computed } from 'vue'
import { site } from '../site'
import Mascot from '../components/Mascot.vue'
const contact = computed(() => site.contact || {})
const powers = [
{ icon: '🎨', title: '文字生图', desc: '一句话出图,画幅与分辨率随模型可选。', tint: 'from-fuchsia-400/20 to-pink-400/10' },
{ icon: '🖼️', title: '参考图生图', desc: '上传参考换风格、改构图、做变体。', tint: 'from-violet-400/20 to-indigo-400/10' },
{ icon: '🎬', title: '文字生视频', desc: '一段描述生成短片,时长分辨率可选。', tint: 'from-sky-400/20 to-cyan-400/10' },
{ icon: '✨', title: '图生视频', desc: '给张参考图,让静态画面动起来。', tint: 'from-amber-400/20 to-orange-400/10' },
]
</script>
<template>
<div class="space-y-12 max-w-3xl">
<!-- intro -->
<section>
<div class="text-[10px] uppercase tracking-[0.3em] text-fuchsia-300/70 font-medium">关于</div>
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight text-[color:var(--fg)]">关于 {{ site.title }}</h1>
<p class="text-[color:var(--fg-2)] mt-5 text-base md:text-lg leading-relaxed max-w-xl">
{{ site.title }} 是一个聚合 GPTGeminiFireflyFlux 等顶级模型的 AI 生图与生视频平台
把脑海里的画面写成一句话,交给我们替你变成图像与视频
</p>
<div class="space-y-16 pb-8">
<!-- ===== hero ===== -->
<section class="relative overflow-hidden rounded-[2rem] anime-card px-6 py-10 md:px-12 md:py-14">
<div class="anime-stars"></div>
<div class="relative grid md:grid-cols-[1.1fr_0.9fr] gap-8 items-center">
<div>
<span class="sticker"> 关于我们</span>
<h1 class="mt-4 text-4xl md:text-5xl font-extrabold tracking-tight text-[color:var(--fg)]">
我是 <span class="anime-grad-text">薇薇</span>
</h1>
<p class="text-[color:var(--fg-2)] mt-5 text-base md:text-lg leading-relaxed max-w-xl">
{{ site.title }} 是一个聚合 GPTGeminiFireflyFlux 等顶级模型的 AI 生图与生视频平台
把脑海里的画面写成一句话交给我替你变成图像与视频
</p>
<router-link to="/user" class="bubble-btn mt-7">开始创作 </router-link>
</div>
<div class="grid place-items-center">
<Mascot :size="240" />
</div>
</div>
</section>
<!-- contact -->
<!-- ===== powers ===== -->
<section>
<h2 class="text-2xl font-bold tracking-tight text-[color:var(--fg)] mb-1">联系我们</h2>
<p class="text-[color:var(--fg-3)] mb-6">有问题合作,或想加入交流群?随时找我们</p>
<div class="anime-eyebrow text-fuchsia-500 dark:text-fuchsia-300">能做什么</div>
<h2 class="mt-3 text-2xl md:text-3xl font-extrabold tracking-tight text-[color:var(--fg)]">四种方式把想象变成画面</h2>
<div class="mt-7 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div v-for="p in powers" :key="p.title"
class="anime-card p-5 bg-gradient-to-br" :class="p.tint">
<div class="text-3xl">{{ p.icon }}</div>
<div class="mt-3 text-base font-bold text-[color:var(--fg)]">{{ p.title }}</div>
<div class="mt-1.5 text-[13px] leading-relaxed text-[color:var(--fg-3)]">{{ p.desc }}</div>
</div>
</div>
</section>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<!-- ===== contact ===== -->
<section>
<div class="anime-eyebrow text-sky-500 dark:text-sky-300">联系我们</div>
<h2 class="mt-3 text-2xl md:text-3xl font-extrabold tracking-tight text-[color:var(--fg)]">有问题来找我们玩</h2>
<p class="text-[color:var(--fg-3)] mt-2 mb-6">合作反馈或想加入交流群随时招呼</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-3xl">
<a v-if="contact.shop" :href="contact.shop" target="_blank" rel="noopener"
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
class="group anime-card flex items-center justify-between gap-3 p-5">
<div>
<div class="text-[10px] uppercase tracking-[0.25em] text-fuchsia-300/80">商店</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 group-hover:text-fuchsia-400 transition-colors">前往商店</div>
<div class="sticker">🛍 商店</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 group-hover:text-fuchsia-400 transition-colors">前往商店</div>
</div>
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform"></span>
</a>
<a v-if="contact.email" :href="`mailto:${contact.email}`"
class="group rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">邮箱</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 break-all group-hover:text-sky-400 transition-colors">{{ contact.email }}</div>
class="group anime-card p-5">
<div class="sticker"> 邮箱</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 break-all group-hover:text-sky-400 transition-colors">{{ contact.email }}</div>
</a>
<a v-if="contact.qq && contact.qq_link" :href="contact.qq_link" target="_blank" rel="noopener"
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
class="group anime-card flex items-center justify-between gap-3 p-5">
<div>
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all group-hover:text-sky-400 transition-colors">{{ contact.qq }}</div>
<div class="sticker">💬 QQ</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all group-hover:text-sky-400 transition-colors">{{ contact.qq }}</div>
</div>
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform"></span>
</a>
<div v-else-if="contact.qq" class="rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5">
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all">{{ contact.qq }}</div>
<div v-else-if="contact.qq" class="anime-card p-5">
<div class="sticker">💬 QQ</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all">{{ contact.qq }}</div>
</div>
<a v-if="contact.qq_group && contact.qq_group_link" :href="contact.qq_group_link" target="_blank" rel="noopener"
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
class="group anime-card flex items-center justify-between gap-3 p-5">
<div>
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ </div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all group-hover:text-emerald-400 transition-colors">{{ contact.qq_group }}</div>
<div class="sticker">🎀 QQ </div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all group-hover:text-emerald-400 transition-colors">{{ contact.qq_group }}</div>
</div>
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform"></span>
</a>
<div v-else-if="contact.qq_group" class="rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5">
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ </div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all">{{ contact.qq_group }}</div>
<div v-else-if="contact.qq_group" class="anime-card p-5">
<div class="sticker">🎀 QQ </div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all">{{ contact.qq_group }}</div>
</div>
</div>
+44 -9
View File
@@ -35,6 +35,7 @@ function applyEdit(payload) {
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)
@@ -70,6 +71,14 @@ function typePill(t) {
imagine: 'bg-teal-500/10 text-teal-300 ring-teal-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
@@ -112,6 +121,7 @@ function buildQs() {
})
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()
}
@@ -250,6 +260,9 @@ function applyQuota(row, result) {
row._unknown = false
row.remaining = result.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) {
@@ -372,6 +385,15 @@ onMounted(() => { loadAccounts(); loadModelList() })
<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-purple'">母号</button>
</div>
</template>
<div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / ID / 类型…" />
</div>
@@ -453,14 +475,17 @@ onMounted(() => { loadAccounts(); loadModelList() })
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>
<span v-if="a.sub_account"
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-sky-500/15 text-sky-300 ring-1 ring-sky-400/20"
:title="'子号 (剩余 ' + (a.remaining ?? '—') + ' 积分)'">子号</span>
<span v-if="a.image_limited && a.status !== 'quota'"
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20"
<!-- 身份互斥子号必然是会员号所以 子号 > 母号 > 普号 只取一个
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'"
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20"
<span v-if="a.video_limited && a.status !== 'quota' && a.plan !== 'free'" class="acct-tag acct-tag-limit"
title="视频额度耗尽,仅图片可用">视频限额</span>
</div>
</td>
@@ -484,8 +509,8 @@ onMounted(() => { loadAccounts(); loadModelList() })
</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">{{ a.concurrency || 1 }}</span>
<span v-else class="text-white/25" title="系统固定">{{ a.type === 'grok' ? 10 : 1 }}</span>
<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">
@@ -622,6 +647,16 @@ onMounted(() => { loadAccounts(); loadModelList() })
color: rgb(94 234 212);
box-shadow: inset 0 0 0 1px rgb(94 234 212 / 0.45);
}
.fp-purple {
background: rgb(168 85 247 / 0.22);
color: rgb(216 180 254);
box-shadow: inset 0 0 0 1px rgb(216 180 254 / 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 {
+90 -14
View File
@@ -5,7 +5,6 @@
import { ref, computed, onMounted } from 'vue'
import { auth } from '../auth'
import { api } from '../api'
import { points } from '../credits'
import Icon from '../components/Icon.vue'
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
@@ -26,6 +25,11 @@ const sampleImage = computed(() => pubName(imageModels.value[0]) || 'firefly-ima
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'firefly-kling3')
const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, ''))
function modeEmpty(m) {
if (m.type === 'image') return !(m.resolutions || []).length && !m.image_to_image && !(m.ratios || []).length
return !(m.resolutions || []).length && !(m.durations || []).length && !m.max_reference_images && !m.id?.startsWith('seedance') && !(m.ratios || []).length
}
function priceOf(m) {
if (m.type === 'video') {
// Video charge = resolution price + duration price; show the combined range.
@@ -59,7 +63,8 @@ const videoParams = [
['prompt', 'string', '必填', '文字描述'],
['seconds', 'string|int', '必填', '时长秒数,如 "5" "8"(取决于模型支持)'],
['size', 'string', '可选', '如 "1280x720" / "720x1280" → 决定比例与分辨率'],
['input_reference', 'file', '可选', '首帧/参考图(multipart 文件;runway 图生视频必填 1 张)'],
['input_reference', 'file', '可选', '参考图/首尾帧(multipart 文件上传;多张时重复 input_reference[] 字段)。runway 图生视频必填 1 张,张数上限见上方模型表'],
['reference_mode', 'string', '可选', '参考图用途:"frame"=首尾帧(最多 2 张,第 1 张为首帧、第 2 张为尾帧,只传 1 张即仅首帧),"asset"=普通参考图。留空用模型默认;仅支持单一模式的模型不可传'],
]
// ---- size → 比例 × 分辨率档 对照表(用 size 该传的值)----
@@ -224,8 +229,8 @@ async function copy(text) {
<template>
<div class="theme-text space-y-10">
<header>
<div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">开发者</div>
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1>
<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>
</header>
@@ -269,18 +274,35 @@ async function copy(text) {
<tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-3 font-medium">model</th>
<th class="px-4 py-3 font-medium">类型</th>
<th class="px-4 py-3 font-medium">分辨率 / 时长</th>
<th class="px-4 py-3 font-medium text-right">价格</th>
<th class="px-4 py-3 font-medium">能力</th>
</tr>
</thead>
<tbody>
<tr v-for="m in models" :key="m.id" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-3 font-mono text-white/90">{{ pubName(m) }}</td>
<td class="px-4 py-3 text-white/60">{{ m.type === 'video' ? '视频' : '图像' }}</td>
<td class="px-4 py-3 text-white/60">{{ (m.type === 'video' ? m.durations : m.resolutions || [])?.join(' · ') || '—' }}</td>
<td class="px-4 py-3 text-right tabular-nums text-white/80">{{ priceOf(m) }}</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>
<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>
</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">参考图 {{ m.max_reference_images }}</span>
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-if="m.type === 'image' && m.image_to_image && (!m.reference_mode || m.reference_mode === 'none')" class="cap cap-ref">参考图 1</span>
<span v-if="m.id?.startsWith('seedance')" class="cap cap-media">视频 3</span>
<span v-if="m.id?.startsWith('seedance')" class="cap cap-media">音频 3</span>
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap cap-ratio">{{ r.replace(':', '×') }}</span>
<span v-if="modeEmpty(m)" class="text-white/30"></span>
</div>
</td>
</tr>
<tr v-if="!models.length"><td colspan="4" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
<tr v-if="!models.length"><td colspan="3" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
</tbody>
</table>
</div>
@@ -408,14 +430,15 @@ async function copy(text) {
<!-- examples -->
<section class="space-y-4">
<h2 class="text-lg font-semibold">调用示例</h2>
<div v-for="ex in examples" :key="ex.title" class="card overflow-hidden">
<div class="flex items-center justify-between px-4 py-2.5 border-b border-white/[0.06]">
<span class="text-xs text-white/55">{{ ex.title }}</span>
<button @click="copy(ex.code)" class="text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors">
<div v-for="ex in examples" :key="ex.title" class="code-card">
<div class="code-card-bar">
<span class="code-dots" aria-hidden="true"><i class="dot dot-r"></i><i class="dot dot-y"></i><i class="dot dot-g"></i></span>
<span class="code-card-title">{{ ex.title }}</span>
<button @click="copy(ex.code)" class="code-copy">
<Icon name="copy" class="w-3.5 h-3.5" /> 复制
</button>
</div>
<pre class="p-4 text-[12px] leading-relaxed text-white/80 overflow-auto"><code>{{ ex.code }}</code></pre>
<pre class="code-card-body"><code>{{ ex.code }}</code></pre>
</div>
</section>
@@ -460,4 +483,57 @@ async function copy(text) {
html.dark .badge-get { background: rgb(16 185 129 / 0.15); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.3); }
html.dark .badge-post { background: rgb(14 165 233 / 0.15); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.3); }
html.dark .badge-err { background: rgb(244 63 94 / 0.15); color: rgb(253 164 175); box-shadow: inset 0 0 0 1px rgb(251 113 133 / 0.3); }
.cap { display: inline-flex; align-items: center; padding: 0.18rem 0.55rem; font-size: 0.7rem; border-radius: 9999px; white-space: nowrap; }
.cap-k { background: rgb(16 185 129 / 0.12); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.3); }
.cap-dur { background: rgb(99 102 241 / 0.12); color: rgb(55 48 163); box-shadow: inset 0 0 0 1px rgb(99 102 241 / 0.3); }
.cap-frame { background: rgb(236 72 153 / 0.12); color: rgb(159 18 57); box-shadow: inset 0 0 0 1px rgb(236 72 153 / 0.3); }
.cap-ref { background: rgb(16 185 129 / 0.12); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.3); }
.cap-media { background: rgb(245 158 11 / 0.12); color: rgb(146 64 14); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.3); }
.cap-ratio { background: rgb(100 116 139 / 0.1); color: rgb(51 65 85); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.25); }
html.dark .cap-k { background: rgb(16 185 129 / 0.18); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.4); }
html.dark .cap-dur { background: rgb(99 102 241 / 0.18); color: rgb(165 180 252); box-shadow: inset 0 0 0 1px rgb(129 140 248 / 0.4); }
html.dark .cap-frame { background: rgb(236 72 153 / 0.18); color: rgb(244 114 182); box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.45); }
html.dark .cap-ref { background: rgb(16 185 129 / 0.18); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.4); }
html.dark .cap-media { background: rgb(245 158 11 / 0.18); color: rgb(252 211 77); box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45); }
html.dark .cap-ratio { background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.65); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12); }
/* 苹果风代码卡片:自带深色配色,不随页面主题切换,深/浅背景下都可读 */
.code-card {
border-radius: 14px; overflow: hidden;
background: #1d1d1f;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.25), 0 8px 24px rgb(0 0 0 / 0.18), inset 0 0 0 1px rgb(255 255 255 / 0.08);
}
.code-card-bar {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px;
background: linear-gradient(180deg, #333336, #2a2a2d);
border-bottom: 1px solid rgb(0 0 0 / 0.4);
}
.code-dots { display: inline-flex; gap: 6px; }
.code-dots .dot { width: 11px; height: 11px; border-radius: 9999px; display: inline-block; }
.dot-r { background: #ff5f57; box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.15); }
.dot-y { background: #febc2e; box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.15); }
.dot-g { background: #28c840; box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.15); }
.code-card-title {
flex: 1; min-width: 0; text-align: center;
font-size: 12px; color: rgb(255 255 255 / 0.55);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.code-copy {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; line-height: 1;
color: rgb(255 255 255 / 0.6);
background: rgb(255 255 255 / 0.08);
border-radius: 7px; padding: 5px 9px;
transition: background 0.15s, color 0.15s;
}
.code-copy:hover { color: #fff; background: rgb(255 255 255 / 0.16); }
.code-card-body {
margin: 0; padding: 14px 16px;
font-size: 12px; line-height: 1.7;
color: #e8e8ed;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
</style>
+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>
+8 -3
View File
@@ -7,6 +7,7 @@ import { auth, refreshMe } from '../auth'
import { api } from '../api'
import { fmtIso } from '../utils/format'
import Icon from '../components/Icon.vue'
import Mascot from '../components/Mascot.vue'
// Reward per completed invite — comes from the backend (credits.invite_reward),
// falling back to 3 until the response lands.
@@ -80,8 +81,8 @@ function toast(m) {
<div class="theme-text space-y-10">
<!-- header -->
<header>
<div class="text-[10px] uppercase tracking-[0.3em] text-amber-300/70 font-medium">奖励</div>
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">邀请好友</h1>
<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">好友用你的链接注册,并完成首次生图后,你得 {{ INVITE_REWARD }} 积分</p>
</header>
@@ -131,7 +132,11 @@ function toast(m) {
<div class="card overflow-hidden">
<div v-if="loading && !records.length" class="text-center text-sm text-white/40 py-16">加载中</div>
<div v-else-if="!records.length" class="text-center text-sm text-white/40 py-16">还没有人通过你的链接注册</div>
<div v-else-if="!records.length" class="relative overflow-hidden flex flex-col items-center gap-3 text-white/40 py-14">
<div class="anime-stars"></div>
<Mascot :size="110" />
<span class="text-sm">还没有人通过你的链接注册快去分享吧</span>
</div>
<table v-else class="w-full text-sm">
<colgroup>
+48 -21
View File
@@ -21,6 +21,11 @@ const search = ref('')
const TYPE_LABEL = { image: '生图', video: '生视频' }
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
function capEmpty(m) {
if (m.type === 'image') return !(m.ratios || []).length && !m.image_to_image && (m.reference_mode === 'none' || !m.reference_mode)
return !(m.durations || []).length && !(m.resolutions || []).length && !m.max_reference_images && !m.id?.startsWith('seedance') && !(m.ratios || []).length
}
async function loadModels() {
loading.value = true
const r = await api('/managed-models')
@@ -196,7 +201,6 @@ onMounted(loadModels)
<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>
@@ -204,33 +208,46 @@ onMounted(loadModels)
<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 v-if="m.duration_prices?.per_second != null" class="price-chip">
<span class="text-white/85">/s</span>
<span class="text-white/30 mx-1">普通</span>
<span class="text-sky-300 tabular-nums">{{ points(m.duration_prices.per_second) }}</span>
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">{{ points(m.duration_prices_agent?.per_second ?? m.duration_prices.per_second) }}</span>
</span>
<span v-else 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>
<span v-if="!(m.resolutions || []).length && !(m.durations || []).length && m.duration_prices?.per_second == null" 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. -->
<!-- Capability duration range, frame/ref modes, media support -->
<td class="px-3 py-3.5 align-middle">
<div class="flex flex-wrap items-center gap-1 text-[11px]">
<!-- reference capability with count: frame mode = 首尾帧, else 参考图 -->
<span v-if="m.max_reference_images > 0"
class="cap-chip cap-emerald"
:title="REF_MODE_LABEL[m.reference_mode]">{{ m.reference_mode === 'frame' ? '首尾帧' : '参考图' }} {{ m.max_reference_images }}</span>
<span v-else-if="m.type === 'image' && m.image_to_image"
class="cap-chip cap-emerald">参考图</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>
<!-- Duration range (4s15s) -->
<span v-if="m.type === 'video' && (m.durations || []).length >= 2" class="cap-chip cap-dur">{{ m.durations[0] }}{{ m.durations[m.durations.length - 1] }}</span>
<span v-else-if="m.type === 'video' && (m.durations || []).length === 1" class="cap-chip cap-dur">{{ m.durations[0] }}</span>
<!-- Resolutions for image -->
<span v-if="m.type === 'image'" v-for="r in (m.resolutions || [])" :key="'ir'+r" class="cap-chip cap-slate">{{ r }}</span>
<!-- Resolutions for video -->
<span v-if="m.type === 'video'" v-for="r in (m.resolutions || [])" :key="'vr'+r" class="cap-chip cap-slate">{{ r }}</span>
<!-- Frame mode (首尾帧 2) -->
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap-chip cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
<!-- Reference image mode (参考图 9) -->
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap-chip cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap-chip cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-else-if="m.type === 'image' && m.image_to_image" class="cap-chip cap-ref">参考图</span>
<!-- Video/Audio for seedance -->
<span v-if="m.id?.startsWith('seedance')" class="cap-chip cap-media">视频 3</span>
<span v-if="m.id?.startsWith('seedance')" class="cap-chip cap-media">音频 3</span>
<!-- Ratios -->
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap-chip cap-mono">{{ r.replace(':', '×') }}</span>
<span v-if="capEmpty(m)" class="text-white/30 text-xs"></span>
</div>
</td>
@@ -326,15 +343,25 @@ onMounted(loadModels)
border-radius: 9999px;
white-space: nowrap;
}
.cap-emerald {
.cap-dur {
background: rgb(99 102 241 / 0.12);
color: rgb(165 180 252);
box-shadow: inset 0 0 0 1px rgb(129 140 248 / 0.3);
}
.cap-frame {
background: rgb(236 72 153 / 0.12);
color: rgb(244 114 182);
box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.35);
}
.cap-ref {
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);
box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.3);
}
.cap-amber {
.cap-media {
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);
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.35);
}
.cap-slate {
background: rgb(255 255 255 / 0.05);
+6 -4
View File
@@ -8,6 +8,7 @@ import { api, jsonBody } from '../api'
import { openPayment } from '../payment'
import { refreshMe } from '../auth'
import Icon from '../components/Icon.vue'
import Mascot from '../components/Mascot.vue'
const router = useRouter()
const items = ref([])
@@ -105,7 +106,7 @@ async function cont(o) {
<button v-for="s in [['','全部'],['pending','待支付'],['paid','已支付'],['cancelled','已取消']]" :key="s[0]"
@click="setStatus(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="status === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
:class="status === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
@@ -115,9 +116,10 @@ async function cont(o) {
</div>
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中</div>
<div v-else-if="!total" class="card flex flex-col items-center gap-3 text-slate-400 py-24">
<span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="log" class="w-6 h-6" /></span>
<span class="text-sm">还没有充值订单</span>
<div v-else-if="!total" class="anime-card relative overflow-hidden flex flex-col items-center gap-3 text-[color:var(--fg-3)] py-20">
<div class="anime-stars"></div>
<Mascot :size="120" />
<span class="text-sm">还没有充值订单</span>
</div>
<div v-else class="card overflow-hidden !p-0">
+207 -31
View File
@@ -110,7 +110,9 @@ const maxRefs = computed(() => {
if (mode.value === 'video') {
const a = Number(familyPreset.value?.max_reference_images || 0)
const b = Number(model.value?.max_reference_images || 0)
return Math.max(a, b)
const raw = Math.max(a, b)
if (refMode.value === 'frame') return Math.min(raw, 2)
return raw
}
// Image-to-image: honor the model's configured max (gpt-image-2=3,
// seedream-4.5=6, flux-klein-2=4 …). Fall back to 1 when image_to_image is on
@@ -119,7 +121,27 @@ const maxRefs = computed(() => {
if (m > 0) return m
return model.value?.image_to_image ? 1 : 0
})
const refMode = computed(() => familyPreset.value?.reference_mode || model.value?.reference_mode || 'none')
const refModeDefault = computed(() => familyPreset.value?.reference_mode || model.value?.reference_mode || 'none')
const supportsBoth = computed(() => { const m = model.value; return m && m.reference_mode === 'style' && (m.max_reference_images || 0) >= 2 })
const refMode = ref(refModeDefault.value)
watch(refModeDefault, (v) => { refMode.value = v })
const isSeedanceModel = computed(() => /^seedance/.test(model.value?.id || ''))
const perSecondRate = computed(() => {
if (mode.value !== 'video' || !model.value) return null
const n = model.value.duration_prices?.per_second
if (n == null) return null
return isAgent.value ? (model.value.duration_prices_agent?.per_second ?? n) : n
})
const secsRange = computed(() => {
if (!perSecondRate.value) return null
const ds = (model.value.durations || []).map(parseFloat).filter(n => !isNaN(n))
if (ds.length >= 2) return { min: ds[0], max: ds[ds.length - 1] }
return { min: 4, max: 15 }
})
const secs = ref(secsRange.value?.min || 5)
watch(secsRange, (r) => { if (r && (secs.value < r.min || secs.value > r.max)) secs.value = r.min })
// Most video models (veo31, luma) support pure text2video, so refs are optional.
// A model can opt into strict image-to-video by declaring `requires_reference`
// in its preset (e.g. runway-gen4-turbo) — then a first-frame image is mandatory.
@@ -150,6 +172,7 @@ const price = computed(() => {
const m = model.value
if (mode.value === 'video') {
const rp = tierPrice(m.prices, m.prices_agent, resolution.value)
if (perSecondRate.value) return rp + perSecondRate.value * secs.value
const dp = tierPrice(m.duration_prices, m.duration_prices_agent, duration.value)
if (rp == null || dp == null) return null
return rp + dp
@@ -185,6 +208,7 @@ function selectModel(id) {
function setMode(m) {
if (mode.value === m) return
mode.value = m
clearRefs() // 切换生图/生视频清空参考文件
// pick a default model of the new kind, if any
const first = allModels.value.find((x) => x.enabled !== false && x.type === m)
modelId.value = first?.id || ''
@@ -196,32 +220,73 @@ function openPicker() { fileInput.value && fileInput.value.click() }
// here at pick time so an oversized image fails fast with a clear message instead
// of charging + failing upstream after the upload.
const MAX_REF_BYTES = 20 * 1024 * 1024
// seedance 参考图模式各类上限
const MAX_VIDEOS = 3
const MAX_AUDIOS = 3
// seedance 参考图(非首尾帧)模式下允许图片/视频/音频
const fileAccept = computed(() => {
if (isSeedanceModel.value && refMode.value !== 'frame' && mode.value === 'video')
return 'image/*,video/*,audio/*'
return 'image/*'
})
// 当前各类文件数量
const refCounts = computed(() => {
const imgs = refImages.value.filter(f => !f.fileType || f.fileType === 'image').length
const vids = refImages.value.filter(f => f.fileType === 'video').length
const auds = refImages.value.filter(f => f.fileType === 'audio').length
return { imgs, vids, auds }
})
function onFiles(ev) {
addFiles(Array.from(ev.target.files || []))
if (ev.target) ev.target.value = ''
}
// Shared by the file picker AND drag-and-drop. Filters to images, honors the
// per-model max + 20MB cap. The preview shows a small downscaled thumbnail;
// the ORIGINAL file is kept untouched and is what gets uploaded at submit time.
// Shared by the file picker AND drag-and-drop. Honors per-model max + per-type
// limits (image/video/audio) + 20MB cap.
function addFiles(files) {
files = files.filter((f) => f && f.type && f.type.startsWith('image/'))
const room = Math.max(0, maxRefs.value - refImages.value.length)
const allowMedia = isSeedanceModel.value && refMode.value !== 'frame' && mode.value === 'video'
const tooBig = []
let added = 0
for (const f of files) {
if (added >= room) break
if (!f || !f.type) continue
const isImage = f.type.startsWith('image/')
const isVideo = f.type.startsWith('video/')
const isAudio = f.type.startsWith('audio/')
if (!isImage && !(allowMedia && (isVideo || isAudio))) continue
if (refImages.value.length >= maxRefs.value) break
if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue }
makeThumb(f).then((thumb) => {
if (thumb) { refImages.value.push({ name: f.name, file: f, thumb }); return }
// thumbnail failed (odd format) — fall back to the full data URL preview
const reader = new FileReader()
reader.onload = () => refImages.value.push({ name: f.name, file: f, dataUrl: reader.result })
reader.readAsDataURL(f)
})
added++
const { imgs, vids, auds } = refCounts.value
if (isVideo && vids >= MAX_VIDEOS) continue
if (isAudio && auds >= MAX_AUDIOS) continue
const fileType = isVideo ? 'video' : isAudio ? 'audio' : 'image'
if (isImage) {
makeThumb(f).then((thumb) => {
if (thumb) { refImages.value.push({ name: f.name, file: f, thumb, fileType: 'image' }); return }
const reader = new FileReader()
reader.onload = () => refImages.value.push({ name: f.name, file: f, dataUrl: reader.result, fileType: 'image' })
reader.readAsDataURL(f)
})
} else if (isVideo) {
// 视频:截首帧当缩略图 + 记录时长
const mediaUrl = URL.createObjectURL(f)
makeVideoThumb(mediaUrl).then(({ thumb, duration }) => {
refImages.value.push({ name: f.name, file: f, fileType, thumb, duration, mediaUrl })
})
} else {
// 音频:objectURL 用于内联播放,metadata 拿时长
const mediaUrl = URL.createObjectURL(f)
const a = new Audio()
a.preload = 'metadata'
a.onloadedmetadata = () => {
refImages.value.push({ name: f.name, file: f, fileType, mediaUrl, duration: a.duration })
}
a.onerror = () => refImages.value.push({ name: f.name, file: f, fileType, mediaUrl })
a.src = mediaUrl
}
}
error.value = tooBig.length
? `图片超过 20MB 已跳过:${tooBig.join('、')}(请压缩后再传)`
? `文件超过 20MB 已跳过:${tooBig.join('、')}(请压缩后再传)`
: ''
}
// Downscale an image blob/file to a small display-only thumbnail so the DOM
@@ -275,7 +340,66 @@ function onDragLeave(ev) {
if (ev.currentTarget.contains(ev.relatedTarget)) return
dragOver.value = false
}
function removeRef(i) { refImages.value.splice(i, 1) }
function clearRefs() {
stopAudio()
for (const r of refImages.value) if (r.mediaUrl) URL.revokeObjectURL(r.mediaUrl)
refImages.value = []
}
function removeRef(r) {
const i = refImages.value.indexOf(r)
if (i < 0) return
if (r.mediaUrl) URL.revokeObjectURL(r.mediaUrl)
if (r === playingAudio.value) stopAudio()
refImages.value.splice(i, 1)
}
// 参考区分两块渲染:图片/视频走方块缩略图,音频单独一行(可播放条)。
const tileRefs = computed(() => refImages.value.filter((r) => r.fileType !== 'audio'))
const audioRefs = computed(() => refImages.value.filter((r) => r.fileType === 'audio'))
// mm:ss
function fmtDur(s) {
if (!isFinite(s) || s <= 0) return ''
const n = Math.round(s)
return `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}`
}
// 音频内联播放:同一时间只播一条,用一个共享 <audio> 元素。
const playingAudio = ref(null)
const audioEl = new Audio()
audioEl.onended = () => { playingAudio.value = null }
function stopAudio() {
audioEl.pause()
playingAudio.value = null
}
function toggleAudio(r) {
if (playingAudio.value === r) { stopAudio(); return }
audioEl.src = r.mediaUrl
audioEl.play().catch(() => {})
playingAudio.value = r
}
// 截视频首帧做缩略图(objectURL 同源,canvas 不会 taint)。
function makeVideoThumb(url, maxSide = 320) {
return new Promise((resolve) => {
const v = document.createElement('video')
v.muted = true
v.preload = 'auto'
v.src = url
const done = (thumb) => resolve({ thumb, duration: v.duration })
v.addEventListener('loadeddata', () => {
try {
const scale = Math.min(1, maxSide / Math.max(v.videoWidth, v.videoHeight))
const c = document.createElement('canvas')
c.width = Math.max(1, Math.round(v.videoWidth * scale))
c.height = Math.max(1, Math.round(v.videoHeight * scale))
c.getContext('2d').drawImage(v, 0, 0, c.width, c.height)
done(c.toDataURL('image/jpeg', 0.8))
} catch { done('') }
})
v.addEventListener('error', () => done(''))
})
}
// Re-hydrate reference thumbnails from server URLs (after a reload). Fetches
// each /images URL (same-origin, cookie-authed) and converts to a data URL so
@@ -402,7 +526,7 @@ async function fireOne() {
prompt: prompt.value,
ratio: ratio.value,
resolution: resolution.value,
duration: mode.value === 'video' ? duration.value : '',
duration: mode.value === 'video' ? (perSecondRate.value ? secs.value + 's' : duration.value) : '',
deai: mode.value === 'image' && deaiEnabled.value ? deai.value : false,
status: 'pending',
url: '',
@@ -425,7 +549,7 @@ async function fireOne() {
const payload = {
model: task.model, prompt: task.prompt, ratio: task.ratio, resolution: task.resolution,
}
if (task.kind === 'video') payload.duration = task.duration
if (task.kind === 'video') { payload.duration = perSecondRate.value ? secs.value + 's' : task.duration; if (refMode.value !== refModeDefault.value) payload.reference_mode = refMode.value }
if (task.kind === 'image' && task.deai) payload.deai = true
if (refsSnapshot.length) {
const refs = await Promise.all(refsSnapshot.map(refToBase64))
@@ -629,6 +753,8 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('keydown', onKey)
clearInterval(pollTimer)
stopAudio()
for (const r of refImages.value) if (r.mediaUrl) URL.revokeObjectURL(r.mediaUrl)
})
</script>
@@ -680,7 +806,7 @@ onUnmounted(() => {
<div class="flex flex-wrap gap-1.5">
<button v-for="r in ratios" :key="r" type="button" @click="ratio = r"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="ratio === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
:class="ratio === r ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ r }}
</button>
</div>
@@ -691,7 +817,7 @@ onUnmounted(() => {
<div class="flex flex-wrap gap-1.5">
<button v-for="r in resolutions" :key="r" type="button" @click="resolution = r"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="resolution === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
:class="resolution === r ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ r }}
</button>
</div>
@@ -711,12 +837,19 @@ onUnmounted(() => {
</button>
</div>
<div v-if="mode === 'video' && durations.length > 0">
<!-- per-second slider -->
<div v-if="perSecondRate && secsRange">
<label class="block text-xs font-medium text-slate-500 mb-1.5">时长 <span class="text-slate-400 font-normal tabular-nums">{{ secs }}s</span></label>
<input type="range" v-model.number="secs" :min="secsRange.min" :max="secsRange.max" step="1"
class="w-full accent-indigo-500 h-2 rounded-lg appearance-none bg-slate-200 cursor-pointer" />
<div class="flex justify-between text-[10px] text-slate-400 mt-0.5"><span>{{ secsRange.min }}s</span><span>{{ secsRange.max }}s</span></div>
</div>
<div v-if="mode === 'video' && durations.length > 0 && !perSecondRate">
<label class="block text-xs font-medium text-slate-500 mb-1.5">时长</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="d in durations" :key="d" type="button" @click="duration = d"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="duration === d ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
:class="duration === d ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ d }}
</button>
</div>
@@ -727,17 +860,37 @@ onUnmounted(() => {
<label class="block text-xs font-medium text-slate-500 mb-1.5">
{{ refMode === 'frame' && mode === 'video' ? '首尾帧' : '参考图' }}
<span class="text-slate-400 font-normal">
(最多 {{ maxRefs }} 张{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单 ≤20MB)
(最多 {{ maxRefs }} 张{{ isSeedanceModel && refMode !== 'frame' && mode === 'video' ? ` · 图片${refCounts.imgs}/${maxRefs} · 视频${refCounts.vids}/${MAX_VIDEOS} · 音频${refCounts.auds}/${MAX_AUDIOS}` : '' }}{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单文件 ≤20MB)
</span>
<span v-if="refsRequired" class="text-rose-500">*</span>
</label>
<!-- 参考图 / 首尾帧 分段切换 — 仅两种模式都支持时显示,单独一行 -->
<div v-if="supportsBoth" class="inline-flex p-0.5 mb-2 rounded-lg bg-slate-100 gap-0.5">
<button v-for="opt in [{ v: 'asset', label: '参考图' }, { v: 'frame', label: '首尾帧' }]" :key="opt.v"
type="button" @click="if ((refMode === 'frame' ? 'frame' : 'asset') !== opt.v) { refMode = opt.v; clearRefs() }"
class="rounded-md px-3 py-1 text-[11px] font-medium transition-colors"
:class="(refMode === 'frame' ? 'frame' : 'asset') === opt.v ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
{{ opt.label }}
</button>
</div>
<div class="flex gap-2 flex-wrap items-start rounded-lg transition-colors"
:class="dragOver ? 'ring-2 ring-indigo-400 ring-offset-2 bg-indigo-50/40' : ''"
@drop="onDrop" @dragover="onDragOver" @dragleave="onDragLeave">
<div v-for="(img, i) in refImages" :key="i"
<div v-for="(img, i) in tileRefs" :key="img.mediaUrl || img.url || img.name + i"
class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all">
<img :src="img.thumb || img.dataUrl || img.url" class="w-full h-full object-cover" />
<button type="button" @click="removeRef(i)"
<!-- 图片预览 -->
<img v-if="!img.fileType || img.fileType === 'image'" :src="img.thumb || img.dataUrl || img.url" class="w-full h-full object-cover" />
<!-- 视频预览:首帧缩略图 + "视频"角标,无缩略图时退回图标 -->
<template v-else-if="img.fileType === 'video'">
<img v-if="img.thumb" :src="img.thumb" class="w-full h-full object-cover" />
<div v-else class="w-full h-full grid place-items-center bg-slate-800 text-white">
<Icon name="video" class="w-6 h-6 opacity-80" />
</div>
<span class="absolute top-1 left-1 rounded bg-slate-900/70 text-white text-[9px] px-1 py-0.5">
视频
</span>
</template>
<button type="button" @click="removeRef(img)"
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-slate-900/70 text-white hover:bg-rose-500 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed">
<Icon name="close" class="w-3 h-3" />
</button>
@@ -752,7 +905,25 @@ onUnmounted(() => {
<Icon :name="dragOver ? 'download' : 'plus'" class="w-5 h-5" />
</button>
</div>
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" />
<!-- 音频:单独一行可播放条 -->
<div v-if="audioRefs.length" class="mt-2 space-y-1.5">
<div v-for="a in audioRefs" :key="a.mediaUrl"
class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5">
<button type="button" @click="toggleAudio(a)" :title="playingAudio === a ? '暂停' : '播放'"
class="w-7 h-7 shrink-0 rounded-full grid place-items-center transition-colors"
:class="playingAudio === a ? 'chip-on' : 'bg-slate-200 text-slate-600 hover:bg-slate-300'">
<Icon :name="playingAudio === a ? 'pause' : 'play'" class="w-3 h-3" />
</button>
<Icon name="music" class="w-3.5 h-3.5 shrink-0 text-slate-400" />
<span class="flex-1 min-w-0 truncate text-xs text-slate-600">{{ a.name }}</span>
<span v-if="fmtDur(a.duration)" class="text-[10px] tabular-nums text-slate-400 shrink-0">{{ fmtDur(a.duration) }}</span>
<button type="button" @click="removeRef(a)" title="移除"
class="w-5 h-5 shrink-0 rounded-full text-slate-400 hover:text-rose-500 grid place-items-center">
<Icon name="close" class="w-3 h-3" />
</button>
</div>
</div>
<input ref="fileInput" type="file" :accept="fileAccept" multiple class="hidden" @change="onFiles" />
</div>
<!-- 生图张数 14 (image only) — each is a separate concurrent generation. -->
@@ -761,7 +932,7 @@ onUnmounted(() => {
<div class="flex gap-1.5">
<button v-for="n in [1, 2, 3, 4]" :key="n" type="button" @click="count = n"
class="flex-1 rounded-lg py-1.5 text-xs font-medium transition-colors"
:class="count === n ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
:class="count === n ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ n }}
</button>
</div>
@@ -808,6 +979,11 @@ onUnmounted(() => {
:style="{ backgroundImage: `url(${item.url}.thumb.jpg)` }"
class="absolute inset-0 w-full h-full bg-cover bg-center cursor-zoom-in transition-transform duration-300 group-hover:scale-105"></div>
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/30 to-transparent pointer-events-none"></div>
<!-- 类型角标视频卡标"视频"图片卡不加 -->
<span v-if="item.kind === 'video'"
class="absolute top-2 left-2 rounded-md bg-black/55 ring-1 ring-white/10 text-white text-[10px] px-1.5 py-0.5 pointer-events-none">
视频
</span>
<!-- hover action: 上参考图. Image use as reference; video 末帧设为首帧
(only shown when the model supports 首尾帧). Clicking the media zooms. -->
<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+12 -5
View File
@@ -54,11 +54,16 @@ async function generateKey() {
toast('新 Key 已生成 — 请立刻复制保存(只显示这一次)')
}
// Only the just-minted plaintext is copyable — the server-side preview is a mask
// (…abcd), so copying it would hand out a key that can never authenticate.
async function copyKey() {
if (!hasKey.value) return
if (!apiKey.value) {
toast('完整 Key 仅生成时显示一次,已无法找回 — 请重新生成')
return
}
try {
await navigator.clipboard.writeText(apiKey.value || keyPreview.value)
toast(apiKey.value ? '已复制完整 Key' : '完整 Key 仅生成时显示一次,这里只能复制预览')
await navigator.clipboard.writeText(apiKey.value)
toast('已复制完整 Key')
} catch { toast('复制失败') }
}
@@ -276,7 +281,7 @@ async function recharge() {
</div>
<h2 class="text-xl font-bold mt-4">API Key</h2>
<p class="text-sm text-white/50 mt-2 leading-relaxed">
调用 <code class="bg-white/10 text-white/90 px-1 py-0.5 rounded text-xs">/v1/*</code> 接口需要的访问密钥。会保存在浏览器本地
调用 <code class="bg-white/10 text-white/90 px-1 py-0.5 rounded text-xs">/v1/*</code> 接口需要的访问密钥。完整密钥不保存,仅生成时显示一次
</p>
</div>
<div>
@@ -290,7 +295,9 @@ async function recharge() {
{{ apiKeyRevealed ? '隐藏' : '显示' }}
</button>
<button @click="copyKey"
class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 hover:bg-white/[0.06] hover:ring-white/20 transition-all">
:title="apiKey ? '复制完整 Key' : '完整 Key 仅生成时显示一次,已无法找回'"
class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 transition-all hover:bg-white/[0.06] hover:ring-white/20"
:class="!apiKey && 'opacity-40'">
复制
</button>
</div>
+7 -5
View File
@@ -11,6 +11,7 @@ import { copyText } from '../utils/clipboard'
import { points } from '../credits'
import Icon from '../components/Icon.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
import Mascot from '../components/Mascot.vue'
const router = useRouter()
const items = ref([]) // current server page
@@ -163,14 +164,14 @@ const params = (e) => {
<button v-for="s in [['','全部'],['success','成功'],['failed','失败'],['pending','进行中']]" :key="s[0]"
@click="setStatus(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="statusFilter === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
:class="statusFilter === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="w-px h-5 bg-slate-200"></div>
<div class="flex items-center gap-1.5">
<button v-for="s in [['','全部来源'],['web','画图台'],['api','API']]" :key="s[0]"
@click="setSource(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="sourceFilter === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
:class="sourceFilter === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
@@ -181,9 +182,10 @@ const params = (e) => {
<!-- States -->
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中</div>
<div v-else-if="!total" class="card flex flex-col items-center gap-3 text-slate-400 py-24">
<span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="log" class="w-6 h-6" /></span>
<span class="text-sm">还没有生成日志</span>
<div v-else-if="!total" class="anime-card relative overflow-hidden flex flex-col items-center gap-3 text-[color:var(--fg-3)] py-20">
<div class="anime-stars"></div>
<Mascot :size="120" />
<span class="text-sm">还没有生成日志</span>
</div>
<!-- Table -->
+10 -8
View File
@@ -7,6 +7,7 @@ import { copyText } from '../utils/clipboard'
import { zipSync } from 'fflate'
import Icon from '../components/Icon.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
import Mascot from '../components/Mascot.vue'
const router = useRouter()
@@ -247,18 +248,18 @@ onUnmounted(() => {
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1.5">
<button @click="setKind('')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="kindFilter === '' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button>
:class="kindFilter === '' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button>
<button @click="setKind('image')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="kindFilter === 'image' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">图像</button>
:class="kindFilter === 'image' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">图像</button>
<button @click="setKind('video')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="kindFilter === 'video' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">视频</button>
:class="kindFilter === 'video' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">视频</button>
</div>
<div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field !py-1.5 text-xs" placeholder="搜索提示词或模型…" />
</div>
<button @click="togglePickAll" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors inline-flex items-center gap-1"
:class="pageAllPicked ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
:class="pageAllPicked ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
<Icon name="check" class="w-3.5 h-3.5" /> 全选本页
</button>
<template v-if="picked.size">
@@ -274,10 +275,11 @@ onUnmounted(() => {
<!-- Empty -->
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中</div>
<div v-else-if="!filtered.length"
class="card flex flex-col items-center gap-3 text-slate-400 py-24">
<span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="spark" class="w-6 h-6" /></span>
<span class="text-sm">还没有创作记录</span>
<button @click="router.push('/user')" class="btn-soft mt-2">开始第一张</button>
class="anime-card relative overflow-hidden flex flex-col items-center gap-3 text-[color:var(--fg-3)] py-20">
<div class="anime-stars"></div>
<Mascot :size="120" />
<span class="text-sm">还没有创作记录画一张试试吧</span>
<button @click="router.push('/user')" class="bubble-btn mt-1">开始第一张 </button>
</div>
<!-- Cards gallery layout, matching 图片管理 (ImagesView) -->