Files
frontend_v2/src/components/AuthModal.astro
toom1996 4b409b5a29 update
2026-09-20 00:44:14 +08:00

173 lines
7.1 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
import { getI18n } from "@/lib/i18n"
// 全站统一登录门禁,两种语义属性(按使用场景二选一,不要混用):
// - login-required:需要登录的「动作」(收藏/翻页/看完整图…)。点击时若未登录 →
// 就地弹出本登录层并记住该元素;登录成功后自动重放这次点击(继续原操作)。
// - login-redirect:需要登录的「完整入口」(想直接去账户/详情受限区…)。未登录 →
// 跳独立登录页(自动带 redirect 回跳当前页),登录完成后回原处。
// 已登录时两者都直接放行。本组件挂在 Layout 一次,全站共用同一 store。
const { t } = getI18n(Astro)
const title = t('login')
const prompt = t('login to continue')
const accountLabel = t('username or email')
const passwordLabel = t('password')
const failLabel = t('login failed')
const closeLabel = t('close')
const fullLoginLabel = t('go to full login')
---
<!-- 全局登录层:fixed 覆盖层,所有页面生效 -->
<div
x-data="$store.auth"
x-show="isOpen"
x-cloak
data-auth-fail={failLabel}
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 px-5"
role="dialog"
aria-modal="true"
aria-label={title}
@click.self="close()"
@keydown.escape.window="close()"
>
<div class="w-full max-w-sm border-2 border-black bg-white p-8">
<div class="mb-6 flex items-start justify-between border-b-2 border-black pb-4">
<h3 class="text-2xl font-black uppercase leading-none tracking-tight">{title}</h3>
<button type="button" @click="close()" class="-mt-1 text-[11px] uppercase tracking-widest opacity-60 transition-opacity hover:opacity-100">{closeLabel}</button>
</div>
<p class="mb-7 text-[11px] uppercase tracking-widest text-[var(--c-gray-600)]">{prompt}</p>
<form @submit.prevent="submit()" class="space-y-6" novalidate>
<div>
<label class="mb-2 block text-[11px] uppercase tracking-widest text-[var(--c-gray-700)]">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-2 text-sm outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<div>
<label class="mb-2 block text-[11px] uppercase tracking-widest text-[var(--c-gray-700)]">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-2 text-sm outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<p x-show="error" x-text="error" x-cloak class="text-xs uppercase tracking-wide text-red-600"></p>
<button type="submit" :disabled="loading"
class="w-full border border-black bg-black py-3.5 text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:opacity-40">
<span x-show="!loading">{title}</span>
<span x-show="loading" x-cloak>…</span>
</button>
</form>
<div class="mt-6 border-t border-black/10 pt-4 text-center">
<button type="button" @click="goFullLogin()" class="text-[10px] uppercase tracking-[0.2em] text-[var(--c-gray-600)] transition-colors hover:text-black">{fullLoginLabel}</button>
</div>
</div>
</div>
<script>
import { getUser } from "@/lib/auth"
import { login } from "@/lib/api"
// 全局登录 store 的数据形状。
// 必须显式声明并注解:@types/alpinejs 的 Stores 是 `[key: string | symbol]: unknown`,
// 内联字面量会失去上下文类型,导致各方法内 this 退化成 {}(原先 16 处报错)。
interface AuthStore {
isOpen: boolean
pending: HTMLElement | null
account: string
password: string
error: string
loading: boolean
request(el: HTMLElement): boolean
close(): void
submit(): Promise<void>
goFullLogin(): void
}
document.addEventListener("alpine:init", () => {
// 全局登录 store:request(el) 返回是否放行;未登录记录 pending 并开层
const authStore: AuthStore = {
isOpen: false,
pending: null as HTMLElement | null,
account: "",
password: "",
error: "",
loading: false,
request(el: HTMLElement): boolean {
if (getUser()) return true // 已登录:放行,交还原生点击
this.pending = el
this.error = ""
this.isOpen = true
document.body.style.overflow = "hidden" // 锁背景滚动
return false
},
close() {
// 未打开时直接返回:本函数的 ESC 监听挂在 window 上,页面里任何一次 ESC 都会调到它。
// 若无条件清掉 body 滚动锁,会把别的浮层(图片灯箱等)的滚动锁一起抢掉。
if (!this.isOpen) return
this.isOpen = false
this.pending = null
this.error = ""
document.body.style.overflow = ""
},
async submit() {
this.error = ""
this.loading = true
try {
await login({ account: this.account, password: this.password })
const el = this.pending
this.pending = null
this.isOpen = false
this.account = ""
this.password = ""
document.body.style.overflow = ""
// 登录成功后自动重放刚才被拦截的那次点击,让用户操作无缝继续
if (el) requestAnimationFrame(() => { try { el.click() } catch { /* 忽略 */ } })
} catch {
const host = document.querySelector<HTMLElement>("[data-auth-fail]")
this.error = host?.dataset.authFail || "login failed"
} finally {
this.loading = false
}
},
// 弹窗内兜底:前往独立完整登录页(带回跳当前页)
goFullLogin() {
const p = window.location.pathname + window.location.search
const loc = p.startsWith("/cn") ? "cn" : "en"
this.close()
window.location.assign(`/${loc}/login?redirect=${encodeURIComponent(p)}`)
},
}
Alpine.store("auth", authStore)
// 全站统一登录门禁:两种语义属性(capture 阶段,先于元素自身 @click 处理)
// login-required → 就地弹窗(登录后自动重放原点击)
// login-redirect → 跳独立登录页(带回跳)
function fullLoginUrl(): string {
const p = window.location.pathname + window.location.search
const loc = p.startsWith("/cn") ? "cn" : "en"
return `/${loc}/login?redirect=${encodeURIComponent(p)}`
}
document.addEventListener("click", (e) => {
const hit = e.target as HTMLElement | null
if (!hit) return
const gate = hit.closest?.("[login-required], [login-redirect]") as HTMLElement | null
if (!gate) return
if (getUser()) return // 已登录:放行,交还原生点击
e.preventDefault()
e.stopPropagation()
if (gate.hasAttribute("login-redirect")) {
window.location.assign(fullLoginUrl())
return
}
;(Alpine.store("auth") as any).request(hit) // 记住实际点击目标,登录后重放
}, true)
})
</script>