update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-08-16 20:51:04 +08:00
parent 781242ede0
commit efd28d59e7
13 changed files with 512 additions and 782 deletions

View File

@ -0,0 +1,50 @@
# 2026-08-16
## i18n 取语言方式重构
- 项目 i18n 已配置(defaultLocale: 'en', locales: ['en','cn'], prefixDefaultLocale: false)。
- 将 `.astro` 页面里 `getLocaleFromUrl(Astro.url)` 改为 `Astro.currentLocale`(Astro 路由解析,更稳健),并补类型兜底 `(Astro.currentLocale as Locale) ?? DEFAULT_LOCALE`。
- 涉及文件:src/layouts/Layout.astro、src/pages/index.astro、src/pages/cn/index.astro。
- src/i18n/utils.ts 的 `getLocaleFromUrl` 纯函数保留(供 .ts 模块或解析任意 URL 使用)。
## 结论
- `Astro.currentLocale` 可替代 `getLocaleFromUrl(Astro.url)`,但仅限 .astro 文件;纯 TS / 解析非当前页 URL 仍需自定义函数。
## Astro 浏览器语言检测(重要,修正此前误解)
- Astro 确实提供 `Astro.preferredLocale` / `Astro.preferredLocaleList`,但它是**服务端按请求**结合 `Accept-Language` 头计算的,仅 SSR 运行时有效。
- **本项目是静态构建 + nginx 托管**:build 阶段无访客、无 Accept-Language 头,运行时也无 Node 进程重算,故 `preferredLocale` 在静态产物里是 undefined、`preferredLocaleList` 回退为全部 i18n.locales,**无法在运行时检测访客语言**。
- 客户端 `navigator.language` 脚本仍是当前静态部署下唯一运行时生效的方案。
- 若要改用 `Astro.preferredLocale` + `Astro.redirect()`:必须迁移到 SSR(加 adapter、output:'server'、nginx 后跑 Node),且需在 i18n.locales 为 `cn` 补 `codes: ['zh','zh-CN','zh-TW','zh-HK']` 才能把浏览器 `zh-CN` 匹配到 `cn`(当前 `locales:['en','cn']` 裸码匹配不到)。
## Bug 修复:header 语言切换器不显示
- 根因:`src/layouts/Layout.astro` 中语言切换器(`LOCALES.map`)原是 `#auth-slot` 的子节点;页脚 auth 脚本 `fetchMe()` 未登录返回 null → `render(null)` 用 `slot.innerHTML` 整块重写成 Login/Signup,把语言切换器一并抹掉。任何未登录访客都触发,故语言选项永远看不见。
- 修复:(1) 将语言切换器移出 `#auth-slot`,独立为 `#lang-switch` 兄弟节点,脚本重写不再波及;(2) 未登录时不再硬编码 `/login` 重写 `#auth-slot`,保留 SSR 的带语言前缀 Login/Signup,顺带修好 /cn 页登录链接丢前缀问题。
- 教训:header 中「语言切换器」与「登录态 UI」必须分属不同容器,避免被 auth 脚本的 innerHTML 重写误伤。
## 语言切换器改为下拉菜单
- 将 header `#lang-switch` 从「English / 中文」并排链接改为 Alpine 下拉(`x-data="{open:false}"` + `@click.outside` + `@keydown.escape.window`,绝对定位 `right-0`、`z-50`,带 chevron 旋转)。选中项 `font-bold`,其余 `opacity-60`。
- 在 `src/styles/global.css` 补 `[x-cloak]{display:none!important}`(原仅 shows/index 各自 `<style>` 有,Layout 作为全站模板缺此规则会导致下拉闪烁)。
- 移除原链接间的分隔斜杠 `<span class="opacity-25">/</span>`。
- 仍保留独立 `#lang-switch` 容器,不被 footer auth 脚本重写。
## 语言切换器升级:地球图标 + hover 展开
- 把按钮内容从 `{LOCALE_NAMES[locale]} + chevron` 换成地球 SVG 图标(`w-[18px] h-[18px]`,圆+经线+纬线),移除文本标签和 chevron。
- 行为从「click 切换」改为「hover 展开」,Alpine 加 `@mouseenter="open=true"` + `@mouseleave="open=false"`;click 仍保留(`@click="open=!open"`)作为触屏兜底。
- 关键坑:按钮与下拉之间的 `mt-2` 空隙会让鼠标从按钮移到下拉时 `mouseleave` 先触发、面板被收起。改为 `top-full`(紧贴按钮,无外间距),且整个切换器(图标+菜单)作为同一 Alpine 容器,鼠标在内部移动不会触发关闭。
- 加 `x-transition` 做淡入淡出(150ms / 100ms)+ `py-2` 给菜单项留呼吸空间。
- 后续补充:按钮在地球图标后追加当前语言名文字 `{LOCALE_NAMES[locale]}`(`normal-case text-[12px] tracking-[0.5px]` + `gap-1.5`),即「图标 + 当前语言」显示。
## i18n URL 转换改用 astro:i18n 内置函数
- `src/i18n/utils.ts` 的 `useTranslatedPath` 内部改为委托 `astro:i18n` 的 `getRelativeLocaleUrl(targetLocale, clean)`,自动遵循 i18n 配置(prefixDefaultLocale:false → 默认语言无前缀)。
- 同文件 `getLocaleFromUrl` 改为委托 `getLocaleByPath(pathname)`(保留 LOCALES 校验 + DEFAULT_LOCALE 兜底,try/catch)。
- `astro:i18n` 可在 `.ts` 模块(utils.ts)中 import,前端 frontmatter 同样可用;项目 astro ^7.1.6 支持。
- 顺手修复首页语言切换器瑕疵:`Layout.astro` 的 `currentPath` 原只剥离语言前缀,未去尾随 `index.html`,导致首页生成 `/index.html`、`/cn/index.html`;改为额外 `if 末段==='index.html' 去掉`,现首页正确生成 `/` 与 `/cn`。`currentPath` 仅用于语言切换器,改动只影响切换器。
- 验证:构建通过(exit 0),产物 dist/index.html 语言切换器 href 为 `/` 与 `/cn`(首页)、`/shows` 与 `/cn/shows`(其他页)。
## 彻底去掉 useTranslatedPath 包装层
- `src/i18n/utils.ts`:删除 `useTranslatedPath` 函数,移除不再使用的 `getRelativeLocaleUrl` 导入(仅 useTranslatedPath 引用过)。`getLocaleFromUrl` 仍委托 `getLocaleByPath`。
- `Layout.astro`:frontmatter 直接 `import { getRelativeLocaleUrl } from 'astro:i18n'`,删除 `const translatePath = useTranslatedPath(locale)`;所有调用点(nav 5 项 + 语言切换器 `getRelativeLocaleUrl(l, currentPath)` + footer 登录/注册/About/Contact)直接改为 `getRelativeLocaleUrl(locale, path)`。行为与原先一致。
## Bug 修复:中文首页切换器链接错误(format:'file' 坑)
- 站点 `build.format:'file'`,中文首页路由 /cn/ 产物是 `dist/cn.html`(英文首页是 `index.html`)。构建期 `Astro.url.pathname` 取的是产物路径 `/cn.html`。
- 原 currentPath 只剥离 `index.html`,不处理 `cn.html`,导致中文首页 currentPath=`/cn.html`,切换器英文项误生成 `/cn.html`(实际是中文首页)、中文项 `/cn/cn.html`。
- 修复:把"去尾随 index.html"改为"去尾随任意 .html"(`_last.endsWith('.html')` 则去掉末段)。现中英文首页切换器均正确:index.html → `/`/`/cn`;cn.html → `/`/`/cn`。
- 教训:`format:'file'` 下 Astro.url.pathname 是产物文件名(含 .html),做"当前逻辑路径"推导时必须剥 .html 后缀,不能只认 index.html。

View File

@ -15,12 +15,12 @@ export default defineConfig({
defaultLocale: 'en',
locales: ['en', 'cn'],
routing: {
prefixDefaultLocale: false, // 英文在根路径 /shows,中文在 /cn/shows
prefixDefaultLocale: true, // 英文在根路径 /shows,中文在 /cn/shows
},
},
build: {
// 示例:在构建过程中生 成`page.html` 而不是 `page/index.html`。
format: 'file'
// format: 'file'
},
integrations: [alpinejs()]
});

340
src/components/Index.astro Normal file
View File

@ -0,0 +1,340 @@
---
import ComingSoon from "./ComingSoon.astro"
import { getShows, seriesLabel } from "../lib/data"
import { useTranslations, DEFAULT_LOCALE, type Locale } from "../i18n/utils"
// SSR 预取走秀数据
const shows = await getShows(20)
// 国际化:取当前页面语言(Astro 路由解析)与翻译函数
const locale = (Astro.currentLocale as Locale) ?? DEFAULT_LOCALE
const { t } = useTranslations(locale)
type GalleryItem = {
src: string;
brand: string;
articleId: number;
year: number;
title: string;
collection_type?: string
}
const galleryItems: GalleryItem[] = []
for (const s of shows) {
if (!s.img) continue
galleryItems.push({
src: s.img,
brand: s.brand,
articleId: s.id,
year: s.year,
title: s.title,
collection_type: s.collection_type,
})
}
---
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
<!-- 二级分类标签页 -->
<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-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'backstage' }"
@click="activeTab = 'backstage'"
>
A
<span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'shows' }"
@click="activeTab = 'shows'"
>
B
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'events' }"
@click="activeTab = 'events'"
>
C
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'street' }"
@click="activeTab = 'street'"
>
D
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<a href="/brands" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap hover:opacity-80">E</a>
</ul>
</div>
<!-- 中间内容展示区 -->
<main class="grow min-h-[400px] mt-10">
<!-- 发布会 面板(默认激活) -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
<!-- 横向滚动画廊 -->
{
galleryItems.length > 0 && (
<section class="hs-section">
<div class="hs-stage">
<div class="swiper hs-swiper" id="hsSwiper">
<div class="swiper-wrapper">
{
galleryItems.map((g, i) => (
<div class="swiper-slide hs-slide">
<a class="hs-item" href={`/article?id=${g.articleId}`} data-idx={i}>
<!-- Alpine 管理单张图片的加载/失败状态 -->
<div
class="hs-img-box"
x-data="{ loaded: false, error: false }"
:class="{ 'is-loaded': loaded || error }"
>
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="hs-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" />
</svg>
</span>
<img
src={g.src}
alt={g.brand}
loading={i < 4 ? "eager" : "lazy"}
decoding="async"
:class="{ 'is-error': error }"
@load="loaded = true; $dispatch('img-ready')"
@error="error = true; $dispatch('img-ready')"
/>
<span class="hs-caption">
<span class="hs-cap-brand">{g.brand}</span>
<span class="hs-cap-meta">
{g.year ? g.year : ""}
{g.collection_type ? " · " + seriesLabel(g.collection_type) : ""}
</span>
</span>
</div>
</a>
</div>
))
}
</div>
</div>
<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" tabindex="0">{t('Previous')}</div>
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Next')}</div>
</div>
</div>
</section>
)
}
</div>
<!-- 灵感 面板 -->
<div class="panel" x-show="activeTab === 'shows'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 社媒 面板 -->
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 街拍 面板 -->
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
</main>
</div>
<style>
@reference "tailwindcss";
:root { --brand: #cf5a44; }
/* 标签基础与激活样式 */
.tab {
@apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline;
}
.tab:hover { @apply text-[#666]; }
.tab::after {
content: "";
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
}
.tab.is-active { @apply text-black; }
.tab.is-active::after { @apply scale-x-100; }
/* 防闪烁标签 */
[x-cloak] { display: none !important; }
/* 区块标题 */
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
.sec-more:hover { @apply opacity-60; }
/* 横向画廊样式 */
.hs-section { @apply mb-9; }
.hs-stage { @apply relative; }
.hs-controls {
position: absolute;
top: 50%;
left: 0;
right: 0;
transform: translateY(-50%);
z-index: 6;
padding: 0 8px;
pointer-events: none;
mix-blend-mode: difference;
color: #fff;
}
.hs-controls .hs-arrow { pointer-events: auto; }
.hs-arrow { @apply select-none; }
.hs-arrow.swiper-button-disabled { pointer-events: none; }
/* Swiper 尺寸规整 */
.hs-swiper {
width: 100%;
height: clamp(500px, 80vh, 900px);
padding: 0 4px;
}
.hs-swiper .swiper-wrapper { align-items: center; }
.hs-swiper .hs-slide {
width: auto;
height: auto;
}
.hs-item {
display: block;
height: auto;
width: max-content;
text-decoration: none;
color: inherit;
}
.hs-img-box {
position: relative;
display: block;
height: clamp(500px, 80vh, 900px);
/* aspect-ratio: 3 / 4; */
background: #f0f0f0;
overflow: hidden;
}
.hs-img-box > img {
width: 100%;
height: 100%;
display: block;
position: relative;
z-index: 1;
opacity: 0;
transition: opacity 0.4s ease;
}
.hs-img-box.is-loaded > img { opacity: 1; }
.hs-img-box > img.is-error { opacity: 0 !important; }
/* 加载骨架屏与旋转 Spinner */
.hs-skel {
position: absolute;
inset: 0;
z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease;
}
.hs-img-box.is-loaded .hs-skel { opacity: 0; }
.hs-spin {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #bdbdbd;
pointer-events: none;
z-index: 2;
transition: opacity 0.3s ease;
}
.hs-img-box.is-loaded .hs-spin { opacity: 0; }
.hs-spin svg { display: block; }
.hs-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
@keyframes hs-rotate { to { transform: rotate(360deg); } }
@keyframes hs-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
/* 悬停品牌遮罩 */
.hs-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 28px 16px 12px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
color: white;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
opacity: 0;
transition: opacity 0.25s ease;
z-index: 3;
font-family: monospace;
pointer-events: none;
}
.hs-item:hover .hs-caption { opacity: 1; }
.hs-cap-brand {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hs-cap-meta {
font-size: 11px;
opacity: 0.85;
letter-spacing: 0.12em;
}
@media (max-width: 768px) {
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
}
</style>
<script>
import Swiper from "swiper";
import { Navigation, FreeMode, Keyboard } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/free-mode";
// 初始化 Swiper 轮播
const el = document.getElementById("hsSwiper");
if (el) {
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
const swiper = new Swiper(el, {
modules: [Navigation, FreeMode, Keyboard],
slidesPerView: "auto",
spaceBetween: 4,
grabCursor: true,
speed: 220,
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 },
keyboard: { enabled: true },
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
observer: true,
observeSlideChildren: true,
});
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
window.addEventListener("img-ready", () => {
requestAnimationFrame(() => swiper.update());
});
}
</script>

View File

@ -1,35 +1,19 @@
import {
DEFAULT_LOCALE,
LOCALES,
PREFIX_DEFAULT_LOCALE,
isLocaleSegment,
type Locale,
} from './config';
import { getLocaleByPath } from 'astro:i18n';
import { DEFAULT_LOCALE, LOCALES, type Locale } from './config';
import { translate } from './dictionary';
/**
* 从当前 URL 解析语言:
* - 路径首段若是支持的语言前缀(如 /cn/shows → cn)则用之;
* - 否则回退到默认语言(en)。
* 从 URL 解析语言,委托 Astro 内置的 astro:i18n.getLocaleByPath。
* 无前缀(默认语言)路径会解析回 DEFAULT_LOCALE。
*/
export function getLocaleFromUrl(url: URL | string): Locale {
const pathname = typeof url === 'string' ? url : url.pathname;
const seg = pathname.split('/').filter(Boolean)[0];
return isLocaleSegment(seg) ? seg : DEFAULT_LOCALE;
}
/**
* 生成带语言前缀的链接。
* - en(默认且 PREFIX_DEFAULT_LOCALE=false)→ /shows
* - cn → /cn/shows
* 第二个参数可指定目标语言,方便做语言切换器。
*/
export function useTranslatedPath(locale: Locale) {
return (path: string, targetLocale: Locale = locale): string => {
const clean = path.startsWith('/') ? path : `/${path}`;
if (targetLocale === DEFAULT_LOCALE && !PREFIX_DEFAULT_LOCALE) return clean;
return `/${targetLocale}${clean}`;
};
try {
const locale = getLocaleByPath(pathname);
return (LOCALES as readonly string[]).includes(locale) ? (locale as Locale) : DEFAULT_LOCALE;
} catch {
return DEFAULT_LOCALE;
}
}
/**

View File

@ -1,6 +1,7 @@
---
import '../styles/global.css'
import { getLocaleFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils'
import { getRelativeLocaleUrl } from 'astro:i18n'
import { useTranslations, DEFAULT_LOCALE, type Locale } from '../i18n/utils'
import { LOCALES, LOCALE_NAMES, isLocaleSegment } from '../i18n/config'
const {
@ -10,24 +11,19 @@ const {
activeThemePalette = null,
} = Astro.props;
const locale = getLocaleFromUrl(Astro.url)
const locale = (Astro.currentLocale as Locale) ?? DEFAULT_LOCALE
const { t } = useTranslations(locale)
const translatePath = useTranslatedPath(locale)
// 当前路径去掉语言前缀,用于语言切换器拼出「同页面、另一语言」的链接
const _segs = Astro.url.pathname.split('/').filter(Boolean);
const _stripped = _segs.length > 0 && isLocaleSegment(_segs[0])
? '/' + _segs.slice(1).join('/')
: Astro.url.pathname;
const currentPath = _stripped === '' ? '/' : _stripped;
const categories: { label: string; value: PortfolioCategory | 'Trending Colors' }[] = [
{ label: 'x', value: 'x' },
{ label: 'Backstage', value: 'Backstage' },
{ label: 'Events', value: 'Events' },
{ label: 'Street', value: 'Street' },
{ label: 'Trending Colors', value: 'Trending Colors' },
];
// 当前路径去掉语言前缀与尾随 .html(format:'file' 产物如 cn.html / shows.html),
// 用于语言切换器拼出「同页面、另一语言」的链接(逻辑路径,不带 .html)
// 例:/cn.html → / ,/cn/shows.html → /shows ,/shows.html → /shows
let _segs = Astro.url.pathname.split('/').filter(Boolean);
if (_segs[0] && isLocaleSegment(_segs[0])) _segs = _segs.slice(1);
const _last = _segs[_segs.length - 1];
if (_last && _last.endsWith('.html')) _segs = _segs.slice(0, -1);
const currentPath = _segs.length === 0 ? '/' : '/' + _segs.join('/');
---
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
@ -61,31 +57,70 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
<!-- 顶部主导航栏 -->
<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={translatePath('/portfolio')} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">{t('Portfolio')}<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
<a href={getRelativeLocaleUrl(locale, '/portfolio')} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">{t('Portfolio')}<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
2026
</span></a>
<a href={translatePath('/shows')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('All Shows')}</a>
<a href={translatePath('/latest-projects')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Latest Projects')}</a>
<a href={translatePath('/about')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('About')}</a>
<a href={translatePath('/contact')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Contact Us')}</a>
<a href={getRelativeLocaleUrl(locale, '/shows')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('All Shows')}</a>
<a href={getRelativeLocaleUrl(locale, '/latest-projects')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Latest Projects')}</a>
<a href={getRelativeLocaleUrl(locale, '/about')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('About')}</a>
<a href={getRelativeLocaleUrl(locale, '/contact')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Contact Us')}</a>
</div>
<div class="flex items-center gap-3" id="auth-slot">
<div class="flex items-center gap-2 normal-case" aria-label="Language">
<div class="flex items-center gap-3">
<!-- 语言切换器:Alpine 下拉,hover 展开;独立容器避免被 auth 脚本 innerHTML 重写误伤 -->
<div
x-data="{ open: false }"
@mouseenter="open = true"
@mouseleave="open = false"
@click.outside="open = false"
@keydown.escape.window="open = false"
class="relative"
id="lang-switch"
>
<button
type="button"
@click="open = !open"
class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[12px] tracking-[0.5px]"
aria-label="Language"
aria-haspopup="listbox"
:aria-expanded="open"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-[18px] h-[18px]" 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]}</span>
</button>
<ul
x-show="open"
x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2"
>
{LOCALES.map((l) => (
<li>
<a
href={translatePath(currentPath, l)}
aria-current={l === locale ? 'true' : undefined}
href={getRelativeLocaleUrl(l, currentPath)}
@click="open = false"
class:list={[
'cursor-pointer hover:opacity-60 transition-opacity duration-200',
l === locale ? 'opacity-100 underline underline-offset-4' : 'opacity-40',
'block px-4 py-1.5 text-[12px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors',
l === locale ? 'font-bold' : 'opacity-60',
]}
>{LOCALE_NAMES[l]}</a>
</li>
))}
</ul>
</div>
<div class="flex items-center gap-2" id="auth-slot">
<a href={getRelativeLocaleUrl(locale, '/login')} class="hover:opacity-60 transition-opacity duration-200">{t('Login')}</a>
<a href={getRelativeLocaleUrl(locale, '/register')} class="hover:opacity-60 transition-opacity duration-200">{t('Signup')}</a>
</div>
<span class="opacity-25">/</span>
<a href={translatePath('/login')} class="hover:opacity-60 transition-opacity duration-200">{t('Login')}</a>
<a href={translatePath('/register')} class="hover:opacity-60 transition-opacity duration-200">{t('Signup')}</a>
</div>
</nav>
@ -98,13 +133,10 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
import { getUser, fetchMe, clearSession } from "../lib/auth.ts"
const slot = document.getElementById("auth-slot")
function render(user) {
if (user) {
if (!user) return // 未登录:保留 SSR 渲染的 Login/Signup(已带语言前缀,无需重写)
slot.innerHTML = `<span class="opacity-70">Hi, ${user.username}</span><button id="logout-btn" class="hover:opacity-60 transition-opacity cursor-pointer bg-transparent border-0 p-0 font-bold text-[13px] uppercase tracking-[0.5px]">Logout</button>`
const btn = slot.querySelector("#logout-btn")
if (btn) btn.addEventListener("click", () => { clearSession(); location.reload() })
} else {
slot.innerHTML = `<a href="/login" class="hover:opacity-60 transition-opacity duration-200">Login</a><a href="/register" class="hover:opacity-60 transition-opacity duration-200">Signup</a>`
}
}
const cached = getUser()
if (cached) render(cached)
@ -140,30 +172,6 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
</div>
</div>
{/* Column 2: Quick Navigation */}
<div class="md:col-span-3 space-y-3">
<span class="text-[11px] font-mono uppercase tracking-widest text-zinc-400 font-bold block mb-2">
Categories
</span>
<ul class="space-y-2 list-none p-0 m-0 text-xs font-mono font-bold uppercase tracking-wider">
{categories.map((cat) => (
<li>
<button
data-category={cat.value}
class={`category-btn hover:opacity-60 transition-opacity cursor-pointer flex items-center gap-2 ${
activeCategory === cat.value ? 'underline font-black text-black' : 'text-zinc-600'
}`}
>
<span>{cat.label}</span>
{cat.value === 'Trending Colors' && (
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
)}
</button>
</li>
))}
</ul>
</div>
{/* Column 3: Color Studio & Actions */}
<div class="md:col-span-4 space-y-3">
<span class="text-[11px] font-mono uppercase tracking-widest text-zinc-400 font-bold block mb-2">
@ -239,13 +247,13 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
<div class="flex items-center gap-6">
<a
href={translatePath('/about')}
href={getRelativeLocaleUrl(locale, '/about')}
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
>
{t('About')}
</a>
<a
href={translatePath('/contact')}
href={getRelativeLocaleUrl(locale, '/contact')}
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
>
{t('Contact')}

View File

@ -186,7 +186,6 @@ export function loadArticlesWithImages(size = 40): Promise<ArticleCard[]> {
if (!API_BASE) return []
try {
const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=${size}&with_images=5`)
console.log(res)
if (!res.ok) return []
const json: any = await res.json()
const items: RawArticle[] = json?.data ?? []

View File

@ -1,8 +0,0 @@
---
import Layout from "../layouts/Layout.astro"
import About from "../components/pages/About.astro"
---
<Layout title="About — 暂未上线">
<About />
</Layout>

8
src/pages/cn/about.astro Normal file
View File

@ -0,0 +1,8 @@
---
import Layout from "../../layouts/Layout.astro"
import About from "../../components/pages/About.astro"
---
<Layout title="About — 暂未上线">
<About />
</Layout>

View File

@ -1,344 +1,8 @@
---
import Layout from "../../layouts/Layout.astro"
import ComingSoon from "../../components/ComingSoon.astro"
import { getShows, seriesLabel } from "../../lib/data"
import { getLocaleFromUrl, useTranslations } from "../../i18n/utils"
// SSR 预取走秀数据
const shows = await getShows(20)
// 国际化:根据 URL 前缀(/cn/...)取当前语言与翻译函数
const locale = getLocaleFromUrl(Astro.url)
const { t } = useTranslations(locale)
type GalleryItem = {
src: string;
brand: string;
articleId: number;
year: number;
title: string;
collection_type?: string
}
const galleryItems: GalleryItem[] = []
for (const s of shows) {
if (!s.img) continue
galleryItems.push({
src: s.img,
brand: s.brand,
articleId: s.id,
year: s.year,
title: s.title,
collection_type: s.collection_type,
})
}
import Layout from '../../layouts/Layout.astro'
import Index from '../../components/Index.astro'
---
<Layout>
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
<!-- 二级分类标签页 -->
<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-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'backstage' }"
@click="activeTab = 'backstage'"
>
A
<span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'shows' }"
@click="activeTab = 'shows'"
>
B
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'events' }"
@click="activeTab = 'events'"
>
C
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'street' }"
@click="activeTab = 'street'"
>
D
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<a href="/brands" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap hover:opacity-80">E</a>
</ul>
</div>
<!-- 中间内容展示区 -->
<main class="grow min-h-[400px] mt-10">
<!-- 发布会 面板(默认激活) -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
<!-- 横向滚动画廊 -->
{
galleryItems.length > 0 && (
<section class="hs-section">
<div class="hs-stage">
<div class="swiper hs-swiper" id="hsSwiper">
<div class="swiper-wrapper">
{
galleryItems.map((g, i) => (
<div class="swiper-slide hs-slide">
<a class="hs-item" href={`/article?id=${g.articleId}`} data-idx={i}>
<!-- Alpine 管理单张图片的加载/失败状态 -->
<div
class="hs-img-box"
x-data="{ loaded: false, error: false }"
:class="{ 'is-loaded': loaded || error }"
>
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="hs-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" />
</svg>
</span>
<img
src={g.src}
alt={g.brand}
loading={i < 4 ? "eager" : "lazy"}
decoding="async"
:class="{ 'is-error': error }"
@load="loaded = true; $dispatch('img-ready')"
@error="error = true; $dispatch('img-ready')"
/>
<span class="hs-caption">
<span class="hs-cap-brand">{g.brand}</span>
<span class="hs-cap-meta">
{g.year ? g.year : ""}
{g.collection_type ? " · " + seriesLabel(g.collection_type) : ""}
</span>
</span>
</div>
</a>
</div>
))
}
</div>
</div>
<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" tabindex="0">{t('Previous')}</div>
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Next')}</div>
</div>
</div>
</section>
)
}
</div>
<!-- 灵感 面板 -->
<div class="panel" x-show="activeTab === 'shows'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 社媒 面板 -->
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 街拍 面板 -->
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
</main>
</div>
<Index />
</Layout>
<style>
@reference "tailwindcss";
:root { --brand: #cf5a44; }
/* 标签基础与激活样式 */
.tab {
@apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline;
}
.tab:hover { @apply text-[#666]; }
.tab::after {
content: "";
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
}
.tab.is-active { @apply text-black; }
.tab.is-active::after { @apply scale-x-100; }
/* 防闪烁标签 */
[x-cloak] { display: none !important; }
/* 区块标题 */
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
.sec-more:hover { @apply opacity-60; }
/* 横向画廊样式 */
.hs-section { @apply mb-9; }
.hs-stage { @apply relative; }
.hs-controls {
position: absolute;
top: 50%;
left: 0;
right: 0;
transform: translateY(-50%);
z-index: 6;
padding: 0 8px;
pointer-events: none;
mix-blend-mode: difference;
color: #fff;
}
.hs-controls .hs-arrow { pointer-events: auto; }
.hs-arrow { @apply select-none; }
.hs-arrow.swiper-button-disabled { pointer-events: none; }
/* Swiper 尺寸规整 */
.hs-swiper {
width: 100%;
height: clamp(500px, 80vh, 900px);
padding: 0 4px;
}
.hs-swiper .swiper-wrapper { align-items: center; }
.hs-swiper .hs-slide {
width: auto;
height: auto;
}
.hs-item {
display: block;
height: auto;
width: max-content;
text-decoration: none;
color: inherit;
}
.hs-img-box {
position: relative;
display: block;
height: clamp(500px, 80vh, 900px);
/* aspect-ratio: 3 / 4; */
background: #f0f0f0;
overflow: hidden;
}
.hs-img-box > img {
width: 100%;
height: 100%;
display: block;
position: relative;
z-index: 1;
opacity: 0;
transition: opacity 0.4s ease;
}
.hs-img-box.is-loaded > img { opacity: 1; }
.hs-img-box > img.is-error { opacity: 0 !important; }
/* 加载骨架屏与旋转 Spinner */
.hs-skel {
position: absolute;
inset: 0;
z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease;
}
.hs-img-box.is-loaded .hs-skel { opacity: 0; }
.hs-spin {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #bdbdbd;
pointer-events: none;
z-index: 2;
transition: opacity 0.3s ease;
}
.hs-img-box.is-loaded .hs-spin { opacity: 0; }
.hs-spin svg { display: block; }
.hs-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
@keyframes hs-rotate { to { transform: rotate(360deg); } }
@keyframes hs-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
/* 悬停品牌遮罩 */
.hs-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 28px 16px 12px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
color: white;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
opacity: 0;
transition: opacity 0.25s ease;
z-index: 3;
font-family: monospace;
pointer-events: none;
}
.hs-item:hover .hs-caption { opacity: 1; }
.hs-cap-brand {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hs-cap-meta {
font-size: 11px;
opacity: 0.85;
letter-spacing: 0.12em;
}
@media (max-width: 768px) {
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
}
</style>
<script>
import Swiper from "swiper";
import { Navigation, FreeMode, Keyboard } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/free-mode";
// 初始化 Swiper 轮播
const el = document.getElementById("hsSwiper");
if (el) {
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
const swiper = new Swiper(el, {
modules: [Navigation, FreeMode, Keyboard],
slidesPerView: "auto",
spaceBetween: 4,
grabCursor: true,
speed: 220,
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 },
keyboard: { enabled: true },
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
observer: true,
observeSlideChildren: true,
});
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
window.addEventListener("img-ready", () => {
requestAnimationFrame(() => swiper.update());
});
}
</script>

8
src/pages/en/about.astro Normal file
View File

@ -0,0 +1,8 @@
---
import Layout from "../../layouts/Layout.astro"
import About from "../../components/pages/About.astro"
---
<Layout title="About — 暂未上线">
<About />
</Layout>

8
src/pages/en/index.astro Normal file
View File

@ -0,0 +1,8 @@
---
import Layout from '../../layouts/Layout.astro'
import Index from '../../components/Index.astro'
---
<Layout>
<Index />
</Layout>

View File

@ -1,344 +1,11 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
import { getShows, seriesLabel } from "../lib/data"
import { getLocaleFromUrl, useTranslations } from "../i18n/utils"
// SSR 预取走秀数据
const shows = await getShows(20)
// 应该跳转到 /en
// 国际化:根据 URL 前缀(/cn/...)取当前语言与翻译函数
const locale = getLocaleFromUrl(Astro.url)
const { t } = useTranslations(locale)
type GalleryItem = {
src: string;
brand: string;
articleId: number;
year: number;
title: string;
collection_type?: string
}
const galleryItems: GalleryItem[] = []
for (const s of shows) {
if (!s.img) continue
galleryItems.push({
src: s.img,
brand: s.brand,
articleId: s.id,
year: s.year,
title: s.title,
collection_type: s.collection_type,
})
}
import Layout from '../layouts/Layout.astro'
import Index from '../components/Index.astro'
---
<Layout>
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
<!-- 二级分类标签页 -->
<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-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'backstage' }"
@click="activeTab = 'backstage'"
>
A
<span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'shows' }"
@click="activeTab = 'shows'"
>
B
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'events' }"
@click="activeTab = 'events'"
>
C
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'street' }"
@click="activeTab = 'street'"
>
D
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<a href="/brands" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap hover:opacity-80">E</a>
</ul>
</div>
<!-- 中间内容展示区 -->
<main class="grow min-h-[400px] mt-10">
<!-- 发布会 面板(默认激活) -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
<!-- 横向滚动画廊 -->
{
galleryItems.length > 0 && (
<section class="hs-section">
<div class="hs-stage">
<div class="swiper hs-swiper" id="hsSwiper">
<div class="swiper-wrapper">
{
galleryItems.map((g, i) => (
<div class="swiper-slide hs-slide">
<a class="hs-item" href={`/article?id=${g.articleId}`} data-idx={i}>
<!-- Alpine 管理单张图片的加载/失败状态 -->
<div
class="hs-img-box"
x-data="{ loaded: false, error: false }"
:class="{ 'is-loaded': loaded || error }"
>
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="hs-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" />
</svg>
</span>
<img
src={g.src}
alt={g.brand}
loading={i < 4 ? "eager" : "lazy"}
decoding="async"
:class="{ 'is-error': error }"
@load="loaded = true; $dispatch('img-ready')"
@error="error = true; $dispatch('img-ready')"
/>
<span class="hs-caption">
<span class="hs-cap-brand">{g.brand}</span>
<span class="hs-cap-meta">
{g.year ? g.year : ""}
{g.collection_type ? " · " + seriesLabel(g.collection_type) : ""}
</span>
</span>
</div>
</a>
</div>
))
}
</div>
</div>
<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" tabindex="0">{t('Previous')}</div>
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Next')}</div>
</div>
</div>
</section>
)
}
</div>
<!-- 灵感 面板 -->
<div class="panel" x-show="activeTab === 'shows'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 社媒 面板 -->
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 街拍 面板 -->
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
</main>
</div>
<Index />
</Layout>
<style>
@reference "tailwindcss";
:root { --brand: #cf5a44; }
/* 标签基础与激活样式 */
.tab {
@apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline;
}
.tab:hover { @apply text-[#666]; }
.tab::after {
content: "";
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
}
.tab.is-active { @apply text-black; }
.tab.is-active::after { @apply scale-x-100; }
/* 防闪烁标签 */
[x-cloak] { display: none !important; }
/* 区块标题 */
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
.sec-more:hover { @apply opacity-60; }
/* 横向画廊样式 */
.hs-section { @apply mb-9; }
.hs-stage { @apply relative; }
.hs-controls {
position: absolute;
top: 50%;
left: 0;
right: 0;
transform: translateY(-50%);
z-index: 6;
padding: 0 8px;
pointer-events: none;
mix-blend-mode: difference;
color: #fff;
}
.hs-controls .hs-arrow { pointer-events: auto; }
.hs-arrow { @apply select-none; }
.hs-arrow.swiper-button-disabled { pointer-events: none; }
/* Swiper 尺寸规整 */
.hs-swiper {
width: 100%;
height: clamp(500px, 80vh, 900px);
padding: 0 4px;
}
.hs-swiper .swiper-wrapper { align-items: center; }
.hs-swiper .hs-slide {
width: auto;
height: auto;
}
.hs-item {
display: block;
height: auto;
width: max-content;
text-decoration: none;
color: inherit;
}
.hs-img-box {
position: relative;
display: block;
height: clamp(500px, 80vh, 900px);
/* aspect-ratio: 3 / 4; */
background: #f0f0f0;
overflow: hidden;
}
.hs-img-box > img {
width: 100%;
height: 100%;
display: block;
position: relative;
z-index: 1;
opacity: 0;
transition: opacity 0.4s ease;
}
.hs-img-box.is-loaded > img { opacity: 1; }
.hs-img-box > img.is-error { opacity: 0 !important; }
/* 加载骨架屏与旋转 Spinner */
.hs-skel {
position: absolute;
inset: 0;
z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease;
}
.hs-img-box.is-loaded .hs-skel { opacity: 0; }
.hs-spin {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #bdbdbd;
pointer-events: none;
z-index: 2;
transition: opacity 0.3s ease;
}
.hs-img-box.is-loaded .hs-spin { opacity: 0; }
.hs-spin svg { display: block; }
.hs-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
@keyframes hs-rotate { to { transform: rotate(360deg); } }
@keyframes hs-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
/* 悬停品牌遮罩 */
.hs-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 28px 16px 12px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
color: white;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
opacity: 0;
transition: opacity 0.25s ease;
z-index: 3;
font-family: monospace;
pointer-events: none;
}
.hs-item:hover .hs-caption { opacity: 1; }
.hs-cap-brand {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hs-cap-meta {
font-size: 11px;
opacity: 0.85;
letter-spacing: 0.12em;
}
@media (max-width: 768px) {
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
}
</style>
<script>
import Swiper from "swiper";
import { Navigation, FreeMode, Keyboard } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/free-mode";
// 初始化 Swiper 轮播
const el = document.getElementById("hsSwiper");
if (el) {
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
const swiper = new Swiper(el, {
modules: [Navigation, FreeMode, Keyboard],
slidesPerView: "auto",
spaceBetween: 4,
grabCursor: true,
speed: 220,
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 },
keyboard: { enabled: true },
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
observer: true,
observeSlideChildren: true,
});
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
window.addEventListener("img-ready", () => {
requestAnimationFrame(() => swiper.update());
});
}
</script>

View File

@ -1 +1,3 @@
@import "tailwindcss";
[x-cloak] { display: none !important; }