173 lines
7.1 KiB
Plaintext
173 lines
7.1 KiB
Plaintext
---
|
||
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>
|