50
.workbuddy/memory/2026-08-16.md
Normal file
50
.workbuddy/memory/2026-08-16.md
Normal 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。
|
||||||
@ -15,12 +15,12 @@ export default defineConfig({
|
|||||||
defaultLocale: 'en',
|
defaultLocale: 'en',
|
||||||
locales: ['en', 'cn'],
|
locales: ['en', 'cn'],
|
||||||
routing: {
|
routing: {
|
||||||
prefixDefaultLocale: false, // 英文在根路径 /shows,中文在 /cn/shows
|
prefixDefaultLocale: true, // 英文在根路径 /shows,中文在 /cn/shows
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
// 示例:在构建过程中生 成`page.html` 而不是 `page/index.html`。
|
// 示例:在构建过程中生 成`page.html` 而不是 `page/index.html`。
|
||||||
format: 'file'
|
// format: 'file'
|
||||||
},
|
},
|
||||||
integrations: [alpinejs()]
|
integrations: [alpinejs()]
|
||||||
});
|
});
|
||||||
340
src/components/Index.astro
Normal file
340
src/components/Index.astro
Normal 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>
|
||||||
@ -1,35 +1,19 @@
|
|||||||
import {
|
import { getLocaleByPath } from 'astro:i18n';
|
||||||
DEFAULT_LOCALE,
|
import { DEFAULT_LOCALE, LOCALES, type Locale } from './config';
|
||||||
LOCALES,
|
|
||||||
PREFIX_DEFAULT_LOCALE,
|
|
||||||
isLocaleSegment,
|
|
||||||
type Locale,
|
|
||||||
} from './config';
|
|
||||||
import { translate } from './dictionary';
|
import { translate } from './dictionary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从当前 URL 解析语言:
|
* 从 URL 解析语言,委托 Astro 内置的 astro:i18n.getLocaleByPath。
|
||||||
* - 路径首段若是支持的语言前缀(如 /cn/shows → cn)则用之;
|
* 无前缀(默认语言)路径会解析回 DEFAULT_LOCALE。
|
||||||
* - 否则回退到默认语言(en)。
|
|
||||||
*/
|
*/
|
||||||
export function getLocaleFromUrl(url: URL | string): Locale {
|
export function getLocaleFromUrl(url: URL | string): Locale {
|
||||||
const pathname = typeof url === 'string' ? url : url.pathname;
|
const pathname = typeof url === 'string' ? url : url.pathname;
|
||||||
const seg = pathname.split('/').filter(Boolean)[0];
|
try {
|
||||||
return isLocaleSegment(seg) ? seg : DEFAULT_LOCALE;
|
const locale = getLocaleByPath(pathname);
|
||||||
}
|
return (LOCALES as readonly string[]).includes(locale) ? (locale as Locale) : DEFAULT_LOCALE;
|
||||||
|
} catch {
|
||||||
/**
|
return 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}`;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import '../styles/global.css'
|
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'
|
import { LOCALES, LOCALE_NAMES, isLocaleSegment } from '../i18n/config'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -10,24 +11,19 @@ const {
|
|||||||
activeThemePalette = null,
|
activeThemePalette = null,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const locale = getLocaleFromUrl(Astro.url)
|
const locale = (Astro.currentLocale as Locale) ?? DEFAULT_LOCALE
|
||||||
const { t } = useTranslations(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' }[] = [
|
// 当前路径去掉语言前缀与尾随 .html(format:'file' 产物如 cn.html / shows.html),
|
||||||
{ label: 'x', value: 'x' },
|
// 用于语言切换器拼出「同页面、另一语言」的链接(逻辑路径,不带 .html)
|
||||||
{ label: 'Backstage', value: 'Backstage' },
|
// 例:/cn.html → / ,/cn/shows.html → /shows ,/shows.html → /shows
|
||||||
{ label: 'Events', value: 'Events' },
|
let _segs = Astro.url.pathname.split('/').filter(Boolean);
|
||||||
{ label: 'Street', value: 'Street' },
|
if (_segs[0] && isLocaleSegment(_segs[0])) _segs = _segs.slice(1);
|
||||||
{ label: 'Trending Colors', value: 'Trending Colors' },
|
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>
|
<!doctype html>
|
||||||
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
|
<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">
|
<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">
|
<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
|
2026
|
||||||
</span></a>
|
</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={getRelativeLocaleUrl(locale, '/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={getRelativeLocaleUrl(locale, '/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={getRelativeLocaleUrl(locale, '/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, '/contact')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Contact Us')}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3" id="auth-slot">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center gap-2 normal-case" aria-label="Language">
|
<!-- 语言切换器:Alpine 下拉,hover 展开;独立容器避免被 auth 脚本 innerHTML 重写误伤 -->
|
||||||
{LOCALES.map((l) => (
|
<div
|
||||||
<a
|
x-data="{ open: false }"
|
||||||
href={translatePath(currentPath, l)}
|
@mouseenter="open = true"
|
||||||
aria-current={l === locale ? 'true' : undefined}
|
@mouseleave="open = false"
|
||||||
class:list={[
|
@click.outside="open = false"
|
||||||
'cursor-pointer hover:opacity-60 transition-opacity duration-200',
|
@keydown.escape.window="open = false"
|
||||||
l === locale ? 'opacity-100 underline underline-offset-4' : 'opacity-40',
|
class="relative"
|
||||||
]}
|
id="lang-switch"
|
||||||
>{LOCALE_NAMES[l]}</a>
|
>
|
||||||
))}
|
<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={getRelativeLocaleUrl(l, currentPath)}
|
||||||
|
@click="open = false"
|
||||||
|
class:list={[
|
||||||
|
'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>
|
</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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@ -98,13 +133,10 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
|
|||||||
import { getUser, fetchMe, clearSession } from "../lib/auth.ts"
|
import { getUser, fetchMe, clearSession } from "../lib/auth.ts"
|
||||||
const slot = document.getElementById("auth-slot")
|
const slot = document.getElementById("auth-slot")
|
||||||
function render(user) {
|
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>`
|
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")
|
const btn = slot.querySelector("#logout-btn")
|
||||||
if (btn) btn.addEventListener("click", () => { clearSession(); location.reload() })
|
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()
|
const cached = getUser()
|
||||||
if (cached) render(cached)
|
if (cached) render(cached)
|
||||||
@ -140,30 +172,6 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Column 3: Color Studio & Actions */}
|
||||||
<div class="md:col-span-4 space-y-3">
|
<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">
|
<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">
|
<div class="flex items-center gap-6">
|
||||||
<a
|
<a
|
||||||
href={translatePath('/about')}
|
href={getRelativeLocaleUrl(locale, '/about')}
|
||||||
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
|
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
|
||||||
>
|
>
|
||||||
{t('About')}
|
{t('About')}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={translatePath('/contact')}
|
href={getRelativeLocaleUrl(locale, '/contact')}
|
||||||
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
|
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
|
||||||
>
|
>
|
||||||
{t('Contact')}
|
{t('Contact')}
|
||||||
|
|||||||
@ -186,7 +186,6 @@ export function loadArticlesWithImages(size = 40): Promise<ArticleCard[]> {
|
|||||||
if (!API_BASE) return []
|
if (!API_BASE) return []
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=${size}&with_images=5`)
|
const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=${size}&with_images=5`)
|
||||||
console.log(res)
|
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
const json: any = await res.json()
|
const json: any = await res.json()
|
||||||
const items: RawArticle[] = json?.data ?? []
|
const items: RawArticle[] = json?.data ?? []
|
||||||
|
|||||||
@ -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
8
src/pages/cn/about.astro
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro"
|
||||||
|
import About from "../../components/pages/About.astro"
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="About — 暂未上线">
|
||||||
|
<About />
|
||||||
|
</Layout>
|
||||||
@ -1,344 +1,8 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../../layouts/Layout.astro"
|
import Layout from '../../layouts/Layout.astro'
|
||||||
import ComingSoon from "../../components/ComingSoon.astro"
|
import Index from '../../components/Index.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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
|
<Index />
|
||||||
<!-- 二级分类标签页 -->
|
|
||||||
<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>
|
|
||||||
</Layout>
|
</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
8
src/pages/en/about.astro
Normal 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
8
src/pages/en/index.astro
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import Layout from '../../layouts/Layout.astro'
|
||||||
|
import Index from '../../components/Index.astro'
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
<Index />
|
||||||
|
</Layout>
|
||||||
@ -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 预取走秀数据
|
// 应该跳转到 /en
|
||||||
const shows = await getShows(20)
|
|
||||||
|
|
||||||
// 国际化:根据 URL 前缀(/cn/...)取当前语言与翻译函数
|
import Layout from '../layouts/Layout.astro'
|
||||||
const locale = getLocaleFromUrl(Astro.url)
|
import Index from '../components/Index.astro'
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
|
<Index />
|
||||||
<!-- 二级分类标签页 -->
|
|
||||||
<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>
|
|
||||||
</Layout>
|
</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>
|
|
||||||
@ -1 +1,3 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
|||||||
Reference in New Issue
Block a user