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,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>
|
||||
Reference in New Issue
Block a user