This commit is contained in:
toom1996
2026-09-15 19:59:14 +08:00
parent 199300b203
commit 3f79cef587
16 changed files with 373 additions and 241 deletions

View File

@ -3,7 +3,7 @@ import '@/styles/global.css'
import LoadingOverlay from '@/components/LoadingOverlay.astro'
import AuthModal from '@/components/AuthModal.astro'
import { href, ROUTES } from '@/lib/routes'
import { getI18n, LOCALE_NAMES, setApiLocale } from '@/lib/i18n'
import { getI18n, LOCALE_NAMES, setApiLocale, DEFAULT_LOCALE, STORED_LOCALE_KEY } from '@/lib/i18n'
import { config } from '@/lib/config'
import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n';
@ -74,6 +74,19 @@ const headers = {
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head>
<script is:inline define:vars={{ defaultLocale: DEFAULT_LOCALE, storedKey: STORED_LOCALE_KEY }}>
// 记住语言偏好:若用户曾选过非默认语言,进首页(/ 或 /<default>)时自动跳到该语言首页
(function () {
try {
var stored = localStorage.getItem(storedKey);
var def = defaultLocale;
var p = location.pathname;
if (stored && stored !== def && (p === '/' || p === '/' + def)) {
location.replace('/' + stored);
}
} catch (e) {}
})();
</script>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
@ -94,7 +107,7 @@ const headers = {
<a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Home')}</a>
<a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">
{t('Portfolio')}
<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full tracking-tight leading-none">
2026
</span>
</a>
@ -138,7 +151,7 @@ const headers = {
<div class="flex items-center gap-3">
<!-- 语言切换器:恢复为 Alpine.js 控制开合(x-data + x-show + x-transition) -->
<div class="relative" id="lang-switch" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<div class="relative" id="lang-switch" x-data="langSwitch()" @mouseenter="open = true" @mouseleave="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-[18px] h-[18px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
@ -154,9 +167,9 @@ const headers = {
>
{headers.languages.map((l) => (
<li>
<a
href={getRelativeLocaleUrl(l.code, pathWithoutLocale)}
@click="open = false"
<a
href={getRelativeLocaleUrl(l.code, pathWithoutLocale)}
@click={`open = false; pick('${l.code}')`}
class:list={[
'block px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors',
l.code === locale ? 'font-bold' : 'opacity-60',
@ -176,7 +189,7 @@ const headers = {
<div x-show="user" x-cloak class="relative" @mouseenter="open = true" @mouseleave="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-haspopup="menu" :aria-expanded="open">
<span x-text="shortName"></span>
<span x-show="user?.tier === 'vip'" x-cloak class="inline-block border border-black bg-black px-1.5 py-[1px] font-mono text-[9px] uppercase leading-none tracking-[0.2em] text-white" aria-label="VIP">VIP</span>
<span x-show="user?.tier === 'vip'" x-cloak class="inline-block border border-black bg-black px-1.5 py-[1px] text-[9px] uppercase leading-none tracking-[0.2em] text-white" aria-label="VIP">VIP</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="acct-chevron w-[14px] h-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
@ -203,11 +216,70 @@ const headers = {
import { getUser } from "@/lib/auth"
import type { AuthUser } from "@/lib/auth"
import { fetchMe, logout, enforceIdleLogout } from "@/lib/api"
import { storeLocale, getStoredLocale, DEFAULT_LOCALE, LOCALE_NAMES } from "@/lib/i18n"
// 空闲登出:超过 config.idleLogoutDays 天没访问则登出(在 Alpine 初始化前先跑,避免登录态闪烁)
enforceIdleLogout()
document.addEventListener("alpine:init", () => {
// 语言切换器:点击某语言时记住偏好(storeLocale),下次进首页自动跳该语言
Alpine.data("langSwitch", () => ({
open: false,
pick(code: string) {
storeLocale(code as "en" | "cn")
},
}))
// 语言建议弹窗:未选过语言、且浏览器语言与当前首页语言不符时,提示是否切换
Alpine.data("localeSuggest", () => {
const names = {
en: LOCALE_NAMES.en.nativeName,
cn: LOCALE_NAMES.cn.nativeName,
}
return {
visible: false,
target: "en" as "en" | "cn",
current: "en" as "en" | "cn",
names,
init() {
try {
const def = DEFAULT_LOCALE
// 已选过偏好(含此前点「保持当前」的标记)→ 交给 head 脚本重定向逻辑,这里不弹
if (getStoredLocale()) return
const nav = (
navigator.language ||
(navigator.languages && navigator.languages[0]) ||
""
).toLowerCase()
const detected: "en" | "cn" = /^(zh|cmn|zho)/.test(nav) ? "cn" : "en"
const p = location.pathname
const onHome = p === "/" || p === "/" + def
if (!onHome) return
const cur = p === "/" ? def : (p.split("/")[1] as "en" | "cn")
if (detected === cur) return
this.target = detected
this.current = cur
this.visible = true
} catch (e) {}
},
get message(): string {
if (this.target === "cn") {
return "检测到您的浏览器语言为「中文」,是否切换到中文界面?"
}
return `Your browser language appears to be ${this.names[this.target]}. Switch to ${this.names[this.target]}?`
},
switchLang() {
storeLocale(this.target)
location.replace("/" + this.target)
},
keep() {
// 记下来:用户选择保持当前语言,后续不再提示
storeLocale(this.current)
this.visible = false
},
}
})
Alpine.data("authMenu", () => ({
user: null as AuthUser | null,
open: false,
@ -245,7 +317,7 @@ const headers = {
<span class="text-base font-black tracking-wider uppercase border-b-2 border-black pb-0.5">
Portfolio Studio
</span>
<span class="text-[10px] font-mono bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest">
<span class="text-[10px] bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest">
2026 Edition
</span>
</div>
@ -255,7 +327,7 @@ const headers = {
</p>
{/* Location Badge */}
<div class="flex flex-wrap gap-3 text-[11px] font-mono text-zinc-500 uppercase tracking-widest pt-1">
<div class="flex flex-wrap gap-3 text-[11px] text-zinc-500 uppercase tracking-widest pt-1">
<span>Paris</span>
<span>•</span>
<span>Milan</span>
@ -267,13 +339,40 @@ const headers = {
</div>
</div>
<div class="mt-6 text-[11px] font-mono text-zinc-400 uppercase tracking-widest">
<div class="mt-6 text-[11px] text-zinc-400 uppercase tracking-widest">
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
</div>
</footer>
{/* 全局登录门禁层(login-required 属性 + 统一登录浮层) */}
<AuthModal />
{/* 语言建议弹窗:未选过语言且浏览器语言与当前首页不符时,提示是否切换到该语言 */}
<div
x-data="localeSuggest()"
x-show="visible"
x-cloak
x-transition
class="fixed bottom-5 left-1/2 -translate-x-1/2 z-[60] w-[min(92vw,420px)] bg-white border border-black/15 shadow-lg px-5 py-4 flex flex-col gap-3"
role="dialog"
aria-label="Language suggestion"
>
<p class="text-[13px] leading-snug text-black" x-text="message"></p>
<div class="flex items-center gap-2 justify-end">
<button
type="button"
@click="keep()"
class="px-3 py-1.5 text-[12px] uppercase tracking-[0.5px] border border-black/20 hover:bg-black/5 transition-colors"
x-text="names[current]"
></button>
<button
type="button"
@click="switchLang()"
class="px-3 py-1.5 text-[12px] uppercase tracking-[0.5px] bg-black text-white hover:opacity-80 transition-opacity"
x-text="names[target]"
></button>
</div>
</div>
</body>
</html>