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>

View File

@ -10,7 +10,7 @@ import { getSsgStreetSnapPopular } from "@/lib/api"
// 国际化:一行取当前语言 + 翻译函数
const { locale, t } = getI18n(Astro)
// 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/v1/ssg/index/runway(server-only),
// 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/internal/ssg/home/runway(server-only),
// 首页 runway 数据含多模块,hotBrand 为其中之一;封面/标题取自每个品牌代表走秀,
// 直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
const popular = (await getSsgIndexRunway()).hotBrand
@ -31,7 +31,7 @@ const galleryItems: GalleryItem[] = popular
title: b.title,
}))
// 首页「Street Style」热门街拍:构建期走 SSG 内部接口 /api/v1/ssg/street-snaps/popular(server-only),
// 首页「Street Style」热门街拍:构建期走 SSG 内部接口 /api/internal/ssg/street-snaps/popular(server-only),
// 返回按图片数前 N 的街拍(含封面),直接烧入静态 HTML;后端不可达则静默留空(区块回退 ComingSoon)。
const streetPopular = (await getSsgStreetSnapPopular(12)).filter((b) => b.cover && b.id)
const streetItems: { src: string; loc: string; event: string; id: string }[] = streetPopular.map((b) => {

View File

@ -111,6 +111,7 @@ if (article?.brand_id) {
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
<button
login-required
type="button"
data-fav-id={im.id}
data-fav-url={im.raw}
@ -139,7 +140,7 @@ if (article?.brand_id) {
<figure
x-show="!isAuthed && preview"
x-cloak
@click="goLogin()"
login-redirect
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
@ -178,6 +179,7 @@ if (article?.brand_id) {
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
<button
login-required
type="button"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@ -239,6 +241,7 @@ if (article?.brand_id) {
></div>
<button
login-required
type="button"
@click.stop="toggleFav(currentFavId, currentFavUrl)"
:class="isFav(currentFavId) ? 'is-fav' : ''"
@ -474,6 +477,21 @@ if (article?.brand_id) {
}))
this.extraImages = mapped
this.showAll = true
// VIP:前 PREVIEW_LIMIT 张是 SSR 静态渲染的普通样式图(SSR 无用户令牌,
// 后端回落 normal)。VIP 已登录后此处把首屏图也替换为高清原图(后端对 VIP 返回 HD URL),
// 否则 VIP 仅第 6 张起看高清,首屏仍是普通图,体验不一致。
const me = getUser()
if (me && me.tier === "vip") {
const hd = (full.images || []).slice(0, 5).map((im: any) => toAbs(im.image || ""))
hd.forEach((url: string, i: number) => {
if (!url) return
const fig = document.querySelector<HTMLElement>(`#article-gallery [data-image-index="${i}"]`)
if (!fig) return
fig.dataset.imageUrl = url
const img = fig.querySelector("img")
if (img) img.setAttribute("src", url)
})
}
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]"
@ -486,13 +504,6 @@ if (article?.brand_id) {
}
},
// 未登录门禁:跳登录并回跳本篇详情
goLogin() {
const back = window.location.pathname + window.location.search
const loc = window.location.pathname.startsWith("/cn") ? "cn" : "en"
window.location.href = `/${loc}/login?redirect=${encodeURIComponent(back)}`
},
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
maybeLocateImageFromUrl() {
@ -568,13 +579,6 @@ if (article?.brand_id) {
toggleFav(id: string, url: string) {
if (!id) return
// 未登录:收藏无处落盘(服务端无 token、本地无用户 key),引导登录后回跳本篇
if (!getUser()) {
const back = window.location.pathname + window.location.search
const loc = window.location.pathname.startsWith("/cn") ? "cn" : "en"
window.location.href = `/${loc}/login?redirect=${encodeURIComponent(back)}`
return
}
toggleImageFavorite({
imageId: id,
type: this.favType === "snap_image" ? "snap_image" : "runway_image",

View File

@ -1,17 +1,15 @@
---
// 加载屏组件:首屏/路由切换时遮盖未加载内容,避免 FOUC。
// - 样式作用域隔离在 #loading-screen 内(is:global,因选择器基于唯一 id,不影响整站黑白调性)。
// - 隐藏逻辑用 Astro 默认打包的 <script>(module / defer),无需 is:inline;
// 模块脚本在 window.load 之前执行,故 addEventListener('load') 必然先挂上,无竞态。
// - Raleway 字体仅加载屏使用,随组件引入(Astro 自动 hoist 到 head)。
// 加载屏:首屏/整页切换时遮盖未渲染内容,避免 FOUC。
// 轻量原则(用户要求"朴实流畅"):
// - 只用静态大字 + mono 小标 + 一条 2px 细线进度(transform/opacity 动画,最便宜);
// - 去掉昂贵的多层 text-shadow 位移动画与外部字体;最短展示时间压缩到 ~350ms,跟手不拖沓。
---
<div id="loading-screen" aria-hidden="true">
<div id="wrapper">
<div id="container">
<h1>Portfolio Studio</h1>
<p class="ls-sub">Loading</p>
</div>
<div id="ls-box">
<h1>Portfolio Studio</h1>
<p class="ls-sub">Loading</p>
<div class="ls-bar" aria-hidden="true"></div>
</div>
</div>
@ -22,117 +20,73 @@
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #ffffff;
color: #0a0a0a;
font: 16px/1.25 'Raleway', sans-serif;
text-align: center;
transition: opacity 0.5s ease;
transition: opacity 0.4s ease;
}
#loading-screen.is-hidden {
opacity: 0;
pointer-events: none;
}
#loading-screen *,
#loading-screen *:before,
#loading-screen *:after {
box-sizing: border-box;
}
#loading-screen #wrapper {
margin-left: auto;
margin-right: auto;
max-width: 80em;
}
#loading-screen #container {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100vh;
padding: 1em;
width: 100%;
}
#loading-screen h1 {
animation: ls-text-shadow 1.5s ease-in-out infinite;
font-size: clamp(2.5rem, 8vw, 5rem);
margin: 0;
font-family: ui-serif, Georgia, Cambria, 'Times New Roman', serif;
font-size: clamp(2.2rem, 6vw, 4rem);
font-weight: 900;
line-height: 1;
}
#loading-screen h1:hover {
animation-play-state: paused;
}
#loading-screen a {
color: #0a0a0a;
}
#loading-screen a:hover {
text-decoration: none;
letter-spacing: -0.02em;
text-transform: uppercase;
}
#loading-screen .ls-sub {
margin-top: 1.5em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
letter-spacing: 0.3em;
margin: 1.1em 0 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.72rem;
letter-spacing: 0.34em;
text-transform: uppercase;
color: #888;
animation: ls-pulse 1.4s ease-in-out infinite;
}
#loading-screen .ls-bar {
margin-top: 2em;
width: min(180px, 60vw);
height: 2px;
background: #e6e6e6;
overflow: hidden;
}
#loading-screen .ls-bar::before {
content: "";
display: block;
width: 100%;
height: 100%;
background: #0a0a0a;
transform-origin: left center;
animation: ls-bar 1.1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
/* 关键帧独立命名(ls- 前缀),避免与站点其它 keyframes 冲突 */
@keyframes ls-text-shadow {
0% {
transform: translateY(0);
text-shadow:
0 0 0 #000,
0 0 0 #3a3a3a,
0 0 0 #777,
0 0 0 #bbb;
}
20% {
transform: translateY(-1em);
text-shadow:
0 0.125em 0 #000,
0 0.25em 0 #3a3a3a,
0 -0.125em 0 #777,
0 -0.25em 0 #bbb;
}
40% {
transform: translateY(0.5em);
text-shadow:
0 -0.0625em 0 #000,
0 -0.125em 0 #3a3a3a,
0 0.0625em 0 #777,
0 0.125em 0 #bbb;
}
60% {
transform: translateY(-0.25em);
text-shadow:
0 0.03125em 0 #000,
0 0.0625em 0 #3a3a3a,
0 -0.03125em 0 #777,
0 -0.0625em 0 #bbb;
}
80% {
transform: translateY(0);
text-shadow:
0 0 0 #000,
0 0 0 #3a3a3a,
0 0 0 #777,
0 0 0 #bbb;
}
@keyframes ls-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
@keyframes ls-bar {
0% { transform: scaleX(0.08); }
45% { transform: scaleX(0.72); }
100% { transform: scaleX(1); }
}
/* 尊重系统“减少动画”偏好:仅作用于加载屏,不波及整站 */
/* 尊重系统"减少动画"偏好 */
@media (prefers-reduced-motion: reduce) {
#loading-screen *,
#loading-screen *:before,
#loading-screen *:after {
animation: none !important;
transition: none !important;
#loading-screen .ls-sub,
#loading-screen .ls-bar::before {
animation: none;
}
#loading-screen .ls-bar::before { transform: scaleX(1); }
}
</style>
<script>
// 页面 load 后淡出加载屏;最少展示 500ms 防闪烁,最多 3s 兜底。
// 作为 Astro 默认打包的 module 脚本(defer),在 window.load 前执行,监听必然先挂上。
// load 后淡出;最少展示 350ms 防闪烁,最多 2s 兜底。
(function () {
var el = document.getElementById('loading-screen');
if (!el) return;
@ -143,16 +97,16 @@
el.classList.add('is-hidden');
setTimeout(function () {
el.style.display = 'none';
}, 600);
}, 500);
}
var start = Date.now();
function tryHide() {
var waited = Date.now() - start;
if (waited >= 500) hide();
else setTimeout(hide, 500 - waited);
if (waited >= 350) hide();
else setTimeout(hide, 350 - waited);
}
if (document.readyState === 'complete') tryHide();
else window.addEventListener('load', tryHide);
setTimeout(hide, 3000);
setTimeout(hide, 2000);
})();
</script>

View File

@ -8,16 +8,7 @@ import "@/styles/look-grid.css"
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
// 登录门禁弹窗文案
const loginModalTitle = t('login')
const loginModalPrompt = t('login to continue')
const loginModalAccountLabel = t('username or email')
const loginModalPasswordLabel = t('password')
const loginModalSubmitLabel = t('login')
const loginModalClose = t('close')
const loginModalFail = t('login failed')
// 热门品牌:走 SSG 专属接口 /api/v1/ssg/brands/hot,后端固定返回热度前 30 个、按热度顺序排列。
// 热门品牌:走 SSG 专属接口 /api/internal/ssg/brands/popular,后端固定返回热度前 30 个、按热度顺序排列。
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
// 接口不可用时回落假数据先看效果。
const FAKE_HOT_BRANDS = [
@ -73,7 +64,7 @@ const seasonOptions: { value: string; label: string }[] = [
---
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale} data-login-fail={loginModalFail}>
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
<!-- 顶部:极简眉头 -->
<header class="showshead">
<div>
@ -118,14 +109,14 @@ const seasonOptions: { value: string; label: string }[] = [
<ul class="showschecks">
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
<li>
<label class="flex items-center gap-2 w-full cursor-pointer">
<label login-required class="flex items-center gap-2 w-full cursor-pointer">
<input type="radio" name="designer" :value="b.id" :checked="selectedBrand === b.id" :class="{ 'is-checked': selectedBrand === b.id }" @click.prevent="toggleBrand(b.id)" />
<span x-text="b.name"></span>
</label>
</li>
</template>
</ul>
<a class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
<a login-required class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
</div>
</section>
@ -140,7 +131,7 @@ const seasonOptions: { value: string; label: string }[] = [
<div class="showsg-b" x-show="!collapsedGroups.collection">
<ul class="showschecks showschecks--flat">
{collectionOptions.map((o) => (
<li><label><input type="radio" name="collection" value={o.value} :checked={'selectedCt === "' + o.value + '"'} :class={`{'is-checked': selectedCt === '${o.value}'}`} @click.prevent={'selectedCt = selectedCt === "' + o.value + '" ? "" : "' + o.value + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
<li><label login-required><input type="radio" name="collection" value={o.value} :checked={'selectedCt === "' + o.value + '"'} :class={`{'is-checked': selectedCt === '${o.value}'}`} @click.prevent={'selectedCt = selectedCt === "' + o.value + '" ? "" : "' + o.value + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
@ -157,7 +148,7 @@ const seasonOptions: { value: string; label: string }[] = [
<div class="showsg-b" x-show="!collapsedGroups.season">
<ul class="showschecks showschecks--flat">
{seasonOptions.map((o) => (
<li><label><input type="radio" name="season" value={o.value} :checked={'selectedSeason === "' + o.value + '"'} :class={`{'is-checked': selectedSeason === '${o.value}'}`} @click.prevent={'selectedSeason = selectedSeason === "' + o.value + '" ? "" : "' + o.value + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
<li><label login-required><input type="radio" name="season" value={o.value} :checked={'selectedSeason === "' + o.value + '"'} :class={`{'is-checked': selectedSeason === '${o.value}'}`} @click.prevent={'selectedSeason = selectedSeason === "' + o.value + '" ? "" : "' + o.value + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
@ -174,13 +165,13 @@ const seasonOptions: { value: string; label: string }[] = [
<div class="showsg-b" x-show="!collapsedGroups.year">
<ul class="showschecks showschecks--flat">
{yearOptions.map((o) => (
<li><label><input type="radio" name="year" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'}`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
<li><label login-required><input type="radio" name="year" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'}`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<button class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
<button login-required class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
</div>
</aside>
@ -221,7 +212,7 @@ const seasonOptions: { value: string; label: string }[] = [
<!-- Card List -->
<template x-for="it in items" :key="it.id">
<div class="showscard-wrap relative group">
<a class="showscard" :href="itemHref(it)" :aria-label="`${it.brand_name} ${it.title}`">
<a class="showscard" :href="itemHref(it)" :aria-label="cardTitle(it)">
<div class="showscard-left">
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
@ -230,7 +221,7 @@ const seasonOptions: { value: string; label: string }[] = [
</div>
<div class="showscard-right">
<div class="showscard-info">
<h3 class="showscard-title" x-text="`${it.brand_name} / ${it.title}`"></h3>
<h3 class="showscard-title" x-text="cardTitle(it)"></h3>
</div>
<div class="showscard-strip">
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
@ -243,22 +234,22 @@ const seasonOptions: { value: string; label: string }[] = [
</div>
</div>
</a>
<button type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
<button login-required type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
</button>
</div>
</template>
</div>
<nav class="showspager" x-show="isAuthed && lastPage > 1" aria-label="Pagination">
<button class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
<nav class="showspager" x-show="lastPage > 1" aria-label="Pagination">
<button login-required class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
<template x-for="(p, i) in pageList" :key="i">
<span class="pg-wrap">
<span class="pg-ellipsis" x-show="p === '...'" x-text="'…'"></span>
<button class="pg" type="button" x-show="p !== '...'" :class="{ 'is-current': p === page }" :disabled="p === page" @click="goPage(p)" x-text="p"></button>
<button login-required class="pg" type="button" x-show="p !== '...'" :class="{ 'is-current': p === page }" :disabled="p === page" @click="goPage(p)" x-text="p"></button>
</span>
</template>
<button class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
<button login-required class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
</nav>
</main>
</div>
@ -266,32 +257,6 @@ const seasonOptions: { value: string; label: string }[] = [
<!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) -->
<BrandModal />
<!-- 登录门禁弹窗:未登录点筛选/翻页时弹出内联登录表单 -->
<div x-show="showLoginModal" x-cloak class="fixed inset-0 z-[100] flex items-center justify-center bg-black/40" @click.self="closeLoginModal()">
<div class="bg-white border border-black/10 shadow-lg w-full max-w-md p-8">
<div class="flex items-center justify-between mb-6">
<h3 class="font-serif text-2xl uppercase">{loginModalTitle}</h3>
<button type="button" @click="closeLoginModal()" class="text-xs uppercase tracking-widest hover:opacity-60">{loginModalClose}</button>
</div>
<p class="text-xs text-zinc-500 uppercase tracking-wide mb-6">{loginModalPrompt}</p>
<form @submit.prevent="modalLogin()" class="space-y-5">
<div>
<label class="block text-[11px] font-mono uppercase tracking-widest text-zinc-500 mb-2">{loginModalAccountLabel}</label>
<input type="text" x-model="loginModalAccount" required class="w-full bg-transparent border-0 border-b border-black/30 rounded-none px-0 py-2 text-sm outline-none focus:border-black focus:ring-0" />
</div>
<div>
<label class="block text-[11px] font-mono uppercase tracking-widest text-zinc-500 mb-2">{loginModalPasswordLabel}</label>
<input type="password" x-model="loginModalPassword" required class="w-full bg-transparent border-0 border-b border-black/30 rounded-none px-0 py-2 text-sm outline-none focus:border-black focus:ring-0" />
</div>
<p x-show="loginModalError" x-text="loginModalError" class="text-xs text-red-600 uppercase tracking-wide"></p>
<button type="submit" :disabled="loginModalLoading" class="w-full border border-black py-3 text-[12px] font-bold uppercase tracking-[0.2em] hover:bg-black hover:text-white transition-colors disabled:opacity-40">
<span x-show="!loginModalLoading">{loginModalSubmitLabel}</span>
<span x-show="loginModalLoading" x-cloak>…</span>
</button>
</form>
</div>
</div>
</div>
@ -300,7 +265,7 @@ const seasonOptions: { value: string; label: string }[] = [
<script>
import { getArticles, getBrands, toAbs, getUser, login, type ArticleItem, type BrandEntry } from "@/lib/api"
import { getArticles, getBrands, toAbs, getUser, type ArticleItem, type BrandEntry } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
@ -338,21 +303,12 @@ const seasonOptions: { value: string; label: string }[] = [
loading: false,
page: 1,
lastPage: 1,
isAuthed: false,
reqToken: 0,
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
favMap: {} as Record<string, boolean>,
// 登录门禁弹窗状态(硬门禁:未登录点筛选/翻页时弹出内联登录表单)
showLoginModal: false,
loginModalAccount: "",
loginModalPassword: "",
loginModalError: "",
loginModalLoading: false,
init() {
this.isAuthed = !!getUser();
this.locale = this.$el.dataset.locale || 'en';
// 支持从 header 下拉深链 ?collection=xxx 直接进入对应分类(与侧栏 COLLECTION 筛选一致)
const ct = new URLSearchParams(window.location.search).get('collection')
@ -496,34 +452,6 @@ const seasonOptions: { value: string; label: string }[] = [
this.refreshFilter()
},
// ===== 登录门禁 =====
// 未登录返回 false 并弹出登录框;已登录返回 true。
requireLogin(): boolean {
if (getUser()) return true
this.showLoginModal = true
return false
},
async modalLogin() {
this.loginModalError = ""
this.loginModalLoading = true
try {
await login({ account: this.loginModalAccount, password: this.loginModalPassword })
this.showLoginModal = false
this.loginModalAccount = ""
this.loginModalPassword = ""
// 登录成功后按当前筛选刷新,用户无需再次点击
this.loadPage(1)
} catch {
this.loginModalError = this.$el.dataset.loginFail || "login failed"
} finally {
this.loginModalLoading = false
}
},
closeLoginModal() {
this.showLoginModal = false
this.loginModalError = ""
},
// API 加载(分页:每次整页替换,不追加)
async loadPage(page: number) {
const myToken = ++this.reqToken
@ -562,7 +490,6 @@ const seasonOptions: { value: string; label: string }[] = [
},
goPage(p: number) {
if (!this.requireLogin()) return
if (this.loading) return
if (p < 1 || p > this.lastPage || p === this.page) return
this.loadPage(p)
@ -580,6 +507,15 @@ const seasonOptions: { value: string; label: string }[] = [
// 渲染文章url
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
// 卡片标题:后端 title 通常已含品牌(如 "Etro Spring 2024"),若再拼 brand_name 会重复
// (Etro / Etro Spring 2024)。title 已以品牌开头则只显示 title,否则 "Brand / Title"。
cardTitle(it: any) {
const t = String(it?.title || "")
const b = String(it?.brand_name || "")
if (b && t.toLowerCase().startsWith(b.toLowerCase())) return t
return b ? `${b} / ${t}` : t
},
// 渲染 thumb 图片列表(复用共享工具):固定 5 个槽位 + 末位叠加「共 N 张」遮罩。
//
// 注意参数顺序:makeCardSlots(images, count, opts) —— 第二个参数是「槽位数量」不是图片总数。
@ -618,7 +554,6 @@ const seasonOptions: { value: string; label: string }[] = [
},
favState(id: string) { return !!this.favMap[String(id)] },
toggleFav(it: any) {
if (!getUser()) { this.showLoginModal = true; return false }
const id = String(it.id)
const now = !this.favState(id)
toggleFavorite({ id, type: 'runway', title: it.title || '', cover: it.cover || '', brand: it.brand_name || '' })

View File

@ -93,6 +93,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
<button
login-required
type="button"
data-fav-id={im.id}
data-fav-url={im.raw}
@ -121,7 +122,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
<figure
x-show="!isAuthed && preview"
x-cloak
@click="goLogin()"
login-redirect
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
@ -160,6 +161,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
<button
login-required
type="button"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@ -215,6 +217,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
></div>
<button
login-required
type="button"
@click.stop="toggleFav(currentFavId, currentFavUrl)"
:class="isFav(currentFavId) ? 'is-fav' : ''"
@ -413,6 +416,19 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
}))
this.extraImages = mapped
this.showAll = true
// VIP:首屏前 PREVIEW_LIMIT 张为 SSR 普通样式图,已登录 VIP 替换为高清原图(同 Item.astro)。
const me = getUser()
if (me && me.tier === "vip") {
const hd = (full.images || []).slice(0, 5).map((im: any) => toAbs(im.image || ""))
hd.forEach((url: string, i: number) => {
if (!url) return
const fig = document.querySelector<HTMLElement>(`#snap-gallery [data-image-index="${i}"]`)
if (!fig) return
fig.dataset.imageUrl = url
const img = fig.querySelector("img")
if (img) img.setAttribute("src", url)
})
}
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]"
@ -425,13 +441,6 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
}
},
// 未登录门禁:跳登录并回跳本篇详情
goLogin() {
const back = window.location.pathname + window.location.search
const loc = window.location.pathname.startsWith("/cn") ? "cn" : "en"
window.location.href = `/${loc}/login?redirect=${encodeURIComponent(back)}`
},
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
maybeLocateImageFromUrl() {
@ -501,13 +510,6 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
toggleFav(id: string, url: string) {
if (!id) return
// 未登录:收藏无处落盘(服务端无 token、本地无用户 key),引导登录后回跳本篇
if (!getUser()) {
const back = window.location.pathname + window.location.search
const loc = window.location.pathname.startsWith("/cn") ? "cn" : "en"
window.location.href = `/${loc}/login?redirect=${encodeURIComponent(back)}`
return
}
toggleImageFavorite({
imageId: id,
type: this.favType === "snap_image" ? "snap_image" : "runway_image",

View File

@ -7,15 +7,6 @@ import "@/styles/look-grid.css"
const { locale, t } = getI18n(Astro)
// 登录门禁弹窗文案
const loginModalTitle = t('login')
const loginModalPrompt = t('login to continue')
const loginModalAccountLabel = t('username or email')
const loginModalPasswordLabel = t('password')
const loginModalSubmitLabel = t('login')
const loginModalClose = t('close')
const loginModalFail = t('login failed')
// 年份筛选:近 10 年(含当年),末项兜底
const currentYear = new Date().getFullYear()
const yearFloor = currentYear - 9
@ -56,7 +47,7 @@ const cityOptions: { value: string; label: string }[] = [
]
---
<div class="snapsroot w-full" x-data="streetSnapsPage()" data-locale={locale} data-login-fail={loginModalFail}>
<div class="snapsroot w-full" x-data="streetSnapsPage()" data-locale={locale}>
<!-- 顶部:极简眉头 -->
<header class="snapshead">
<div>
@ -97,7 +88,7 @@ const cityOptions: { value: string; label: string }[] = [
<div class="snapsg-b" x-show="!collapsedGroups.year">
<ul class="snapschecks snapschecks--flat">
{yearOptions.map((o) => (
<li><label><input type="radio" name="snapYear" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; requireLogin() && loadPage(1)'} /> <span>{o.label}</span></label></li>
<li><label login-required><input type="radio" name="snapYear" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
@ -114,7 +105,7 @@ const cityOptions: { value: string; label: string }[] = [
<div class="snapsg-b" x-show="!collapsedGroups.sort">
<ul class="snapschecks snapschecks--flat">
{sortOptions.map((o) => (
<li><label><input type="radio" name="snapSort" value={o.value} :checked={'selectedSort === "' + o.value + '"'} :class={`{'is-checked': selectedSort === '${o.value}'}`} @click.prevent={'selectedSort = selectedSort === "' + o.value + '" ? "image_count" : "' + o.value + '"; requireLogin() && loadPage(1)'} /> <span>{o.label}</span></label></li>
<li><label login-required><input type="radio" name="snapSort" value={o.value} :checked={'selectedSort === "' + o.value + '"'} :class={`{'is-checked': selectedSort === '${o.value}'}`} @click.prevent={'selectedSort = selectedSort === "' + o.value + '" ? "image_count" : "' + o.value + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
@ -131,13 +122,13 @@ const cityOptions: { value: string; label: string }[] = [
<div class="snapsg-b" x-show="!collapsedGroups.city">
<ul class="snapschecks snapschecks--flat">
{cityOptions.map((o) => (
<li><label><input type="radio" name="snapCity" value={o.value} :checked={'selectedCity === "' + o.value + '"'} :class={`{'is-checked': selectedCity === '${o.value}'}`} @click.prevent={'selectedCity = selectedCity === "' + o.value + '" ? "" : "' + o.value + '"; requireLogin() && loadPage(1)'} /> <span>{o.label}</span></label></li>
<li><label login-required><input type="radio" name="snapCity" value={o.value} :checked={'selectedCity === "' + o.value + '"'} :class={`{'is-checked': selectedCity === '${o.value}'}`} @click.prevent={'selectedCity = selectedCity === "' + o.value + '" ? "" : "' + o.value + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<button class="snapsclear" type="button" @click="requireLogin() && clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
<button login-required class="snapsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
</div>
</aside>
@ -203,7 +194,7 @@ const cityOptions: { value: string; label: string }[] = [
</div>
</div>
</a>
<button type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
<button login-required type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
</button>
</div>
@ -212,45 +203,19 @@ const cityOptions: { value: string; label: string }[] = [
</template>
</div>
<nav class="snappager" x-show="isAuthed && lastPage > 1" aria-label="Pagination">
<button class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
<nav class="snappager" x-show="lastPage > 1" aria-label="Pagination">
<button login-required class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
<template x-for="(p, i) in pageList" :key="i">
<span class="pg-wrap">
<span class="pg-ellipsis" x-show="p === '...'" x-text="'…'"></span>
<button class="pg" type="button" x-show="p !== '...'" :class="{ 'is-current': p === page }" :disabled="p === page" @click="goPage(p)" x-text="p"></button>
<button login-required class="pg" type="button" x-show="p !== '...'" :class="{ 'is-current': p === page }" :disabled="p === page" @click="goPage(p)" x-text="p"></button>
</span>
</template>
<button class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
<button login-required class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
</nav>
</main>
</div>
<!-- 登录门禁弹窗:未登录点筛选/翻页时弹出内联登录表单 -->
<div x-show="showLoginModal" x-cloak class="fixed inset-0 z-[100] flex items-center justify-center bg-black/40" @click.self="closeLoginModal()">
<div class="bg-white border border-black/10 shadow-lg w-full max-w-md p-8">
<div class="flex items-center justify-between mb-6">
<h3 class="font-serif text-2xl uppercase">{loginModalTitle}</h3>
<button type="button" @click="closeLoginModal()" class="text-xs uppercase tracking-widest hover:opacity-60">{loginModalClose}</button>
</div>
<p class="text-xs text-zinc-500 uppercase tracking-wide mb-6">{loginModalPrompt}</p>
<form @submit.prevent="modalLogin()" class="space-y-5">
<div>
<label class="block text-[11px] font-mono uppercase tracking-widest text-zinc-500 mb-2">{loginModalAccountLabel}</label>
<input type="text" x-model="loginModalAccount" required class="w-full bg-transparent border-0 border-b border-black/30 rounded-none px-0 py-2 text-sm outline-none focus:border-black focus:ring-0" />
</div>
<div>
<label class="block text-[11px] font-mono uppercase tracking-widest text-zinc-500 mb-2">{loginModalPasswordLabel}</label>
<input type="password" x-model="loginModalPassword" required class="w-full bg-transparent border-0 border-b border-black/30 rounded-none px-0 py-2 text-sm outline-none focus:border-black focus:ring-0" />
</div>
<p x-show="loginModalError" x-text="loginModalError" class="text-xs text-red-600 uppercase tracking-wide"></p>
<button type="submit" :disabled="loginModalLoading" class="w-full border border-black py-3 text-[12px] font-bold uppercase tracking-[0.2em] hover:bg-black hover:text-white transition-colors disabled:opacity-40">
<span x-show="!loginModalLoading">{loginModalSubmitLabel}</span>
<span x-show="loginModalLoading" x-cloak>…</span>
</button>
</form>
</div>
</div>
</div>
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
@ -258,7 +223,7 @@ const cityOptions: { value: string; label: string }[] = [
<script>
import { getStreetSnaps, toAbs, getUser, login, type StreetSnapItem } from "@/lib/api"
import { getStreetSnaps, toAbs, getUser, type StreetSnapItem } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
@ -287,21 +252,12 @@ const cityOptions: { value: string; label: string }[] = [
loading: false,
page: 1,
lastPage: 1,
isAuthed: false,
reqToken: 0,
// 登录门禁弹窗状态(硬门禁:未登录点筛选/翻页时弹出内联登录表单)
showLoginModal: false,
loginModalAccount: "",
loginModalPassword: "",
loginModalError: "",
loginModalLoading: false,
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
favMap: {} as Record<string, boolean>,
init() {
this.isAuthed = !!getUser()
this.locale = this.$el.dataset.locale || 'en'
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
const city = new URLSearchParams(window.location.search).get('city')
@ -324,34 +280,6 @@ const cityOptions: { value: string; label: string }[] = [
this.loadPage(1)
},
// ===== 登录门禁 =====
// 未登录返回 false 并弹出登录框;已登录返回 true。
requireLogin(): boolean {
if (getUser()) return true
this.showLoginModal = true
return false
},
async modalLogin() {
this.loginModalError = ""
this.loginModalLoading = true
try {
await login({ account: this.loginModalAccount, password: this.loginModalPassword })
this.showLoginModal = false
this.loginModalAccount = ""
this.loginModalPassword = ""
// 登录成功后按当前筛选刷新,用户无需再次点击
this.loadPage(1)
} catch {
this.loginModalError = this.$el.dataset.loginFail || "login failed"
} finally {
this.loginModalLoading = false
}
},
closeLoginModal() {
this.showLoginModal = false
this.loginModalError = ""
},
// 已激活筛选 chips(顶部 pills + 侧栏 Clear all 的可见性都依赖它)
get pills() {
const out: { label: string; clear: () => void }[] = []
@ -417,7 +345,6 @@ const cityOptions: { value: string; label: string }[] = [
},
goPage(p: number) {
if (!this.requireLogin()) return
if (this.loading) return
if (p < 1 || p > this.lastPage || p === this.page) return
this.loadPage(p)
@ -466,7 +393,6 @@ const cityOptions: { value: string; label: string }[] = [
},
favState(id: string) { return !!this.favMap[String(id)] },
toggleFav(it: any) {
if (!getUser()) { this.showLoginModal = true; return false }
const id = String(it.id)
const now = !this.favState(id)
toggleFavorite({ id, type: 'street', title: it.title || '', cover: it.cover || '', brand: (it as any).brand || '' })

View File

@ -50,6 +50,7 @@ export const dict: Record<string, TranslationEntry> = {
'login failed': { cn: '登录失败,请检查账号或密码' },
'please log in': { cn: '请先登录' },
'login to continue': { cn: '登录以继续使用筛选与翻页' },
'go to full login': { cn: '前往完整登录页' },
'login prompt': { cn: '登录后可筛选与翻页浏览全部档案' },
'login to view': { cn: '登录查看' },
'photos': { cn: '张照片' },
@ -90,6 +91,7 @@ export const dict: Record<string, TranslationEntry> = {
'quick stats': { cn: '数据概览' },
'now': { cn: '刚刚' },
'member profile': { cn: '会员资料' },
'vip': { cn: '高级会员' },
'save': { cn: '收藏' },
'saved': { cn: '已收藏' },
'single image': { cn: '单图' },

View File

@ -1,6 +1,7 @@
---
import '@/styles/global.css'
import LoadingScreen from '@/components/LoadingScreen.astro'
import AuthModal from '@/components/AuthModal.astro'
import { href, ROUTES } from '@/lib/routes'
import { getI18n } from '@/i18n/utils'
import { LOCALE_NAMES } from '@/i18n/config'
@ -241,6 +242,9 @@ const headers = {
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
</div>
</footer>
{/* 全局登录门禁层(login-required 属性 + 统一登录浮层) */}
<AuthModal />
</body>
</html>

View File

@ -1,18 +1,18 @@
// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口)
//
// 集中封装全部 /api/v1 调用:公开查询(/api/v1/public/*)、账号体系(/api/v1/auth/*)、
// 以及构建期专用的 SSG 接口(/api/v1/ssg/*,仅在 Node 构建期使用)。
// 以及构建期专用的 SSG 接口(/api/internal/ssg/*,内部契约,仅 Node 构建期使用)。
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。
//
// base URL 解析规则(全项目统一两个基准地址,命名见下):
// BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。
// 空值 = 同源相对 /api;DEV 构建期固定走线上域名。
// BASE_API_SSG —— 构建期 Node 调 /api/ssg/* 的「内部端口」(默认 8091),
// BASE_API_SSG —— 构建期 Node 调 /api/internal/ssg/* 的「内部端口」(默认 8091),
// 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。
//
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
// - ssgBase():构建期 Node 调 /api/ssg/*,只读 BASE_API_SSG。
// - ssgBase():构建期 Node 调 /api/internal/ssg/*,只读 BASE_API_SSG。
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
import "@/lib/http"
@ -76,6 +76,8 @@ export interface AuthUser {
id: number
username: string
email: string
/** 用户等级:free(普通)| vip(高级,图集详情页可见高清原图)。 */
tier: 'free' | 'vip'
}
// SSG 构建专用类型
@ -110,7 +112,7 @@ export function ssrBase(): string {
}
// ===================== i18n:请求语言透传 =====================
// 后端 /api/v1/public/* 与 /api/v1/ssg/* 都通过 ?locale=cn|en(优先级最高,高于 Accept-Language)
// 后端 /api/v1/public/* 与 /api/internal/ssg/* 都通过 ?locale=cn|en(优先级最高,高于 Accept-Language)
// 决定 name / title / brand_name 选用 *_en 还是 *_cn 列(service.pickLocale 选列并缺翻译回落),
// 出参字段名保持不变,故前端契约无需改动,仅需在请求上带上当前语言。
//
@ -420,7 +422,7 @@ export async function fetchMe(): Promise<AuthUser | null> {
if (typeof localStorage === "undefined") return null
if (!getAccessToken()) return null
try {
const res = await authedFetch(`${BASE_API_URL}/api/v1/auth/me?locale=${encodeURIComponent(currentLocale())}`)
const res = await authedFetch(`${BASE_API_URL}/api/v1/me?locale=${encodeURIComponent(currentLocale())}`)
if (!res.ok) {
clearSession()
return null
@ -462,8 +464,9 @@ export async function logout(): Promise<void> {
const refresh = getRefreshToken()
if (refresh) {
try {
await fetch(`${BASE_API_URL}/api/v1/auth/logout`, {
method: "POST",
// 会话即 refresh token:单设备登出 = 吊销当前会话
await fetch(`${BASE_API_URL}/api/v1/auth/sessions/current`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
})
@ -510,7 +513,7 @@ export async function getHistoryMeta(id: string): Promise<HistoryMeta | null> {
}
}
// ===================== SSG 构建专用(/api/ssg/*,仅 Node 构建期) =====================
// ===================== SSG 构建专用(/api/internal/ssg/*,仅 Node 构建期) =====================
function ssgEnv(name: string): string | undefined {
const e = import.meta.env as any
@ -546,18 +549,18 @@ async function ssgRequest<T>(path: string): Promise<T | null> {
}
}
// 接口:GET /api/v1/ssg/index/runway(SSG 内部端口,构建期 Node 调用)
// 接口:GET /api/internal/ssg/home/runway(SSG 内部端口,构建期 Node 调用)
// 首页(/index/)runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。
export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> {
const j = await ssgRequest<{ data?: { hotBrand?: SsgPopularBrand[] } }>(`/api/v1/ssg/index/runway`)
const j = await ssgRequest<{ data?: { hotBrand?: SsgPopularBrand[] } }>(`/api/internal/ssg/home/runway`)
return j?.data ?? { hotBrand: [] }
}
// 接口:GET /api/ssg/brands(SSG 内部端口,构建期 Node 调用)
// 接口:GET /api/internal/ssg/brands/popular(SSG 内部端口,构建期 Node 调用)
// SSG 构建期:走秀页热门品牌(按图片总数排名,结果按热度顺序)。
// 后端固定返回前 30 个;侧栏 filter 展示前 10,品牌弹窗 HOT 标签展示全部。
export async function getSsgHotBrands(): Promise<BrandEntry[]> {
const j = await ssgRequest<{ data?: any[] }>("/api/v1/ssg/brands/hot")
const j = await ssgRequest<{ data?: any[] }>("/api/internal/ssg/brands/popular")
return (j?.data ?? []).map((b: any) => {
const nameEn = (b.name_en || b.name || "").toString()
return {
@ -670,10 +673,10 @@ export async function getStreetSnapDetailAuthed(
}
}
// 接口:GET /api/v1/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用)
// 接口:GET /api/internal/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用)
// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。
export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapItem[]> {
const j = await ssgRequest<{ data?: any[] }>(`/api/v1/ssg/street-snaps/popular?limit=${limit}`)
const j = await ssgRequest<{ data?: any[] }>(`/api/internal/ssg/street-snaps/popular?limit=${limit}`)
return (j?.data ?? []).map((b: any) => ({
id: String(b.id),
title: b.title ?? "",

View File

@ -1,12 +1,12 @@
// src/lib/favorites.ts — 收藏(服务端持久化 + 本地缓存兜底)
//
// 设计要点(应对"收藏量极大"场景):
// - 列表卡片打勾:只把「当前可见页的 id」发给后端 /auth/favorites/check,
// - 列表卡片打勾:只把「当前可见页的 id」发给后端 /me/favorites/checks,
// 问哪些已收藏。复杂度与「一屏多少张」成正比,与「用户收藏了多少」彻底解耦,
// 即便收藏 10 万也只传输约 24~50 个 id(常数级)。
// - 个人中心 Saved looks:走 /auth/favorites 的「服务端分页」,每次只取一页(默认 24 条),
// - 个人中心 Saved looks:走 /me/favorites 的「服务端分页」,每次只取一页(默认 24 条),
// DOM 永远轻量,不存在一次渲染上万张卡片的问题。
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到 /api/v1/auth/favorites(POST/DELETE),
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到 /api/v1/me/favorites(POST/DELETE),
// 即便网络失败也不回滚本地,保证前端不"卡死"。
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
//
@ -148,7 +148,7 @@ export function toggleImageFavorite(args: {
export function removeFavorite(id: string): void {
setLocal(getLocal().filter((f) => f.id !== id))
if (serverMode()) {
authJson("DELETE", `/auth/favorites/${encodeURIComponent(id)}`).catch(() => {
authJson("DELETE", `/me/favorites/${encodeURIComponent(id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
@ -181,7 +181,7 @@ export async function fetchFavoritesPage(
const qs = kind ? `&kind=${kind}` : ""
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
"GET",
`/auth/favorites?page=${page}&per_page=${perPage}${qs}`,
`/me/favorites?page=${page}&per_page=${perPage}${qs}`,
)
const items = (r?.items ?? []).map((it) => ({
id: String(it.id),
@ -215,7 +215,7 @@ export async function fetchFavoritesPage(
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
if (!serverMode() || ids.length === 0) return new Set()
try {
const arr = await authJson<string[]>("POST", "/auth/favorites/check", { ids })
const arr = await authJson<string[]>("POST", "/me/favorites/checks", { ids })
return new Set(Array.isArray(arr) ? arr.map(String) : [])
} catch {
return new Set()
@ -260,11 +260,11 @@ function syncServer(item: Omit<Favorite, "addedAt">, newState: boolean): void {
parent_uid: item.parentUid || "",
}
if (newState) {
authJson("POST", "/auth/favorites", payload).catch(() => {
authJson("POST", "/me/favorites", payload).catch(() => {
/* 失败不回滚本地 */
})
} else {
authJson("DELETE", `/auth/favorites/${encodeURIComponent(item.id)}`).catch(() => {
authJson("DELETE", `/me/favorites/${encodeURIComponent(item.id)}`).catch(() => {
/* 失败不回滚本地 */
})
}

View File

@ -78,7 +78,7 @@ export function recordHistory(id: string): void {
setLocal(list)
if (serverMode()) {
authJson("POST", "/auth/history", { target_uid: id }).catch(() => {
authJson("POST", "/me/history", { target_uid: id }).catch(() => {
/* 失败不回滚本地 */
})
}
@ -102,7 +102,7 @@ export async function fetchHistoryPage(page = 1, perPage = 24): Promise<HistPage
const qs = `page=${page}&per_page=${perPage}`
const r = await authJson<{ items: ServerHistory[]; total: number; last_page: number }>(
"GET",
`/auth/history?${qs}`,
`/me/history?${qs}`,
)
const base: HistoryEntry[] = (r?.items ?? []).map((it) => ({
id: String(it.id),
@ -131,7 +131,7 @@ export async function fetchHistoryPage(page = 1, perPage = 24): Promise<HistPage
export function removeHistory(id: string): void {
setLocal(getLocal().filter((h) => h.id !== id))
if (serverMode()) {
authJson("DELETE", `/auth/history/${encodeURIComponent(id)}`).catch(() => {
authJson("DELETE", `/me/history/${encodeURIComponent(id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
@ -141,7 +141,7 @@ export function removeHistory(id: string): void {
export function clearHistory(): void {
setLocal([])
if (serverMode()) {
authJson("DELETE", "/auth/history").catch(() => {
authJson("DELETE", "/me/history").catch(() => {
/* 失败不回滚本地 */
})
}

View File

@ -38,9 +38,12 @@ const browseLabel = t("browse the archive")
<header class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8 border-b-2 border-black pb-10 pt-2">
<div class="min-w-0">
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
<div class="flex flex-wrap items-end gap-x-4">
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
<span x-show="user?.tier === 'vip'" x-cloak class="mb-2 inline-block border border-black bg-black px-2.5 py-1 font-mono text-[10px] font-bold uppercase tracking-[0.3em] text-white">{t("vip")}</span>
</div>
</div>
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.24em] text-zinc-400">
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
<p>Portfolio Studio</p>
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>

View File

@ -53,7 +53,7 @@ const savedLabel = t("saved")
</style>
{(article || snap) && (
<button id="fav-fab" type="button"
<button login-required id="fav-fab" type="button"
data-id={meta.id}
data-type={meta.type}
data-title={meta.title}
@ -109,10 +109,6 @@ const savedLabel = t("saved")
}
btn.addEventListener("click", () => {
if (!getUser()) {
window.location.href = "/login"
return
}
toggleFavorite({
id: meta.id,
type: meta.type,

View File

@ -33,100 +33,95 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
</head>
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<div class="absolute right-0 top-0 z-30 p-6 md:p-10" x-data="{ open: false }" @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 rounded-full border border-black/10 bg-white/85 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.26em] text-black backdrop-blur-sm transition-colors hover:bg-black hover:text-white" 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="h-[14px] w-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<span>{LOCALE_NAMES[locale].nativeName}</span>
</button>
<!-- 全站一致:白底 + 衬线大标题 + 等宽小标 + 粗黑细线,直角无阴影 -->
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
<ul x-show="open" x-transition.opacity.duration.150ms class="absolute right-0 top-full mt-2 min-w-[170px] border border-black/10 bg-white py-2 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
{languages.map((l) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 text-[10px] uppercase tracking-[0.22em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/60']}>{l.label}</a>
</li>
))}
</ul>
</div>
<!-- 顶部工具行:返回档案 + 语言切换 -->
<header class="mb-12 flex items-center justify-between gap-6">
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black/50 transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
<main class="grid min-h-screen md:grid-cols-[1.1fr_1.25fr]">
<section class="relative hidden overflow-hidden bg-black px-10 py-12 text-white md:flex md:flex-col md:justify-between lg:px-14 lg:py-16">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.12),transparent_30%),linear-gradient(135deg,rgba(255,255,255,0.04),transparent_55%)]"></div>
<div class="pointer-events-none absolute -bottom-16 -right-10 select-none font-serif text-[220px] font-black leading-none text-white/[0.04]">PS</div>
<div class="relative z-10 flex items-center justify-between">
<span class="text-[10px] font-mono uppercase tracking-[0.42em] 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.35em] text-white">2026</span>
</div>
<div class="relative z-10">
<p class="mb-6 text-[10px] font-mono uppercase tracking-[0.42em] text-white/50">Member access</p>
<h2 class="font-serif text-5xl uppercase leading-[0.88] tracking-[-0.06em] lg:text-[5.4rem]">Portfolio<br />Studio</h2>
<p class="mt-8 text-[11px] font-mono uppercase tracking-[0.34em] text-white/50">Runway · Street · Archive</p>
</div>
<div class="relative z-10 max-w-md">
<div class="mb-8 h-px w-24 bg-white/20"></div>
<p class="text-sm leading-relaxed text-white/75">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
<div class="mt-8 flex flex-wrap gap-3 text-[10px] font-mono uppercase tracking-[0.28em] text-white/50">
<span>Paris</span>
<span>•</span>
<span>Milan</span>
<span>•</span>
<span>Tokyo</span>
<span>•</span>
<span>New York</span>
</div>
</div>
</section>
<section class="flex items-center justify-center px-6 py-12 md:px-8 lg:px-12">
<div class="w-full max-w-[440px] rounded-[32px] border border-black/10 bg-white/80 p-6 shadow-[0_24px_80px_rgba(0,0,0,0.06)] backdrop-blur-sm md:p-8 lg:p-10">
<div class="mb-8 flex items-center justify-between gap-4 border-b border-black/10 pb-5">
<div>
<p class="mb-2 text-[10px] font-mono uppercase tracking-[0.36em] text-black/45">{membersLabel}</p>
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.06em] md:text-5xl">{loginLabel}</h1>
</div>
<a href={homeUrl} class="inline-flex items-center justify-center rounded-full border border-black/10 bg-black px-2.5 py-1.5 text-[9px] font-mono uppercase tracking-[0.28em] text-white transition-colors hover:bg-white hover:text-black">
Studio
</a>
</div>
<p class="mb-8 text-[10px] font-mono uppercase tracking-[0.28em] text-black/45">{promptLabel}</p>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-7" novalidate>
<div>
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required autofocus class="w-full border-0 border-b border-black/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
</div>
<div>
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required class="w-full border-0 border-b border-black/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
</div>
<p x-show="error" x-text="error" x-cloak class="text-[10px] font-mono uppercase tracking-[0.18em] text-red-600"></p>
<button type="submit" :disabled="loading" class="flex w-full items-center justify-center border border-black bg-black px-4 py-3.5 text-[11px] font-bold uppercase tracking-[0.28em] text-white transition-all duration-200 hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-60">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak class="inline-flex items-center gap-2">Loading</span>
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false" @click.outside="open = false">
<button type="button" @click="open = !open" class="inline-flex items-center gap-2 border border-black/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
<span>{LOCALE_NAMES[locale].nativeName}</span>
<svg viewBox="0 0 12 12" width="11" height="11" class="transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</form>
<div class="mt-8 flex items-center justify-between border-t border-black/10 pt-5">
<a href={homeUrl} class="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.32em] text-black/55 transition-colors hover:text-black">
<span aria-hidden="true">←</span>
{backLabel}
</a>
<span class="text-[9px] font-mono uppercase tracking-[0.28em] text-black/35">Editorial access</span>
<ul x-show="open" x-cloak class="absolute right-0 top-full z-20 mt-2 min-w-[170px] border border-black/15 bg-white py-1">
{languages.map((l) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 font-mono text-[11px] uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/55']}>{l.label}</a>
</li>
))}
</ul>
</div>
</header>
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 2px 粗黑线) -->
<section class="border-b-2 border-black pb-10">
<div class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
<div>
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
</div>
<div class="pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
<p>Portfolio Studio</p>
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</div>
</section>
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
<div class="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
<div class="w-full max-w-md">
<p class="mb-10 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400">{promptLabel}</p>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required autofocus
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] 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-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<p x-show="error" x-text="error" x-cloak class="font-mono text-[11px] uppercase tracking-[0.2em] text-red-600"></p>
<button type="submit" :disabled="loading"
class="w-full border border-black bg-black py-4 font-mono text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-50">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak>…</span>
</button>
</form>
<div class="mt-10 border-t border-black/15 pt-5">
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
</div>
</div>
<aside class="border-t border-black/10 pt-8 md:border-l md:border-t-0 md:pl-8 md:pt-0">
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">Archive note</p>
<p class="font-serif text-lg leading-relaxed text-black/80">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
<div class="mt-8 border-t border-black/10 pt-5 font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400">
<p class="mb-3">Runway · Street · Archive</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</aside>
</div>
</section>
</main>
</main>
<script>
import { login, getUser } from "@/lib/api"

View File

@ -38,9 +38,13 @@ const browseLabel = t("browse the archive")
<header class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8 border-b-2 border-black pb-10 pt-2">
<div class="min-w-0">
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
<div class="flex flex-wrap items-end gap-x-4">
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
<!-- VIP 徽标:仅高级用户(图集详情页可见高清原图)显示 -->
<span x-show="user?.tier === 'vip'" x-cloak class="mb-2 inline-block border border-black bg-black px-2.5 py-1 font-mono text-[10px] font-bold uppercase tracking-[0.3em] text-white">{t("vip")}</span>
</div>
</div>
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.24em] text-zinc-400">
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
<p>Portfolio Studio</p>
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>

View File

@ -53,7 +53,7 @@ const savedLabel = t("saved")
</style>
{(article || snap) && (
<button id="fav-fab" type="button"
<button login-required id="fav-fab" type="button"
data-id={meta.id}
data-type={meta.type}
data-title={meta.title}
@ -109,10 +109,6 @@ const savedLabel = t("saved")
}
btn.addEventListener("click", () => {
if (!getUser()) {
window.location.href = "/login"
return
}
toggleFavorite({
id: meta.id,
type: meta.type,

View File

@ -33,100 +33,95 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
</head>
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<div class="absolute right-0 top-0 z-30 p-6 md:p-10" x-data="{ open: false }" @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 rounded-full border border-black/10 bg-white/85 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.26em] text-black backdrop-blur-sm transition-colors hover:bg-black hover:text-white" 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="h-[14px] w-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<span>{LOCALE_NAMES[locale].nativeName}</span>
</button>
<!-- 全站一致:白底 + 衬线大标题 + 等宽小标 + 粗黑细线,直角无阴影 -->
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
<ul x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute right-0 top-full mt-2 min-w-[170px] border border-black/10 bg-white py-2 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
{languages.map((l) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 text-[10px] uppercase tracking-[0.22em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/60']}>{l.label}</a>
</li>
))}
</ul>
</div>
<!-- 顶部工具行:返回档案 + 语言切换 -->
<header class="mb-12 flex items-center justify-between gap-6">
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black/50 transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
<main class="grid min-h-screen md:grid-cols-[1.1fr_1.25fr]">
<section class="relative hidden overflow-hidden bg-black px-10 py-12 text-white md:flex md:flex-col md:justify-between lg:px-14 lg:py-16">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.12),transparent_30%),linear-gradient(135deg,rgba(255,255,255,0.04),transparent_55%)]"></div>
<div class="pointer-events-none absolute -bottom-16 -right-10 select-none font-serif text-[220px] font-black leading-none text-white/[0.04]">PS</div>
<div class="relative z-10 flex items-center justify-between">
<span class="text-[10px] font-mono uppercase tracking-[0.42em] 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.35em] text-white">2026</span>
</div>
<div class="relative z-10">
<p class="mb-6 text-[10px] font-mono uppercase tracking-[0.42em] text-white/50">Member access</p>
<h2 class="font-serif text-5xl uppercase leading-[0.88] tracking-[-0.06em] lg:text-[5.4rem]">Portfolio<br />Studio</h2>
<p class="mt-8 text-[11px] font-mono uppercase tracking-[0.34em] text-white/50">Runway · Street · Archive</p>
</div>
<div class="relative z-10 max-w-md">
<div class="mb-8 h-px w-24 bg-white/20"></div>
<p class="text-sm leading-relaxed text-white/75">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
<div class="mt-8 flex flex-wrap gap-3 text-[10px] font-mono uppercase tracking-[0.28em] text-white/50">
<span>Paris</span>
<span>•</span>
<span>Milan</span>
<span>•</span>
<span>Tokyo</span>
<span>•</span>
<span>New York</span>
</div>
</div>
</section>
<section class="flex items-center justify-center px-6 py-12 md:px-8 lg:px-12">
<div class="w-full max-w-[440px] rounded-[32px] border border-black/10 bg-white/80 p-6 shadow-[0_24px_80px_rgba(0,0,0,0.06)] backdrop-blur-sm md:p-8 lg:p-10">
<div class="mb-8 flex items-center justify-between gap-4 border-b border-black/10 pb-5">
<div>
<p class="mb-2 text-[10px] font-mono uppercase tracking-[0.36em] text-black/45">{membersLabel}</p>
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.06em] md:text-5xl">{loginLabel}</h1>
</div>
<a href={homeUrl} class="inline-flex items-center justify-center rounded-full border border-black/10 bg-black px-2.5 py-1.5 text-[9px] font-mono uppercase tracking-[0.28em] text-white transition-colors hover:bg-white hover:text-black">
Studio
</a>
</div>
<p class="mb-8 text-[10px] font-mono uppercase tracking-[0.28em] text-black/45">{promptLabel}</p>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-7" novalidate>
<div>
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required autofocus class="w-full border-0 border-b border-black/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
</div>
<div>
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required class="w-full border-0 border-b border-black/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
</div>
<p x-show="error" x-text="error" x-cloak class="text-[10px] font-mono uppercase tracking-[0.18em] text-red-600"></p>
<button type="submit" :disabled="loading" class="flex w-full items-center justify-center border border-black bg-black px-4 py-3.5 text-[11px] font-bold uppercase tracking-[0.28em] text-white transition-all duration-200 hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-60">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak class="inline-flex items-center gap-2">Loading</span>
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false" @click.outside="open = false">
<button type="button" @click="open = !open" class="inline-flex items-center gap-2 border border-black/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
<span>{LOCALE_NAMES[locale].nativeName}</span>
<svg viewBox="0 0 12 12" width="11" height="11" class="transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</form>
<div class="mt-8 flex items-center justify-between border-t border-black/10 pt-5">
<a href={homeUrl} class="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.32em] text-black/55 transition-colors hover:text-black">
<span aria-hidden="true">←</span>
{backLabel}
</a>
<span class="text-[9px] font-mono uppercase tracking-[0.28em] text-black/35">Editorial access</span>
<ul x-show="open" x-cloak class="absolute right-0 top-full z-20 mt-2 min-w-[170px] border border-black/15 bg-white py-1">
{languages.map((l) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 font-mono text-[11px] uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/55']}>{l.label}</a>
</li>
))}
</ul>
</div>
</header>
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 2px 粗黑线) -->
<section class="border-b-2 border-black pb-10">
<div class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
<div>
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
</div>
<div class="pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
<p>Portfolio Studio</p>
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</div>
</section>
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
<div class="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
<div class="w-full max-w-md">
<p class="mb-10 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400">{promptLabel}</p>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required autofocus
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] 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-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<p x-show="error" x-text="error" x-cloak class="font-mono text-[11px] uppercase tracking-[0.2em] text-red-600"></p>
<button type="submit" :disabled="loading"
class="w-full border border-black bg-black py-4 font-mono text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-50">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak>…</span>
</button>
</form>
<div class="mt-10 border-t border-black/15 pt-5">
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
</div>
</div>
<aside class="border-t border-black/10 pt-8 md:border-l md:border-t-0 md:pl-8 md:pt-0">
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">Archive note</p>
<p class="font-serif text-lg leading-relaxed text-black/80">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
<div class="mt-8 border-t border-black/10 pt-5 font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400">
<p class="mb-3">Runway · Street · Archive</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</aside>
</div>
</section>
</main>
</main>
<script>
import { login, getUser } from "@/lib/api"