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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 22:59:04 +08:00
co-authored by Claude Opus 4.8
commit 606caaf047
142 changed files with 33648 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
<script setup>
// Lightweight inline icon set (Lucide path data) — no extra dependency.
const PATHS = {
overview: '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
models: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
accounts: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
test: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0Z"/><path d="M20 3v4"/><path d="M22 5h-4"/>',
files: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
config: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/>',
spark: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0Z"/>',
plug: '<path d="M9 2v6"/><path d="M15 2v6"/><path d="M12 17v5"/><path d="M5 8h14"/><path d="M6 11V8h12v3a6 6 0 0 1-12 0Z"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
open: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
video: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18"/><path d="M3 7.5h4"/><path d="M3 12h18"/><path d="M3 16.5h4"/><path d="M17 3v18"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/>',
log: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
chevron: '<path d="m6 9 6 6 6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
}
defineProps({ name: { type: String, required: true } })
</script>
<template>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" v-html="PATHS[name] || ''" />
</template>
+134
View File
@@ -0,0 +1,134 @@
<script setup>
import { ref, computed } from 'vue'
import { api, jsonBody } from '../api'
import { parseImportInput } from '../utils/import'
import Icon from './Icon.vue'
const emit = defineEmits(['close', 'imported'])
const input = ref('')
const status = ref('')
const isError = ref(false)
const submitting = ref(false)
// Live preview of what the parser would extract — updates as the user types
// so they can see whether their paste was understood before clicking import.
const detected = computed(() => {
const items = parseImportInput(input.value)
const openai = items.filter((x) => x.type === 'openai').length
const adobe = items.filter((x) => x.type === 'adobe').length
const runway = items.filter((x) => x.type === 'runway').length
const leonardo = items.filter((x) => x.type === 'leonardo').length
const krea = items.filter((x) => x.type === 'krea').length
const imagine = items.filter((x) => x.type === 'imagine').length
return { total: items.length, openai, adobe, runway, leonardo, krea, imagine }
})
function setStatus(text, err = false) {
status.value = text || ''
isError.value = err
}
async function doSmartImport() {
const items = parseImportInput(input.value)
if (!items.length) {
setStatus('未识别到任何 Cookie 或 JWT', true)
return
}
submitting.value = true
let ok = 0, fail = 0
const errs = []
for (let i = 0; i < items.length; i++) {
const it = items[i]
setStatus(`正在导入 ${i + 1}/${items.length} (${it.type})…`)
try {
const r = it.type === 'openai'
? await api('/tokens/import-chatgpt-token', jsonBody('POST', { access_token: it.value }))
: it.type === 'runway'
? await api('/tokens/import-runway-token', jsonBody('POST', { access_token: it.value }))
: it.type === 'leonardo'
? await api('/tokens/import-leonardo-cookie', jsonBody('POST', { cookie: it.value }))
: it.type === 'krea'
? await api('/tokens/import-krea-cookie', jsonBody('POST', { cookie: it.value }))
: it.type === 'imagine'
? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value }))
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
if (r.ok) ok++
else { fail++; errs.push(`${it.type}: ${r.data?.detail || r.status}`) }
} catch (e) {
fail++; errs.push(`${it.type}: ${e}`)
}
}
submitting.value = false
// Quota isn't checked here — the server probes each token off-thread and the
// account list flips pending → active/dead on its own.
if (fail === 0) {
setStatus(`✓ 导入 ${ok} 项 · 正在后台检测额度…`)
emit('imported')
setTimeout(() => emit('close'), 1000)
} else {
setStatus(`成功 ${ok} · 失败 ${fail} · ${errs.slice(0, 3).join(' | ')}`, true)
emit('imported')
}
}
</script>
<template>
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
@click.self="emit('close')">
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-2xl">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 class="text-sm font-semibold">导入账号</h2>
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
<Icon name="close" class="w-5 h-5" />
</button>
</div>
<div class="p-5">
<p class="text-xs text-slate-500 mb-3 leading-relaxed">自动识别
<strong class="text-slate-700">Adobe Cookie 字符串</strong>(<code class="px-1 bg-slate-100 rounded">k=v; k=v; ...</code>)
<strong class="text-slate-700">Cookie JSON 对象</strong>
<strong class="text-slate-700">Cookie 数组</strong>( Adobe 批量)
<strong class="text-slate-700">ChatGPT JWT</strong>(<code class="px-1 bg-slate-100 rounded">eyJhbGciOi...</code>)
<strong class="text-slate-700">Runway JWT</strong>(自动与 ChatGPT 区分)
<strong class="text-slate-700">Leonardo Cookie</strong>( better-auth)
<strong class="text-slate-700">Krea Cookie</strong>( sb-superb-auth)
<strong class="text-slate-700">Imagine Token</strong>(<code class="px-1 bg-slate-100 rounded">{"token","refreshToken","email","parentId"}</code>)
<strong class="text-slate-700">多个 JWT</strong>(换行分隔)
全粘进来即可无需任何前缀
</p>
<textarea v-model="input" rows="10"
class="field font-mono text-xs resize-none"
placeholder="直接粘 Cookie 字符串 / JWT / JSON,自动识别"></textarea>
<div v-if="input.trim()" class="mt-2 flex items-center gap-2 text-xs">
<template v-if="detected.total">
<span class="text-emerald-600"> 识别到 <strong class="tabular-nums">{{ detected.total }}</strong> 个账号</span>
<span v-if="detected.openai" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200">
OpenAI · <span class="tabular-nums">{{ detected.openai }}</span>
</span>
<span v-if="detected.adobe" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-rose-700 bg-rose-50 ring-1 ring-rose-200">
Adobe · <span class="tabular-nums">{{ detected.adobe }}</span>
</span>
<span v-if="detected.runway" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-violet-700 bg-violet-50 ring-1 ring-violet-200">
Runway · <span class="tabular-nums">{{ detected.runway }}</span>
</span>
<span v-if="detected.leonardo" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-amber-700 bg-amber-50 ring-1 ring-amber-200">
Leonardo · <span class="tabular-nums">{{ detected.leonardo }}</span>
</span>
<span v-if="detected.krea" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-sky-700 bg-sky-50 ring-1 ring-sky-200">
Krea · <span class="tabular-nums">{{ detected.krea }}</span>
</span>
<span v-if="detected.imagine" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-teal-700 bg-teal-50 ring-1 ring-teal-200">
Imagine · <span class="tabular-nums">{{ detected.imagine }}</span>
</span>
</template>
<span v-else class="text-rose-600">未识别到任何 Cookie JWT</span>
</div>
<button @click="doSmartImport" :disabled="submitting || !detected.total" class="btn-primary w-full mt-3">
{{ submitting ? '导入中…' : (detected.total ? `识别并导入 (${detected.total})` : '识别并导入') }}
</button>
<p v-if="status" class="text-xs mt-2" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
</div>
</div>
</div>
</template>
+404
View File
@@ -0,0 +1,404 @@
<script setup>
// Global login/register/forgot modal. Controlled by shared auth.loginOpen.
// Mounted once in App.vue so it overlays any page (home, user, redirect).
import { ref, reactive, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { auth, setSession, closeLogin } from '../auth'
import Logo from './Logo.vue'
const BASE = import.meta.env.VITE_API_BASE || ''
const router = useRouter()
const mode = ref('login') // login | register | forgot
const cfg = reactive({ open: true, email_code: false, allow_password_reset: true, has_admin: true })
// `identifier` is the login field (email OR username); `email` is only used by
// register/forgot, where an actual email address is required.
const form = reactive({ identifier: '', username: '', email: '', password: '', code: '' })
const busy = ref(false)
const error = ref('')
const notice = ref('')
const showPw = ref(false) // password visibility toggle
const codeCooldown = ref(0) // seconds left before re-sending
const sendingCode = ref(false) // request in flight → spinner on the button
let cooldownTimer = null
// Per-mode copy for the header so each tab reads as its own little page.
const heading = computed(() => ({
login: { title: '欢迎回来', sub: '登录以继续你的创作' },
register: { title: '创建账号', sub: '加入 Vivid,开始 AI 生图' },
forgot: { title: '找回密码', sub: '通过邮箱验证重置你的密码' },
}[mode.value]))
// Register/reset need an email code only once an admin exists (the very first
// account bootstraps the admin and skips it).
const needsCode = computed(() =>
cfg.email_code && cfg.has_admin && (mode.value === 'register' || mode.value === 'forgot'))
// Hide the tab bar when 登录 is the only available tab (注册 + 找回密码 both
// hidden) — a lone tab reads as a stray button. Conditions mirror the v-if on
// each tab below.
const showTabs = computed(() =>
(cfg.open || !cfg.has_admin) || cfg.allow_password_reset)
async function loadConfig() {
try {
const r = await fetch(`${BASE}/admin/api/auth/config`)
if (r.ok) Object.assign(cfg, await r.json())
} catch { /* offline — keep defaults */ }
}
async function sendCode() {
error.value = ''; notice.value = ''
if (!form.email) { error.value = '请先输入邮箱'; return }
if (codeCooldown.value > 0 || sendingCode.value) return
// Spin while the request is in flight; only start the countdown once the
// server actually accepts the send (so a failure lets the user retry at once).
sendingCode.value = true
try {
const r = await post('/auth/send-code', { email: form.email, purpose: mode.value === 'forgot' ? 'reset' : 'register' })
if (!r.ok) { error.value = r.detail || '验证码发送失败'; return }
notice.value = '验证码已发送,请查收邮箱'
codeCooldown.value = 60
clearInterval(cooldownTimer)
cooldownTimer = setInterval(() => { if (--codeCooldown.value <= 0) clearInterval(cooldownTimer) }, 1000)
} catch {
error.value = '验证码发送失败'
} finally {
sendingCode.value = false
}
}
function switchMode(m) {
mode.value = m
error.value = ''; notice.value = ''; showPw.value = false
// Clear all inputs on tab switch so a password/email typed under one tab
// doesn't carry over (and read as an auto-filled value) into another.
form.identifier = ''; form.username = ''; form.email = ''; form.password = ''; form.code = ''
codeCooldown.value = 0; clearInterval(cooldownTimer)
}
// Reset + reload config every time the modal opens. Honour startMode so an
// invite link can open straight on the register tab.
watch(() => auth.loginOpen, (open) => {
if (!open) return
error.value = ''; notice.value = ''; showPw.value = false
form.identifier = ''; form.username = ''; form.email = ''; form.password = ''; form.code = ''
loadConfig()
if (auth.startMode === 'register') switchMode('register')
else mode.value = 'login'
})
async function post(path, body) {
const r = await fetch(`${BASE}/admin/api${path}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
let data = {}
try { data = await r.json() } catch { /* ignore */ }
return { ok: r.ok, ...data }
}
async function submit() {
error.value = ''; notice.value = ''
if (mode.value === 'login') {
if (!form.identifier || !form.password) { error.value = '请输入账号和密码'; return }
} else {
if (!form.email || !form.password) { error.value = '请输入邮箱和密码'; return }
if (mode.value === 'register' && !form.username.trim()) { error.value = '请输入用户名'; return }
if (mode.value === 'register') {
const u = form.username.trim()
if (!/^[A-Za-z0-9]{6,24}$/.test(u)) {
error.value = '用户名需为 6-24 位字母或数字'
return
}
}
if (!/^\d{6}$/.test((form.code || '').trim()) && needsCode.value) {
error.value = '邮箱验证码必须是 6 位纯数字'
return
}
const pw = form.password || ''
if (pw.length < 8 || pw.length > 24) {
error.value = '密码长度需为 8-24 位'
return
}
}
busy.value = true
try {
if (needsCode.value && !form.code.trim()) { error.value = '请输入邮箱验证码'; busy.value = false; return }
if (mode.value === 'forgot') {
const r = await post('/auth/reset-password', {
email: form.email, password: form.password, email_code: form.code.trim(),
})
if (!r.ok) throw new Error(r.detail || '重置失败')
notice.value = '密码已重置,请用新密码登录'
switchMode('login')
return
}
const path = mode.value === 'register' ? '/auth/register' : '/auth/login'
const payload = mode.value === 'login'
? { identifier: form.identifier.trim(), password: form.password }
: { email: form.email, password: form.password }
if (mode.value === 'register') {
payload.username = form.username.trim()
payload.email_code = form.code.trim()
if (auth.pendingInvite) payload.invite_code = auth.pendingInvite
}
const r = await post(path, payload)
if (!r.ok) throw new Error(r.detail || '操作失败')
auth.pendingInvite = '' // consumed
setSession(r.token, r.user)
const intent = auth.loginIntent
closeLogin()
const dest = intent || (r.user.role === 'admin' ? '/admin/overview' : '/user')
router.push(dest)
} catch (e) {
error.value = e.message || String(e)
} finally {
busy.value = false
}
}
</script>
<template>
<transition name="modal">
<div v-if="auth.loginOpen" class="fixed inset-0 z-50 grid place-items-center px-4">
<div class="absolute inset-0 bg-black/70 backdrop-blur-md" @click="closeLogin"></div>
<div class="card relative z-10 w-full max-w-[26rem]">
<!-- ambient brand glow -->
<div class="pointer-events-none absolute -top-24 -right-16 h-56 w-56 rounded-full bg-fuchsia-500/20 blur-3xl"></div>
<div class="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-violet-500/20 blur-3xl"></div>
<div class="relative p-7">
<button class="absolute top-4 right-4 grid h-7 w-7 place-items-center rounded-lg text-[color:var(--fg-3)] hover:text-[color:var(--fg)] hover:bg-[var(--hover)] transition-colors"
@click="closeLogin" aria-label="关闭">
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<!-- header -->
<div class="flex flex-col items-center text-center mb-6">
<div class="logo-halo mb-3"><Logo :size="44" class="rounded-[13px]" /></div>
<transition name="swap" mode="out-in">
<div :key="mode">
<h2 class="text-lg font-semibold tracking-tight text-[color:var(--fg)]">{{ heading.title }}</h2>
<p class="mt-1 text-[13px] text-[color:var(--fg-3)]">{{ heading.sub }}</p>
</div>
</transition>
</div>
<!-- segmented tabs hidden when 登录 is the only tab -->
<div v-if="showTabs" class="tabs mb-5">
<button class="tab" :class="mode === 'login' && 'tab-on'" @click="switchMode('login')">登录</button>
<button v-if="cfg.open || !cfg.has_admin" class="tab" :class="mode === 'register' && 'tab-on'" @click="switchMode('register')">注册</button>
<button v-if="cfg.allow_password_reset" class="tab" :class="mode === 'forgot' && 'tab-on'" @click="switchMode('forgot')">找回密码</button>
</div>
<transition name="swap" mode="out-in">
<p v-if="mode === 'register' && auth.pendingInvite" class="invite-banner">
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12v10H4V12"/><path d="M2 7h20v5H2z"/><path d="M12 22V7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>
<span>已应用邀请码 <span class="font-mono font-semibold text-emerald-200">{{ auth.pendingInvite }}</span>,完成首次生图后邀请人得 3 积分</span>
</p>
</transition>
<form @submit.prevent="submit" class="space-y-3">
<div v-if="mode === 'register'" class="lm-field">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<input v-model="form.username" type="text" autocomplete="username" placeholder="用户名(6-24位,仅字母数字)" class="fld" />
</div>
<div v-if="mode === 'login'" class="lm-field">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<input v-model="form.identifier" type="text" autocomplete="username" placeholder="邮箱或用户名" class="fld" />
</div>
<div v-else class="lm-field">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
<input v-model="form.email" type="email" autocomplete="email" placeholder="邮箱" class="fld" />
</div>
<div class="lm-field">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input v-model="form.password" :type="showPw ? 'text' : 'password'" autocomplete="current-password"
:placeholder="mode === 'forgot' ? '新密码(8-24位,含大小写/数字/符号)' : '密码'" class="fld pr-10" />
<button type="button" class="eye" @click="showPw = !showPw" :aria-label="showPw ? '隐藏密码' : '显示密码'">
<svg v-if="showPw" viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
<svg v-else viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><path d="m2 2 20 20"/></svg>
</button>
</div>
<div v-if="needsCode" class="flex items-center gap-2">
<div class="lm-field flex-1">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 11 3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<input v-model="form.code" type="text" maxlength="6" inputmode="numeric" placeholder="邮箱验证码(6位数字)" class="fld tracking-[0.3em]" />
</div>
<button type="button" @click="sendCode" :disabled="codeCooldown > 0 || sendingCode" class="code-btn">
<svg v-if="sendingCode" class="code-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<template v-else>{{ codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码' }}</template>
</button>
</div>
<p v-if="mode !== 'login'" class="text-[11px] leading-5 text-[color:var(--fg-3)]">
用户名6-24 仅字母数字密码8-24 必须包含大写字母小写字母数字和符号
</p>
<button type="submit" :disabled="busy" class="btn-primary w-full">
<span v-if="busy" class="spinner"></span>
{{ busy ? '处理中' : mode === 'login' ? ' ' : mode === 'register' ? ' ' : '重置密码' }}
</button>
</form>
<transition name="swap" mode="out-in">
<p v-if="error" class="msg msg-err"><svg viewBox="0 0 24 24" class="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>{{ error }}</p>
<p v-else-if="notice" class="msg msg-ok"><svg viewBox="0 0 24 24" class="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{ notice }}</p>
</transition>
<p v-if="!cfg.open && cfg.has_admin" class="text-xs text-[color:var(--fg-3)] mt-4 text-center">当前未开放注册,请联系管理员开通账号</p>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.card {
border-radius: 1.25rem;
background: linear-gradient(165deg, #ffffff 0%, #f6f6fb 100%);
color: var(--fg);
border: 1px solid var(--hairline);
box-shadow: 0 30px 70px -22px rgb(15 23 42 / 0.25);
overflow: hidden;
}
html.dark .card {
background: linear-gradient(165deg, #14161f 0%, #0d0f15 100%);
color: rgb(255 255 255 / 0.9);
border: none;
box-shadow: 0 30px 70px -20px rgb(0 0 0 / 0.7);
}
.logo-halo {
position: relative;
filter: drop-shadow(0 8px 18px rgb(168 85 247 / 0.45));
}
/* segmented tab control */
.tabs {
display: flex; gap: 0.25rem; padding: 0.25rem;
border-radius: 0.75rem;
background: var(--surface-2);
border: 1px solid var(--hairline);
}
.tab {
flex: 1; padding: 0.45rem 0; border-radius: 0.55rem;
font-size: 0.8125rem; color: var(--fg-3);
transition: background 0.18s, color 0.18s, box-shadow 0.18s;
}
.tab:hover { color: var(--fg-2); }
.tab-on {
color: rgb(124 58 237);
background: linear-gradient(135deg, rgb(167 139 250 / 0.22), rgb(236 72 153 / 0.18));
box-shadow: 0 1px 0 rgb(255 255 255 / 0.08) inset, 0 4px 12px -4px rgb(168 85 247 / 0.5);
}
html.dark .tab-on { color: white; }
/* icon-prefixed input */
.lm-field { position: relative; }
.lm-field .ic {
position: absolute; left: 0.8rem; top: 50%; transform: translateY(-50%);
width: 1.05rem; height: 1.05rem; color: var(--fg-3);
pointer-events: none; transition: color 0.18s;
}
.lm-field:focus-within .ic { color: rgb(124 58 237 / 0.95); }
html.dark .lm-field:focus-within .ic { color: rgb(196 181 253 / 0.95); }
.fld {
width: 100%; padding: 0.7rem 0.85rem 0.7rem 2.4rem; border-radius: 0.7rem;
background: rgb(15 23 42 / 0.03); border: 1px solid var(--hairline);
color: var(--fg); font-size: 0.875rem; outline: none;
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
}
html.dark .fld { background: rgb(255 255 255 / 0.04); }
.fld:focus {
border-color: rgb(167 139 250 / 0.65);
background: rgb(124 58 237 / 0.04);
box-shadow: 0 0 0 3px rgb(167 139 250 / 0.18);
}
html.dark .fld:focus { background: rgb(255 255 255 / 0.06); }
.fld::placeholder { color: var(--fg-faint); }
/* Keep autofill on-theme in dark (otherwise it paints white/yellow). */
html.dark .fld:-webkit-autofill,
html.dark .fld:-webkit-autofill:hover,
html.dark .fld:-webkit-autofill:focus {
-webkit-text-fill-color: white;
caret-color: white;
-webkit-box-shadow: 0 0 0 1000px #1a1c26 inset;
box-shadow: 0 0 0 1000px #1a1c26 inset;
transition: background-color 9999s ease-in-out 0s;
}
.eye {
position: absolute; right: 0.55rem; top: 50%; transform: translateY(-50%);
display: grid; place-items: center; width: 1.9rem; height: 1.9rem;
border-radius: 0.45rem; color: var(--fg-3);
transition: color 0.15s, background 0.15s;
}
.eye:hover { color: var(--fg); background: var(--hover); }
.code-btn {
flex-shrink: 0; height: 2.7rem; padding: 0 0.85rem; border-radius: 0.7rem;
min-width: 5.5rem; /* keep width stable between text / spinner */
display: inline-flex; align-items: center; justify-content: center;
font-size: 0.75rem; white-space: nowrap;
color: rgb(196 181 253 / 0.95);
background: rgb(167 139 250 / 0.1); border: 1px solid rgb(167 139 250 / 0.3);
transition: background 0.15s, opacity 0.15s;
}
.code-btn:hover:not(:disabled) { background: rgb(167 139 250 / 0.2); }
.code-btn:disabled { opacity: 0.45; cursor: not-allowed; color: rgb(255 255 255 / 0.5); border-color: rgb(255 255 255 / 0.12); }
.code-spin { width: 1.05rem; height: 1.05rem; animation: code-spin 0.7s linear infinite; }
@keyframes code-spin { to { transform: rotate(360deg); } }
.btn-primary {
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.72rem 0; border-radius: 0.7rem; font-size: 0.9rem; font-weight: 600;
letter-spacing: 0.02em; color: white; margin-top: 0.35rem;
background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%);
box-shadow: 0 10px 24px -8px rgb(168 85 247 / 0.6);
transition: transform 0.12s, box-shadow 0.18s, filter 0.18s;
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); box-shadow: 0 12px 28px -8px rgb(168 85 247 / 0.75); }
.btn-primary:active:not(:disabled) { transform: translateY(1px); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.spinner {
width: 0.95rem; height: 0.95rem; border-radius: 9999px;
border: 2px solid rgb(255 255 255 / 0.35); border-top-color: white;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.invite-banner {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.85rem;
padding: 0.6rem 0.75rem; border-radius: 0.7rem; font-size: 0.75rem; line-height: 1.4;
color: rgb(167 243 208);
background: rgb(16 185 129 / 0.1); border: 1px solid rgb(16 185 129 / 0.25);
}
.invite-banner svg { color: rgb(110 231 183); }
.msg {
display: flex; align-items: center; gap: 0.4rem; margin-top: 0.85rem;
font-size: 0.78rem; line-height: 1.4;
}
.msg-err { color: rgb(253 164 175); }
.msg-ok { color: rgb(110 231 183); }
/* modal in/out */
.modal-enter-active { transition: opacity 0.2s ease; }
.modal-leave-active { transition: opacity 0.16s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-active .card { transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.26s; }
.modal-enter-from .card { transform: translateY(12px) scale(0.97); opacity: 0; }
/* per-mode content swap */
.swap-enter-active, .swap-leave-active { transition: opacity 0.16s ease, transform 0.16s ease; }
.swap-enter-from { opacity: 0; transform: translateY(4px); }
.swap-leave-to { opacity: 0; transform: translateY(-4px); }
</style>
+39
View File
@@ -0,0 +1,39 @@
<script setup>
// Vivid mark: a violet→fuchsia rounded badge with a faceted, folded "V".
// Self-contained SVG so it renders identically on dark and light shells.
// Each instance gets unique gradient ids (useId) so multiple logos on one
// page don't share/clobber defs.
import { useId } from 'vue'
defineProps({ size: { type: [Number, String], default: 40 } })
const uid = useId()
const gBg = `vbg-${uid}`
const gGloss = `vgl-${uid}`
</script>
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none"
xmlns="http://www.w3.org/2000/svg" class="shrink-0">
<defs>
<linearGradient :id="gBg" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#a855f7" />
<stop offset="0.55" stop-color="#7c3aed" />
<stop offset="1" stop-color="#ec4899" />
</linearGradient>
<linearGradient :id="gGloss" x1="24" y1="2" x2="24" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffffff" stop-opacity="0.28" />
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
</linearGradient>
</defs>
<!-- badge -->
<rect x="2" y="2" width="44" height="44" rx="13" :fill="`url(#${gBg})`" />
<!-- top gloss for depth -->
<rect x="2" y="2" width="44" height="22" rx="13" :fill="`url(#${gGloss})`" />
<!-- faceted V: left arm brighter, right arm dimmer -> folded look -->
<path d="M12 15 H19 L24 26 L24 37 Z" fill="#ffffff" fill-opacity="0.95" />
<path d="M36 15 H29 L24 26 L24 37 Z" fill="#ffffff" fill-opacity="0.72" />
</svg>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup>
// Shared full-screen preview for a generated image/video. One look across the
// admin (图片管理 / 日志) and the user-facing (画图记录) surfaces. Parent controls
// mount via v-if and passes the resolved media URL + meta; the component owns the
// overlay shell, image/video element, prompt + meta block, and action buttons.
import Icon from './Icon.vue'
defineProps({
src: { type: String, required: true }, // resolved media URL (generatedUrl)
kind: { type: String, default: 'image' }, // 'image' | 'video'
prompt: { type: String, default: '' },
meta: { type: String, default: '' }, // primary meta line (mono)
metaSub: { type: String, default: '' }, // optional second meta line
downloadName: { type: String, default: '' },
})
const emit = defineEmits(['close'])
</script>
<template>
<transition name="lb-fade" appear>
<div class="media-card fixed inset-0 z-50 bg-slate-950/85 backdrop-blur-sm flex items-center justify-center p-6"
@click.self="emit('close')">
<!-- Wrapper shrinks to the media's rendered width, so the info row below
lines up flush with the image's left & right edges (one clean column). -->
<div class="flex flex-col max-h-full max-w-full">
<video v-if="kind === 'video'" :src="src" controls autoplay
class="max-h-[76vh] max-w-[88vw] rounded-xl shadow-2xl object-contain"></video>
<img v-else :src="src" class="max-h-[76vh] max-w-[88vw] rounded-xl shadow-2xl object-contain" />
<div class="mt-3 flex items-start justify-between gap-4 text-white">
<div class="min-w-0 flex-1">
<div v-if="prompt" class="text-sm font-medium leading-snug line-clamp-3 break-words" :title="prompt">{{ prompt }}</div>
<div v-if="meta" class="text-xs text-white/60 mt-1 font-mono break-all">{{ meta }}</div>
<div v-if="metaSub" class="text-xs text-white/45 mt-1">{{ metaSub }}</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<a :href="src" target="_blank"
class="inline-flex items-center gap-1.5 rounded-lg bg-white/10 hover:bg-white/20 px-3 py-1.5 text-xs transition-colors">
<Icon name="open" class="w-3.5 h-3.5" /> 原图
</a>
<a :href="src" :download="downloadName"
class="inline-flex items-center gap-1.5 rounded-lg bg-white text-slate-900 hover:bg-slate-100 px-3 py-1.5 text-xs font-medium transition-colors">
<Icon name="download" class="w-3.5 h-3.5" /> 下载
</a>
<button @click="emit('close')"
class="w-8 h-8 rounded-lg bg-white/10 hover:bg-white/20 grid place-items-center transition-colors">
<Icon name="close" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.lb-fade-enter-active, .lb-fade-leave-active { transition: opacity 0.18s ease; }
.lb-fade-enter-from, .lb-fade-leave-to { opacity: 0; }
</style>
+342
View File
@@ -0,0 +1,342 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { api, jsonBody } from '../api'
import { sortResolutions } from '../utils/format'
import Icon from './Icon.vue'
import SelectMenu from './SelectMenu.vue'
const props = defineProps({
// null → add mode; an object (managed-model record) → edit mode
model: { type: Object, default: null },
})
const emit = defineEmits(['close', 'saved'])
const isEdit = computed(() => !!props.model)
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
const catalog = ref([])
const loading = ref(true)
const selectedId = ref(props.model?.id || '')
const imagePrices = ref({}) // 普通价 { '1K': '', '2K': '', ... } keyed by resolutions
const videoPrices = ref({}) // 普通价 { '5s': '', '10s': '', ... } keyed by durations
const imagePricesAgent = ref({}) // 代理价(留空 = 跟随普通价)
const videoPricesAgent = ref({}) // 代理价(留空 = 跟随普通价)
// Display weight — admin-set (NOT a catalog param): higher = higher up the
// dropdown / model list. Defaults to the stored value in edit mode, else 0.
const weight = ref(Number(props.model?.weight) || 0)
const error = ref('')
const saving = ref(false)
// The entry whose params drive the form: in edit mode it's the stored record,
// in add mode it's the catalog row for the picked id. All generation params are
// read straight off it — the admin never types them, only the price.
const entry = computed(() => {
if (isEdit.value) return props.model
return catalog.value.find((e) => e.id === selectedId.value) || null
})
const isVideo = computed(() => entry.value?.type === 'video')
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
// regardless of how the catalog/stored record happens to list them.
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
// Duration tiers for the price inputs. Prefer the declared `durations`; fall
// back to the keys of any stored duration_prices so a model saved without a
// `durations` array (the legacy sync bug) is still viewable/editable.
const durationTiers = computed(() => {
const e = entry.value
if (!e) return []
const ds = e.durations || []
return ds.length ? ds : Object.keys(e.duration_prices || {})
})
// Dropdown options: catalog rows not already in the managed store.
const addOptions = computed(() =>
catalog.value
.filter((e) => !e.added)
.map((e) => ({ value: e.id, label: `${e.id} · ${e.type === 'video' ? '视频' : '图像'}` }))
)
function resetPrices(e) {
imagePrices.value = {}
videoPrices.value = {}
imagePricesAgent.value = {}
videoPricesAgent.value = {}
if (!e) return
// Both image and video price per resolution; video ALSO prices per duration
// (real video price = resolution price + duration price).
for (const r of (e.resolutions || [])) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
if (e.type === 'video') {
for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
}
}
// In add mode, switching the selected model rebuilds the price inputs to match
// that model's resolution / duration tiers.
watch(selectedId, () => { if (!isEdit.value) resetPrices(entry.value) })
onMounted(async () => {
const r = await api('/catalog')
catalog.value = r.data?.data || []
loading.value = false
if (isEdit.value) {
const m = props.model
// Resolution prices apply to both; video additionally has duration prices.
// Agent prices are an optional overlay (blank = follows the normal price).
for (const r of (m.resolutions || [])) {
imagePrices.value[r] = m.prices?.[r] ?? ''
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
}
if (m.type === 'video') {
const durs = (m.durations && m.durations.length) ? m.durations : Object.keys(m.duration_prices || {})
for (const d of durs) {
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
}
}
}
})
async function save() {
const e = entry.value
if (!e) { error.value = '请选择模型'; return }
error.value = ''
// Collect valid (>=0) numeric prices from a {key: value} ref into a plain map.
const collect = (src, keys) => {
const out = {}
for (const k of keys) {
const raw = String(src[k] ?? '').trim()
if (raw === '') continue
const n = Number(raw)
if (!isNaN(n) && n >= 0) out[k] = n
}
return out
}
let payload
if (e.type === 'video') {
// Real video price = resolution price + duration price. Both tiers are
// priced independently; a blank tier on either axis = unsupported. Charge
// happens server-side as prices[res] + duration_prices[dur].
const prices = collect(imagePrices.value, e.resolutions || [])
const duration_prices = collect(videoPrices.value, durationTiers.value)
// 代理价:可选覆盖,留空的档跟随普通价。
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value)
if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return }
if (!Object.keys(duration_prices).length) { error.value = '至少填写一个时长价格'; return }
payload = {
type: 'video',
provider: e.provider,
ratios: e.ratios || [],
resolutions: e.resolutions || [],
prices,
// durations MUST track duration_prices — the model list / docs iterate
// `durations` to render the per-second price chips. Persisting prices
// without the matching durations array hides them. Keep them in sync.
durations: Object.keys(duration_prices),
duration_prices,
prices_agent,
duration_prices_agent,
max_reference_images: e.max_reference_images || 0,
reference_mode: e.reference_mode || 'none',
weight: Number(weight.value) || 0,
}
} else {
const prices = collect(imagePrices.value, e.resolutions || [])
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return }
payload = {
type: 'image',
provider: e.provider,
ratios: e.ratios || [],
prices,
prices_agent,
image_to_image: !!e.image_to_image,
// 多参考图:把目录定义的张数(gpt=3/seedream=6/flux=4 …)写进模型,
// 否则后端仍按旧值(默认 1)限制。
max_reference_images: e.max_reference_images || 0,
reference_mode: e.reference_mode || 'none',
weight: Number(weight.value) || 0,
}
}
saving.value = true
const r = isEdit.value
? await api(`/managed-models/${encodeURIComponent(e.id)}`, jsonBody('PATCH', payload))
: await api('/managed-models', jsonBody('POST', { id: e.id, ...payload }))
saving.value = false
if (r.ok) emit('saved')
else error.value = r.data?.detail || `保存失败 (${r.status})`
}
</script>
<template>
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
@click.self="emit('close')">
<div class="card !shadow-2xl my-12 w-full max-w-md">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">{{ isEdit ? '编辑价格' : '新增模型' }}</h2>
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
<Icon name="close" class="w-5 h-5" />
</button>
</div>
<div class="p-5 space-y-4">
<div v-if="loading" class="text-center text-sm text-white/40 py-8">加载支持的模型</div>
<template v-else>
<!-- model id: dropdown when adding, fixed label when editing -->
<div>
<label class="lbl">模型</label>
<SelectMenu v-if="!isEdit" v-model="selectedId" :options="addOptions"
placeholder="选择一个支持的模型" />
<div v-else class="field font-mono !cursor-default opacity-90">{{ entry?.id }}</div>
<p v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-300/80 mt-1.5">
所有支持的模型都已添加
</p>
</div>
<!-- read-only param summary, straight from the loaded catalog -->
<div v-if="entry" class="rounded-xl bg-white/[0.03] ring-1 ring-white/[0.06] p-3.5 space-y-2.5">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="isVideo ? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30'
: 'bg-indigo-500/10 text-indigo-300 ring-indigo-400/30'">
{{ isVideo ? '生视频' : '生图' }}
</span>
<span class="text-[11px] text-white/45 capitalize">{{ entry.provider }}</span>
<span class="ml-auto text-[10px] text-white/30">参数自动加载,不可改</span>
</div>
<div class="grid grid-cols-[3.5rem_1fr] gap-x-3 gap-y-1.5 text-[11px]">
<span class="text-white/40">比例</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in (entry.ratios || [])" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!(entry.ratios || []).length" class="text-white/30"></span>
</div>
<template v-if="isVideo">
<span class="text-white/40">分辨率</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!resolutions.length" class="text-white/30"></span>
</div>
<span class="text-white/40">时长</span>
<div class="flex flex-wrap gap-1">
<span v-for="d in (entry.durations || [])" :key="d" class="ro-chip">{{ d }}</span>
<span v-if="!(entry.durations || []).length" class="text-white/30"></span>
</div>
<span class="text-white/40">参考图</span>
<div class="text-white/70">
{{ entry.max_reference_images > 0
? `${entry.max_reference_images} 张 · ${REF_MODE_LABEL[entry.reference_mode] || entry.reference_mode}`
: '不支持' }}
</div>
</template>
<template v-else>
<span class="text-white/40">画质</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!resolutions.length" class="text-white/30"></span>
</div>
<span class="text-white/40">图生图</span>
<div class="text-white/70">{{ entry.image_to_image ? '支持' : '不支持' }}</div>
</template>
</div>
</div>
<!-- ===== PRICE: image = per-quality; video = per-quality + per-duration (additive) ===== -->
<template v-if="entry">
<div class="space-y-4">
<!-- resolution prices (both image & video): 普通价 + 代理价 -->
<div>
<label class="lbl">{{ isVideo ? '分辨率价格' : '画质价格' }} <span class="text-white/35">(普通价留空 = 不支持该档;代理价留空 = 跟随普通价)</span></label>
<div class="space-y-2">
<div v-if="resolutions.length" class="flex items-center gap-2 text-[10px] text-white/35 pl-14">
<span class="flex-1">普通价</span>
<span class="flex-1">代理价</span>
</div>
<div v-for="r in resolutions" :key="r" class="flex items-center gap-2">
<div class="w-12 shrink-0 text-sm text-white/85 font-mono">{{ r }}</div>
<div class="relative flex-1">
<input v-model="imagePrices[r]" type="number" min="0" step="1" class="field !pr-10" placeholder="普通价" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/30 text-[10px]">积分</span>
</div>
<div class="relative flex-1">
<input v-model="imagePricesAgent[r]" type="number" min="0" step="1" class="field !pr-10" placeholder="跟随普通" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-300/40 text-[10px]">代理</span>
</div>
</div>
<p v-if="!resolutions.length" class="text-xs text-white/35">该模型未声明分辨率档位</p>
</div>
</div>
<!-- duration prices (video only): 普通价 + 代理价 -->
<div v-if="isVideo">
<label class="lbl">时长价格 <span class="text-white/35">(代理价留空 = 跟随普通价)</span></label>
<div v-if="durationTiers.length" class="space-y-2">
<div class="flex items-center gap-2 text-[10px] text-white/35 pl-14">
<span class="flex-1">普通价</span>
<span class="flex-1">代理价</span>
</div>
<div v-for="d in durationTiers" :key="d" class="flex items-center gap-2">
<div class="w-12 shrink-0 text-sm text-white/85 font-mono">{{ d }}</div>
<div class="relative flex-1">
<input v-model="videoPrices[d]" type="number" min="0" step="1" class="field !pr-10" placeholder="普通价" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/30 text-[10px]">积分</span>
</div>
<div class="relative flex-1">
<input v-model="videoPricesAgent[d]" type="number" min="0" step="1" class="field !pr-10" placeholder="跟随普通" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-300/40 text-[10px]">代理</span>
</div>
</div>
</div>
<p v-else class="text-xs text-white/35">该模型未声明时长档位</p>
</div>
<p v-if="isVideo" class="text-[11px] text-white/40">实付 = 分辨率价 + 时长价(:720p 50 + 5s 30 = 80 积分)</p>
<!-- display weight: admin-set ordering (not a catalog param) -->
<div>
<label class="lbl">展示权重 <span class="text-white/35">(数值越大,在下拉 / 列表中越靠前;相同权重按新建时间)</span></label>
<input v-model="weight" type="number" step="1" class="field" placeholder="0" />
</div>
</div>
</template>
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
<div class="flex justify-end gap-2 pt-1">
<button @click="emit('close')" class="btn-soft">取消</button>
<button @click="save" :disabled="saving || !entry" class="btn-primary">{{ saving ? '保存中…' : '保存' }}</button>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.lbl {
display: block;
font-size: 0.72rem;
font-weight: 500;
color: rgb(255 255 255 / 0.55);
margin-bottom: 0.4rem;
}
/* read-only param chip */
.ro-chip {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.45rem;
font-size: 0.68rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
border-radius: 0.4rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.05);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<script setup>
// Custom dropdown that replaces the native <select> so the OPEN list is themed
// too (native option popups can't be styled). Trigger mirrors the `.field` look;
// the panel is a floating dark menu with hover + selected states. Keyboard:
// Enter/Space/↑/↓ open, ↑/↓ move, Enter select, Esc close.
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import Icon from './Icon.vue'
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
// [{ value, label }]
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '请选择' },
mono: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const root = ref(null)
const active = ref(-1) // keyboard-highlighted index
const selected = computed(() => props.options.find((o) => o.value === props.modelValue) || null)
const label = computed(() => (selected.value ? selected.value.label : props.placeholder))
function toggle() {
if (props.disabled) return
open.value ? close() : openMenu()
}
function openMenu() {
open.value = true
active.value = Math.max(0, props.options.findIndex((o) => o.value === props.modelValue))
nextTick(scrollActiveIntoView)
}
function close() {
open.value = false
active.value = -1
}
function pick(opt) {
emit('update:modelValue', opt.value)
close()
}
function onKeydown(e) {
if (!open.value) {
if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(e.key)) { e.preventDefault(); openMenu() }
return
}
if (e.key === 'Escape') { e.preventDefault(); close() }
else if (e.key === 'ArrowDown') { e.preventDefault(); active.value = Math.min(props.options.length - 1, active.value + 1); scrollActiveIntoView() }
else if (e.key === 'ArrowUp') { e.preventDefault(); active.value = Math.max(0, active.value - 1); scrollActiveIntoView() }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.options[active.value]) pick(props.options[active.value]) }
}
const panel = ref(null)
function scrollActiveIntoView() {
nextTick(() => {
const el = panel.value?.querySelector(`[data-idx="${active.value}"]`)
el?.scrollIntoView({ block: 'nearest' })
})
}
function onDocClick(e) {
if (open.value && root.value && !root.value.contains(e.target)) close()
}
onMounted(() => document.addEventListener('mousedown', onDocClick))
onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
</script>
<template>
<div ref="root" class="relative">
<!-- trigger -->
<button type="button" @click="toggle" @keydown="onKeydown"
:aria-expanded="open"
:disabled="disabled"
class="field flex items-center justify-between gap-2 text-left disabled:opacity-50 disabled:cursor-not-allowed"
:class="[mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']">
<span class="truncate">{{ label }}</span>
<Icon name="chevron"
class="w-4 h-4 shrink-0 text-[color:var(--fg-3)] transition-transform duration-200"
:class="open ? 'rotate-180' : ''" />
</button>
<!-- panel -->
<transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 -translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-1">
<div v-if="open" ref="panel"
class="absolute z-30 mt-2 w-full max-h-64 overflow-auto rounded-xl border border-[color:var(--hairline)]
bg-[var(--menu-bg)] backdrop-blur-xl p-1.5 shadow-2xl shadow-black/20 ring-1 ring-[color:var(--hairline)]">
<button v-for="(o, i) in options" :key="o.value" type="button"
:data-idx="i" @click="pick(o)" @mouseenter="active = i"
class="w-full flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors"
:class="[
mono ? 'font-mono' : '',
i === active ? 'bg-[var(--hover)] text-[color:var(--fg)]' : 'text-[color:var(--fg-2)]',
]">
<span class="truncate">{{ o.label }}</span>
<Icon v-if="o.value === modelValue" name="check" class="w-4 h-4 shrink-0 text-violet-400" />
</button>
<div v-if="!options.length" class="px-3 py-2 text-xs text-[color:var(--fg-3)]">无选项</div>
</div>
</transition>
</div>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue'
import Icon from './Icon.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
presets: { type: Array, default: () => [] },
placeholder: { type: String, default: '输入后回车添加' },
})
const emit = defineEmits(['update:modelValue'])
const input = ref('')
function add(v) {
const s = String(v ?? '').trim()
if (s && !props.modelValue.includes(s)) emit('update:modelValue', [...props.modelValue, s])
}
function addInput() { add(input.value); input.value = '' }
function remove(i) {
const arr = [...props.modelValue]
arr.splice(i, 1)
emit('update:modelValue', arr)
}
</script>
<template>
<div>
<div v-if="modelValue.length" class="flex flex-wrap gap-1.5 mb-2">
<span v-for="(t, i) in modelValue" :key="t" class="chip">
{{ t }}
<button type="button" @click="remove(i)" class="chip-x" :title="`移除 ${t}`">
<Icon name="close" class="w-3 h-3" />
</button>
</span>
</div>
<div class="flex gap-2">
<input v-model="input" @keyup.enter.prevent="addInput" class="field" :placeholder="placeholder" />
<button type="button" @click="addInput" class="btn-soft shrink-0">添加</button>
</div>
<div v-if="presets.length" class="flex flex-wrap gap-1.5 mt-2">
<button v-for="p in presets" :key="p" type="button" @click="add(p)" :disabled="modelValue.includes(p)"
class="preset-btn">
+ {{ p }}
</button>
</div>
</div>
</template>
<style scoped>
/* Tag chip — small pill that reads as a brand-tinted token on the dark
admin shell. The close button stays subtle until you hover, then turns
white on a muted-rose hover state so removal feels intentional. */
.chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.55rem 0.25rem 0.65rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
color: rgb(238 224 255); /* near-white violet */
background: linear-gradient(135deg, rgb(167 139 250 / 0.18), rgb(236 72 153 / 0.14));
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.3);
transition: background 0.15s ease;
}
.chip:hover { background: linear-gradient(135deg, rgb(167 139 250 / 0.25), rgb(236 72 153 / 0.2)); }
.chip-x {
display: grid;
place-items: center;
width: 1.1rem;
height: 1.1rem;
margin-right: -0.15rem;
border-radius: 9999px;
color: rgb(255 255 255 / 0.55);
transition: color 0.12s ease, background 0.12s ease;
}
.chip-x:hover {
color: white;
background: rgb(244 63 94 / 0.35); /* rose hint on hover */
}
/* Preset suggestion buttons — sit below the input, soft dark surface. */
.preset-btn {
font-size: 0.72rem;
padding: 0.25rem 0.55rem;
border-radius: 0.5rem;
color: rgb(255 255 255 / 0.6);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s ease, color 0.15s ease;
}
.preset-btn:hover:not(:disabled) { background: rgb(255 255 255 / 0.08); color: white; }
.preset-btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
+314
View File
@@ -0,0 +1,314 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api, jsonBody } from '../api'
import { sortResolutions } from '../utils/format'
import Icon from './Icon.vue'
const props = defineProps({
model: { type: Object, required: true },
})
const emit = defineEmits(['close'])
const isVideo = props.model.type === 'video'
// Video capabilities — the authoritative source is the family preset
// (core/video_models.FAMILIES), not the user-edited managed-model record
// which may have stale max_reference_images / reference_mode values.
const familyPreset = ref(null)
onMounted(async () => {
if (!isVideo) return
const r = await api('/video-presets')
const list = r.data?.data || []
familyPreset.value = list.find((p) => p.key === props.model.id) || null
})
const ratios = (props.model.ratios && props.model.ratios.length)
? props.model.ratios
: (isVideo ? ['16x9'] : ['1:1'])
const resolutions = (props.model.resolutions && props.model.resolutions.length)
? sortResolutions(props.model.resolutions)
: (isVideo ? ['720p'] : ['2K'])
const durations = computed(() => {
// duration_prices keys come back alphabetically from Go ("10s" before "5s");
// sort by numeric seconds so the shortest is first.
const keys = Object.keys(props.model?.duration_prices || {})
.sort((a, b) => parseFloat(a) - parseFloat(b))
if (keys.length) return keys
if (familyPreset.value?.durations?.length) return familyPreset.value.durations
return ['5s', '10s']
})
// Reference image support — for video models, max_reference_images > 0 means
// frames can/must be uploaded. Kling 3 i2v actually requires >= 1.
const maxRefs = computed(() => {
const fromPreset = Number(familyPreset.value?.max_reference_images || 0)
const fromModel = Number(props.model?.max_reference_images || 0)
const m = Math.max(fromPreset, fromModel)
if (m > 0) return m
// Image models advertise image-to-image via a boolean (not a count) — allow one.
if (!isVideo && props.model?.image_to_image) return 1
return 0
})
const refMode = computed(() => familyPreset.value?.reference_mode || props.model?.reference_mode || 'none')
// Reference images are ALWAYS optional — every video model supports pure
// text2video; frame refs (首帧/末帧) only enhance the result when supplied.
const refsRequired = computed(() => false)
const refsLabel = computed(() => {
if (!isVideo) return maxRefs.value > 0 ? '参考图 (可选, 图生图)' : ''
if (refMode.value === 'asset') return `参考图 (asset 模式, 最多 ${maxRefs.value} 张)`
if (refMode.value === 'frame') {
if (maxRefs.value >= 2) return `首帧 / 末帧 (1=首帧, 2=首尾帧, 最多 ${maxRefs.value} 张)`
return `首帧 (可选, ${maxRefs.value} 张)`
}
return ''
})
const prompt = ref(isVideo
? 'A cinematic shot of a golden retriever running through a wheat field at sunset.'
: 'a cute cat sitting on a desk, studio lighting')
const ratio = ref(ratios[0])
const resolution = ref(resolutions[0])
const duration = ref(durations.value[0])
const refImages = ref([]) // [{ name, dataUrl }]
const fileInput = ref(null)
// Image 5 instruct-edit derives aspect from the reference image — hide the ratio
// picker when a ref is attached (backend omits aspectRatio to avoid a 400).
const showRatio = computed(() => !(props.model.id === 'firefly-image-5' && refImages.value.length > 0))
function openPicker() { fileInput.value && fileInput.value.click() }
function onFiles(ev) {
const files = Array.from(ev.target.files || [])
const room = Math.max(0, maxRefs.value - refImages.value.length)
const toAdd = files.slice(0, room)
for (const f of toAdd) {
const reader = new FileReader()
reader.onload = () => {
refImages.value.push({ name: f.name, dataUrl: reader.result })
}
reader.readAsDataURL(f)
}
if (ev.target) ev.target.value = ''
}
function removeRef(i) { refImages.value.splice(i, 1) }
const busy = ref(false)
const status = ref('')
const error = ref('')
const resultUrl = ref('')
const resultKind = ref('')
// Gateway-timeout recovery: the backend detaches the render from the request
// (context.WithoutCancel), so an EdgeOne 524 / proxy timeout does NOT kill it —
// it finishes and is logged as a source="admin" event. On such a timeout we keep
// the modal "生成中" and poll /jobs/mine?source=admin to recover the result.
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
let recoverTimer = null
let recoverJobId = ''
let recoverSubmitTs = 0
onUnmounted(() => clearTimeout(recoverTimer))
async function run() {
if (!prompt.value.trim()) { error.value = '请输入提示词'; return }
if (refsRequired.value && refImages.value.length < 1) {
error.value = '该视频模型需要至少 1 张参考图 (首帧)'
return
}
busy.value = true
error.value = ''
status.value = isVideo ? '正在生成视频 (约 13 分钟)…' : '正在生成…'
resultUrl.value = ''
resultKind.value = ''
const payload = {
model: props.model.id,
prompt: prompt.value,
ratio: ratio.value,
resolution: resolution.value,
}
if (isVideo) {
payload.duration = duration.value
}
if (refImages.value.length) {
// Backend accepts raw base64 only — strip the "data:...;base64," prefix.
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
}
recoverSubmitTs = Date.now()
const r = await api('/test', jsonBody('POST', payload))
if (r.ok && r.data?.url) {
busy.value = false
resultUrl.value = r.data.url
resultKind.value = r.data.kind || (isVideo ? 'video' : 'image')
status.value = `完成 · ${r.data.provider} · ${r.data.elapsed_ms}ms`
} else if (GATEWAY_TIMEOUT.has(r.status)) {
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。保持锁住,轮询恢复结果。
status.value = isVideo ? '生成视频中 (约 13 分钟)…' : '生成中…'
recoverJobId = ''
clearTimeout(recoverTimer)
recoverTimer = setTimeout(recover, 3000)
} else {
busy.value = false
status.value = ''
error.value = r.data?.detail || `失败 (${r.status})`
}
}
// Poll the admin's own in-flight/just-finished test job after a gateway timeout.
async function recover() {
const r = await api('/jobs/mine?source=admin')
if (!r.ok) { recoverTimer = setTimeout(recover, 3000); return }
const { pending, latest } = r.data || {}
if (pending) {
// Still rendering server-side — remember its id and keep waiting.
recoverJobId = pending.id
recoverTimer = setTimeout(recover, 3000)
return
}
// No pending: did OUR job finish? Match by the id we saw, or (if it completed
// before our first poll) by a latest that started at/after our submit.
const mine = latest && (
(recoverJobId && latest.id === recoverJobId) ||
(!recoverJobId && latest.ts && latest.ts * 1000 >= recoverSubmitTs - 2000)
)
if (mine && latest.status === 'success' && latest.url) {
busy.value = false
resultUrl.value = latest.url
resultKind.value = latest.kind || (isVideo ? 'video' : 'image')
status.value = `完成 · ${(latest.elapsed_ms / 1000).toFixed(1)}s`
return
}
if (mine && latest.status === 'failed') {
busy.value = false
status.value = ''
error.value = latest.error || '生成失败'
return
}
// Not resolved yet (event still committing) — keep polling.
recoverTimer = setTimeout(recover, 3000)
}
</script>
<template>
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
@click.self="emit('close')">
<div class="card !shadow-2xl my-12 w-full max-w-lg">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<div class="min-w-0">
<h2 class="text-sm font-semibold">测试模型</h2>
<div class="text-xs text-white/45 font-mono truncate">{{ model.id }}</div>
</div>
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
<Icon name="close" class="w-5 h-5" />
</button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="lbl">提示词</label>
<textarea v-model="prompt" rows="3" class="field resize-none" placeholder="输入测试提示词…"></textarea>
</div>
<!-- Param controls same pill-button row as the public 画图 page.
Show the row whenever there's at least one option (even a single
one) so the chosen value is visible, not silently hidden. -->
<div v-if="ratios.length > 0 && showRatio">
<label class="lbl">比例</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="r in ratios" :key="r" type="button" @click="ratio = r"
class="opt" :class="ratio === r && 'opt-on'">{{ r }}</button>
</div>
</div>
<div v-if="resolutions.length > 0">
<label class="lbl">{{ isVideo ? '分辨率' : '画质' }}</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="r in resolutions" :key="r" type="button" @click="resolution = r"
class="opt" :class="resolution === r && 'opt-on'">{{ r }}</button>
</div>
</div>
<div v-if="isVideo && durations.length > 0">
<label class="lbl">时长</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="d in durations" :key="d" type="button" @click="duration = d"
class="opt" :class="duration === d && 'opt-on'">{{ d }}</button>
</div>
</div>
<!-- reference images -->
<div v-if="maxRefs > 0">
<label class="lbl">
{{ refsLabel }}
<span v-if="refsRequired" class="text-rose-300">*</span>
</label>
<div class="flex gap-2 flex-wrap items-start">
<div v-for="(img, i) in refImages" :key="i"
class="relative w-20 h-20 rounded-lg overflow-hidden ring-1 ring-white/10 bg-white/[0.04]">
<img :src="img.dataUrl" class="w-full h-full object-cover" />
<button type="button" @click="removeRef(i)"
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white hover:bg-rose-500 transition-colors grid place-items-center">
<Icon name="close" class="w-3 h-3" />
</button>
<div v-if="refMode === 'frame' && maxRefs >= 2"
class="absolute bottom-0 inset-x-0 text-[10px] text-white bg-black/60 text-center py-0.5">
{{ i === 0 ? '首帧' : (i === 1 ? '末帧' : '') }}
</div>
</div>
<button v-if="refImages.length < maxRefs" type="button" @click="openPicker"
class="w-20 h-20 rounded-lg border-2 border-dashed border-white/15 text-white/40 hover:bg-white/[0.04] hover:border-white/30 transition-colors grid place-items-center">
<Icon name="plus" class="w-5 h-5" />
</button>
</div>
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" />
</div>
<button @click="run" :disabled="busy" class="btn-primary w-full">
<Icon name="spark" class="w-4 h-4" /> {{ busy ? (isVideo ? '生成中…(请耐心等待)' : '生成中…') : '生成' }}
</button>
<p v-if="status" class="text-xs text-white/55">{{ status }}</p>
<p v-if="error" class="text-xs text-rose-300 break-all">{{ error }}</p>
<div v-if="resultUrl" class="rounded-xl ring-1 ring-white/10 bg-white/[0.03] overflow-hidden grid place-items-center min-h-[220px]">
<video v-if="resultKind === 'video'" :src="resultUrl" controls autoplay
class="max-w-full max-h-[420px] object-contain" />
<img v-else :src="resultUrl" class="max-w-full max-h-[360px] object-contain" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.lbl {
display: block;
font-size: 0.72rem;
font-weight: 500;
color: rgb(255 255 255 / 0.55);
margin-bottom: 0.4rem;
}
/* Pill option button — exactly the same shape as the public 画图 page's
ratio/resolution/duration row. Replaces the native <select> drop-down. */
.opt {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.5rem;
color: rgb(255 255 255 / 0.65);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.opt:hover { background: rgb(255 255 255 / 0.08); color: white; }
.opt-on {
background: rgb(255 255 255 / 0.92);
color: rgb(15 23 42);
box-shadow: none;
}
</style>