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

This commit is contained in:
toom1996
2026-08-29 20:17:13 +08:00
parent a133413d74
commit e24bfe164d
29 changed files with 598 additions and 1517 deletions

View File

@ -0,0 +1,32 @@
# 2026-08-29 工作日志
## 前端列表页重构 + 详情路由统一(17:00–17:45)
用户要求:① 所有文章详情集成到 `/item`(含 street 详情);② 之前分析过的列表页抽象(共享 CSS / 共享 Alpine 工具 / 品牌弹窗组件)一并落地。
### 现状核对(重要:大部分已提前落地)
- 排查发现 RunwayLooks.astro 与 StreetSnaps.astro 的 `<script>` **已**抽成共享工具(import `@/lib/looks-grid`,`getCardSlots`→`makeCardSlots`、`pageList`→`buildPageList`、`SPIN_SVG` 从工具导入),且 RunwayLooks 的重复 `toggleBrand`(selectedBrands 数组误写)**已**删除;品牌弹窗**已**抽成 `src/components/BrandModal.astro`。
- 详情路由:`cn/item/[id].astro` 与 `en/item/[id].astro` **已**实现「先 article 后 street 回落」统一渲染;RunwayLooks 卡片链接**已**用 `ROUTES.item` → `/item/`。
### 本次实际改动
- **共享 CSS**:新建 `src/styles/look-grid.css`,集中两列表页重复的卡片/网格/分页/侧栏/spinner/骨架/动画样式(类名保留 `.shows*`/`.snaps*`,keyframes 统一为 `looksProg`/`looksShim`)。用 node 脚本剥离两个组件内部 `<style>` 块,frontmatter 各 `import "@/styles/look-grid.css"`。
- **路由统一**:`src/lib/routes.ts` 将 `article`/`streetSnap` 两个工厂函数都改为 `(id)=>/item/${id}`(原 `article` 还是旧的 `/article?id=` 死路径)。
- **删遗留旧详情页**:`cn|en/street-snaps/[id].astro`、`cn|en/article.astro`(?id= 旧详情,全仓无任何内链)。`runway-looks/[id]` 早就被删。
- 验证:`npm run build` 通过(BUILD_EXIT=0);详情组件回链指向列表页 `/street-snaps`、`/runway-looks`(仍存在),api.ts 内 `/api/v1/public/...` 是后端地址未动;全仓无悬空引用。
### 余留(已解决)
- `src/components/pages/article.astro`(`Article` 组件)已删除(见下方清理)。
- `ROUTES.article` 与 `ROUTES.item` 现为同义重复,保留 `article` 仅为语义兼容(暂未动)。
## 清理孤儿代码(18:50)
用户要求删除没用的页面/css/js。排查全仓引用后执行:
- **删 9 个孤儿组件/资源**:`components/pages/` 下 8 个(`About`/`Contact`/`LatestProjects`/`Portfolio`/`Login`/`Register`/`AuthForm`/`article`,全是 ComingSoon 占位或旧详情组件,无任何页面 import、也不是真实路由)+ `components/LanguageDetector.astro`(Layout 里被注释掉的死 import)。并移除空的 `components/pages/` 目录 + `assets/astro.svg`、`assets/background.svg`(Astro 脚手架残留,全仓零引用)。
- **修隐藏 bug**:`ComingSoon.astro` 引用了 `ROUTES.articles`(routes.ts 从未定义该 key)→ 改为 `ROUTES.runwayLooks`(真实路由),否则「浏览走秀档案」按钮指向 undefined。
- **清 Layout 死引用**:删除 `import LanguageDetector` 与 `<!-- TODO <LanguageDetector /> -->`。
- **本次保留(未动)**:`ComingSoon`(Index 在用)、`looks-grid.ts`(两列表页 import)、`global.css`(Layout 引用)、`Breadcrumb/BrandModal/Item/StreetSnap` 等都在用。
- **未动 header 导航**:导航仍链到 `/about` `/contact` `/portfolio` `/latest-projects` `/login` `/register`,但这些路由**根本不存在**(占位组件已删、也从未有真实页面)→ 是死链。按用户「header 别动」旧约束未删导航项,待用户决定是否一并移除。
- 验证:`npm run build` BUILD_EXIT=0;read_lints Layout/ComingSoon 均 0;全仓 grep `LanguageDetector|components/pages|ROUTES.articles|astro.svg|background.svg` 零命中。
## 删冗余 ROUTES.article(19:00)
- `ROUTES.article`(routes.ts 里 `(id)=>/item/${id}`)全仓只有定义、零引用(另几处 `article:` 是 Item.astro / api.ts 的数据字段,无关),与 `ROUTES.item` 完全同义 → 按用户要求删除。保留 `streetSnap`(同样指向 /item,暂未动)。
- 验证:`npm run build` BUILD_EXIT=0;read_lints routes.ts 0。

View File

@ -3,7 +3,7 @@
## 模块策略(重要)
- 站点按模块逐个完善。当前上线「发布会模块」:首页 index + 走秀列表/详情(RunwayLooks = 走秀档案页,articles/shows 页面已在 i18n 重构中重组)。
- **品牌索引页已下线**(2026-08-26 确认):`src/pages/brands.astro` 及孤儿组件 `src/components/pages/brands.astro` 均已删除;品牌数据现在只通过走秀页侧栏 filter + 品牌弹窗呈现(热门 30 条走 SSG 接口,搜索/字母走运行时 `/api/v1/public/brands`)。
- 其余模块(Portfolio / Latest Projects / About / Contact / 根路径登录注册)为 ComingSoon 占位页;**导航(header)保持原样未改动**(用户 2026-08-07 明确「header 别动」),门禁完全靠占位页实现。`/en/login`、`/en/register` 已是真实页面(`AuthForm.astro`,mode login/register)。
- **占位模块组件已清理(2026-08-29)**:`components/pages/` 下 `About`/`Contact`/`LatestProjects`/`Portfolio`/`Login`/`Register`/`AuthForm`/`article` 等占位/旧详情组件查无引用、也不是真实路由,已删除并移除空目录。**这些模块的导航链接(`/about` `/contact` `/portfolio` `/latest-projects` `/login` `/register`)仍是死链**——从未有对应页面,待用户决定是否移除导航项。**导航(header)本身保持原样未改动**(用户 2026-08-07 明确「header 别动」)。`/en/login`、`/en/register` 实际并非真实页面(旧 MEMORY 记录有误),AuthForm 组件已随清理删除。
## 架构总览
- 前端:**d:/project/frontend_v2** — Astro SSG + Tailwind v4 + Alpine.js。`npm run build`(SSG 取数走 ssgRequest,失败回落空/假数据不影响构建)。
@ -17,7 +17,7 @@
- **品牌名前端一律只显示英文**(用户偏好 2026-08-26:「前端品牌不要展示中文名称,标题可以留着」):api.ts 映射 `display = nameEn`;走秀标题/描述仍按 locale 选列。
- **API 命名哲学(用户原则)**:「每一个地方尽可能不要通过参数来复用接口,尽量一个功能一个接口」。查询参数仅限「同一资源的列表筛选/分页」;跨资源/独立功能一律独立路径(如 `brands/hot` 而非 `?featured=1`)。待办:公开侧 `/api/v1/public/brands` 的 featured/keyword/letter 参数拆分、`/articles` 的 `brand_id` 拆 `brands/:id/shows`、资源命名 `shows` vs `articles`、`popular` vs `featured`(目标清单见 artifact `api_endpoints_catalog.md`)。
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`。Tailwind v4 无零值类。
- 导航(Layout.astro)保持原样,门禁仅由占位页承担。
- 导航(Layout.astro)保持原样未改动(用户「header 别动」),但其部分链接(/about /contact /portfolio /latest-projects /login /register)指向不存在的路由,属待清理死链。
## Layout 陷阱(2026-08-12)
- Layout 把 `<slot />` 包在 `flex-1 flex flex-col` 容器里;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 内容多少决定宽度、页面抖动)。用 `w-full`(可配 `max-w-[xxx]` + `mx-auto`)。
@ -33,5 +33,9 @@
- 地理/城市维度:用户明确暂不做。
- 分布:rtw 15203 / menswear 4061 / couture 672 / resort 2894 / pre_fall 2376。
## 前端组件复用(2026-08-29)
- **详情页统一 `/item`**:走秀 lookbook 与街拍详情全部收口到 `src/pages/{locale}/item/[id].astro`,服务端按 `getSsrArticle(id)` → `getSsrStreetSnap(id)` 回落渲染(先 runway 后 street)。`ROUTES.article` 与 `ROUTES.streetSnap` 均指向 `/item/${id}`(`runway-looks/[id]`、`street-snaps/[id]`、`article.astro(?id=)` 等旧详情页均已删除)。列表页回链仍指向列表 `/runway-looks`、`/street-snaps`。
- **列表页共享资源**:两个列表组件(RunwayLooks / StreetSnaps)同构,抽成 `src/styles/look-grid.css`(卡片/网格/分页/侧栏/spinner/骨架/动画)+ `src/lib/looks-grid.ts`(`SPIN_SVG`、`makeCardSlots(images,count,opts)`、`buildPageList(total,cur)`);品牌筛选弹窗 `src/components/BrandModal.astro`(仅 RunwayLooks 用,`openBrandModal/confirmBrandModal` 等由页面脚本驱动)。卡片标记因在 Alpine `x-for` 内客户端渲染,**不能**提成 Astro 组件。
## 部署(Docker + Gitea Actions)
- 交付文件:前端 Dockerfile / nginx.conf / docker-compose.yml / deploy.sh / .gitea/workflows/deploy.yml / .env.example / DEPLOY.md;后端 Dockerfile。nginx 拦截 `/api/v1/ssg/` 404;健康检查 `/api/v1/public/brands`。

View File

@ -1,11 +1,38 @@
node.exe : npm warn Unknown user config "home". This will stop working in the next major version of npm.
所在位置 C:\Program Files\nodejs\npm.ps1:29 字符: 3
+ & $NODE_EXE $NPM_CLI_JS $args
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (npm warn Unknow...version of npm.:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
> test@0.0.1 build
> astro build
[safe-delete] 操作失败: ERROR D:\project\test\test\node_modules\.vite\deps: Error during a `trash` operation: Unknown { description: "Some operations were aborted" }
Stack trace:
 at trashViaBinary (D:\apps\workbuddy\resources\app.asar.unpacked\cli\vendor\shim\genie-safe-delete.cjs:270:15)
at tryTrash (D:\apps\workbuddy\resources\app.asar.unpacked\cli\vendor\shim\genie-safe-delete.cjs:547:5)
at Object.wrappedPromisesRm [as rm] (D:\apps\workbuddy\resources\app.asar.unpacked\cli\vendor\shim\genie-safe-delete.cjs:766:15)
at async optimizeExplicitEnvironmentDeps (file:///D:/project/test/test/node_modules/vite/dist/node/chunks/node.js:31978:25)
at async Promise.all (index 1)
18:59:58 [@astrojs/node] Enabling sessions with filesystem storage
18:59:59 [types] Generated 233ms
18:59:59 [build] output: "static"
18:59:59 [build] mode: "server"
18:59:59 [build] directory: D:\project\frontend_v2\dist\
18:59:59 [build] adapter: @astrojs/node
18:59:59 [build] Collecting build info...
18:59:59 [build] ✓ Completed in 435ms.
18:59:59 [build] Building server entrypoints...
19:00:01 [vite] ✓ built in 2.33s
19:00:03 [vite] ✓ built in 1.51s
19:00:03 [vite] ✓ built in 814ms
 prerendering static routes 
19:00:04 ├─ /cn/runway-looks/index.html (+110ms)
19:00:04 ├─ /cn/street-snaps/index.html (+19ms)
19:00:04 ├─ /cn/index.html (+45ms)
19:00:04 ├─ /en/runway-looks/index.html (+31ms)
19:00:04 ├─ /en/street-snaps/index.html (+16ms)
19:00:04 ├─ /en/index.html (+30ms)
19:00:04 ├─ /index.html (+37ms)
19:00:04 ✓ Completed in 349ms.

19:00:04 [build] Rearranging server assets...
19:00:04 [build] ✓ Completed in 5.26s.
19:00:04 [build] Server built in 5.76s
19:00:04 [build] Complete!

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,160 @@
---
// 品牌筛选弹窗(仅 markup + 样式)。
// Alpine 状态(modalOpen / modalKeyword / modalLetter / modalResults / modalLoading /
// modalSelected / SPIN_SVG 等)由父级 RunwayLooks 的 showsPage() 提供,本组件渲染在
// .showsroot 的 x-data 作用域内,故可直接引用这些变量,无需 props。
---
<!-- 品牌筛选弹窗:搜索 + A-Z 跳转 + 热门推荐,按需加载,避免一次拉全量 -->
<div class="showsmodal" x-show="modalOpen" x-cloak @keydown.escape.window="closeBrandModal()">
<div class="showsmodal-mask" @click="closeBrandModal()"></div>
<div class="showsmodal-panel">
<header class="showsmodal-head">
<h3>全部品牌</h3>
<button class="showsmodal-x" type="button" @click="closeBrandModal()" aria-label="关闭">✕</button>
</header>
<div class="showsmodal-search">
<input
class="showsmodal-input"
type="text"
x-model="modalKeyword"
@input.debounce.300ms="modalSearch()"
placeholder="搜索品牌名…"
autocomplete="off"
/>
<p class="showsmodal-tip">最多展示 30 条结果,可用搜索或字母筛选缩小范围</p>
</div>
<div class="showsmodal-az">
<button class="az az-hot" type="button" :class="{ active: modalLetter === 'HOT' }" @click="modalPickLetter('HOT')">HOT</button>
<template x-for="l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split('')" :key="l">
<button class="az" type="button" :class="{ active: modalLetter === l }" @click="modalPickLetter(l)" x-text="l"></button>
</template>
</div>
<div class="showsmodal-body">
<div class="showsmodal-hint" x-show="!modalKeyword && modalLetter === 'HOT'">热门推荐</div>
<div class="showsmodal-loading" x-show="modalLoading" x-html="SPIN_SVG"></div>
<ul class="showsmodal-list">
<template x-for="b in modalResults" :key="b.id">
<li>
<label class="showsmodal-row">
<input
type="radio"
name="modalDesigner"
:value="b.id"
x-model="modalSelected"
:class="{ 'is-checked': modalSelected === b.id }"
/>
<span class="showsmodal-name" x-text="b.display || b.name"></span>
</label>
</li>
</template>
<li class="showsmodal-empty" x-show="!modalLoading && modalResults.length === 0">未找到匹配的品牌</li>
</ul>
</div>
<footer class="showsmodal-foot">
<!-- <span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 最多 5 个`"></span> -->
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
</footer>
</div>
</div>
<style>
@reference "tailwindcss";
[x-cloak] { display: none !important; }
/* ===== 品牌筛选弹窗(语言切换器风格:无 mask、顶部小箭头、干净白底列表) ===== */
.showsmodal {
@apply fixed inset-0 z-[60] flex items-start justify-center pt-[14vh] pointer-events-none;
}
.showsmodal-mask {
@apply absolute inset-0 pointer-events-auto cursor-default;
background: rgba(0, 0, 0, 0.4);
}
.showsmodal-panel {
@apply relative w-full max-w-[680px] max-h-[72vh] bg-white flex flex-col border border-black/10 pointer-events-auto;
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
}
/* 顶部小三角箭头(语言切换器同款:白底 + 浅边框) */
.showsmodal-panel::before {
content: "";
position: absolute;
top: -5px;
left: 50%;
width: 10px;
height: 10px;
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
transform: translateX(-50%) rotate(45deg);
}
.showsmodal-head {
@apply flex items-center justify-between px-4 pt-4 pb-2 shrink-0;
}
.showsmodal-head h3 {
@apply m-0 text-[12px] tracking-[0.22em] uppercase font-bold;
}
.showsmodal-x {
@apply appearance-none bg-transparent border-0 w-6 h-6 inline-flex items-center justify-center cursor-pointer text-[14px] leading-none text-[#999] transition-colors hover:text-black;
}
.showsmodal-search {
@apply px-4 pt-1 pb-3 shrink-0;
}
.showsmodal-input {
@apply w-full border border-black/15 px-3 py-2 text-[13px] outline-none bg-[#fafafa] focus:bg-white focus:border-black;
}
.showsmodal-tip {
@apply mt-2 text-[11px] leading-snug text-black/45 font-mono;
}
.showsmodal-az {
@apply flex flex-wrap gap-1 px-4 pb-3 shrink-0;
}
.showsmodal-az .az {
@apply appearance-none bg-white border border-black/10 w-6 h-6 text-[10px] cursor-pointer leading-none font-mono transition-colors hover:bg-black/5;
}
.showsmodal-az .az.active {
@apply bg-black text-white border-black;
}
.showsmodal-az .az-hot {
@apply w-auto px-2 font-sans font-bold tracking-[0.12em] text-[9px];
}
.showsmodal-body {
@apply flex-1 min-h-0 overflow-y-auto px-2 py-1 overscroll-contain;
}
.showsmodal-hint {
@apply text-[10px] tracking-[0.22em] uppercase text-[#999] px-3 py-2;
}
.showsmodal-loading {
@apply flex justify-center py-6 text-[#bdbdbd];
}
.showsmodal-list {
@apply list-none m-0 p-0;
}
.showsmodal-row {
@apply flex items-center gap-2 w-full cursor-pointer px-3 py-2 text-[13px] transition-colors hover:bg-black/5;
}
.showsmodal-row input[type="radio"] {
@apply appearance-none w-4 h-4 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
.showsmodal-row input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showsmodal-name {
@apply flex-1 truncate;
}
.showsmodal-ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showsmodal-empty {
@apply py-6 text-center text-[#999] text-[13px];
}
.showsmodal-foot {
@apply flex items-center justify-between px-4 py-3 border-t border-black/5 shrink-0 text-[11px] text-[#888];
}
.showsmodal-counter {
@apply tracking-[0.06em];
}
.showsmodal-ok {
@apply appearance-none bg-black text-white border border-black px-4 py-1.5 cursor-pointer text-[12px] tracking-[0.1em] transition-colors hover:bg-black/85;
}
</style>

View File

@ -22,7 +22,7 @@ const { locale } = getI18n(Astro)
{note || "该模块正在筹备中,尚未对外开放。站点正按模块逐个完善,你可以先浏览已上线的走秀档案。"}
</p>
<div class="cs-actions">
<a class="cs-btn" href={href(locale, ROUTES.articles)}>浏览走秀档案 →</a>
<a class="cs-btn" href={href(locale, ROUTES.runwayLooks)}>浏览走秀档案 →</a>
<a class="cs-btn-ghost" href={href(locale, ROUTES.home)}>返回首页</a>
</div>
</section>

View File

@ -1,36 +0,0 @@
---
import { pathHasLocale, getRelativeLocaleUrl } from 'astro:i18n';
// 使用 pathHasLocale 安全判断当前路径是否已包含任何语言前缀(返回 boolean)
const hasLocale = pathHasLocale(Astro.url.pathname);
// 自动生成中文根路径
const cnRootUrl = getRelativeLocaleUrl('cn', '');
---
<script is:inline define:vars={{ hasLocale, cnRootUrl }}>
(() => {
try {
// 当前路径已带有语言前缀(如 /cn/ 或 /en/),直接跳过
if (hasLocale) return;
// 避免重复跳转
if (sessionStorage.getItem('lang-redirect')) return;
// 判定浏览器偏好语言
const langs = navigator.languages || [navigator.language || 'en'];
const isZh = langs.some((l) => l.toLowerCase().startsWith('zh'));
if (!isZh) return;
sessionStorage.setItem('lang-redirect', '1');
// 拼接真实跳转路径
const { pathname, search, hash } = location;
const cleanPath = pathname === '/' ? '' : pathname;
const targetUrl = `${cnRootUrl}${cleanPath.replace(/^\//, '')}${search}${hash}`;
location.replace(targetUrl);
} catch (e) {}
})();
</script>

View File

@ -1,7 +1,9 @@
---
import Breadcrumb from "@/components/Breadcrumb.astro"
import BrandModal from "@/components/BrandModal.astro"
import { getSsgHotBrands } from "@/lib/api"
import { getI18n } from '@/i18n/utils'
import "@/styles/look-grid.css"
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
@ -247,392 +249,22 @@ const seasonOptions: { value: string; label: string }[] = [
</main>
</div>
<!-- 品牌筛选弹窗:搜索 + A-Z 跳转 + 热门推荐,按需加载,避免一次拉全量 -->
<div class="showsmodal" x-show="modalOpen" x-cloak @keydown.escape.window="closeBrandModal()">
<div class="showsmodal-mask" @click="closeBrandModal()"></div>
<div class="showsmodal-panel">
<header class="showsmodal-head">
<h3>全部品牌</h3>
<button class="showsmodal-x" type="button" @click="closeBrandModal()" aria-label="关闭">✕</button>
</header>
<div class="showsmodal-search">
<input
class="showsmodal-input"
type="text"
x-model="modalKeyword"
@input.debounce.300ms="modalSearch()"
placeholder="搜索品牌名…"
autocomplete="off"
/>
<p class="showsmodal-tip">最多展示 30 条结果,可用搜索或字母筛选缩小范围</p>
</div>
<div class="showsmodal-az">
<button class="az az-hot" type="button" :class="{ active: modalLetter === 'HOT' }" @click="modalPickLetter('HOT')">HOT</button>
<template x-for="l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split('')" :key="l">
<button class="az" type="button" :class="{ active: modalLetter === l }" @click="modalPickLetter(l)" x-text="l"></button>
</template>
</div>
<div class="showsmodal-body">
<div class="showsmodal-hint" x-show="!modalKeyword && modalLetter === 'HOT'">热门推荐</div>
<div class="showsmodal-loading" x-show="modalLoading" x-html="SPIN_SVG"></div>
<ul class="showsmodal-list">
<template x-for="b in modalResults" :key="b.id">
<li>
<label class="showsmodal-row">
<input
type="radio"
name="modalDesigner"
:value="b.id"
x-model="modalSelected"
:class="{ 'is-checked': modalSelected === b.id }"
/>
<span class="showsmodal-name" x-text="b.display || b.name"></span>
</label>
</li>
</template>
<li class="showsmodal-empty" x-show="!modalLoading && modalResults.length === 0">未找到匹配的品牌</li>
</ul>
</div>
<footer class="showsmodal-foot">
<!-- <span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 最多 5 个`"></span> -->
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
</footer>
</div>
</div>
<!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) -->
<BrandModal />
</div>
<script id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
<style>
@reference "tailwindcss";
[x-cloak] { display: none !important; }
/* 使用 @apply 精简并重构样式 */
.showsroot {
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
}
.showshead {
@apply flex justify-between items-end gap-6 py-5 pb-[26px] ;
}
.showsh1 {
@apply text-[clamp(40px,6vw,72px)] leading-[0.9] font-black tracking-[-0.02em] my-0 mb-[10px];
}
.showspills {
@apply flex flex-wrap gap-2 pt-3.5;
}
.showspill {
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
}
.showspill .x {
@apply inline-flex w-3.5 h-3.5 items-center justify-center border-none bg-black text-white cursor-pointer text-xs leading-none;
}
.showsbody {
@apply flex gap-[36px] items-start max-md:flex-col max-md:gap-[18px];
}
.showsaside {
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] rounded-none transition-[flex-basis] duration-250 ease-in-out flex flex-col max-md:static max-md:max-h-none max-md:w-full;
}
.showsaside-head {
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
}
.showsaside-h {
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none transition-opacity duration-200;
}
.showsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
}
.showsg {
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
}
.showsg-h {
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
}
.showsg-right {
@apply inline-flex items-center gap-2.5;
}
.showsg-chev {
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
}
.showsg-chev.is-collapsed {
@apply -rotate-90;
}
.showsg-count {
@apply text-[#999] font-normal text-[10px] tracking-[0.14em];
}
.showschecks {
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin];
}
.showschecks li {
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
}
.showschecks li.letter-head {
@apply p-[8px_0_3px] text-[10px] tracking-[0.22em] text-[#8a8a8a] cursor-default pointer-events-none border-t border-dotted border-[#e6e6e6] first:border-t-0 first:pt-1;
}
.showschecks input[type="radio"] {
@apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
/* 选中态由 Alpine 状态驱动(.is-checked),不依赖原生 :checked 伪类,
因为 @click.prevent 会阻止原生选中,伪类不可靠;实心黑圆一眼可辨 */
.showschecks input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showschecks .ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showschecks--flat li {
@apply py-[4px];
}
.showschecks--flat li label {
@apply inline-flex items-center gap-2 cursor-pointer w-full;
}
.showsmore {
@apply block w-full mt-[12px] text-center text-[11px] uppercase tracking-[0.22em] text-black font-bold cursor-pointer;
}
.showsclear {
@apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[#666] cursor-pointer hover:text-black transition-colors duration-150;
}
.showsmain {
@apply flex-1 min-w-0;
}
.showsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
}
.showsgrid.is-loading::before {
content: "";
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
animation: hbxProg 1s ease-in-out infinite;
}
.showscard {
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px];
}
.showscard:hover .showscard-img img {
@apply scale-104;
}
.showscard-left {
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
}
.showscard-img {
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
}
.showscard-img img {
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
}
.showscard-img img.is-loaded {
@apply opacity-100;
}
.showsspin {
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
}
.showscard-img.is-loaded .showsspin,
.showscard-thumb.is-loaded .showsspin {
@apply opacity-0;
}
.showsbadge {
@apply absolute top-[6px] right-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-white bg-black p-[3px_6px];
}
.showsflag-featured {
@apply absolute top-[6px] left-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-black bg-white p-[3px_6px] border border-black;
}
.showscard-right {
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
}
.showscard-info {
@apply flex-none min-w-0 flex flex-col p-[2px_2px_0];
}
.showscard-title {
@apply text-[15.5px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] tracking-[-0.01em] line-clamp-2 h-[41px] overflow-hidden;
}
.showscard-meta {
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
}
.showscard-meta-foot {
@apply flex flex-col gap-[6px] h-[38px] overflow-hidden;
}
.showscard-meta-line {
@apply inline-flex items-center gap-[6px] text-[10.5px] tracking-[0.06em] uppercase text-[#444] h-[16px] leading-[16px] overflow-hidden whitespace-nowrap;
}
.showscard-strip {
@apply grid grid-cols-5 gap-1 mt-[10px];
}
.showscard-thumb {
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
}
.showscard-thumb img {
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
}
.showscard-thumb img.is-loaded {
@apply opacity-100;
}
.showscard-thumb.placeholder {
@apply bg-[#f6f6f6];
}
.showscard-thumb-more {
@apply absolute inset-0 bg-black/55 text-white text-[11px] font-bold tracking-[0.04em] flex items-center justify-center;
}
.showscard--skel .showscard-img,
.showscard--skel .showscard-thumb,
.showscard--skel .showsline {
@apply bg-[#efefef];
animation: hbxShim 1.6s ease infinite;
}
.showspager {
@apply flex flex-wrap items-center justify-center gap-2 mt-[38px];
}
.pg {
@apply min-w-[40px] h-[40px] px-3 border-[1.5px] border-black bg-white text-black text-[11px] uppercase tracking-[0.12em] font-bold cursor-pointer rounded-none transition-colors duration-200;
}
.pg.is-current {
@apply bg-black text-white cursor-default;
}
.pg:disabled {
@apply opacity-30 cursor-default;
}
.pg.enabled:hover {
@apply bg-black text-white;
}
.pg-ellipsis {
@apply px-1 text-[#999] select-none text-[11px];
}
.pg-wrap {
@apply inline-flex items-center;
}
.showsempty {
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
}
@keyframes hbxProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes hbxShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }
/* ===== 品牌筛选弹窗(语言切换器风格:无 mask、顶部小箭头、干净白底列表) ===== */
.showsmodal {
@apply fixed inset-0 z-[60] flex items-start justify-center pt-[14vh] pointer-events-none;
}
.showsmodal-mask {
@apply absolute inset-0 pointer-events-auto cursor-default;
background: rgba(0, 0, 0, 0.4);
}
.showsmodal-panel {
@apply relative w-full max-w-[680px] max-h-[72vh] bg-white flex flex-col border border-black/10 pointer-events-auto;
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
}
/* 顶部小三角箭头(语言切换器同款:白底 + 浅边框) */
.showsmodal-panel::before {
content: "";
position: absolute;
top: -5px;
left: 50%;
width: 10px;
height: 10px;
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
transform: translateX(-50%) rotate(45deg);
}
.showsmodal-head {
@apply flex items-center justify-between px-4 pt-4 pb-2 shrink-0;
}
.showsmodal-head h3 {
@apply m-0 text-[12px] tracking-[0.22em] uppercase font-bold;
}
.showsmodal-x {
@apply appearance-none bg-transparent border-0 w-6 h-6 inline-flex items-center justify-center cursor-pointer text-[14px] leading-none text-[#999] transition-colors hover:text-black;
}
.showsmodal-search {
@apply px-4 pt-1 pb-3 shrink-0;
}
.showsmodal-input {
@apply w-full border border-black/15 px-3 py-2 text-[13px] outline-none bg-[#fafafa] focus:bg-white focus:border-black;
}
.showsmodal-tip {
@apply mt-2 text-[11px] leading-snug text-black/45 font-mono;
}
.showsmodal-az {
@apply flex flex-wrap gap-1 px-4 pb-3 shrink-0;
}
.showsmodal-az .az {
@apply appearance-none bg-white border border-black/10 w-6 h-6 text-[10px] cursor-pointer leading-none font-mono transition-colors hover:bg-black/5;
}
.showsmodal-az .az.active {
@apply bg-black text-white border-black;
}
.showsmodal-az .az-hot {
@apply w-auto px-2 font-sans font-bold tracking-[0.12em] text-[9px];
}
.showsmodal-body {
@apply flex-1 min-h-0 overflow-y-auto px-2 py-1 overscroll-contain;
}
.showsmodal-hint {
@apply text-[10px] tracking-[0.22em] uppercase text-[#999] px-3 py-2;
}
.showsmodal-loading {
@apply flex justify-center py-6 text-[#bdbdbd];
}
.showsmodal-list {
@apply list-none m-0 p-0;
}
.showsmodal-row {
@apply flex items-center gap-2 w-full cursor-pointer px-3 py-2 text-[13px] transition-colors hover:bg-black/5;
}
.showsmodal-row input[type="radio"] {
@apply appearance-none w-4 h-4 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
.showsmodal-row input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showsmodal-name {
@apply flex-1 truncate;
}
.showsmodal-ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showsmodal-empty {
@apply py-6 text-center text-[#999] text-[13px];
}
.showsmodal-foot {
@apply flex items-center justify-between px-4 py-3 border-t border-black/5 shrink-0 text-[11px] text-[#888];
}
.showsmodal-counter {
@apply tracking-[0.06em];
}
.showsmodal-ok {
@apply appearance-none bg-black text-white border border-black px-4 py-1.5 cursor-pointer text-[12px] tracking-[0.1em] transition-colors hover:bg-black/85;
}
</style>
<script>
import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
const SPIN_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block"><g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g><g opacity="0.55" fill="none" stroke="currentColor" stroke-width="1"><rect x="8" y="9.6" width="8" height="5.4"></rect><circle cx="12" cy="12.3" r="1.6"></circle></g></svg>`
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => {
@ -716,13 +348,6 @@ const seasonOptions: { value: string; label: string }[] = [
localStorage.setItem(`showsg-${group}`, this.collapsedGroups[group] ? "1" : "")
},
toggleBrand(id: number) {
const idx = this.selectedBrands.indexOf(id)
if (idx >= 0) this.selectedBrands.splice(idx, 1)
else this.selectedBrands.push(id)
this.refreshFilter()
},
refreshFilter() {
this.loadPage(1)
},
@ -845,19 +470,7 @@ const seasonOptions: { value: string; label: string }[] = [
// ===== 分页导航 =====
// 生成窗口化的页码列表:始终含首尾页,当前页 ±1,其余用 "..." 折叠。
get pageList() {
const total = this.lastPage
const cur = this.page
if (total <= 7) {
return Array.from({ length: total }, (_: any, i: number) => i + 1)
}
const pages: (number | "...")[] = [1]
const start = Math.max(2, cur - 1)
const end = Math.min(total - 1, cur + 1)
if (start > 2) pages.push("...")
for (let i = start; i <= end; i++) pages.push(i)
if (end < total - 1) pages.push("...")
pages.push(total)
return pages
return buildPageList(this.lastPage, this.page)
},
goPage(p: number) {
@ -878,18 +491,9 @@ const seasonOptions: { value: string; label: string }[] = [
// 渲染文章url
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
// 渲染thumb图片列表
// 渲染thumb图片列表(复用共享工具:固定 5 槽位 + 末位 moreLabel 兜底)
getCardSlots(it: ArticleItem) {
const gallery = it.gallery || []
const totalCount = it.image_count || 0
const moreLabel = totalCount > 30 ? "30+" : `${totalCount || 0}+`
const slots = []
for (let i = 0; i < 5; i++) {
const src = gallery[i]
const isLast = i === 4
slots.push({ src, loaded: false, isLast, moreLabel: (isLast && totalCount > 0) ? moreLabel : "" })
}
return slots
return makeCardSlots(it.gallery || [], it.image_count || 0)
}
}
})

View File

@ -3,6 +3,7 @@ import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from '@/i18n/utils'
import { clientHref, ROUTES } from '@/lib/routes'
import { toAbs } from '@/lib/api'
import "@/styles/look-grid.css"
const { locale, t } = getI18n(Astro)
@ -173,186 +174,12 @@ const sortOptions: { value: string; label: string }[] = [
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions })}></script>
<style>
@reference "tailwindcss";
[x-cloak] { display: none !important; }
.snapsroot {
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
}
.snapshead {
@apply flex justify-between items-end gap-6 py-5 pb-[22px];
}
.snapsh1 {
@apply text-[clamp(34px,5.5vw,64px)] leading-[0.95] font-black tracking-[-0.02em] my-0 mb-[8px];
}
/* 已激活筛选 pills */
.snapspills {
@apply flex flex-wrap gap-2 mb-4;
}
.snapspill {
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
}
.snapspill .x {
@apply inline-flex w-3.5 h-3.5 items-center justify-center border-none bg-black text-white cursor-pointer text-xs leading-none;
}
/* 主体:左 sidebar + 右网格 */
.snapsbody {
@apply flex gap-[36px] items-start max-md:flex-col max-md:gap-[18px];
}
.snapsaside {
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] rounded-none flex flex-col max-md:static max-md:max-h-none max-md:w-full;
}
.snapsaside-head {
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
}
.snapsaside-h {
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none;
}
.snapsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
}
.snapsg {
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
}
.snapsg-h {
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
}
.snapsg-chev {
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
}
.snapsg-chev.is-collapsed { @apply -rotate-90; }
.snapschecks {
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin];
}
.snapschecks li {
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
}
.snapschecks input[type="radio"] {
@apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
/* 选中态由 Alpine 状态驱动(.is-checked),不依赖原生 :checked 伪类:
@click.prevent 会阻止原生选中,伪类不可靠;实心黑圆一眼可辨 */
.snapschecks input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.snapschecks--flat li { @apply py-[4px]; }
.snapschecks--flat li label { @apply inline-flex items-center gap-2 cursor-pointer w-full; }
.snapsclear {
@apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[#666] cursor-pointer hover:text-black transition-colors duration-150;
}
/* 主体内容区 */
.snapsmain { @apply flex-1 min-w-0; }
.snapscontent {
@apply relative;
}
.snapscontent.is-loading::before {
content: "";
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
animation: snapProg 1s ease-in-out infinite;
}
/* ===== 卡片网格(与 runway-looks 同款) ===== */
.snapsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
}
.snapscard {
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px];
}
.snapscard:hover .snapscard-img img {
@apply scale-104;
}
.snapscard-left {
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
}
.snapscard-img {
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
}
.snapscard-img img {
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
}
.snapscard-img img.is-loaded {
@apply opacity-100;
}
.snapsspin {
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
}
.snapscard-img.is-loaded .snapsspin,
.snapscard-thumb.is-loaded .snapsspin {
@apply opacity-0;
}
.snapscard-right {
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
}
.snapscard-info {
@apply flex-none min-w-0 flex flex-col p-[2px_2px_0];
}
.snapscard-title {
@apply text-[15.5px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] tracking-[-0.01em] line-clamp-2 h-[41px] overflow-hidden;
}
.snapscard-meta {
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
}
.snapscard-strip {
@apply grid grid-cols-5 gap-1 mt-[10px];
}
.snapscard-thumb {
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
}
.snapscard-thumb img {
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
}
.snapscard-thumb img.is-loaded {
@apply opacity-100;
}
.snapscard-thumb.placeholder {
@apply bg-[#f6f6f6];
}
.snapscard--skel .snapscard-img,
.snapscard--skel .snapscard-thumb,
.snapscard--skel .snapsline {
@apply bg-[#efefef];
animation: snapShim 1.6s ease infinite;
}
/* ===== 分页 ===== */
.snappager { @apply flex flex-wrap items-center justify-center gap-2 mt-[38px]; }
.pg {
@apply min-w-[40px] h-[40px] px-3 border-[1.5px] border-black bg-white text-black text-[11px] uppercase tracking-[0.12em] font-bold cursor-pointer rounded-none transition-colors duration-200;
}
.pg.is-current { @apply bg-black text-white cursor-default; }
.pg:disabled { @apply opacity-30 cursor-default; }
.pg.enabled:hover { @apply bg-black text-white; }
.pg-ellipsis { @apply px-1 text-[#999] select-none text-[11px]; }
.pg-wrap { @apply inline-flex items-center; }
.snapsempty {
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
}
@keyframes snapProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes snapShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }
</style>
<script>
import { getStreetSnaps, toAbs, type StreetSnapItem } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
const SPIN_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block"><g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g><g opacity="0.55" fill="none" stroke="currentColor" stroke-width="1"><rect x="8" y="9.6" width="8" height="5.4"></rect><circle cx="12" cy="12.3" r="1.6"></circle></g></svg>`
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
const DEFAULT_SORT = "image_count"
@ -414,15 +241,10 @@ const sortOptions: { value: string; label: string }[] = [
},
// 卡片右侧缩略图条带:固定 5 个槽位,缺图留 placeholder
// 街拍 images 是 { image, name }[](与 runway 的 string[] gallery 不同)
// 街拍 images 是 { image, name }[](与 runway 的 string[] gallery 不同),先抽成纯 URL 数组
getCardSlots(it: StreetSnapItem) {
const imgs = it.images || []
const slots: { src: string; loaded: boolean }[] = []
for (let i = 0; i < 5; i++) {
const im: any = imgs[i]
slots.push({ src: im ? (im.image || im) : "", loaded: false })
}
return slots
const imgs = (it.images || []).map((im: any) => (im && (im.image || im)) || "")
return makeCardSlots(imgs)
},
async loadPage(page: number) {
@ -449,17 +271,7 @@ const sortOptions: { value: string; label: string }[] = [
},
get pageList() {
const total = this.lastPage
const cur = this.page
if (total <= 7) return Array.from({ length: total }, (_: any, i: number) => i + 1)
const pages: (number | "...")[] = [1]
const start = Math.max(2, cur - 1)
const end = Math.min(total - 1, cur + 1)
if (start > 2) pages.push("...")
for (let i = start; i <= end; i++) pages.push(i)
if (end < total - 1) pages.push("...")
pages.push(total)
return pages
return buildPageList(this.lastPage, this.page)
},
goPage(p: number) {
@ -474,7 +286,8 @@ const sortOptions: { value: string; label: string }[] = [
nextPage() { this.goPage(this.page + 1) },
toAbsUrl(url: string) { return toAbs(url) },
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.streetSnap(it.id)) },
// 街拍详情统一收口到 /item/[id](与服务端 /item 回落逻辑对应)
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
// theimpression 标题含字面 \t:地点 \t 场次,拆成 [location, event]
titleParts(it: StreetSnapItem): string[] {

View File

@ -1,210 +0,0 @@
---
import astroLogo from '@/assets/astro.svg';
import background from '@/assets/background.svg';
---
<div id="container">
<img id="background" src={background.src} alt="" fetchpriority="high" />
<main>
<section id="hero">
<a href="https://astro.build"
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
>
<h1>
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
</h1>
<section id="links">
<a class="button" href="https://docs.astro.build">Read our docs</a>
<a href="https://astro.build/chat"
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
><path
fill="currentColor"
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
></path></svg
>
</a>
</section>
</section>
</main>
<a href="https://astro.build/blog/astro-6-beta/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 6.0?</h2>
<p>
Redesigned dev server, fonts, live collections, built-in CSP support, and more! Click to
explore Astro 6.0's new features.
</p>
</a>
</div>
<style>
#background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
filter: blur(100px);
}
#container {
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
height: 100%;
}
main {
height: 100%;
display: flex;
justify-content: center;
}
#hero {
display: flex;
align-items: start;
flex-direction: column;
justify-content: center;
padding: 16px;
}
h1 {
font-size: 22px;
margin-top: 0.25em;
}
#links {
display: flex;
gap: 16px;
}
#links a {
display: flex;
align-items: center;
padding: 10px 12px;
color: #111827;
text-decoration: none;
transition: color 0.2s;
}
#links a:hover {
color: rgb(78, 80, 86);
}
#links a svg {
height: 1em;
margin-left: 8px;
}
#links a.button {
color: white;
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
border-radius: 10px;
}
#links a.button:hover {
color: rgb(230, 230, 230);
box-shadow: none;
}
pre {
font-family:
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
monospace;
font-weight: normal;
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
h2 {
margin: 0 0 1em;
font-weight: normal;
color: #111827;
font-size: 20px;
}
p {
color: #4b5563;
font-size: 16px;
line-height: 24px;
letter-spacing: -0.006em;
margin: 0;
}
code {
display: inline-block;
background:
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
border-radius: 8px;
border: 1px solid transparent;
padding: 6px 8px;
}
.box {
padding: 16px;
background: rgba(255, 255, 255, 1);
border-radius: 16px;
border: 1px solid white;
}
#news {
position: absolute;
bottom: 16px;
right: 16px;
max-width: 300px;
text-decoration: none;
transition: background 0.2s;
backdrop-filter: blur(50px);
}
#news:hover {
background: rgba(255, 255, 255, 0.55);
}
@media screen and (max-height: 368px) {
#news {
display: none;
}
}
@media screen and (max-width: 768px) {
#container {
display: flex;
flex-direction: column;
}
#hero {
display: block;
padding-top: 10%;
}
#links {
flex-wrap: wrap;
}
#links a.button {
padding: 14px 18px;
}
#news {
right: 16px;
left: 16px;
bottom: 2.5rem;
max-width: 100%;
}
h1 {
line-height: 1.5;
}
}
</style>

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="About" moduleZh="关于" />

View File

@ -1,262 +0,0 @@
---
// 账号表单(登录 / 注册通用),编辑风黑白设计,匹配全站视觉。
// - mode: "login" | "register"
// - 提交:POST /api/auth/login 或 /api/auth/register,成功后写入本地会话并跳转。
// - 后端不可用时也能优雅显示错误(不破坏设计)。
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from "@/lib/routes"
interface Props {
mode?: "login" | "register"
}
const { mode = "login" } = Astro.props
const { locale } = getI18n(Astro)
const isLogin = mode === "login"
const title = isLogin ? "Sign In" : "Create Account"
const kickerRight = isLogin ? "Sign In" : "Create Account"
const subtitle = isLogin
? "Sign in to your account to access the runway archive."
: "Create an account to save shows and build your own archive."
const submitLabel = isLogin ? "Sign In" : "Create Account"
// 登录/注册成功后回跳目标:优先 ?redirect=,否则回 /en/
const redirect = Astro.url.searchParams.get("redirect") || href(locale, ROUTES.home)
const loginHref = href(locale, ROUTES.login)
const registerHref = href(locale, ROUTES.register)
---
<section class="auth-wrap">
<div class="auth-kicker">
<span>Account</span>
<span>{kickerRight}</span>
</div>
<h1 class="auth-title">{title}</h1>
<p class="auth-sub">{subtitle}</p>
<form id="auth-form" class="auth-form" data-mode={mode} data-redirect={redirect} novalidate>
{!isLogin && (
<label class="auth-field">
<span class="auth-label">Username</span>
<input
class="auth-input"
type="text"
name="username"
autocomplete="username"
placeholder="yourname"
required
/>
</label>
)}
<label class="auth-field">
<span class="auth-label">Email</span>
<input
class="auth-input"
type="email"
name="email"
autocomplete="email"
placeholder="you@example.com"
required
/>
</label>
<label class="auth-field">
<span class="auth-label">Password</span>
<input
class="auth-input"
type="password"
name="password"
autocomplete={isLogin ? "current-password" : "new-password"}
placeholder="••••••••"
required
/>
</label>
{!isLogin && (
<label class="auth-field">
<span class="auth-label">Confirm Password</span>
<input
class="auth-input"
type="password"
name="confirm"
autocomplete="new-password"
placeholder="••••••••"
required
/>
</label>
)}
<p id="auth-error" class="auth-error" hidden></p>
<button type="submit" class="auth-submit">{submitLabel}</button>
</form>
<div class="auth-foot">
{isLogin ? (
<>
<a class="auth-link" href={registerHref}>Don&rsquo;t have an account? Sign up &rarr;</a>
<a class="auth-link muted" href="#">Forgot your password?</a>
</>
) : (
<a class="auth-link" href={loginHref}>Already have an account? Sign in &rarr;</a>
)}
</div>
</section>
<style>
@reference "tailwindcss";
.auth-wrap {
@apply w-full max-w-[460px] mx-auto;
padding-top: clamp(40px, 7vw, 90px);
padding-bottom: 40px;
}
.auth-kicker {
display: flex;
justify-content: space-between;
border-top: 1px solid #000;
padding-top: 18px;
@apply font-mono text-[11px] tracking-[0.25em] uppercase text-[#666];
}
.auth-title {
@apply font-serif font-black tracking-[-0.02em] text-black;
line-height: 0.9;
font-size: clamp(44px, 9vw, 88px);
margin: 0.16em 0 0.18em;
}
.auth-sub {
@apply font-mono text-[12px] text-[#666];
letter-spacing: 0.04em;
margin: 0 0 34px;
max-width: 42ch;
}
.auth-form {
@apply flex flex-col;
}
.auth-field {
@apply block mb-[22px];
}
.auth-label {
display: block;
@apply font-mono text-[11px] uppercase tracking-[0.2em] text-black mb-[8px];
}
.auth-input {
@apply w-full border border-black bg-white px-[14px] py-[12px] font-sans text-[14px] text-black;
outline: none;
transition: background-color 0.15s ease;
}
.auth-input::placeholder {
@apply text-black/30;
}
.auth-input:focus {
background-color: rgba(0, 0, 0, 0.03);
}
.auth-error {
@apply font-mono text-[12px] text-black;
border: 1px solid #000;
background-color: rgba(0, 0, 0, 0.04);
padding: 10px 12px;
margin: 0 0 18px;
}
.auth-submit {
@apply w-full bg-black text-white border border-black px-[18px] py-[14px] text-[12px] uppercase tracking-[0.18em] font-bold cursor-pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.auth-submit:hover {
@apply bg-white text-black;
}
.auth-submit:disabled {
@apply opacity-50 cursor-not-allowed;
}
.auth-foot {
@apply mt-[26px] pt-[22px] border-t border-zinc-200 flex flex-col gap-[10px];
@apply font-mono text-[12px] text-[#666];
}
.auth-link {
@apply text-black no-underline;
}
.auth-link:hover {
text-decoration: underline;
}
.auth-link.muted {
@apply text-[#666];
}
</style>
<script>
import { saveSession } from "@/lib/api"
const form = document.getElementById("auth-form") as HTMLFormElement | null
if (form) {
const errEl = document.getElementById("auth-error") as HTMLParagraphElement | null
const btn = form.querySelector('button[type="submit"]') as HTMLButtonElement | null
const mode = (form.dataset.mode || "login") as "login" | "register"
const redirect = form.dataset.redirect || "/"
const showErr = (msg: string) => {
if (!errEl) return
errEl.textContent = msg
errEl.hidden = false
}
form.addEventListener("submit", async (e) => {
e.preventDefault()
if (errEl) errEl.hidden = true
const data = Object.fromEntries(new FormData(form).entries()) as Record<string, string>
if (mode === "register" && data.password !== data.confirm) {
showErr("Passwords do not match.")
return
}
const label = mode === "login" ? "Sign In" : "Create Account"
const orig = btn?.textContent ?? label
if (btn) {
btn.disabled = true
btn.textContent = "…"
}
try {
const endpoint = mode === "login" ? "/api/v1/auth/login" : "/api/v1/auth/register"
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
const json: any = await res.json().catch(() => ({}))
if (!res.ok) {
showErr(json?.message || json?.error || `Unable to ${label.toLowerCase()}. Please try again.`)
return
}
const token = json?.data?.token ?? json?.token
const user = json?.data?.user ?? json?.user
if (token && user) {
saveSession(token, user)
location.href = redirect
return
}
showErr("Unexpected response. Please try again.")
} catch {
showErr("Network error. Please try again.")
} finally {
if (btn) {
btn.disabled = false
btn.textContent = orig
}
}
})
}
</script>

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="Contact" moduleZh="联系" />

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="Latest Projects" moduleZh="最新项目" />

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="Sign In" moduleZh="登录" note="账户系统正在筹备中,暂未开放注册与登录。" />

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="Portfolio" moduleZh="作品集" />

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="Create Account" moduleZh="注册" note="账户系统正在筹备中,暂未开放注册与登录。" />

View File

@ -1,285 +0,0 @@
---
import type { RawArticle } from "@/lib/api"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { ssrBase } from "@/lib/api"
interface Props {
article: RawArticle | null
locale?: "en" | "cn"
error?: "missing" | "failed" | null
}
const { article, locale = "en", error = null } = Astro.props
// 把相对图片地址补全为绝对地址(服务端完成,避免浏览器再拼;base 由 ssr.ts 统一解析)
const full = (u: string): string =>
!u ? "" : /^https?:\/\//i.test(u) ? u : ssrBase().replace(/\/$/, "") + u
// 图片数组:服务端预处理为灯箱可用的 {id,url,name}
const images = (article?.images ?? []).map((im) => ({
id: im.id,
url: full(im.image || ""),
name: im.name || "",
}))
// 正文段落(如有)
const descParas = (article?.description || "")
.split(/\n\n+/)
.map((s) => s.trim())
.filter(Boolean)
// 文案(按语言切换)
const L = {
en: {
back: "← Back to Index",
source: "Source ↗",
runway: "Runway",
notFound: "Article not found",
missing: "No article ID specified",
failed: "Failed to load article. Please refresh or try later.",
},
cn: {
back: "← 返回首页",
source: "来源 ↗",
runway: "秀场",
notFound: "未找到该文章",
missing: "未指定文章 ID",
failed: "加载文章失败,请刷新或稍后再试",
},
}
const lbl = L[locale === "cn" ? "cn" : "en"]
const backLabel = lbl.back.replace("← ", "")
// 面包屑:首页 → 走秀档案(/shows) → 品牌筛选(带 brand_id) → 当前文章(复用项目 Breadcrumb 组件)
const homeHref = locale === "cn" ? "/cn" : "/en/"
const archiveHref = locale === "cn" ? "/cn/shows" : "/en/shows"
const crumbs: { label: string; href: string }[] = [
{ label: "Runway Looks", href: archiveHref },
]
if (article?.brand_id) {
crumbs.push({
label: article.brand_name || "Brand",
href: `${archiveHref}?brand_id=${article.brand_id}`,
})
}
---
<!-- 外层 x-data 承载灯箱状态;图片数据由服务端注入,无需浏览器再 fetch -->
<div x-data={`articleView(${JSON.stringify(images)})`} class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white">
{article ? (
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
<!-- 面包屑(复用项目 Breadcrumb 组件):首页 / 走秀档案 / 品牌 / 当前文章 -->
<Breadcrumb items={crumbs}>
<span slot="current" class="max-w-[60vw] truncate">{article.title}</span>
</Breadcrumb>
<!-- 标题 -->
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">{article.title}</h1>
<!-- 图片集(服务端渲染,利于 SEO 与首屏) -->
{images.length > 0 && (
<section class="mt-16">
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
{images.map((im, i) => (
<figure @click={`openLb(${i})`} class="bg-[#f2f2f2] cursor-zoom-in group">
<img src={im.url} alt={im.name} loading="lazy" class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity" />
{im.name && <figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">{im.name}</figcaption>}
</figure>
))}
</div>
</section>
)}
<!-- 正文(如有) -->
{descParas.length > 0 && (
<section class="mt-16 max-w-[720px] space-y-5 text-[15px] leading-[1.8] text-[#222]">
{descParas.map((p) => <p>{p}</p>)}
</section>
)}
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">{error === "missing" ? lbl.missing : lbl.failed}</p>
<a href={homeHref} class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors">← {backLabel}</a>
</div>
)}
<!-- 4. 全屏灯箱组件 (Lightbox,Alpine 客户端交互) -->
<div
x-show="lbOpen"
x-cloak
@keydown.window.escape="closeLb()"
@keydown.window.arrow-right="nextLb()"
@keydown.window.arrow-left="prevLb()"
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
>
<!-- 计数器 -->
<div class="absolute top-5 left-1/2 -translate-x-1/2 z-30 font-mono text-[12px] font-semibold tracking-[0.14em] text-[#111] bg-white/75 border border-black/10 px-3 py-1.5 rounded-[6px] backdrop-blur-md" x-text="`${lbIndex + 1} / ${images.length || 0}`"></div>
<!-- 关闭按钮 -->
<button @click="closeLb()" class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer" aria-label="关闭">
✕
</button>
<!-- 主舞台 -->
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
<!-- 左侧缩略图纵向列表 -->
<div x-ref="lbThumbs" class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar">
<template x-for="(im, i) in images" :key="im.id || i">
<button
type="button"
@click="showLb(i)"
class="relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200"
:class="lbIndex === i ? 'opacity-100 scale-[1.03] ring-2 ring-[#111]' : 'opacity-40 hover:opacity-85'"
>
<img :src="im.url" alt="" loading="lazy" class="w-full h-full object-cover block" />
<span
class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm"
:class="lbIndex === i ? '!bg-[#111]' : ''"
x-text="String(i + 1).padStart(2, '0')"
></span>
</button>
</template>
</div>
<!-- 左侧上下翻页辅助按键 -->
<button @click.stop="prevLb()" type="button" class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer">
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 14l6-6 6 6"/></svg>
</button>
<button @click.stop="nextLb()" type="button" class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer">
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 10l6 6 6-6"/></svg>
</button>
<!-- 左右主切换按钮 -->
<button @click.stop="prevLb()" type="button" class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer">
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<button @click.stop="nextLb()" type="button" class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer">
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>
</button>
<!-- 大图显示区域(支持点击切换放大/缩小状态) -->
<div
x-ref="lbMain"
@click.self="closeLb()"
@touchstart="handleTouchStart($event)"
@touchend="handleTouchEnd($event)"
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
:class="lbZoomed ? 'block overflow-y-auto overflow-x-hidden items-start' : 'items-center'"
>
<template x-if="images && images[lbIndex]">
<img
:src="images[lbIndex].url"
:alt="images[lbIndex].name || ''"
@click="toggleZoom()"
loading="lazy"
class="block select-none transition-opacity duration-200"
:class="lbZoomed ? 'w-full max-w-full h-auto max-h-none cursor-zoom-out' : 'max-w-full max-h-full object-contain cursor-zoom-in'"
/>
</template>
</div>
</div>
<!-- 图说标题 -->
<div class="absolute left-0 right-0 bottom-11 z-20 text-center text-black/60 font-mono text-[11px] uppercase tracking-[0.18em] pointer-events-none px-6" x-text="images[lbIndex]?.name || ''"></div>
<!-- 提示文本 -->
<div x-show="!lbZoomed" class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200">
点击图片放大 · 上下滚动查看 · Esc 关闭
</div>
</div>
</div>
<!-- Alpine.js 业务逻辑:仅负责灯箱交互,图片数据由服务端注入 -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('articleView', (images = []) => ({
images,
lbOpen: false,
lbIndex: 0,
lbZoomed: false,
// 触摸滑动记录
touchStartX: 0,
touchStartY: 0,
// 打开灯箱
openLb(index = 0) {
if (!this.images?.length) return
this.lbIndex = index
this.lbZoomed = false
this.lbOpen = true
document.body.style.overflow = "hidden"
this.scrollThumbIntoView()
},
// 关闭灯箱
closeLb() {
this.lbOpen = false
this.lbZoomed = false
document.body.style.overflow = ""
},
// 切换视图
showLb(index) {
const total = this.images?.length || 0
if (total === 0) return
this.lbIndex = (index + total) % total
this.resetZoom()
this.scrollThumbIntoView()
},
nextLb() { this.showLb(this.lbIndex + 1) },
prevLb() { this.showLb(this.lbIndex - 1) },
toggleZoom() {
this.lbZoomed = !this.lbZoomed
if (this.$refs.lbMain) this.$refs.lbMain.scrollTop = 0
},
resetZoom() {
this.lbZoomed = false
if (this.$refs.lbMain) this.$refs.lbMain.scrollTop = 0
},
// 左侧缩略图自动居中滚到视野中央
scrollThumbIntoView() {
this.$nextTick(() => {
const thumbsEl = this.$refs.lbThumbs
if (!thumbsEl) return
const activeThumb = thumbsEl.children[this.lbIndex]
if (activeThumb) {
const target = activeThumb.offsetTop - (thumbsEl.clientHeight - activeThumb.clientHeight) / 2
thumbsEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
}
})
},
// 手势处理
handleTouchStart(e) {
if (e.touches.length === 1) {
this.touchStartX = e.touches[0].clientX
this.touchStartY = e.touches[0].clientY
}
},
handleTouchEnd(e) {
if (this.lbZoomed) return
const t = e.changedTouches[0]
const dx = t.clientX - this.touchStartX
const dy = t.clientY - this.touchStartY
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
dx < 0 ? this.nextLb() : this.prevLb()
}
}
}))
});
</script>
<style>
/* 隐藏缩略图侧边栏滚动条 */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@ -7,8 +7,6 @@ import { setApiLocale } from '@/lib/api'
import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n';
import LanguageDetector from '@/components/LanguageDetector.astro'
const { title } = Astro.props as { title?: string }
const siteTitle = title ? `${title} — Portfolio Studio` : "Portfolio Studio"
@ -50,7 +48,6 @@ const headers = {
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head>
<!-- TODO <LanguageDetector /> -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

59
src/lib/looks-grid.ts Normal file
View File

@ -0,0 +1,59 @@
/**
* 走秀 / 街拍 列表页共享的 Alpine 工具(从 RunwayLooks.astro / StreetSnaps.astro 抽出)。
* 仅承载两个模块同构、且与各自筛选项无关的逻辑:加载转圈 SVG、卡片缩略图槽位、分页页码计算。
* 页面特有逻辑(品牌弹窗、各筛选维度、取数 API)仍保留在各组件内。
*/
export const SPIN_SVG =
`<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block">` +
`<g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g>` +
`<g opacity="0.55" fill="none" stroke="currentColor" stroke-width="1"><rect x="8" y="9.6" width="8" height="5.4"></rect><circle cx="12" cy="12.3" r="1.6"></circle></g>` +
`</svg>`
export interface CardSlot {
src: string
loaded: boolean
isLast?: boolean
moreLabel?: string
}
/**
* 卡片底部缩略图条带:固定 count 个槽位,缺图留空(前端用 .placeholder 占位)。
* @param images 图片数组;runway 为 string[](gallery),street 为 { image, name }[](images)。
* @param opts.moreLabel 当图片多于 count 时,在最后一个槽位上叠加 "+N" 文案的回调。
*/
export function makeCardSlots(
images: any[],
count = 5,
opts?: { moreLabel?: (extra: number) => string },
): CardSlot[] {
const list = images || []
const slots: CardSlot[] = []
for (let i = 0; i < count; i++) {
const im = list[i]
let src = ""
if (im) src = typeof im === "string" ? im : im.image || im.src || ""
const slot: CardSlot = { src, loaded: false }
if (opts?.moreLabel && i === count - 1 && list.length > count) {
slot.isLast = true
slot.moreLabel = opts.moreLabel(list.length - count)
}
slots.push(slot)
}
return slots
}
/**
* 分页页码:总数 <=7 全展示;否则围绕当前页展示 [1, ..., cur-1..cur+1, ..., total]。
*/
export function buildPageList(total: number, cur: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_: any, i: number) => i + 1)
const pages: (number | "...")[] = [1]
const start = Math.max(2, cur - 1)
const end = Math.min(total - 1, cur + 1)
if (start > 2) pages.push("...")
for (let i = start; i <= end; i++) pages.push(i)
if (end < total - 1) pages.push("...")
pages.push(total)
return pages
}

View File

@ -19,11 +19,10 @@ export const ROUTES = {
runwayLooks: '/runway-looks',
// 街拍
streetSnaps: '/street-snaps',
// 动态路由(工厂函数)
// 动态路由(工厂函数)。所有详情(走秀 lookbook / 街拍)统一收口到 /item/[id],
// 由 src/pages/{locale}/item/[id].astro 按 article→street 顺序回落渲染。
item: (id: string | number) => `/item/${id}`,
article: (id: string | number) => `/article?id=${id}`,
streetSnap: (id: string | number) => `/street-snaps/${id}`,
// runwayLooksDetail: (id: string | number) => `/runway-looks/${id}`,
streetSnap: (id: string | number) => `/item/${id}`,
} as const;
/** 服务端:生成带 locale 前缀的完整 URL(依赖 astro:i18n,遵循 astro.config)。 */

View File

@ -1,15 +0,0 @@
---
import Layout from "@/layouts/Layout.astro"
import Article from "@/components/pages/article.astro"
import { getSsrArticle } from "@/lib/api"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
export const prerender = false
const id = Astro.url.searchParams.get("id")
const { article, error } = await getSsrArticle(id, Astro.currentLocale)
---
<Layout>
<Article article={article} locale="en" error={error} />
</Layout>

View File

@ -1,15 +1,25 @@
---
import Layout from "@/layouts/Layout.astro"
import Item from "@/components/Item.astro"
import { getSsrArticle } from "@/lib/api"
import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/items/:id 链接,无需改动其他页面)
// 按需 SSR:走秀详情与街拍详情统一收口到 /item/[id]。
// 先按 article 取数(runway lookbook);取不到再回落 street snap,实现「所有详情进 /item」。
export const prerender = false
const { id } = Astro.params
const { article, error } = await getSsrArticle(id, Astro.currentLocale)
const { article, error: articleErr } = await getSsrArticle(id, Astro.currentLocale)
let snap = null
let snapErr: "missing" | "failed" | null = null
if (!article) {
const res = await getSsrStreetSnap(id, Astro.currentLocale)
snap = res.snap
snapErr = res.error
}
---
<Layout>
<Item article={article} error={error} />
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
</Layout>

View File

@ -1,15 +0,0 @@
---
import Layout from "@/layouts/Layout.astro"
import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrStreetSnap } from "@/lib/api"
// 按需 SSR:服务端按 :id 取数并渲染(保留 /cn/street-snaps/:id 链接)
export const prerender = false
const { id } = Astro.params
const { snap, error } = await getSsrStreetSnap(id, Astro.currentLocale)
---
<Layout>
<StreetSnap snap={snap} error={error} />
</Layout>

View File

@ -1,15 +0,0 @@
---
import Layout from "@/layouts/Layout.astro"
import Article from "@/components/pages/article.astro"
import { getSsrArticle } from "@/lib/api"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
export const prerender = false
const id = Astro.url.searchParams.get("id")
const { article, error } = await getSsrArticle(id, Astro.currentLocale)
---
<Layout>
<Article article={article} locale="en" error={error} />
</Layout>

View File

@ -1,15 +1,25 @@
---
import Layout from "@/layouts/Layout.astro"
import Item from "@/components/Item.astro"
import { getSsrArticle } from "@/lib/api"
import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/items/:id 链接,无需改动其他页面)
// 按需 SSR:走秀详情与街拍详情统一收口到 /item/[id]。
// 先按 article 取数(runway lookbook);取不到再回落 street snap,实现「所有详情进 /item」。
export const prerender = false
const { id } = Astro.params
const { article, error } = await getSsrArticle(id, Astro.currentLocale)
const { article, error: articleErr } = await getSsrArticle(id, Astro.currentLocale)
let snap = null
let snapErr: "missing" | "failed" | null = null
if (!article) {
const res = await getSsrStreetSnap(id, Astro.currentLocale)
snap = res.snap
snapErr = res.error
}
---
<Layout>
<Item article={article} error={error} />
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
</Layout>

View File

@ -1,15 +0,0 @@
---
import Layout from "@/layouts/Layout.astro"
import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrStreetSnap } from "@/lib/api"
// 按需 SSR:服务端按 :id 取数并渲染(保留 /en/street-snaps/:id 链接)
export const prerender = false
const { id } = Astro.params
const { snap, error } = await getSsrStreetSnap(id, Astro.currentLocale)
---
<Layout>
<StreetSnap snap={snap} error={error} />
</Layout>

257
src/styles/look-grid.css Normal file
View File

@ -0,0 +1,257 @@
/* 走秀 / 街拍 列表页共享样式(从 RunwayLooks.astro / StreetSnaps.astro 抽出)。
两模块 UI 同构:左封面 + 右标题 + 底部 5 缩略图条带 + 侧栏筛选 + 分页 + 骨架屏。
类名保留各模块原前缀(.shows* / .snaps*),仅把加载动画 keyframes 统一为 looksProg/looksShim。
由两个列表组件在前端 frontmatter `import "@/styles/look-grid.css"` 引入,本地 <style> 已删除。 */
@reference "tailwindcss";
[x-cloak] { display: none !important; }
/* ====================== 顶部 / 主体布局 ====================== */
.showsroot,
.snapsroot {
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
}
.showshead {
@apply flex justify-between items-end gap-6 py-5 pb-[26px];
}
.snapshead {
@apply flex justify-between items-end gap-6 py-5 pb-[22px];
}
.showsh1 {
@apply text-[clamp(40px,6vw,72px)] leading-[0.9] font-black tracking-[-0.02em] my-0 mb-[10px];
}
.snapsh1 {
@apply text-[clamp(34px,5.5vw,64px)] leading-[0.95] font-black tracking-[-0.02em] my-0 mb-[8px];
}
/* 已激活筛选 pills */
.showspills { @apply flex flex-wrap gap-2 pt-3.5; }
.snapspills { @apply flex flex-wrap gap-2 mb-4; }
.showspill,
.snapspill {
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
}
.showspill .x,
.snapspill .x {
@apply inline-flex w-3.5 h-3.5 items-center justify-center border-none bg-black text-white cursor-pointer text-xs leading-none;
}
.showsbody,
.snapsbody {
@apply flex gap-[36px] items-start max-md:flex-col max-md:gap-[18px];
}
/* ====================== 左 sidebar(REFINE 筛选) ====================== */
.showsaside,
.snapsaside {
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] flex flex-col max-md:static max-md:max-h-none max-md:w-full;
}
.showsaside {
@apply rounded-none transition-[flex-basis] duration-250 ease-in-out;
}
.showsaside-head,
.snapsaside-head {
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
}
.showsaside-h,
.snapsaside-h {
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none transition-opacity duration-200;
}
.showsaside-body,
.snapsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
}
/* 筛选分组 */
.showsg,
.snapsg {
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
}
.showsg-h,
.snapsg-h {
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
}
.showsg-right {
@apply inline-flex items-center gap-2.5;
}
.showsg-chev,
.snapsg-chev {
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
}
.showsg-chev.is-collapsed,
.snapsg-chev.is-collapsed { @apply -rotate-90; }
.showsg-count {
@apply text-[#999] font-normal text-[10px] tracking-[0.14em];
}
/* 复选 / 单选列表 */
.showschecks,
.snapschecks {
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin];
}
.showschecks li,
.snapschecks li {
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
}
.showschecks li.letter-head {
@apply p-[8px_0_3px] text-[10px] tracking-[0.22em] text-[#8a8a8a] cursor-default pointer-events-none border-t border-dotted border-[#e6e6e6] first:border-t-0 first:pt-1;
}
.showschecks input[type="radio"],
.snapschecks input[type="radio"] {
@apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
/* 选中态由 Alpine 状态驱动(.is-checked),不依赖原生 :checked 伪类:
@click.prevent 会阻止原生选中,伪类不可靠;实心黑圆一眼可辨 */
.showschecks input[type="radio"].is-checked,
.snapschecks input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showschecks .ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showschecks--flat li,
.snapschecks--flat li { @apply py-[4px]; }
.showschecks--flat li label,
.snapschecks--flat li label { @apply inline-flex items-center gap-2 cursor-pointer w-full; }
.showsmore {
@apply block w-full mt-[12px] text-center text-[11px] uppercase tracking-[0.22em] text-black font-bold cursor-pointer;
}
.showsclear,
.snapsclear {
@apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[#666] cursor-pointer hover:text-black transition-colors duration-150;
}
/* ====================== 右 网格 / 卡片 ====================== */
.showsmain,
.snapsmain { @apply flex-1 min-w-0; }
.snapscontent { @apply relative; }
.showsgrid,
.snapsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
}
.showsgrid.is-loading::before,
.snapscontent.is-loading::before {
content: "";
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
animation: looksProg 1s ease-in-out infinite;
}
.showscard,
.snapscard {
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px];
}
.showscard:hover .showscard-img img,
.snapscard:hover .snapscard-img img { @apply scale-104; }
.showscard-left,
.snapscard-left {
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
}
.showscard-img,
.snapscard-img {
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
}
.showscard-img img,
.snapscard-img img {
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
}
.showscard-img img.is-loaded,
.snapscard-img img.is-loaded { @apply opacity-100; }
.showsspin,
.snapsspin {
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
}
.showscard-img.is-loaded .showsspin,
.snapscard-img.is-loaded .snapsspin,
.showscard-thumb.is-loaded .showsspin,
.snapscard-thumb.is-loaded .snapsspin { @apply opacity-0; }
/* runway 专属徽标 */
.showsbadge {
@apply absolute top-[6px] right-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-white bg-black p-[3px_6px];
}
.showsflag-featured {
@apply absolute top-[6px] left-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-black bg-white p-[3px_6px] border border-black;
}
.showscard-right,
.snapscard-right {
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
}
.showscard-info,
.snapscard-info {
@apply flex-none min-w-0 flex flex-col p-[2px_2px_0];
}
.showscard-title,
.snapscard-title {
@apply text-[15.5px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] tracking-[-0.01em] line-clamp-2 h-[41px] overflow-hidden;
}
.showscard-meta,
.snapscard-meta {
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
}
/* runway 专属:卡片底部多行 meta */
.showscard-meta-foot {
@apply flex flex-col gap-[6px] h-[38px] overflow-hidden;
}
.showscard-meta-line {
@apply inline-flex items-center gap-[6px] text-[10.5px] tracking-[0.06em] uppercase text-[#444] h-[16px] leading-[16px] overflow-hidden whitespace-nowrap;
}
.showscard-strip,
.snapscard-strip {
@apply grid grid-cols-5 gap-1 mt-[10px];
}
.showscard-thumb,
.snapscard-thumb {
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
}
.showscard-thumb img,
.snapscard-thumb img {
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
}
.showscard-thumb img.is-loaded,
.snapscard-thumb img.is-loaded { @apply opacity-100; }
.showscard-thumb.placeholder,
.snapscard-thumb.placeholder { @apply bg-[#f6f6f6]; }
.showscard-thumb-more {
@apply absolute inset-0 bg-black/55 text-white text-[11px] font-bold tracking-[0.04em] flex items-center justify-center;
}
/* 骨架屏 */
.showscard--skel .showscard-img,
.showscard--skel .showscard-thumb,
.showscard--skel .showsline,
.snapscard--skel .snapscard-img,
.snapscard--skel .snapscard-thumb,
.snapscard--skel .snapsline {
@apply bg-[#efefef];
animation: looksShim 1.6s ease infinite;
}
/* ====================== 分页 ====================== */
.showspager,
.snappager { @apply flex flex-wrap items-center justify-center gap-2 mt-[38px]; }
.pg {
@apply min-w-[40px] h-[40px] px-3 border-[1.5px] border-black bg-white text-black text-[11px] uppercase tracking-[0.12em] font-bold cursor-pointer rounded-none transition-colors duration-200;
}
.pg.is-current { @apply bg-black text-white cursor-default; }
.pg:disabled { @apply opacity-30 cursor-default; }
.pg.enabled:hover { @apply bg-black text-white; }
.pg-ellipsis { @apply px-1 text-[#999] select-none text-[11px]; }
.pg-wrap { @apply inline-flex items-center; }
.showsempty,
.snapsempty {
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
}
@keyframes looksProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes looksShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }