21
_tmp_dbg.mjs
21
_tmp_dbg.mjs
@ -1,21 +0,0 @@
|
|||||||
const BASE = "http://localhost:8090";
|
|
||||||
const login = await (await fetch(BASE + "/api/v1/auth/login", {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ account: "root", password: "root" }),
|
|
||||||
})).json();
|
|
||||||
const tok = login.access_token;
|
|
||||||
|
|
||||||
async function raw(method, path, body) {
|
|
||||||
const res = await fetch(BASE + path, {
|
|
||||||
method, headers: { "Content-Type": "application/json", Authorization: "Bearer " + tok },
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
const text = await res.text();
|
|
||||||
console.log(method, path, "=>", res.status, text.slice(0, 200));
|
|
||||||
}
|
|
||||||
|
|
||||||
await raw("POST", "/api/v1/auth/favorites", { target_type: "runway", target_uid: "r0042h1mx", title: "t", cover: "c", brand: "b" });
|
|
||||||
await raw("POST", "/api/v1/auth/history", { target_uid: "r0042h1mx" });
|
|
||||||
await raw("POST", "/api/v1/auth/history", { target_uid: "r0042h1mx", extra: 1 });
|
|
||||||
await raw("POST", "/api/v1/auth/history", JSON.stringify({ target_uid: "r0042h1mx" }));
|
|
||||||
process.exit(0);
|
|
||||||
@ -11,8 +11,9 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 网关隔离:SSG 内部接口只存在于 backend 的 8091 端口(回环绑定,nginx 不反代)。
|
# 网关隔离:SSG 内部接口只存在于 backend 的 8091 端口(回环绑定,nginx 不反代)。
|
||||||
# 若有人从公网域名探测 /api/ssg/*,此处直接 404,确保构建期全量数据绝不从公网出口。
|
# 若有人从公网域名探测 /api/internal/ssg/*(或旧 /api/v1/ssg/*),此处直接 404,
|
||||||
location /api/v1/ssg/ {
|
# 确保构建期全量数据绝不从公网出口。
|
||||||
|
location ~ ^/api/(internal/ssg/|v1/ssg/) {
|
||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
150
src/components/AuthModal.astro
Normal file
150
src/components/AuthModal.astro
Normal 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>
|
||||||
@ -10,7 +10,7 @@ import { getSsgStreetSnapPopular } from "@/lib/api"
|
|||||||
// 国际化:一行取当前语言 + 翻译函数
|
// 国际化:一行取当前语言 + 翻译函数
|
||||||
const { locale, t } = getI18n(Astro)
|
const { locale, t } = getI18n(Astro)
|
||||||
|
|
||||||
// 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/v1/ssg/index/runway(server-only),
|
// 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/internal/ssg/home/runway(server-only),
|
||||||
// 首页 runway 数据含多模块,hotBrand 为其中之一;封面/标题取自每个品牌代表走秀,
|
// 首页 runway 数据含多模块,hotBrand 为其中之一;封面/标题取自每个品牌代表走秀,
|
||||||
// 直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
|
// 直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
|
||||||
const popular = (await getSsgIndexRunway()).hotBrand
|
const popular = (await getSsgIndexRunway()).hotBrand
|
||||||
@ -31,7 +31,7 @@ const galleryItems: GalleryItem[] = popular
|
|||||||
title: b.title,
|
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)。
|
// 返回按图片数前 N 的街拍(含封面),直接烧入静态 HTML;后端不可达则静默留空(区块回退 ComingSoon)。
|
||||||
const streetPopular = (await getSsgStreetSnapPopular(12)).filter((b) => b.cover && b.id)
|
const streetPopular = (await getSsgStreetSnapPopular(12)).filter((b) => b.cover && b.id)
|
||||||
const streetItems: { src: string; loc: string; event: string; id: string }[] = streetPopular.map((b) => {
|
const streetItems: { src: string; loc: string; event: string; id: string }[] = streetPopular.map((b) => {
|
||||||
|
|||||||
@ -111,6 +111,7 @@ if (article?.brand_id) {
|
|||||||
|
|
||||||
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
|
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
|
||||||
<button
|
<button
|
||||||
|
login-required
|
||||||
type="button"
|
type="button"
|
||||||
data-fav-id={im.id}
|
data-fav-id={im.id}
|
||||||
data-fav-url={im.raw}
|
data-fav-url={im.raw}
|
||||||
@ -139,7 +140,7 @@ if (article?.brand_id) {
|
|||||||
<figure
|
<figure
|
||||||
x-show="!isAuthed && preview"
|
x-show="!isAuthed && preview"
|
||||||
x-cloak
|
x-cloak
|
||||||
@click="goLogin()"
|
login-redirect
|
||||||
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
|
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
|
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
|
||||||
@ -178,6 +179,7 @@ if (article?.brand_id) {
|
|||||||
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
|
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
login-required
|
||||||
type="button"
|
type="button"
|
||||||
:data-fav-id="im.id"
|
:data-fav-id="im.id"
|
||||||
:data-fav-url="im.raw"
|
:data-fav-url="im.raw"
|
||||||
@ -239,6 +241,7 @@ if (article?.brand_id) {
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
login-required
|
||||||
type="button"
|
type="button"
|
||||||
@click.stop="toggleFav(currentFavId, currentFavUrl)"
|
@click.stop="toggleFav(currentFavId, currentFavUrl)"
|
||||||
:class="isFav(currentFavId) ? 'is-fav' : ''"
|
:class="isFav(currentFavId) ? 'is-fav' : ''"
|
||||||
@ -474,6 +477,21 @@ if (article?.brand_id) {
|
|||||||
}))
|
}))
|
||||||
this.extraImages = mapped
|
this.extraImages = mapped
|
||||||
this.showAll = true
|
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()
|
await this.$nextTick()
|
||||||
this.imageCount = document.querySelectorAll(
|
this.imageCount = document.querySelectorAll(
|
||||||
"#article-gallery [data-image-index]"
|
"#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> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
||||||
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
|
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
|
||||||
maybeLocateImageFromUrl() {
|
maybeLocateImageFromUrl() {
|
||||||
@ -568,13 +579,6 @@ if (article?.brand_id) {
|
|||||||
|
|
||||||
toggleFav(id: string, url: string) {
|
toggleFav(id: string, url: string) {
|
||||||
if (!id) return
|
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({
|
toggleImageFavorite({
|
||||||
imageId: id,
|
imageId: id,
|
||||||
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
|
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
---
|
---
|
||||||
// 加载屏组件:首屏/路由切换时遮盖未加载内容,避免 FOUC。
|
// 加载屏:首屏/整页切换时遮盖未渲染内容,避免 FOUC。
|
||||||
// - 样式作用域隔离在 #loading-screen 内(is:global,因选择器基于唯一 id,不影响整站黑白调性)。
|
// 轻量原则(用户要求"朴实流畅"):
|
||||||
// - 隐藏逻辑用 Astro 默认打包的 <script>(module / defer),无需 is:inline;
|
// - 只用静态大字 + mono 小标 + 一条 2px 细线进度(transform/opacity 动画,最便宜);
|
||||||
// 模块脚本在 window.load 之前执行,故 addEventListener('load') 必然先挂上,无竞态。
|
// - 去掉昂贵的多层 text-shadow 位移动画与外部字体;最短展示时间压缩到 ~350ms,跟手不拖沓。
|
||||||
// - Raleway 字体仅加载屏使用,随组件引入(Astro 自动 hoist 到 head)。
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="loading-screen" aria-hidden="true">
|
<div id="loading-screen" aria-hidden="true">
|
||||||
<div id="wrapper">
|
<div id="ls-box">
|
||||||
<div id="container">
|
|
||||||
<h1>Portfolio Studio</h1>
|
<h1>Portfolio Studio</h1>
|
||||||
<p class="ls-sub">Loading</p>
|
<p class="ls-sub">Loading</p>
|
||||||
</div>
|
<div class="ls-bar" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -22,117 +20,73 @@
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
color: #0a0a0a;
|
color: #0a0a0a;
|
||||||
font: 16px/1.25 'Raleway', sans-serif;
|
transition: opacity 0.4s ease;
|
||||||
text-align: center;
|
|
||||||
transition: opacity 0.5s ease;
|
|
||||||
}
|
}
|
||||||
#loading-screen.is-hidden {
|
#loading-screen.is-hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
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 {
|
#loading-screen h1 {
|
||||||
animation: ls-text-shadow 1.5s ease-in-out infinite;
|
margin: 0;
|
||||||
font-size: clamp(2.5rem, 8vw, 5rem);
|
font-family: ui-serif, Georgia, Cambria, 'Times New Roman', serif;
|
||||||
|
font-size: clamp(2.2rem, 6vw, 4rem);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
letter-spacing: -0.02em;
|
||||||
#loading-screen h1:hover {
|
text-transform: uppercase;
|
||||||
animation-play-state: paused;
|
|
||||||
}
|
|
||||||
#loading-screen a {
|
|
||||||
color: #0a0a0a;
|
|
||||||
}
|
|
||||||
#loading-screen a:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
#loading-screen .ls-sub {
|
#loading-screen .ls-sub {
|
||||||
margin-top: 1.5em;
|
margin: 1.1em 0 0;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.3em;
|
letter-spacing: 0.34em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #888;
|
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-pulse {
|
||||||
@keyframes ls-text-shadow {
|
0%, 100% { opacity: 0.35; }
|
||||||
0% {
|
50% { opacity: 1; }
|
||||||
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-bar {
|
||||||
|
0% { transform: scaleX(0.08); }
|
||||||
|
45% { transform: scaleX(0.72); }
|
||||||
|
100% { transform: scaleX(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 尊重系统“减少动画”偏好:仅作用于加载屏,不波及整站 */
|
/* 尊重系统"减少动画"偏好 */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
#loading-screen *,
|
#loading-screen .ls-sub,
|
||||||
#loading-screen *:before,
|
#loading-screen .ls-bar::before {
|
||||||
#loading-screen *:after {
|
animation: none;
|
||||||
animation: none !important;
|
|
||||||
transition: none !important;
|
|
||||||
}
|
}
|
||||||
|
#loading-screen .ls-bar::before { transform: scaleX(1); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 页面 load 后淡出加载屏;最少展示 500ms 防闪烁,最多 3s 兜底。
|
// load 后淡出;最少展示 350ms 防闪烁,最多 2s 兜底。
|
||||||
// 作为 Astro 默认打包的 module 脚本(defer),在 window.load 前执行,监听必然先挂上。
|
|
||||||
(function () {
|
(function () {
|
||||||
var el = document.getElementById('loading-screen');
|
var el = document.getElementById('loading-screen');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@ -143,16 +97,16 @@
|
|||||||
el.classList.add('is-hidden');
|
el.classList.add('is-hidden');
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
}, 600);
|
}, 500);
|
||||||
}
|
}
|
||||||
var start = Date.now();
|
var start = Date.now();
|
||||||
function tryHide() {
|
function tryHide() {
|
||||||
var waited = Date.now() - start;
|
var waited = Date.now() - start;
|
||||||
if (waited >= 500) hide();
|
if (waited >= 350) hide();
|
||||||
else setTimeout(hide, 500 - waited);
|
else setTimeout(hide, 350 - waited);
|
||||||
}
|
}
|
||||||
if (document.readyState === 'complete') tryHide();
|
if (document.readyState === 'complete') tryHide();
|
||||||
else window.addEventListener('load', tryHide);
|
else window.addEventListener('load', tryHide);
|
||||||
setTimeout(hide, 3000);
|
setTimeout(hide, 2000);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -8,16 +8,7 @@ import "@/styles/look-grid.css"
|
|||||||
|
|
||||||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||||
|
|
||||||
// 登录门禁弹窗文案
|
// 热门品牌:走 SSG 专属接口 /api/internal/ssg/brands/popular,后端固定返回热度前 30 个、按热度顺序排列。
|
||||||
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 个、按热度顺序排列。
|
|
||||||
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
|
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
|
||||||
// 接口不可用时回落假数据先看效果。
|
// 接口不可用时回落假数据先看效果。
|
||||||
const FAKE_HOT_BRANDS = [
|
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">
|
<header class="showshead">
|
||||||
<div>
|
<div>
|
||||||
@ -118,14 +109,14 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
<ul class="showschecks">
|
<ul class="showschecks">
|
||||||
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
|
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
|
||||||
<li>
|
<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)" />
|
<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>
|
<span x-text="b.name"></span>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -140,7 +131,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
<div class="showsg-b" x-show="!collapsedGroups.collection">
|
<div class="showsg-b" x-show="!collapsedGroups.collection">
|
||||||
<ul class="showschecks showschecks--flat">
|
<ul class="showschecks showschecks--flat">
|
||||||
{collectionOptions.map((o) => (
|
{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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -157,7 +148,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
<div class="showsg-b" x-show="!collapsedGroups.season">
|
<div class="showsg-b" x-show="!collapsedGroups.season">
|
||||||
<ul class="showschecks showschecks--flat">
|
<ul class="showschecks showschecks--flat">
|
||||||
{seasonOptions.map((o) => (
|
{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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -174,13 +165,13 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
<div class="showsg-b" x-show="!collapsedGroups.year">
|
<div class="showsg-b" x-show="!collapsedGroups.year">
|
||||||
<ul class="showschecks showschecks--flat">
|
<ul class="showschecks showschecks--flat">
|
||||||
{yearOptions.map((o) => (
|
{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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@ -221,7 +212,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
<!-- Card List -->
|
<!-- Card List -->
|
||||||
<template x-for="it in items" :key="it.id">
|
<template x-for="it in items" :key="it.id">
|
||||||
<div class="showscard-wrap relative group">
|
<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-left">
|
||||||
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
||||||
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
|
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
|
||||||
@ -230,7 +221,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
</div>
|
</div>
|
||||||
<div class="showscard-right">
|
<div class="showscard-right">
|
||||||
<div class="showscard-info">
|
<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>
|
||||||
<div class="showscard-strip">
|
<div class="showscard-strip">
|
||||||
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
|
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
|
||||||
@ -243,22 +234,22 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="showspager" x-show="isAuthed && lastPage > 1" aria-label="Pagination">
|
<nav class="showspager" x-show="lastPage > 1" aria-label="Pagination">
|
||||||
<button class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
|
<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">
|
<template x-for="(p, i) in pageList" :key="i">
|
||||||
<span class="pg-wrap">
|
<span class="pg-wrap">
|
||||||
<span class="pg-ellipsis" x-show="p === '...'" x-text="'…'"></span>
|
<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>
|
</span>
|
||||||
</template>
|
</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>
|
</nav>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@ -266,32 +257,6 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
<!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) -->
|
<!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) -->
|
||||||
<BrandModal />
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@ -300,7 +265,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
|
|
||||||
|
|
||||||
<script>
|
<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 { clientHref, ROUTES } from "@/lib/routes"
|
||||||
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
|
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
|
||||||
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
||||||
@ -338,21 +303,12 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
loading: false,
|
loading: false,
|
||||||
page: 1,
|
page: 1,
|
||||||
lastPage: 1,
|
lastPage: 1,
|
||||||
isAuthed: false,
|
|
||||||
reqToken: 0,
|
reqToken: 0,
|
||||||
|
|
||||||
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
|
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
|
||||||
favMap: {} as Record<string, boolean>,
|
favMap: {} as Record<string, boolean>,
|
||||||
|
|
||||||
// 登录门禁弹窗状态(硬门禁:未登录点筛选/翻页时弹出内联登录表单)
|
|
||||||
showLoginModal: false,
|
|
||||||
loginModalAccount: "",
|
|
||||||
loginModalPassword: "",
|
|
||||||
loginModalError: "",
|
|
||||||
loginModalLoading: false,
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.isAuthed = !!getUser();
|
|
||||||
this.locale = this.$el.dataset.locale || 'en';
|
this.locale = this.$el.dataset.locale || 'en';
|
||||||
// 支持从 header 下拉深链 ?collection=xxx 直接进入对应分类(与侧栏 COLLECTION 筛选一致)
|
// 支持从 header 下拉深链 ?collection=xxx 直接进入对应分类(与侧栏 COLLECTION 筛选一致)
|
||||||
const ct = new URLSearchParams(window.location.search).get('collection')
|
const ct = new URLSearchParams(window.location.search).get('collection')
|
||||||
@ -496,34 +452,6 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
this.refreshFilter()
|
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 加载(分页:每次整页替换,不追加)
|
// API 加载(分页:每次整页替换,不追加)
|
||||||
async loadPage(page: number) {
|
async loadPage(page: number) {
|
||||||
const myToken = ++this.reqToken
|
const myToken = ++this.reqToken
|
||||||
@ -562,7 +490,6 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
goPage(p: number) {
|
goPage(p: number) {
|
||||||
if (!this.requireLogin()) return
|
|
||||||
if (this.loading) return
|
if (this.loading) return
|
||||||
if (p < 1 || p > this.lastPage || p === this.page) return
|
if (p < 1 || p > this.lastPage || p === this.page) return
|
||||||
this.loadPage(p)
|
this.loadPage(p)
|
||||||
@ -580,6 +507,15 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
// 渲染文章url
|
// 渲染文章url
|
||||||
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
|
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 张」遮罩。
|
// 渲染 thumb 图片列表(复用共享工具):固定 5 个槽位 + 末位叠加「共 N 张」遮罩。
|
||||||
//
|
//
|
||||||
// 注意参数顺序:makeCardSlots(images, count, opts) —— 第二个参数是「槽位数量」不是图片总数。
|
// 注意参数顺序:makeCardSlots(images, count, opts) —— 第二个参数是「槽位数量」不是图片总数。
|
||||||
@ -618,7 +554,6 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
},
|
},
|
||||||
favState(id: string) { return !!this.favMap[String(id)] },
|
favState(id: string) { return !!this.favMap[String(id)] },
|
||||||
toggleFav(it: any) {
|
toggleFav(it: any) {
|
||||||
if (!getUser()) { this.showLoginModal = true; return false }
|
|
||||||
const id = String(it.id)
|
const id = String(it.id)
|
||||||
const now = !this.favState(id)
|
const now = !this.favState(id)
|
||||||
toggleFavorite({ id, type: 'runway', title: it.title || '', cover: it.cover || '', brand: it.brand_name || '' })
|
toggleFavorite({ id, type: 'runway', title: it.title || '', cover: it.cover || '', brand: it.brand_name || '' })
|
||||||
|
|||||||
@ -93,6 +93,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
|
|
||||||
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
|
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
|
||||||
<button
|
<button
|
||||||
|
login-required
|
||||||
type="button"
|
type="button"
|
||||||
data-fav-id={im.id}
|
data-fav-id={im.id}
|
||||||
data-fav-url={im.raw}
|
data-fav-url={im.raw}
|
||||||
@ -121,7 +122,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
<figure
|
<figure
|
||||||
x-show="!isAuthed && preview"
|
x-show="!isAuthed && preview"
|
||||||
x-cloak
|
x-cloak
|
||||||
@click="goLogin()"
|
login-redirect
|
||||||
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
|
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
|
{/* 与真实图片等高的 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"
|
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
login-required
|
||||||
type="button"
|
type="button"
|
||||||
:data-fav-id="im.id"
|
:data-fav-id="im.id"
|
||||||
:data-fav-url="im.raw"
|
:data-fav-url="im.raw"
|
||||||
@ -215,6 +217,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
login-required
|
||||||
type="button"
|
type="button"
|
||||||
@click.stop="toggleFav(currentFavId, currentFavUrl)"
|
@click.stop="toggleFav(currentFavId, currentFavUrl)"
|
||||||
:class="isFav(currentFavId) ? 'is-fav' : ''"
|
:class="isFav(currentFavId) ? 'is-fav' : ''"
|
||||||
@ -413,6 +416,19 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
}))
|
}))
|
||||||
this.extraImages = mapped
|
this.extraImages = mapped
|
||||||
this.showAll = true
|
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()
|
await this.$nextTick()
|
||||||
this.imageCount = document.querySelectorAll(
|
this.imageCount = document.querySelectorAll(
|
||||||
"#snap-gallery [data-image-index]"
|
"#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> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
||||||
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
|
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
|
||||||
maybeLocateImageFromUrl() {
|
maybeLocateImageFromUrl() {
|
||||||
@ -501,13 +510,6 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
|
|
||||||
toggleFav(id: string, url: string) {
|
toggleFav(id: string, url: string) {
|
||||||
if (!id) return
|
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({
|
toggleImageFavorite({
|
||||||
imageId: id,
|
imageId: id,
|
||||||
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
|
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
|
||||||
|
|||||||
@ -7,15 +7,6 @@ import "@/styles/look-grid.css"
|
|||||||
|
|
||||||
const { locale, t } = getI18n(Astro)
|
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 年(含当年),末项兜底
|
// 年份筛选:近 10 年(含当年),末项兜底
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const yearFloor = currentYear - 9
|
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">
|
<header class="snapshead">
|
||||||
<div>
|
<div>
|
||||||
@ -97,7 +88,7 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
<div class="snapsg-b" x-show="!collapsedGroups.year">
|
<div class="snapsg-b" x-show="!collapsedGroups.year">
|
||||||
<ul class="snapschecks snapschecks--flat">
|
<ul class="snapschecks snapschecks--flat">
|
||||||
{yearOptions.map((o) => (
|
{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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -114,7 +105,7 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
<div class="snapsg-b" x-show="!collapsedGroups.sort">
|
<div class="snapsg-b" x-show="!collapsedGroups.sort">
|
||||||
<ul class="snapschecks snapschecks--flat">
|
<ul class="snapschecks snapschecks--flat">
|
||||||
{sortOptions.map((o) => (
|
{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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -131,13 +122,13 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
<div class="snapsg-b" x-show="!collapsedGroups.city">
|
<div class="snapsg-b" x-show="!collapsedGroups.city">
|
||||||
<ul class="snapschecks snapschecks--flat">
|
<ul class="snapschecks snapschecks--flat">
|
||||||
{cityOptions.map((o) => (
|
{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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@ -203,7 +194,7 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -212,45 +203,19 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="snappager" x-show="isAuthed && lastPage > 1" aria-label="Pagination">
|
<nav class="snappager" x-show="lastPage > 1" aria-label="Pagination">
|
||||||
<button class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
|
<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">
|
<template x-for="(p, i) in pageList" :key="i">
|
||||||
<span class="pg-wrap">
|
<span class="pg-wrap">
|
||||||
<span class="pg-ellipsis" x-show="p === '...'" x-text="'…'"></span>
|
<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>
|
</span>
|
||||||
</template>
|
</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>
|
</nav>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
|
<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>
|
<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 { clientHref, ROUTES } from "@/lib/routes"
|
||||||
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
|
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
|
||||||
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
||||||
@ -287,21 +252,12 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
loading: false,
|
loading: false,
|
||||||
page: 1,
|
page: 1,
|
||||||
lastPage: 1,
|
lastPage: 1,
|
||||||
isAuthed: false,
|
|
||||||
reqToken: 0,
|
reqToken: 0,
|
||||||
|
|
||||||
// 登录门禁弹窗状态(硬门禁:未登录点筛选/翻页时弹出内联登录表单)
|
|
||||||
showLoginModal: false,
|
|
||||||
loginModalAccount: "",
|
|
||||||
loginModalPassword: "",
|
|
||||||
loginModalError: "",
|
|
||||||
loginModalLoading: false,
|
|
||||||
|
|
||||||
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
|
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
|
||||||
favMap: {} as Record<string, boolean>,
|
favMap: {} as Record<string, boolean>,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.isAuthed = !!getUser()
|
|
||||||
this.locale = this.$el.dataset.locale || 'en'
|
this.locale = this.$el.dataset.locale || 'en'
|
||||||
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
|
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
|
||||||
const city = new URLSearchParams(window.location.search).get('city')
|
const city = new URLSearchParams(window.location.search).get('city')
|
||||||
@ -324,34 +280,6 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
this.loadPage(1)
|
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 的可见性都依赖它)
|
// 已激活筛选 chips(顶部 pills + 侧栏 Clear all 的可见性都依赖它)
|
||||||
get pills() {
|
get pills() {
|
||||||
const out: { label: string; clear: () => void }[] = []
|
const out: { label: string; clear: () => void }[] = []
|
||||||
@ -417,7 +345,6 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
goPage(p: number) {
|
goPage(p: number) {
|
||||||
if (!this.requireLogin()) return
|
|
||||||
if (this.loading) return
|
if (this.loading) return
|
||||||
if (p < 1 || p > this.lastPage || p === this.page) return
|
if (p < 1 || p > this.lastPage || p === this.page) return
|
||||||
this.loadPage(p)
|
this.loadPage(p)
|
||||||
@ -466,7 +393,6 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
},
|
},
|
||||||
favState(id: string) { return !!this.favMap[String(id)] },
|
favState(id: string) { return !!this.favMap[String(id)] },
|
||||||
toggleFav(it: any) {
|
toggleFav(it: any) {
|
||||||
if (!getUser()) { this.showLoginModal = true; return false }
|
|
||||||
const id = String(it.id)
|
const id = String(it.id)
|
||||||
const now = !this.favState(id)
|
const now = !this.favState(id)
|
||||||
toggleFavorite({ id, type: 'street', title: it.title || '', cover: it.cover || '', brand: (it as any).brand || '' })
|
toggleFavorite({ id, type: 'street', title: it.title || '', cover: it.cover || '', brand: (it as any).brand || '' })
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export const dict: Record<string, TranslationEntry> = {
|
|||||||
'login failed': { cn: '登录失败,请检查账号或密码' },
|
'login failed': { cn: '登录失败,请检查账号或密码' },
|
||||||
'please log in': { cn: '请先登录' },
|
'please log in': { cn: '请先登录' },
|
||||||
'login to continue': { cn: '登录以继续使用筛选与翻页' },
|
'login to continue': { cn: '登录以继续使用筛选与翻页' },
|
||||||
|
'go to full login': { cn: '前往完整登录页' },
|
||||||
'login prompt': { cn: '登录后可筛选与翻页浏览全部档案' },
|
'login prompt': { cn: '登录后可筛选与翻页浏览全部档案' },
|
||||||
'login to view': { cn: '登录查看' },
|
'login to view': { cn: '登录查看' },
|
||||||
'photos': { cn: '张照片' },
|
'photos': { cn: '张照片' },
|
||||||
@ -90,6 +91,7 @@ export const dict: Record<string, TranslationEntry> = {
|
|||||||
'quick stats': { cn: '数据概览' },
|
'quick stats': { cn: '数据概览' },
|
||||||
'now': { cn: '刚刚' },
|
'now': { cn: '刚刚' },
|
||||||
'member profile': { cn: '会员资料' },
|
'member profile': { cn: '会员资料' },
|
||||||
|
'vip': { cn: '高级会员' },
|
||||||
'save': { cn: '收藏' },
|
'save': { cn: '收藏' },
|
||||||
'saved': { cn: '已收藏' },
|
'saved': { cn: '已收藏' },
|
||||||
'single image': { cn: '单图' },
|
'single image': { cn: '单图' },
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import '@/styles/global.css'
|
import '@/styles/global.css'
|
||||||
import LoadingScreen from '@/components/LoadingScreen.astro'
|
import LoadingScreen from '@/components/LoadingScreen.astro'
|
||||||
|
import AuthModal from '@/components/AuthModal.astro'
|
||||||
import { href, ROUTES } from '@/lib/routes'
|
import { href, ROUTES } from '@/lib/routes'
|
||||||
import { getI18n } from '@/i18n/utils'
|
import { getI18n } from '@/i18n/utils'
|
||||||
import { LOCALE_NAMES } from '@/i18n/config'
|
import { LOCALE_NAMES } from '@/i18n/config'
|
||||||
@ -241,6 +242,9 @@ const headers = {
|
|||||||
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
|
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{/* 全局登录门禁层(login-required 属性 + 统一登录浮层) */}
|
||||||
|
<AuthModal />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口)
|
// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口)
|
||||||
//
|
//
|
||||||
// 集中封装全部 /api/v1 调用:公开查询(/api/v1/public/*)、账号体系(/api/v1/auth/*)、
|
// 集中封装全部 /api/v1 调用:公开查询(/api/v1/public/*)、账号体系(/api/v1/auth/*)、
|
||||||
// 以及构建期专用的 SSG 接口(/api/v1/ssg/*,仅在 Node 构建期使用)。
|
// 以及构建期专用的 SSG 接口(/api/internal/ssg/*,内部契约,仅 Node 构建期使用)。
|
||||||
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。
|
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。
|
||||||
//
|
//
|
||||||
// base URL 解析规则(全项目统一两个基准地址,命名见下):
|
// base URL 解析规则(全项目统一两个基准地址,命名见下):
|
||||||
// BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。
|
// BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。
|
||||||
// 空值 = 同源相对 /api;DEV 构建期固定走线上域名。
|
// 空值 = 同源相对 /api;DEV 构建期固定走线上域名。
|
||||||
// BASE_API_SSG —— 构建期 Node 调 /api/ssg/* 的「内部端口」(默认 8091),
|
// BASE_API_SSG —— 构建期 Node 调 /api/internal/ssg/* 的「内部端口」(默认 8091),
|
||||||
// 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。
|
// 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。
|
||||||
//
|
//
|
||||||
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
|
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
|
||||||
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
|
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
|
||||||
// - ssgBase():构建期 Node 调 /api/ssg/*,只读 BASE_API_SSG。
|
// - ssgBase():构建期 Node 调 /api/internal/ssg/*,只读 BASE_API_SSG。
|
||||||
|
|
||||||
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
|
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
|
||||||
import "@/lib/http"
|
import "@/lib/http"
|
||||||
@ -76,6 +76,8 @@ export interface AuthUser {
|
|||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
|
/** 用户等级:free(普通)| vip(高级,图集详情页可见高清原图)。 */
|
||||||
|
tier: 'free' | 'vip'
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSG 构建专用类型
|
// SSG 构建专用类型
|
||||||
@ -110,7 +112,7 @@ export function ssrBase(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===================== i18n:请求语言透传 =====================
|
// ===================== 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 选列并缺翻译回落),
|
// 决定 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 (typeof localStorage === "undefined") return null
|
||||||
if (!getAccessToken()) return null
|
if (!getAccessToken()) return null
|
||||||
try {
|
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) {
|
if (!res.ok) {
|
||||||
clearSession()
|
clearSession()
|
||||||
return null
|
return null
|
||||||
@ -462,8 +464,9 @@ export async function logout(): Promise<void> {
|
|||||||
const refresh = getRefreshToken()
|
const refresh = getRefreshToken()
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
try {
|
try {
|
||||||
await fetch(`${BASE_API_URL}/api/v1/auth/logout`, {
|
// 会话即 refresh token:单设备登出 = 吊销当前会话
|
||||||
method: "POST",
|
await fetch(`${BASE_API_URL}/api/v1/auth/sessions/current`, {
|
||||||
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ refresh_token: refresh }),
|
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 {
|
function ssgEnv(name: string): string | undefined {
|
||||||
const e = import.meta.env as any
|
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 为其中之一(当前仅此模块)。
|
// 首页(/index/)runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。
|
||||||
export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> {
|
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: [] }
|
return j?.data ?? { hotBrand: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 接口:GET /api/ssg/brands(SSG 内部端口,构建期 Node 调用)
|
// 接口:GET /api/internal/ssg/brands/popular(SSG 内部端口,构建期 Node 调用)
|
||||||
// SSG 构建期:走秀页热门品牌(按图片总数排名,结果按热度顺序)。
|
// SSG 构建期:走秀页热门品牌(按图片总数排名,结果按热度顺序)。
|
||||||
// 后端固定返回前 30 个;侧栏 filter 展示前 10,品牌弹窗 HOT 标签展示全部。
|
// 后端固定返回前 30 个;侧栏 filter 展示前 10,品牌弹窗 HOT 标签展示全部。
|
||||||
export async function getSsgHotBrands(): Promise<BrandEntry[]> {
|
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) => {
|
return (j?.data ?? []).map((b: any) => {
|
||||||
const nameEn = (b.name_en || b.name || "").toString()
|
const nameEn = (b.name_en || b.name || "").toString()
|
||||||
return {
|
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,返回完整对象含封面)。
|
// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。
|
||||||
export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapItem[]> {
|
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) => ({
|
return (j?.data ?? []).map((b: any) => ({
|
||||||
id: String(b.id),
|
id: String(b.id),
|
||||||
title: b.title ?? "",
|
title: b.title ?? "",
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
// src/lib/favorites.ts — 收藏(服务端持久化 + 本地缓存兜底)
|
// src/lib/favorites.ts — 收藏(服务端持久化 + 本地缓存兜底)
|
||||||
//
|
//
|
||||||
// 设计要点(应对"收藏量极大"场景):
|
// 设计要点(应对"收藏量极大"场景):
|
||||||
// - 列表卡片打勾:只把「当前可见页的 id」发给后端 /auth/favorites/check,
|
// - 列表卡片打勾:只把「当前可见页的 id」发给后端 /me/favorites/checks,
|
||||||
// 问哪些已收藏。复杂度与「一屏多少张」成正比,与「用户收藏了多少」彻底解耦,
|
// 问哪些已收藏。复杂度与「一屏多少张」成正比,与「用户收藏了多少」彻底解耦,
|
||||||
// 即便收藏 10 万也只传输约 24~50 个 id(常数级)。
|
// 即便收藏 10 万也只传输约 24~50 个 id(常数级)。
|
||||||
// - 个人中心 Saved looks:走 /auth/favorites 的「服务端分页」,每次只取一页(默认 24 条),
|
// - 个人中心 Saved looks:走 /me/favorites 的「服务端分页」,每次只取一页(默认 24 条),
|
||||||
// DOM 永远轻量,不存在一次渲染上万张卡片的问题。
|
// DOM 永远轻量,不存在一次渲染上万张卡片的问题。
|
||||||
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到 /api/v1/auth/favorites(POST/DELETE),
|
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到 /api/v1/me/favorites(POST/DELETE),
|
||||||
// 即便网络失败也不回滚本地,保证前端不"卡死"。
|
// 即便网络失败也不回滚本地,保证前端不"卡死"。
|
||||||
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
|
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
|
||||||
//
|
//
|
||||||
@ -148,7 +148,7 @@ export function toggleImageFavorite(args: {
|
|||||||
export function removeFavorite(id: string): void {
|
export function removeFavorite(id: string): void {
|
||||||
setLocal(getLocal().filter((f) => f.id !== id))
|
setLocal(getLocal().filter((f) => f.id !== id))
|
||||||
if (serverMode()) {
|
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 qs = kind ? `&kind=${kind}` : ""
|
||||||
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
|
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
|
||||||
"GET",
|
"GET",
|
||||||
`/auth/favorites?page=${page}&per_page=${perPage}${qs}`,
|
`/me/favorites?page=${page}&per_page=${perPage}${qs}`,
|
||||||
)
|
)
|
||||||
const items = (r?.items ?? []).map((it) => ({
|
const items = (r?.items ?? []).map((it) => ({
|
||||||
id: String(it.id),
|
id: String(it.id),
|
||||||
@ -215,7 +215,7 @@ export async function fetchFavoritesPage(
|
|||||||
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
|
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
|
||||||
if (!serverMode() || ids.length === 0) return new Set()
|
if (!serverMode() || ids.length === 0) return new Set()
|
||||||
try {
|
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) : [])
|
return new Set(Array.isArray(arr) ? arr.map(String) : [])
|
||||||
} catch {
|
} catch {
|
||||||
return new Set()
|
return new Set()
|
||||||
@ -260,11 +260,11 @@ function syncServer(item: Omit<Favorite, "addedAt">, newState: boolean): void {
|
|||||||
parent_uid: item.parentUid || "",
|
parent_uid: item.parentUid || "",
|
||||||
}
|
}
|
||||||
if (newState) {
|
if (newState) {
|
||||||
authJson("POST", "/auth/favorites", payload).catch(() => {
|
authJson("POST", "/me/favorites", payload).catch(() => {
|
||||||
/* 失败不回滚本地 */
|
/* 失败不回滚本地 */
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
authJson("DELETE", `/auth/favorites/${encodeURIComponent(item.id)}`).catch(() => {
|
authJson("DELETE", `/me/favorites/${encodeURIComponent(item.id)}`).catch(() => {
|
||||||
/* 失败不回滚本地 */
|
/* 失败不回滚本地 */
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,7 +78,7 @@ export function recordHistory(id: string): void {
|
|||||||
setLocal(list)
|
setLocal(list)
|
||||||
|
|
||||||
if (serverMode()) {
|
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 qs = `page=${page}&per_page=${perPage}`
|
||||||
const r = await authJson<{ items: ServerHistory[]; total: number; last_page: number }>(
|
const r = await authJson<{ items: ServerHistory[]; total: number; last_page: number }>(
|
||||||
"GET",
|
"GET",
|
||||||
`/auth/history?${qs}`,
|
`/me/history?${qs}`,
|
||||||
)
|
)
|
||||||
const base: HistoryEntry[] = (r?.items ?? []).map((it) => ({
|
const base: HistoryEntry[] = (r?.items ?? []).map((it) => ({
|
||||||
id: String(it.id),
|
id: String(it.id),
|
||||||
@ -131,7 +131,7 @@ export async function fetchHistoryPage(page = 1, perPage = 24): Promise<HistPage
|
|||||||
export function removeHistory(id: string): void {
|
export function removeHistory(id: string): void {
|
||||||
setLocal(getLocal().filter((h) => h.id !== id))
|
setLocal(getLocal().filter((h) => h.id !== id))
|
||||||
if (serverMode()) {
|
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 {
|
export function clearHistory(): void {
|
||||||
setLocal([])
|
setLocal([])
|
||||||
if (serverMode()) {
|
if (serverMode()) {
|
||||||
authJson("DELETE", "/auth/history").catch(() => {
|
authJson("DELETE", "/me/history").catch(() => {
|
||||||
/* 失败不回滚本地 */
|
/* 失败不回滚本地 */
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
<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">
|
<div class="min-w-0">
|
||||||
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
|
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
|
||||||
|
<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>
|
<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>
|
||||||
|
<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>Portfolio Studio</p>
|
||||||
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
<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>
|
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||||
|
|||||||
@ -53,7 +53,7 @@ const savedLabel = t("saved")
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
{(article || snap) && (
|
{(article || snap) && (
|
||||||
<button id="fav-fab" type="button"
|
<button login-required id="fav-fab" type="button"
|
||||||
data-id={meta.id}
|
data-id={meta.id}
|
||||||
data-type={meta.type}
|
data-type={meta.type}
|
||||||
data-title={meta.title}
|
data-title={meta.title}
|
||||||
@ -109,10 +109,6 @@ const savedLabel = t("saved")
|
|||||||
}
|
}
|
||||||
|
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
if (!getUser()) {
|
|
||||||
window.location.href = "/login"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toggleFavorite({
|
toggleFavorite({
|
||||||
id: meta.id,
|
id: meta.id,
|
||||||
type: meta.type,
|
type: meta.type,
|
||||||
|
|||||||
@ -33,99 +33,94 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
|
|||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{pageTitle}</title>
|
<title>{pageTitle}</title>
|
||||||
</head>
|
</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">
|
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
|
||||||
<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">
|
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
|
||||||
<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>
|
|
||||||
|
|
||||||
<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)]">
|
<!-- 顶部工具行:返回档案 + 语言切换 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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) => (
|
{languages.map((l) => (
|
||||||
<li>
|
<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>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main class="grid min-h-screen md:grid-cols-[1.1fr_1.25fr]">
|
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 2px 粗黑线) -->
|
||||||
<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">
|
<section class="border-b-2 border-black pb-10">
|
||||||
<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="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
|
||||||
<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>
|
||||||
|
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
|
||||||
<div class="relative z-10 flex items-center justify-between">
|
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
|
||||||
<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>
|
||||||
|
<div class="pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
|
||||||
<div class="relative z-10">
|
<p>Portfolio Studio</p>
|
||||||
<p class="mb-6 text-[10px] font-mono uppercase tracking-[0.42em] text-white/50">Member access</p>
|
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
||||||
<h2 class="font-serif text-5xl uppercase leading-[0.88] tracking-[-0.06em] lg:text-[5.4rem]">Portfolio<br />Studio</h2>
|
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||||
<p class="mt-8 text-[11px] font-mono uppercase tracking-[0.34em] text-white/50">Runway · Street · Archive</p>
|
</p>
|
||||||
</div>
|
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="flex items-center justify-center px-6 py-12 md:px-8 lg:px-12">
|
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
|
||||||
<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="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
|
||||||
<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>
|
<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-7" novalidate>
|
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{accountLabel}</label>
|
<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/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{passwordLabel}</label>
|
<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/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<p x-show="error" x-text="error" x-cloak class="text-[10px] font-mono uppercase tracking-[0.18em] text-red-600"></p>
|
<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="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">
|
<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">{loginLabel}</span>
|
||||||
<span x-show="loading" x-cloak class="inline-flex items-center gap-2">Loading</span>
|
<span x-show="loading" x-cloak>…</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-8 flex items-center justify-between border-t border-black/10 pt-5">
|
<div class="mt-10 border-t border-black/15 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">
|
<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>
|
<span aria-hidden="true">←</span>{backLabel}
|
||||||
{backLabel}
|
|
||||||
</a>
|
</a>
|
||||||
<span class="text-[9px] font-mono uppercase tracking-[0.28em] text-black/35">Editorial access</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<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>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -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">
|
<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">
|
<div class="min-w-0">
|
||||||
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
|
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
|
||||||
|
<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>
|
<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>
|
||||||
|
<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>Portfolio Studio</p>
|
||||||
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
<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>
|
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||||
|
|||||||
@ -53,7 +53,7 @@ const savedLabel = t("saved")
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
{(article || snap) && (
|
{(article || snap) && (
|
||||||
<button id="fav-fab" type="button"
|
<button login-required id="fav-fab" type="button"
|
||||||
data-id={meta.id}
|
data-id={meta.id}
|
||||||
data-type={meta.type}
|
data-type={meta.type}
|
||||||
data-title={meta.title}
|
data-title={meta.title}
|
||||||
@ -109,10 +109,6 @@ const savedLabel = t("saved")
|
|||||||
}
|
}
|
||||||
|
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
if (!getUser()) {
|
|
||||||
window.location.href = "/login"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toggleFavorite({
|
toggleFavorite({
|
||||||
id: meta.id,
|
id: meta.id,
|
||||||
type: meta.type,
|
type: meta.type,
|
||||||
|
|||||||
@ -33,99 +33,94 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
|
|||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{pageTitle}</title>
|
<title>{pageTitle}</title>
|
||||||
</head>
|
</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">
|
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
|
||||||
<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">
|
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
|
||||||
<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>
|
|
||||||
|
|
||||||
<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)]">
|
<!-- 顶部工具行:返回档案 + 语言切换 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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) => (
|
{languages.map((l) => (
|
||||||
<li>
|
<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>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main class="grid min-h-screen md:grid-cols-[1.1fr_1.25fr]">
|
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 2px 粗黑线) -->
|
||||||
<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">
|
<section class="border-b-2 border-black pb-10">
|
||||||
<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="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
|
||||||
<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>
|
||||||
|
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
|
||||||
<div class="relative z-10 flex items-center justify-between">
|
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
|
||||||
<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>
|
||||||
|
<div class="pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
|
||||||
<div class="relative z-10">
|
<p>Portfolio Studio</p>
|
||||||
<p class="mb-6 text-[10px] font-mono uppercase tracking-[0.42em] text-white/50">Member access</p>
|
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
||||||
<h2 class="font-serif text-5xl uppercase leading-[0.88] tracking-[-0.06em] lg:text-[5.4rem]">Portfolio<br />Studio</h2>
|
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||||
<p class="mt-8 text-[11px] font-mono uppercase tracking-[0.34em] text-white/50">Runway · Street · Archive</p>
|
</p>
|
||||||
</div>
|
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="flex items-center justify-center px-6 py-12 md:px-8 lg:px-12">
|
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
|
||||||
<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="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
|
||||||
<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>
|
<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-7" novalidate>
|
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{accountLabel}</label>
|
<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/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{passwordLabel}</label>
|
<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/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<p x-show="error" x-text="error" x-cloak class="text-[10px] font-mono uppercase tracking-[0.18em] text-red-600"></p>
|
<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="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">
|
<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">{loginLabel}</span>
|
||||||
<span x-show="loading" x-cloak class="inline-flex items-center gap-2">Loading</span>
|
<span x-show="loading" x-cloak>…</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-8 flex items-center justify-between border-t border-black/10 pt-5">
|
<div class="mt-10 border-t border-black/15 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">
|
<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>
|
<span aria-hidden="true">←</span>{backLabel}
|
||||||
{backLabel}
|
|
||||||
</a>
|
</a>
|
||||||
<span class="text-[9px] font-mono uppercase tracking-[0.28em] text-black/35">Editorial access</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<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>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user