@ -1,102 +1,277 @@
---
import Layout from ' @/layouts/Layout.astro'
import { getI18n } from ' @/i18n/utils'
import { href, ROUTES } from ' @/lib/routes'
import Layout from " @/layouts/Layout.astro"
import { getI18n } from " @/i18n/utils"
import { href, ROUTES } from " @/lib/routes"
const { locale, t } = getI18n(Astro)
const titleLabel = t(' my account' )
const pleaseLabel = t(' please log in' )
const loginLabel = t(' login' )
const account Label = t('account' )
const emailLabel = t('email' )
const signOutLabel = t('sign out' )
const collectionsLabel = t('my collections')
const history Label = t('my history' )
const soon Label = t('coming soon' )
const titleLabel = t(" my account" )
const pleaseLabel = t(" please log in" )
const loginLabel = t(" login" )
const history Label = t("my history" )
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account) )
const archiveUrl = href(locale, ROUTES.runwayLooks )
// 预翻译常量: Alpine 表达式里没有 t()( t 仅 Astro 服务端存在),文案一律走模板插值。
const remove Label = t("remove" )
const items Label = t("items" )
const clearHistoryLabel = t("clear history")
---
<Layout title={titleLabel}>
<main class="mx-auto w-full max-w-6 xl px-6 py-12 md:px-8 lg:px-12 lg :py-16 " x-data="accountPage" data-locale={locale}>
<div class="grid gap-8 lg:grid-cols-[0.9fr_1.5fr]">
<aside class="rounded-[30px] border border-black/10 bg-black p-6 text-white shadow-[0_20px_60px_rgba(0,0,0,0.08)] md:p-8">
<div class="mb-10 flex items-center justify-between gap-3">
<span class="text-[10px] font-mono uppercase tracking-[0.38em] text-white/60">Archive</span>
<span class="border border-white/20 bg-white/5 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.32em] text-white">2026</span>
</div>
<main class="account-root mx-auto w-full max-w-7 xl px-6 py-10 md:px-8 md :py-14 " x-data="accountPage" data-locale={locale}>
<div class="mb-10" >
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.38em] text-white/50">Member profile</p >
<h1 class="font-serif text-4xl uppercase leading-non e tracking-[- 0.06 em] md: text-5xl">{titl eLabel}</h1 >
</div >
<!-- 未登录:引导登录 -- >
<div x-show="!user" x-cloak class="border border-black/15 bg-white px-8 py-20 text-center" >
<p class="mb-6 text-[10px] uppercas e tracking-[0.32 em] text-black/45">{pleas eLabel}</p >
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-6 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a >
</div>
<div class="space-y-3 text-sm text-white/70" >
<p class="text-[11px] font-mono uppercase tracking-[0.32em] text-white/45">Portfolio Studio</p >
<p class="leading-relaxed">Curated access to runway archives, editorial notes, and private collections.</p>
</div>
</aside>
<!-- 已登录:个人中心 -- >
<div x-show="user" x-cloak class="space-y-10" >
<section class="rounded-[30px] border border-black/10 bg-[#f7f5f1] p-6 shadow-[0_18px_50px_rgba(0,0,0,0.04)] md:p-8 lg:p-10" >
<div x-show="!user" x-cloak class="space-y-6 ">
<p class="text-[10px] font-mono uppercase tracking-[0.32 em] text-black/45">{pleaseLabel}</p >
<a href={href(locale, ROUTES.login) + '?redirect=' + encodeURIComponent(href(locale, ROUTES.account))}
class="inline-flex items-center justify-center border border-black bg-black px-5 py-3 text-[11 px] font-bold uppercase tracking-[0.24 em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel }</a >
</div >
<div x-show="user" x-cloak class="space-y-8">
<div>
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">Account</p>
<dl class="space-y-4">
<div class="flex gap-5 border-b border-black/10 pb-4">
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{accountLabel}</dt>
<dd class="flex-1 font-medium" x-text="user?.username"></dd>
</div>
<div class="flex gap-5 border-b border-black/10 pb-4">
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{emailLabel}</dt>
<dd class="flex-1 font-medium break-all" x-text="user?.email"></dd>
</div>
</dl>
</div>
<div class="flex flex-wrap gap-3">
<button @click="logout()" class="border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{signOutLabel}</button>
<a href={href(locale, ROUTES.runwayLooks)} class="border border-black/15 bg-transparent px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t('View archive')}</a>
</div>
<div class="space-y-8 pt-4">
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{collectionsLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{historyLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
</div>
<!-- 报头:头像 + 用户名 -- >
<section class="flex items-center gap-5 border-b border-black/15 pb-8 ">
<div class="flex h-16 w-16 shrink-0 items-center justify-center border border-black text-2xl tracking-[- 0.04 em]" x-text="(user?.username || '?').slice(0,2).toUpperCase()"></div >
<div>
<p class="mb-2 text-[10 px] uppercase tracking-[0.3 2em] text-black/45">{t("member profile") }</p >
<h1 class="text-4xl uppercase leading-none tracking-[-0.04em] md:text-5xl" x-text="user?.username"></h1 >
</div>
</section>
<!-- 主体:左侧导航 + 右侧内容 -->
<div class="grid gap-8 lg:grid-cols-[200px_1fr]">
<!-- 侧栏导航 -->
<aside class="lg:sticky lg:top-8 lg:self-start">
<nav class="flex gap-1 overflow-x-auto border-b border-black/15 lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0">
<button @click="tab='saved'" :class="tab==='saved' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{t("saved looks")}</button>
<button @click="tab='history'" :class="tab==='history' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{historyLabel}</button>
</nav>
</aside>
<!-- 内容面板 -->
<section class="js-surface js-panel border border-black/15 bg-white p-6 md:p-10">
<!-- Saved looks -->
<div x-show="tab==='saved'" x-cloak>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl uppercase tracking-[-0.03em] md:text-3xl">{t("saved looks")}</h2>
<span class="text-[10px] uppercase tracking-[0.24em] text-black/40"><span x-text="favoritesCount"></span> {itemsLabel}</span>
</div>
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1Z"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no saved looks yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="f in favorites" :key="f.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(f)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeFav(f.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(f)"><p class="truncate text-[12px] font-medium" x-text="f.title"></p></a>
<p class="mt-0.5 truncate text-[10px] uppercase tracking-[0.16em] text-black/45" x-text="f.brand || '—'"></p>
</div>
</div>
</template>
</div>
<!-- 收藏分页( 服务端分页, 与收藏总量解耦, 10万也常数级) -->
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="favLastPage > 1" aria-label="Saved pagination">
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
<template x-for="(p, i) in favPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next › </button>
</nav>
</div>
<!-- History -->
<div x-show="tab==='history'" x-cloak>
<div class="mb-6">
<h2 class="text-2xl uppercase tracking-[-0.03em] md:text-3xl">{historyLabel}</h2>
</div>
<div class="mb-4 flex items-center justify-between">
<span class="text-[10px] uppercase tracking-[0.24em] text-black/40"><span x-text="histTotal"></span> {itemsLabel}</span>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[10px] uppercase tracking-[0.22em] text-black/45 underline-offset-4 hover:text-black hover:underline">{clearHistoryLabel}</button>
</div>
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no history yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="h in history" :key="h.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(h)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeHist(h.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(h)"><p class="truncate text-[12px] font-medium" x-text="h.data?.title || '…'"></p></a>
<p class="mt-0.5 truncate text-[10px] uppercase tracking-[0.16em] text-black/45" x-text="h.data?.brand || '—'"></p>
</div>
</div>
</template>
</div>
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="histLastPage > 1" aria-label="History pagination">
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
<template x-for="(p, i) in histPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next › </button>
</nav>
</div>
</section>
</div>
</div>
</main>
</Layout>
<script>
import { getUser, logout as logoutApi } from "@/lib/api"
import { getUser, toAbs } from "@/lib/api"
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
import { clientHref, ROUTES } from "@/lib/routes"
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
'<rect width="300" height="400" fill="#f1f1f1"/>' +
'<text x="150" y="200" font-family="monospace" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
'</svg>'
)
document.addEventListener("alpine:init", () => {
Alpine.data("accountPage", () => ({
user: null,
user: null as any ,
locale: "en",
tab: "saved",
favorites: [] as any[],
favoritesCount: 0,
favPage: 1,
favPerPage: 24,
favLastPage: 1,
favTotal: 0,
history: [] as any[],
historyCount: 0,
histPage: 1,
histPerPage: 24,
histLastPage: 1,
histTotal: 0,
init() {
const loc = this.$el.dataset.locale || "en"
this.locale = this.$el.dataset.locale || "en"
const u = getUser()
if (!u) {
window.location.href = clientHref(loc , ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(loc , ROUTES.account))
window.location.href = clientHref(this.locale , ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale , ROUTES.account))
return
}
this.user = u
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
this.loadFavPage(1)
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
this.loadHistPage(1)
},
// 服务端分页拉取收藏( 与收藏总量解耦, 10万也常数级)
async loadFavPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchFavoritesPage(p, this.favPerPage)
this.favorites = r.items
this.favTotal = r.total
this.favLastPage = r.lastPage
this.favPage = p
this.favoritesCount = r.total
} catch {
const local = getFavorites()
this.favorites = local
this.favTotal = local.length
this.favLastPage = 1
this.favPage = 1
this.favoritesCount = local.length
}
},
goFavPage(p: number) {
if (p < 1 || p > this.favLastPage || p === this.favPage) return
this.loadFavPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1, 其余用 … 折叠
get favPageList() {
const cur = this.favPage, last = this.favLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
coverUrl(x: any): string {
const c = x.data?.cover || x.cover
return c ? toAbs(c) : PLACEHOLDER
},
itemHref(f: any): string {
return clientHref(this.locale, ROUTES.item(f.id))
},
removeFav(id: string) {
removeFavorite(id)
this.loadFavPage(this.favPage) // 重新拉当前页,保证分页计数一致
},
// ===== 浏览历史(服务端分页)=====
async loadHistPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchHistoryPage(p, this.histPerPage)
this.history = r.items
this.histTotal = r.total
this.histLastPage = r.lastPage
this.histPage = p
} catch {
this.history = []
this.histTotal = 0
this.histLastPage = 1
this.histPage = 1
}
},
goHistPage(p: number) {
if (p < 1 || p > this.histLastPage || p === this.histPage) return
this.loadHistPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1, 其余用 … 折叠
get histPageList() {
const cur = this.histPage, last = this.histLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
removeHist(id: string) {
removeHistory(id)
this.loadHistPage(this.histPage) // 重新拉当前页,保证分页计数一致
},
clearAllHistory() {
clearHistory()
this.loadHistPage(1)
},
logout() {
logoutApi().finally(() => window.location.reload())
}
}))
})
</script>