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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 ? '正在生成视频 (约 1–3 分钟)…' : '正在生成…'
|
||||
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 ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
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>
|
||||
Reference in New Issue
Block a user