update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-09-06 00:50:52 +08:00
parent 36f3ad039f
commit 19fb597bb4
20 changed files with 497 additions and 548 deletions

View File

@ -0,0 +1,150 @@
---
import { getI18n } from "@/i18n/utils"
// 全站统一登录门禁,两种语义属性(按使用场景二选一,不要混用):
// - 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="font-serif 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 font-mono text-[11px] uppercase tracking-widest text-zinc-400">{prompt}</p>
<form @submit.prevent="submit()" class="space-y-6" novalidate>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-widest text-zinc-500">{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 font-mono text-[11px] uppercase tracking-widest text-zinc-500">{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 font-mono 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="font-mono text-[10px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">{fullLoginLabel}</button>
</div>
</div>
</div>
<script>
import { getUser, login } from "@/lib/api"
document.addEventListener("alpine:init", () => {
// 全局登录 store:request(el) 返回是否放行;未登录记录 pending 并开层
Alpine.store("auth", {
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() {
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)}`)
},
})
// 全站统一登录门禁:两种语义属性(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>