Compare commits

..

1 Commits

Author SHA1 Message Date
56e3cfcff7 Merge pull request 'dev' (#1) from dev into main
Some checks failed
deploy / deploy (push) Has been cancelled
Reviewed-on: #1
2026-10-19 17:49:28 +08:00
17 changed files with 194 additions and 700 deletions

View File

@ -1,5 +1,5 @@
node_modules
# ⚠️ 不要忽略 dist:本地预构建时 Dockerfile.prebuilt 需要 COPY 它
dist
.git
.github
.gitea

8
.gitattributes vendored
View File

@ -1,8 +0,0 @@
# Dockerfile 必须 LF:CRLF 会让续行符 `\` 后面多出一个 \r,被当成指令参数
Dockerfile text eol=lf
Dockerfile.* text eol=lf
# 脚本与配置统一 LF
*.sh text eol=lf
*.yml text eol=lf
*.conf text eol=lf

View File

@ -1,27 +1,18 @@
name: deploy
on:
<<<<<<< HEAD
# 手动触发:在 Gitea 仓库 Actions 页面点「Run workflow」才部署,
# 避免每次 push 到 main 的半成品代码被自动上线。
=======
>>>>>>> 29c2d77a8585ff20a7ed193cd45a7a291c63befe
workflow_dispatch:
push:
branches: [main, master]
jobs:
deploy:
runs-on: docker
steps:
- name: SSH deploy
shell: sh
env:
KEY_B64: ${{ secrets.DEPLOY_KEY_B64 }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
run: |
set -e
apk add --no-cache openssh-client >/dev/null 2>&1 || true
mkdir -p "$HOME/.ssh"
echo "$KEY_B64" | base64 -d > "$HOME/.ssh/id"
chmod 600 "$HOME/.ssh/id"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i "$HOME/.ssh/id" \
"$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/fashion/backend_v2 && ./deploy.sh web --no-cache"
- name: SSH 到服务器执行上线脚本
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/fashion/test
./deploy.sh

View File

@ -1,23 +0,0 @@
# 预构建镜像:dist/ 在本机 build 好(构建期 SSG 数据经 SSH 隧道取自线上),
# 服务器只装运行时依赖并打层,不跑 astro build。
# 由 backend/scripts/push-build.sh 与 backend/deploy.sh --prebuilt 使用。
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000
# 运行时依赖层:只在 package-lock 变化时重装,日常发布命中缓存
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY dist ./dist
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=3s --start-period=15s --retries=3 \
CMD nc -z 127.0.0.1 3000 || exit 1
CMD ["node", "./dist/server/entry.mjs"]

View File

@ -10,7 +10,6 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"preview:full": "node scripts/local-proxy.mjs",
"check": "astro check",
"astro": "astro"
},

View File

@ -1,57 +0,0 @@
// 本地预览用的极简代理:模拟生产里 proxy(nginx) 的分流。
//
// /api/* → 后端(默认 127.0.0.1:8090)
// 其余 → astro preview(默认 127.0.0.1:4322)
//
// 为什么需要它:生产环境里浏览器只访问一个域名,/api 由 nginx 反代到后端;
// 本地没有 nginx,astro preview 只跑 Astro 自己的路由,/api 会 404。
// 这个脚本让本地的访问方式和生产一致(同源),不产生 CORS,也不需要改任何业务配置。
//
// 用法:
// node scripts/local-proxy.mjs # 默认 http://localhost:8099
// PROXY_PORT=9000 node scripts/local-proxy.mjs
// (先启动 astro preview,再启动它)
import http from 'node:http'
const PORT = Number(process.env.PROXY_PORT || 8099)
const WEB_ORIGIN = process.env.WEB_ORIGIN || 'http://127.0.0.1:4322'
const API_ORIGIN = process.env.API_ORIGIN || 'http://127.0.0.1:8090'
const server = http.createServer((req, res) => {
const url = req.url || '/'
const isApi = url === '/api' || url.startsWith('/api/')
const target = new URL(url, isApi ? API_ORIGIN : WEB_ORIGIN)
const proxyReq = http.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port,
path: target.pathname + target.search,
method: req.method,
headers: { ...req.headers, host: target.host },
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers)
proxyRes.pipe(res)
}
)
proxyReq.on('error', (err) => {
// 后端没起 / preview 没起时给出明确提示,而不是挂住
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
res.end(
`local-proxy: 连不上 ${isApi ? API_ORIGIN : WEB_ORIGIN}\n` +
`${err.message}\n\n` +
(isApi ? '先把后端跑起来(8090)\n' : '先跑 npm run preview(4322)\n')
)
})
req.pipe(proxyReq)
})
server.listen(PORT, () => {
console.log(`[local-proxy] http://localhost:${PORT}`)
console.log(` /api/* → ${API_ORIGIN}`)
console.log(` 其余 → ${WEB_ORIGIN}`)
})

View File

@ -23,18 +23,16 @@ const fullLoginLabel = t('go to full login')
x-show="isOpen"
x-cloak
data-auth-fail={failLabel}
data-auth-modal
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()"
@keydown.tab="trapFocus($event)"
>
<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="text-2xl font-semibold uppercase leading-none tracking-display">{title}</h3>
<h3 class="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>
@ -42,19 +40,19 @@ const fullLoginLabel = t('go to full login')
<form @submit.prevent="submit()" class="space-y-6" novalidate>
<div>
<label for="auth-account" class="mb-2 block text-[11px] uppercase tracking-widest text-[var(--c-gray-700)]">{accountLabel}</label>
<input id="auth-account" type="text" x-model="account" autocomplete="username" required
<label class="mb-2 block text-[11px] uppercase tracking-widest text-[var(--c-gray-700)]">{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 for="auth-password" class="mb-2 block text-[11px] uppercase tracking-widest text-[var(--c-gray-700)]">{passwordLabel}</label>
<input id="auth-password" type="password" x-model="password" autocomplete="current-password" required
<label class="mb-2 block text-[11px] uppercase tracking-widest text-[var(--c-gray-700)]">{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" :aria-busy="loading"
<button type="submit" :disabled="loading"
class="w-full border border-black bg-black py-3.5 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>
@ -77,7 +75,6 @@ const fullLoginLabel = t('go to full login')
interface AuthStore {
isOpen: boolean
pending: HTMLElement | null
lastFocused: HTMLElement | null
account: string
password: string
error: string
@ -86,7 +83,6 @@ const fullLoginLabel = t('go to full login')
close(): void
submit(): Promise<void>
goFullLogin(): void
trapFocus(e: KeyboardEvent): void
}
document.addEventListener("alpine:init", () => {
@ -94,7 +90,6 @@ const fullLoginLabel = t('go to full login')
const authStore: AuthStore = {
isOpen: false,
pending: null as HTMLElement | null,
lastFocused: null as HTMLElement | null,
account: "",
password: "",
error: "",
@ -103,15 +98,9 @@ const fullLoginLabel = t('go to full login')
request(el: HTMLElement): boolean {
if (getUser()) return true // 已登录:放行,交还原生点击
this.pending = el
this.lastFocused = el
this.error = ""
this.isOpen = true
document.body.style.overflow = "hidden" // 锁背景滚动
// 焦点移入弹窗:屏幕阅读器立即播报 aria-label,键盘用户不必从页面顶端重新 Tab
requestAnimationFrame(() => {
const first = document.querySelector<HTMLElement>("[data-auth-modal] input")
first?.focus()
})
return false
},
@ -123,11 +112,6 @@ const fullLoginLabel = t('go to full login')
this.pending = null
this.error = ""
document.body.style.overflow = ""
// 关闭后把焦点还给触发它的元素。否则焦点会掉回 <body>,
// 键盘用户等于被「传送」回页面顶部,需要重新 Tab 十几下。
const back = this.lastFocused
this.lastFocused = null
if (back && document.contains(back)) back.focus?.()
},
async submit() {
@ -158,29 +142,6 @@ const fullLoginLabel = t('go to full login')
this.close()
window.location.assign(`/${loc}/login?redirect=${encodeURIComponent(p)}`)
},
// 焦点陷阱:aria-modal="true" 只是「声明」,不会自动把 Tab 关在弹窗里。
// 不实现这个,用户 Tab 几下就会跑到背后被遮住的页面元素上。
trapFocus(e: KeyboardEvent) {
const root = document.querySelector<HTMLElement>("[data-auth-modal]")
if (!root) return
const items = Array.from(
root.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.offsetParent !== null)
if (items.length === 0) return
const first = items[0]
const last = items[items.length - 1]
if (!first || !last) return
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
},
}
Alpine.store("auth", authStore)

View File

@ -7,27 +7,23 @@ interface Props {
moduleZh?: string
note?: string
}
const { module = "", moduleZh = "", note = "" } = Astro.props
const { locale, t } = getI18n(Astro)
// 原实现整段是硬编码中文,英文站会直接显示中文。这里全部走词典,
// 模块名按语言取:中文优先 moduleZh,英文只取 module。
const moduleLabel = locale === 'cn' ? moduleZh || module : module || moduleZh
const { module = "This module", moduleZh = "", note = "" } = Astro.props
const { locale } = getI18n(Astro)
---
<section class="cs-wrap">
<div class="cs-kicker">
<span>{t('Coming Soon')}</span>
<span>{t('Archive')}</span>
<span>Coming Soon</span>
<span>暂未上线</span>
</div>
<h1 class="cs-title">{t('Coming Soon')}</h1>
<p class="cs-mod">{moduleLabel}</p>
<h1 class="cs-title">暂未上线</h1>
<p class="cs-mod">{module}{moduleZh && ` · ${moduleZh}`}</p>
<p class="cs-desc">
{note || t('This module is under construction and not open to the public yet. Browse the runway archive first.')}
{note || "该模块正在筹备中,尚未对外开放。站点正按模块逐个完善,你可以先浏览已上线的走秀档案。"}
</p>
<div class="cs-actions">
<a class="cs-btn" href={href(locale, ROUTES.runwayLooks)}>{t('Browse runway archive →')}</a>
<a class="cs-btn-ghost" href={href(locale, ROUTES.home)}>{t('Back to home')}</a>
<a class="cs-btn" href={href(locale, ROUTES.runwayLooks)}>浏览走秀档案 →</a>
<a class="cs-btn-ghost" href={href(locale, ROUTES.home)}>返回首页</a>
</div>
</section>
@ -48,7 +44,7 @@ const moduleLabel = locale === 'cn' ? moduleZh || module : module || moduleZh
@apply text-[11px] tracking-[0.25em] uppercase text-[var(--c-gray-800)];
}
.cs-title {
@apply font-bold tracking-[-0.02em];
@apply font-black tracking-[-0.02em];
line-height: 0.9;
font-size: clamp(56px, 13vw, 128px);
margin: 0.16em 0 0.3em;

View File

@ -132,7 +132,7 @@ const hasImages = previewImages.length > 0
{
hasItem ? (
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
<h1 class="font-bold leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{title}
</h1>

View File

@ -38,7 +38,7 @@ const browseLabel = t("browse the archive")
<!-- ===== 未登录:整版引导 ===== -->
<div x-show="!user" x-cloak class="flex flex-col items-center justify-center border border-black/15 px-6 py-32 text-center">
<p class="mb-5 text-[11px] uppercase tracking-[0.3em] text-[var(--c-gray-600)]">{pleaseLabel}</p>
<p class="mb-12 text-5xl font-bold uppercase leading-none tracking-tight md:text-7xl">{titleLabel}</p>
<p class="mb-12 text-5xl font-black uppercase leading-none tracking-tight md:text-7xl">{titleLabel}</p>
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-10 py-4 text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
@ -50,7 +50,7 @@ const browseLabel = t("browse the archive")
<div class="min-w-0">
<p class="mb-4 text-[11px] uppercase tracking-[0.3em] text-[var(--c-gray-600)]">{t("member profile")}</p>
<div class="flex flex-wrap items-end gap-x-4">
<h1 class="text-6xl font-bold uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
<h1 class="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 text-[10px] font-bold uppercase tracking-[0.3em] text-white">{t("vip")}</span>
</div>
@ -85,7 +85,7 @@ const browseLabel = t("browse the archive")
<div x-show="tab==='saved'" x-cloak>
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<h2 class="text-4xl font-bold uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
<h2 class="text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
<p class="pb-1 text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
</div>
@ -97,7 +97,7 @@ const browseLabel = t("browse the archive")
<!-- 空态 -->
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
<p class="mb-10 text-3xl font-bold uppercase tracking-tight text-black/80 md:text-4xl">{noSavedLabel}</p>
<p class="mb-10 text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noSavedLabel}</p>
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
</div>
@ -136,7 +136,7 @@ const browseLabel = t("browse the archive")
<div x-show="tab==='history'" x-cloak>
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<h2 class="text-4xl font-bold uppercase leading-none tracking-tight md:text-6xl">{historyLabel}</h2>
<h2 class="text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{historyLabel}</h2>
<div class="flex items-baseline gap-8 pb-1">
<p class="text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]"><span class="font-bold text-black" x-text="histTotal"></span> {itemsLabel}</p>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[11px] uppercase tracking-[0.2em] text-[var(--c-gray-600)] underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
@ -145,7 +145,7 @@ const browseLabel = t("browse the archive")
<!-- 空态 -->
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
<p class="mb-10 text-3xl font-bold uppercase tracking-tight text-black/80 md:text-4xl">{noHistoryLabel}</p>
<p class="mb-10 text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noHistoryLabel}</p>
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
</div>

View File

@ -46,10 +46,10 @@ const streetItems: {
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
<!-- 二级分类标签页 -->
<div id="tabbar" class="mt-20 md:mt-28">
<ul class="flex items-center gap-7 md:gap-9 list-none p-0 m-0 overflow-x-auto no-scrollbar">
<div id="tabbar" class="mt-16 md:mt-24">
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
<li
class="tab text-[clamp(1.875rem,4.6vw,3.25rem)] font-bold tracking-display leading-[0.95] cursor-pointer whitespace-nowrap"
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'backstage' }"
@click="activeTab = 'backstage'"
>
@ -57,7 +57,7 @@ const streetItems: {
</li>
<li
class="tab text-[clamp(1.875rem,4.6vw,3.25rem)] font-bold tracking-display leading-[0.95] cursor-pointer whitespace-nowrap"
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'street' }"
@click="activeTab = 'street'"
>
@ -65,7 +65,7 @@ const streetItems: {
</li>
<li
class="tab text-[clamp(1.875rem,4.6vw,3.25rem)] font-bold tracking-display leading-[0.95] cursor-pointer whitespace-nowrap"
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'events' }"
@click="activeTab = 'events'"
>
@ -75,11 +75,11 @@ const streetItems: {
</div>
<!-- 中间内容展示区 -->
<main class="grow min-h-[400px] mt-14">
<main class="grow min-h-[400px] mt-10">
<!-- Runway -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="text-[18px] uppercase tracking-label font-semibold mb-6 text-[var(--c-gray-800)]">
<div class="text-2xl font-medium mb-4">
Hot Brand
</div>
@ -122,7 +122,7 @@ const streetItems: {
</div>
</div>
<div class="hs-controls flex justify-between items-center text-[13px] font-medium">
<div class="hs-controls flex justify-between items-center text-[13px] font-bold">
<div
class="text-[16px] hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200"
role="button"
@ -209,15 +209,14 @@ const streetItems: {
/* 标签 */
.tab {
@apply relative pb-[6px] text-[var(--c-gray-500)] transition-colors duration-[240ms] no-underline;
@apply relative pb-[6px] text-[var(--c-gray-600)] transition-colors duration-200 no-underline;
}
.tab:hover {
@apply text-[var(--c-gray-800)];
}
.tab::after {
content: "";
@apply absolute left-0 right-0 bottom-0 h-[1px] bg-black scale-x-0 origin-left transition-transform duration-[240ms];
transition-timing-function: var(--ease-editorial, ease);
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-black scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
}
.tab.is-active {
@apply text-black;
@ -368,18 +367,18 @@ const streetItems: {
.hs-cap-brand {
@apply font-sans;
font-size: 21px;
font-weight: 500;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
letter-spacing: 0.02em;
line-height: 1.1;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
}
.hs-cap-meta {
@apply font-sans;
font-size: 13px;
font-weight: 400;
opacity: 0.85;
letter-spacing: 0.02em;
font-weight: 500;
opacity: 0.9;
letter-spacing: 0.04em;
line-height: 1.3;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
max-width: 90%;
@ -406,12 +405,8 @@ const streetItems: {
@apply mb-9;
}
.ss-grid {
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 md:gap-5;
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[14px] md:gap-[18px];
}
/* 刻意不做逐项入场动画。
frontend-design skill 把它列为「AI 生成的识别特征」:
每个 section 淡入上滑、每张卡片都错落出现,是通用水印,不承载信息。
这里只保留图片加载的 blur/opacity 淡入(那是真实的状态变化)。 */
.ss-card {
@apply relative block w-full overflow-hidden bg-[var(--c-gray-100)] no-underline text-inherit;
aspect-ratio: 3 / 4;
@ -488,17 +483,17 @@ const streetItems: {
pointer-events: none;
}
.ss-cap-loc {
font-size: clamp(1.125rem, 2vw, 1.625rem);
font-weight: 500;
font-size: clamp(18px, 2vw, 26px);
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.01em;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
}
.ss-cap-event {
font-size: clamp(0.625rem, 0.8vw, 0.75rem);
font-weight: 400;
font-size: clamp(10px, 0.8vw, 12px);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.18em;
letter-spacing: 0.16em;
line-height: 1.4;
opacity: 0.85;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);

View File

@ -1,17 +1,12 @@
---
// 登录页的 en/cn 共享实现(语言由 Astro.currentLocale 运行时决定)。
// 对应页面:src/pages/{en,cn}/login.astro —— 仅做薄包装。
//
// 设计方向:把登录页当成「一期杂志的封面页」——整幅满血封面 + 竖排刊头 + 套准标记 +
// 极端字距的巨标题。延续全站的黑白 / 直角 / 粗线语言,但把张力拉满,避免"白底表单"的平庸。
// 本页**不走 Layout**,自带完整 <html> 文档。
// 注意:本页**不走 Layout**,自带完整 <html> 文档(独立极简版式,故保留 <!doctype html>)。
import '@/styles/global.css'
import { getI18n } from '@/lib/i18n'
import { href, ROUTES } from '@/lib/routes'
import { config } from '@/lib/config'
import { LOCALE_NAMES } from '@/lib/i18n'
import { getSsgIndexRunway, getSsgStreetSnapPopular } from '@/lib/ssg'
import { toAbs } from '@/lib/request'
import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n';
@ -29,6 +24,7 @@ const promptLabel = t('login prompt')
const accountLabel = t('username or email')
const passwordLabel = t('password')
const failLabel = t('login failed')
const membersLabel = t('members')
const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks)
const SITE_NAME = config.siteName ?? "Xisoa"
@ -39,19 +35,7 @@ const loginDesc = locale === 'cn'
const loginKeywords = locale === 'cn'
? `登录,账户,${SITE_NAME}`
: `login,account,${SITE_NAME}`
// 封面区:库里原图统一是 240×360(爬虫入库下的是缩略图),
// 放大成整屏必然糊,所以改用「接触印样」版式——多张图按原始尺寸铺成网格,
// 既不会拉伸,也符合时尚档案的视觉习惯。取不到图则降级为纯黑排版块。
const showCovers = (await getSsgIndexRunway()).hotBrand
.filter((b) => b.cover)
.map((b) => toAbs(b.cover))
const snapCovers = (await getSsgStreetSnapPopular(8))
.filter((s) => s.cover)
.map((s) => toAbs(s.cover))
const sheet = [...new Set([...showCovers, ...snapCovers])].slice(0, 6)
---
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head>
@ -61,187 +45,96 @@ const sheet = [...new Set([...showCovers, ...snapCovers])].slice(0, 6)
<title>{pageTitle}</title>
<meta name="description" content={loginDesc} />
<meta name="keywords" content={loginKeywords} />
<style>
/* 印刷质感:极细扫描线,替代噪声图,零外部资源 */
.paper-grain {
background-image: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.035) 0 1px,
transparent 1px 3px
);
}
/* 逐行上浮:整页一次编排好的入场,而不是零散的微交互 */
@keyframes rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: none; }
}
.rise { animation: rise 0.62s cubic-bezier(0.22, 0.61, 0.36, 1) both; }
.rise-1 { animation-delay: 0.04s; }
.rise-2 { animation-delay: 0.12s; }
.rise-3 { animation-delay: 0.20s; }
.rise-4 { animation-delay: 0.28s; }
.rise-5 { animation-delay: 0.36s; }
@media (prefers-reduced-motion: reduce) {
.rise { animation: none; }
}
/* 竖排刊头(东方式版式,与"档案"气质一致) */
.vertical-label {
writing-mode: vertical-rl;
text-orientation: mixed;
}
/* 输入框:底线 2px,聚焦时整行压黑 */
.inkinput {
border: 0;
border-bottom: 2px solid rgba(0, 0, 0, 0.35);
background: transparent;
transition: border-color 0.18s ease;
}
.inkinput:focus { outline: none; border-bottom-color: #000; }
</style>
</head>
<!-- 全站一致:白底 + 粗黑大标题 + 宽字距小标 + 粗黑细线,直角无阴影 -->
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
<div class="mx-auto flex min-h-screen w-full max-w-[1700px] flex-col">
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
<!-- 顶栏:返回 / 期号 / 语言 -->
<header class="flex items-center justify-between gap-4 border-b border-black/15 px-5 py-4 md:px-10">
<a href={homeUrl} class="inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-black/55 transition-colors hover:text-black">
<!-- 顶部工具行:返回档案 + 语言切换 -->
<header class="mb-12 flex items-center justify-between gap-6">
<a href={homeUrl} class="inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-black/50 transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
<div class="flex items-center gap-5">
<span class="hidden text-[10px] uppercase tracking-[0.3em] text-black/35 sm:inline">N° 04 / 2026</span>
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false" @click.outside="open = false">
<button type="button" @click="open = !open" class="inline-flex items-center gap-2 border border-black/20 px-3 py-2 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-30 mt-2 min-w-[170px] border border-black/20 bg-white py-1">
{languages.map((l) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 text-[11px] uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/55']}>{l.label}</a>
</li>
))}
</ul>
</div>
<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 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) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 text-[11px] uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/55']}>{l.label}</a>
</li>
))}
</ul>
</div>
</header>
<!-- 主体:左封面 / 右表单 -->
<main class="grid flex-1 md:grid-cols-[1.08fr_1fr]">
<!-- 左:整幅封面 -->
<aside class="relative hidden overflow-hidden bg-black md:block">
{sheet.length > 0 ? (
<div class="grid h-full grid-cols-3 grid-rows-2 gap-px bg-black">
{sheet.map((src, i) => (
<div class="relative min-h-0 overflow-hidden">
<img
src={src}
alt=""
loading={i < 3 ? 'eager' : 'lazy'}
decoding="async"
class="absolute inset-0 h-full w-full object-cover"
/>
</div>
))}
</div>
) : (
<div class="h-full w-full bg-black"></div>
)}
<!-- 压暗 + 颗粒 -->
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/45 to-black/10"></div>
<div class="paper-grain absolute inset-0 opacity-60 mix-blend-overlay"></div>
<!-- 左侧竖排刊头 -->
<div class="absolute left-5 top-8 flex flex-col items-center gap-4">
<span class="vertical-label text-[10px] uppercase tracking-[0.42em] text-white/55">{t('fashion archive')}</span>
<span class="h-16 w-px bg-white/30"></span>
<!-- 报头:与账户页同一套 masthead(宽字距眉标 + 粗黑巨标题 + 档案标签 + 2px 粗黑线) -->
<section class="border-b-2 border-black pb-10">
<div class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
<div>
<p class="mb-4 text-[11px] uppercase tracking-[0.3em] text-[var(--c-gray-600)]">{membersLabel}</p>
<h1 class="text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
</div>
<!-- 四角套准标记 -->
<span class="absolute left-5 top-5 h-3 w-3 border-l border-t border-white/40"></span>
<span class="absolute right-5 top-5 h-3 w-3 border-r border-t border-white/40"></span>
<span class="absolute bottom-5 left-5 h-3 w-3 border-b border-l border-white/40"></span>
<span class="absolute bottom-5 right-5 h-3 w-3 border-b border-r border-white/40"></span>
<!-- 底部刊名(压在图上) -->
<div class="absolute inset-x-0 bottom-0 p-8 md:p-12">
<p class="text-[10px] uppercase tracking-[0.4em] text-white/60">{t('archive')}</p>
<p class="mt-3 text-5xl font-bold uppercase leading-[0.8] tracking-[-0.04em] text-white lg:text-7xl">{SITE_NAME}</p>
<p class="mt-5 text-[10px] uppercase tracking-[0.34em] text-white/50">{t('paris · milan · tokyo')}</p>
<div class="pb-1 text-right text-[11px] uppercase leading-loose tracking-[0.22em] text-[var(--c-gray-600)]">
<p>{SITE_NAME}</p>
<p class="flex items-center justify-end gap-2 text-[var(--c-gray-700)]">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</aside>
</div>
</section>
<!-- 右:表单 -->
<section class="relative flex flex-col justify-center px-5 py-10 md:px-14 md:py-14">
<span class="absolute right-5 top-5 hidden h-3 w-3 border-r border-t border-black/20 md:block"></span>
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
<div class="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
<p class="rise rise-1 flex items-center gap-2 text-[10px] uppercase tracking-[0.36em] text-black/45">
<span class="inline-block h-[7px] w-[7px] bg-black"></span>{t('members only')}
</p>
<div class="w-full max-w-md">
<p class="mb-10 text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">{promptLabel}</p>
<h1 class="rise rise-2 mt-5 text-[clamp(52px,8vw,104px)] font-bold uppercase leading-[0.82] tracking-[-0.045em]">
{loginLabel}
</h1>
<p class="rise rise-2 mt-6 max-w-sm text-sm leading-relaxed text-black/55">{promptLabel}</p>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="rise rise-3 mt-12 w-full max-w-md" novalidate>
<div class="flex items-baseline gap-4">
<span class="w-6 shrink-0 text-[10px] font-bold tracking-[0.2em] text-black/30">01</span>
<div class="flex-1">
<label for="lg-account" class="mb-1 block text-[10px] uppercase tracking-[0.28em] text-black/45">{accountLabel}</label>
<input id="lg-account" type="text" x-model="account" autocomplete="username" required autofocus
class="inkinput w-full py-3 text-base text-black md:text-lg" />
</div>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
<div>
<label class="mb-2 block text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-700)]">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required autofocus
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<div class="mt-9 flex items-baseline gap-4">
<span class="w-6 shrink-0 text-[10px] font-bold tracking-[0.2em] text-black/30">02</span>
<div class="flex-1">
<label for="lg-password" class="mb-1 block text-[10px] uppercase tracking-[0.28em] text-black/45">{passwordLabel}</label>
<input id="lg-password" type="password" x-model="password" autocomplete="current-password" required
class="inkinput w-full py-3 text-base text-black md:text-lg" />
</div>
<div>
<label class="mb-2 block text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-700)]">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<p x-show="error" x-text="error" x-cloak
class="mt-7 border-l-4 border-black bg-black px-4 py-3 text-[11px] uppercase tracking-[0.18em] text-white"></p>
<p x-show="error" x-text="error" x-cloak class="text-[11px] uppercase tracking-[0.2em] text-red-600"></p>
<button type="submit" :disabled="loading"
class="group mt-10 flex w-full items-center justify-between border border-black bg-black px-6 py-5 text-[12px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-50">
<span x-show="!loading">{t('enter the archive →')}</span>
<span x-show="loading" x-cloak>{t('signing in…')}</span>
<span aria-hidden="true" class="text-base transition-transform duration-200 group-hover:translate-x-1">→</span>
class="w-full border border-black bg-black py-4 text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-50">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak>…</span>
</button>
</form>
<!-- 说明与补充入口 -->
<div class="rise rise-4 mt-12 max-w-md border-t border-black/10 pt-6">
<p class="text-[10px] uppercase tracking-[0.3em] text-black/40">{t('archive note')}</p>
<p class="mt-3 text-[13px] leading-relaxed text-black/55">
{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>
<div class="rise rise-5 mt-8 flex max-w-md flex-wrap items-center justify-between gap-3 text-[10px] uppercase tracking-[0.24em] text-black/40">
<a href={homeUrl} class="inline-flex items-center gap-2 transition-colors hover:text-black">
<div class="mt-10 border-t border-black/15 pt-5">
<a href={homeUrl} class="inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.2em] text-[var(--c-gray-600)] transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
<span class="tracking-[0.28em]">{t('runway · street · archive')}</span>
</div>
</section>
</main>
</div>
<footer class="flex items-center justify-between border-t border-black/10 px-5 py-4 text-[10px] uppercase tracking-[0.28em] text-black/35 md:px-10">
<p>© 2026 {SITE_NAME}</p>
<p>Paris · Milan · Tokyo</p>
</footer>
</div>
<aside class="border-t border-black/10 pt-8 md:border-l md:border-t-0 md:pl-8 md:pt-0">
<p class="mb-5 text-[11px] uppercase tracking-[0.3em] text-[var(--c-gray-600)]">Archive note</p>
<p class="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 text-[10px] uppercase tracking-[0.3em] text-[var(--c-gray-600)]">
<p class="mb-3">Runway · Street · Archive</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</aside>
</div>
</main>
<script>
import { getUser } from "@/lib/auth"

View File

@ -62,11 +62,6 @@ const pathWithoutLocale = locales.includes(pathName.split('/')[1])
? pathName.replace(`/${locale}`, '')
: pathName;
// 导航当前项:给对应链接加 aria-current="page",屏幕阅读器据此播报「当前页面」。
// 只比较去尾斜杠后的路径,避免 /runway-looks 与 /runway-looks/ 被判成两项。
const normPath = (p: string) => p.replace(/\/+$/, '') || '/';
const isCurrent = (target: string) => normPath(pathWithoutLocale) === normPath(target);
// 顶部导航数据模型(替代原 Alpine x-data 的 menu/state)。
// 下拉菜单与语言列表均由此驱动渲染,逻辑用 Astro 原生 script 控制开合。
const headers = {
@ -141,18 +136,11 @@ const headers = {
<!-- 加载动画屏:独立组件,标记/样式/隐藏逻辑封装在 LoadingOverlay.astro -->
<LoadingOverlay />
<!-- 键盘用户跳过导航:平时视觉隐藏,Tab 聚焦时出现在左上角。
没有它,键盘用户每进一页都要按十几次 Tab 才能摸到正文。 -->
<a
href="#main"
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:bg-black focus:px-4 focus:py-2 focus:text-[12px] focus:uppercase focus:tracking-label focus:text-white"
>跳到主内容</a>
<!-- 顶部主导航栏 -->
<nav class="flex justify-between items-center text-[13px] font-semibold tracking-label uppercase">
<nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase">
<div class="flex flex-wrap items-center gap-6 md:gap-8">
<a href={href(locale, ROUTES.home)} class="nav-link cursor-pointer" aria-current={isCurrent(ROUTES.home) ? 'page' : undefined}>{t('Home')}</a>
<a href={href(locale, ROUTES.home)} class="nav-link cursor-pointer flex items-center gap-1.5 opacity-70">
<a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Home')}</a>
<a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">
{t('Portfolio')}
<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full tracking-tight leading-none">
2026
@ -161,7 +149,7 @@ const headers = {
<!-- 全部的 runway:菜单项用 Astro 渲染(headers.runways.map),开合逻辑用 Alpine.js -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<div class="flex items-center gap-1">
<a href={href(locale, ROUTES.runwayLooks)} class="nav-link cursor-pointer" aria-current={isCurrent(ROUTES.runwayLooks) ? 'page' : undefined}>{t('all runway looks')}</a>
<a href={href(locale, ROUTES.runwayLooks)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('all runway looks')}</a>
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 runway 列表" aria-haspopup="true" :aria-expanded="open">
<svg class="w-2.5 h-2.5 transition-transform duration-200" :class="open ? 'rotate-180' : ''" viewBox="0 0 10 6" fill="none"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
@ -179,7 +167,7 @@ const headers = {
<!-- 街拍:菜单项用 Astro 渲染(headers.streets.map),开合逻辑用 Alpine.js -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<div class="flex items-center gap-1">
<a href={href(locale, ROUTES.streetSnaps)} class="nav-link cursor-pointer" aria-current={isCurrent(ROUTES.streetSnaps) ? 'page' : undefined}>{t('Street Style')}</a>
<a href={href(locale, ROUTES.streetSnaps)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Street Style')}</a>
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 street style 列表" aria-haspopup="true" :aria-expanded="open">
<svg viewBox="0 0 12 12" width="10" height="10"><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>
@ -219,7 +207,7 @@ const headers = {
@click={`open = false; pick('${l.code}')`}
class:list={[
'block px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors',
l.code === locale ? 'font-medium' : 'opacity-60',
l.code === locale ? 'font-bold' : 'opacity-60',
]}
>{l.label}</a>
</li>
@ -230,7 +218,7 @@ const headers = {
<!-- 未登录:Login 链接(Alpine 登录后置为隐藏)。
login-redirect:交给 AuthModal 的全局门禁捕获,带上「当前页」跳登录页,
登录成功后回到原页,而不是一律落到个人中心(无 JS 时回落 href 到 /login)。 -->
<a x-show="!user" x-cloak login-redirect href={href(locale, ROUTES.login)} class="nav-link uppercase tracking-label text-[13px]">{t('Login')}</a>
<a x-show="!user" x-cloak login-redirect href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200 uppercase tracking-[0.5px] text-[13px]">{t('Login')}</a>
<!-- 已登录:账号下拉(hover 展开,与语言切换器同款交互) -->
<div x-show="user" x-cloak class="relative" @mouseenter="open = true" @mouseleave="open = false">
@ -254,29 +242,11 @@ const headers = {
</div>
</nav>
<!-- 公告条:放在导航之下,作为头部的收边。
纯 CSS 循环(内容重复两遍,轨道左移 50% 即为一个周期),零 JS。 -->
<div class="marquee -mx-5 md:-mx-[50px] mt-6 border-b border-black/10 py-2 text-[11px] uppercase tracking-wide text-[var(--c-gray-800)]">
<div class="marquee__track">
{Array.from({ length: 2 }).map(() => (
<Fragment>
<span>✦ 2026 秋冬时装周档案</span>
<span>✦ Runway · Street · Archive</span>
<span>✦ 全球品牌持续收录</span>
<span>✦ New Season</span>
</Fragment>
))}
</div>
</div>
<div id="main" tabindex="-1" class="flex-1 flex flex-col">
<div class="flex-1 flex flex-col">
<slot />
</div>
{/* 黑色页脚要贴到视口左右与底部边缘:body 上有 px-5 py-5 md:px-[50px] md:py-[30px],
子元素默认会缩进这层 padding;这里用等量负外边距抵消(与上方 marquee 同一手法)。
注意不能加 w-full:width:100% + 负 margin-left 会让右边少 20px,只能靠 auto 宽度撑满。 */}
<footer class="relative z-[1] mt-20 -mx-5 -mb-5 bg-black font-sans text-white md:-mx-[50px] md:-mb-[30px]">
<footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans">
<script>
import { getUser } from "@/lib/auth"
import type { AuthUser } from "@/lib/auth"
@ -373,64 +343,58 @@ const headers = {
}))
})
</script>
{/* 页脚主体:黑底反白,作为整页收尾(沿用全站直角 / 粗线 / 宽字距语言) */}
{/* 黑底已用负边距铺到视口边缘,内层补回被抵消的那层缩进,让文字位置与改动前完全一致:
移动端 body 20 + 原 footer 20 = 40 → px-10
桌面 body 50 + 原 footer 40 = 90 → md:px-[90px] */}
<div class="mx-auto w-full max-w-[1400px] px-10 py-14 md:px-[90px] md:py-20">
<div class="grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-8">
{/* 品牌 */}
<div class="md:col-span-6">
<p class="text-[10px] uppercase tracking-wide text-white/35">{t('Archive')}</p>
<p class="mt-5 text-5xl font-bold uppercase leading-[0.85] tracking-display md:text-7xl">
{/* Top Footer Grid */}
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-8 mb-10 pb-10 border-b border-black/10">
{/* Column 1: Brand & Manifesto */}
<div class="md:col-span-5 space-y-4">
<div class="flex items-center gap-2">
<span class="text-base font-black tracking-wider uppercase border-b-2 border-black pb-0.5">
{SITE_NAME}
</p>
<p class="mt-7 max-w-md text-sm leading-relaxed text-white/45">
{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 items-center gap-x-3 gap-y-2 text-[10px] uppercase tracking-wide text-white/30">
<span>Paris</span><span>·</span><span>Milan</span><span>·</span><span>Tokyo</span><span>·</span><span>New York</span>
</div>
</span>
<span class="text-[10px] bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest rounded-full">
2026
</span>
</div>
{/* 导航 */}
<div class="md:col-span-3 md:col-start-8">
<h3 class="text-[10px] uppercase tracking-wide text-white/35">{t('Explore')}</h3>
<ul class="mt-5 space-y-3 text-[11px] uppercase tracking-[0.18em]">
<li><a href={href(locale, ROUTES.runwayLooks)} class="text-white/65 transition-colors hover:text-white">{t('all runway looks')}</a></li>
<li><a href={href(locale, ROUTES.streetSnaps)} class="text-white/65 transition-colors hover:text-white">{t('Street Style')}</a></li>
<li><a href={href(locale, ROUTES.account)} class="text-white/65 transition-colors hover:text-white">{t('my account')}</a></li>
</ul>
</div>
<p class="text-xs text-[var(--c-gray-800)] leading-relaxed max-w-md">
{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="md:col-span-3">
<h3 class="text-[10px] uppercase tracking-wide text-white/35">{t('Site')}</h3>
<ul class="mt-5 space-y-3 text-[11px] uppercase tracking-[0.18em]">
<li>
<a href={href(locale === 'cn' ? 'en' : 'cn', ROUTES.home)} class="text-white/65 transition-colors hover:text-white">
{locale === 'cn' ? 'English' : '中文'}
</a>
</li>
<li>
<button
type="button"
onclick="window.scrollTo({ top: 0, behavior: 'smooth' })"
class="cursor-pointer text-left uppercase tracking-[0.18em] text-white/65 transition-colors hover:text-white"
>
{t('Back to top ↑')}
</button>
</li>
</ul>
{/* Location Badge */}
<div class="flex flex-wrap gap-3 text-[11px] text-[var(--c-gray-700)] uppercase tracking-widest pt-1">
<span>Paris</span>
<span>•</span>
<span>Milan</span>
<span>•</span>
<span>Tokyo</span>
<span>•</span>
<span>New York</span>
</div>
</div>
{/* 底部条 */}
<div class="mt-14 flex flex-col gap-4 border-t border-white/10 pt-6 text-[10px] uppercase tracking-label text-white/35 sm:flex-row sm:items-center sm:justify-between">
<p>© 2026 {SITE_NAME}. {t('all rights reserved.')}</p>
<p class="tracking-wide">{t('Runway · Street · Archive')}</p>
{/* Column 2: Explore */}
<div class="md:col-span-4 md:col-start-7 space-y-3">
<h3 class="text-[11px] tracking-[0.26em] uppercase font-bold text-black">Explore</h3>
<ul class="space-y-2 text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-800)]">
<li><a href={href(locale, ROUTES.runwayLooks)} class="hover:text-black transition-colors">{t('all runway looks')}</a></li>
<li><a href={href(locale, ROUTES.streetSnaps)} class="hover:text-black transition-colors">{t('Street Style')}</a></li>
<li><a href={href(locale, ROUTES.account)} class="hover:text-black transition-colors">{t('my account')}</a></li>
</ul>
</div>
{/* Column 3: Connect */}
<div class="md:col-span-2 space-y-3">
<h3 class="text-[11px] tracking-[0.26em] uppercase font-bold text-black">Connect</h3>
<ul class="space-y-2 text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-800)]">
<li><a href="#" class="hover:text-black transition-colors">Instagram</a></li>
<li><a href="#" class="hover:text-black transition-colors">Newsletter</a></li>
<li><a href="#" class="hover:text-black transition-colors">Contact</a></li>
</ul>
</div>
</div>
<div class="mt-6 text-[11px] text-[var(--c-gray-600)] uppercase tracking-widest">
© 2026 {SITE_NAME}. {t('all rights reserved.')}
</div>
</footer>

View File

@ -21,29 +21,6 @@ export const dict: Record<string, TranslationEntry> = {
'loading': { cn: '加载中' },
// ── 通用文案 / 页脚 / 登录页 ──
// ⚠️ 必须全小写:translate() 用 dict[key.toLowerCase()] 查表,
// 带大写字母的 key 永远命中不了,会静默回退英文。
'page not found': { cn: '页面不存在' },
'coming soon': { cn: '即将上线' },
'archive': { cn: '档案' },
'archive note': { cn: '档案说明' },
'explore': { cn: '探索' },
'site': { cn: '站点' },
'back to top ↑': { cn: '返回顶部 ↑' },
'signing in…': { cn: '登录中…' },
'runway · street · archive': { cn: '秀场 · 街拍 · 档案' },
'paris · milan · tokyo': { cn: '巴黎 · 米兰 · 东京' },
'this module is under construction and not open to the public yet. browse the runway archive first.':
{ cn: '该模块正在筹备中,尚未对外开放。你可以先浏览已上线的走秀档案。' },
'browse runway archive →': { cn: '浏览走秀档案 →' },
'back to home': { cn: '返回首页' },
'enter the archive →': { cn: '进入档案 →' },
'members only': { cn: '仅限会员' },
'fashion archive': { cn: '时尚档案' },
'forgot password?': { cn: '忘记密码?' },
'ask the editor': { cn: '联系管理员' },
// ── 导航 / Navigation ──
'home': { cn: '首页' },
'login': { cn: '登录' },
@ -118,11 +95,11 @@ export const dict: Record<string, TranslationEntry> = {
'no street snap id specified': { cn: '未指定街拍 ID' },
'failed to load street snap. please refresh or try later.': { cn: '加载街拍失败,请刷新或稍后再试' },
'brands': { cn: '品牌' },
'year': { cn: '年份' },
'sort': { cn: '排序' },
'most photos': { cn: '照片最多' },
'newest year': { cn: '年份最新' },
'oldest year': { cn: '年份最早' },
'Year': { cn: '年份' },
'Sort': { cn: '排序' },
'Most photos': { cn: '照片最多' },
'Newest year': { cn: '年份最新' },
'Oldest year': { cn: '年份最早' },
// ── 地区 / Cities(街拍按城市分类,值对齐后端 street_snap.city 列;translate 会小写化 key)──
'city': { cn: '地区' },

View File

@ -10,7 +10,7 @@ Astro.response.status = 404
<Layout title={t('Page not found')}>
<div class="flex flex-1 flex-col items-center justify-center min-h-[50vh] text-center gap-6">
<h1 class="text-6xl font-medium tracking-tight">404</h1>
<h1 class="text-6xl font-black tracking-tight">404</h1>
<p class="text-sm text-[var(--c-gray-700)] uppercase tracking-[0.2em]">{t('Page not found')}</p>
<a
href={href(locale, ROUTES.home)}

View File

@ -1,57 +1,6 @@
@import "tailwindcss";
/* ============================================================
设计令牌(Tailwind v4 @theme)
参考规律(NABOUN 实站抓取 + ui-ux-pro-max 方法论):
1. 字重只用 400 / 500,高级感来自字距与留白,不来自粗体
2. 全大写 + 0.12em 字距 + 11~13px 是标签的统一签名
3. 1px 发丝线分块,不用色块;全站只保留黑白灰三层明度
4. 图片统一竖版,卡片无圆角无遮罩
约束:不加载任何 webfont(体积与首屏优先),层级靠字重 + 字距 + 尺度建立。
============================================================ */
@theme {
--font-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC",
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
/* 字距:全站只允许这四档。
历史上散落的 0.5px / 0.04em / 0.14em / 0.22em / 0.26em 全部收敛到这里。 */
--tracking-display: -0.03em;
--tracking-label: 0.12em;
--tracking-wide: 0.18em;
--tracking-meta: 0.02em;
/* 动效:统一缓动 + 三档时长,替代 150/200/250/300/400/450ms 混用。
0.22,1,0.36,1 是「快出慢停」曲线,进入视口时更稳,不弹。 */
--ease-editorial: cubic-bezier(0.22, 1, 0.36, 1);
/* 字号阶梯:全部用 rem,而不是 px。
默认根字号 16px 下数值与原来的 px 完全等价,视觉零变化;
但用户把浏览器「字体大小」调大时,rem 会跟着放大,px 不会(Vercel #1.3)。
括号里是 16px 基准下的等价像素。 */
--text-micro: 0.625rem; /* 10px */
--text-micro--line-height: 1.4;
--text-label: 0.6875rem; /* 11px */
--text-label--line-height: 1.2;
--text-meta: 0.8125rem; /* 13px */
--text-meta--line-height: 1.55;
--text-body: 0.9375rem; /* 15px */
--text-body--line-height: 1.75;
--text-lead: 1.0625rem; /* 17px */
--text-lead--line-height: 1.55;
--text-h3: 1.375rem; /* 22px */
--text-h3--line-height: 1.25;
--text-h2: clamp(1.625rem, 3vw, 2.125rem); /* 26 → 34px */
--text-h2--line-height: 1.15;
--text-h1: clamp(2.125rem, 5.5vw, 4rem); /* 34 → 64px */
--text-h1--line-height: 0.95;
--text-display: clamp(2.625rem, 7vw, 5.25rem); /* 42 → 84px */
--text-display--line-height: 0.92;
}
/* 中性灰阶 + 发丝线令牌:统一全站散落的 #f1f1f1/#efefef/#e6e6e6/#ccc/#bdbdbd/#999/#888/#666 等 */
/* 中性灰阶令牌:统一全站散落的 #f1f1f1/#efefef/#e6e6e6/#ccc/#bdbdbd/#999/#888/#666 等 */
:root {
--c-gray-50: #f6f6f6;
--c-gray-100: #f1f1f1;
@ -62,20 +11,6 @@
--c-gray-600: #999;
--c-gray-700: #888;
--c-gray-800: #666;
/* 发丝线:现代极简电商的主要分隔手段。1px + 12% 黑,比纯黑细框更透气。 */
--line: rgba(0, 0, 0, 0.12);
--line-strong: rgba(0, 0, 0, 0.24);
}
/* 英文站字距单独收紧一档。
原因:英文标签是全大写 + 宽字距,大写字母本身等高、无升降部,
加上 0.12em 字间空隙,整行密度明显低于中文,视觉上会「变轻」。
收窄到 0.09em 让单词重新聚拢,重量感就回来了。
中文站保持原值(方块字之间需要更多呼吸,收太紧会糊)。 */
html[lang="en"] {
--tracking-label: 0.09em;
--tracking-wide: 0.14em;
}
[x-cloak] { display: none !important; }
@ -114,109 +49,4 @@ select:focus-visible,
outline-offset: 2px;
}
/* ============================================================
全局排版细节
============================================================ */
/* 数字等宽:页码 / 年份 / 计数在内容切换时不会左右跳动 */
body {
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 翻页 / 筛选导致内容高度变化时,避免滚动条出现消失引起的整页横向抖动。
来自 Vercel web-design-guidelines #3.6 */
scrollbar-gutter: stable;
}
/* 长标题折行更均衡,避免最后一行只剩一个词(#1.5)。
目前只作用于大标题,正文不设,避免影响折行节奏。 */
h1,
.display-title {
text-wrap: balance;
}
::selection {
background: #111;
color: #fff;
}
/* 导航链接:hover 时 1px 黑线从左滑入。
比原来的整体 opacity 变化更克制,也更像编辑类网站的处理。 */
.nav-link {
position: relative;
}
.nav-link::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -3px;
height: 1px;
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
transition: transform 240ms var(--ease-editorial, ease);
}
.nav-link:hover::after {
transform: scaleX(1);
}
/* 触屏(粗指针)上把点击热区撑到 ≥44px 高,但不动布局。
13px 的导航文字在手机上可点区域只有 ~16px 高,拇指很容易点空(Vercel #3.3)。
用伪元素扩展命中区,而不是加 padding —— 加 padding 会把下划线一起推下去。 */
@media (pointer: coarse) {
.nav-link::before {
content: "";
position: absolute;
inset: -0.75rem -0.5rem;
}
}
/* ============================================================
顶部公告条(marquee)
NABOUN 式的循环滚动条:零 JS、纯 CSS、无缝衔接。
用法:内容在容器里重复两遍,轨道整体左移 50% 即为一个周期。
============================================================ */
.marquee {
overflow: hidden;
white-space: nowrap;
}
.marquee__track {
display: inline-flex;
align-items: center;
gap: 2.5rem;
padding-left: 2.5rem;
animation: marquee-scroll 32s linear infinite;
will-change: transform;
}
.marquee:hover .marquee__track {
animation-play-state: paused;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
/* 列表入场:逐项错落 40ms,避免整屏同时出现造成的「跳一下」 */
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@ -23,12 +23,10 @@
@apply flex justify-between items-end gap-6 py-5 pb-[22px];
}
.showsh1 {
@apply text-[clamp(2.5rem,6vw,4.5rem)] leading-[0.9] font-bold my-0 mb-[10px];
letter-spacing: var(--tracking-display, -0.03em);
@apply text-[clamp(40px,6vw,72px)] leading-[0.9] font-black tracking-[-0.02em] my-0 mb-[10px];
}
.snapsh1 {
@apply text-[clamp(2.125rem,5.5vw,4rem)] leading-[0.95] font-bold my-0 mb-[8px];
letter-spacing: var(--tracking-display, -0.03em);
@apply text-[clamp(34px,5.5vw,64px)] leading-[0.95] font-black tracking-[-0.02em] my-0 mb-[8px];
}
/* 已激活筛选 pills */
@ -36,8 +34,7 @@
.snapspills { @apply flex flex-wrap gap-2 mt-4; }
.showspill,
.snapspill {
@apply inline-flex items-center gap-2 border border-black p-[6px_10px] text-[10px] uppercase text-black bg-white;
letter-spacing: var(--tracking-label, 0.12em);
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
}
.showspill .x,
.snapspill .x {
@ -59,14 +56,11 @@
}
.showsaside-head,
.snapsaside-head {
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black/15 shrink-0;
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
}
.showsaside-h,
.snapsaside-h {
@apply text-[11px] uppercase font-medium m-0 leading-none transition-opacity duration-[240ms];
/* 不用 @apply tracking-*:本文件 @reference 的是 tailwind 默认主题,
取不到 global.css 里 @theme 定义的值,只能走运行时 CSS 变量。 */
letter-spacing: var(--tracking-wide, 0.18em);
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none transition-opacity duration-200;
}
.showsaside-body,
.snapsaside-body {
@ -80,8 +74,7 @@
}
.showsg-h,
.snapsg-h {
@apply flex justify-between items-center text-[11px] uppercase font-medium text-black mb-2 cursor-pointer select-none;
letter-spacing: var(--tracking-wide, 0.18em);
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
}
.showsg-right {
@apply inline-flex items-center gap-2.5;
@ -120,8 +113,7 @@
.snapschecks--flat li label { @apply inline-flex items-center gap-2 cursor-pointer w-full; }
.showsmore {
@apply block w-full mt-[12px] text-center text-[11px] uppercase text-black font-medium cursor-pointer;
letter-spacing: var(--tracking-wide, 0.18em);
@apply block w-full mt-[12px] text-center text-[11px] uppercase tracking-[0.22em] text-black font-bold cursor-pointer;
}
.showsclear,
.snapsclear {
@ -134,7 +126,7 @@
.showsgrid,
.snapsgrid {
@apply grid grid-cols-2 gap-6 relative max-sm:grid-cols-1;
@apply grid grid-cols-2 gap-[22px] relative max-sm:grid-cols-1;
}
.showsgrid.is-loading::before,
.snapsgrid.is-loading::before {
@ -145,17 +137,7 @@
.showscard,
.snapscard {
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 border border-transparent transition-[color,background-color,border-color,transform] duration-[240ms] hover:border-black/20 max-md:flex-col max-md:gap-[10px];
transition-timing-function: var(--ease-editorial, ease);
}
/* hover 时整体上浮 2px:比单纯变色更能表达「可点」,且不触发重排。
必须包在 (hover: hover) 里:触屏浏览器会「粘滞 hover」,
手指离开后卡片停在上浮状态,看起来像坏了。 */
@media (hover: hover) {
.showscard:hover,
.snapscard:hover {
@apply -translate-y-[2px];
}
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-md:flex-col max-md:gap-[10px];
}
.showscard:hover .showscard-img img,
.snapscard:hover .snapscard-img img { @apply scale-[1.04]; }
@ -194,13 +176,11 @@
}
.showscard-title,
.snapscard-title {
@apply text-[16px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] line-clamp-2 h-[42px] overflow-hidden;
letter-spacing: var(--tracking-display, -0.03em);
@apply text-[15.5px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] tracking-[-0.01em] line-clamp-2 h-[41px] overflow-hidden;
}
.showscard-meta,
.snapscard-meta {
@apply text-[10px] text-[var(--c-gray-700)] my-0 mb-[14px] leading-[1.55] uppercase line-clamp-2 h-[31px] overflow-hidden;
letter-spacing: var(--tracking-meta, 0.02em);
@apply text-[9.5px] text-[var(--c-gray-700)] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
}
.showscard-strip,
@ -220,8 +200,7 @@
.showscard-thumb.placeholder,
.snapscard-thumb.placeholder { @apply bg-[var(--c-gray-50)]; }
.showscard-thumb-more {
@apply absolute inset-0 bg-black/55 text-white text-[11px] font-medium flex items-center justify-center;
letter-spacing: var(--tracking-meta, 0.02em);
@apply absolute inset-0 bg-black/55 text-white text-[11px] font-bold tracking-[0.04em] flex items-center justify-center;
}
/* 骨架屏 */
@ -239,10 +218,7 @@
.showspager,
.snapspager { @apply flex flex-wrap items-center justify-center gap-2 mt-[38px]; }
.pg {
/* 边框不透明度提到 50%:UI 组件的边界对比度需 ≥3:1(WCAG 2.2 / Vercel #2.3),
black/25 只有约 1.8:1,弱视用户几乎看不见分页框。 */
@apply min-w-[40px] h-[40px] px-3 border border-black/50 bg-white text-black text-[11px] uppercase font-medium cursor-pointer rounded-none transition-colors duration-[240ms];
letter-spacing: var(--tracking-label, 0.12em);
@apply min-w-[40px] h-[40px] px-3 border-[1.5px] border-black bg-white text-black text-[11px] uppercase tracking-[0.12em] font-bold cursor-pointer rounded-none transition-colors duration-200;
}
.pg.is-current { @apply bg-black text-white cursor-default; }
.pg:disabled { @apply opacity-30 cursor-default; }