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

This commit is contained in:
toom1996
2026-08-26 19:42:10 +08:00
parent d64a046f82
commit eee2265e15
29 changed files with 337 additions and 2506 deletions

View File

@ -0,0 +1,151 @@
# 2026-08-26 工作日志
## 新增前端 debug mode(本地联调开关)
- 用户要求:加一个 debug mode,启用后 SSR 用 8090、SSG 用 8091。
- 背景:本地 `npm run build` 时 SSG 走 `internal:8091`(Docker 网络别名)在本地解析不到,且 `astro dev` 时 `getBase()` 硬写线上域名 `seo.23cm.cn`,无法联调本地后端。本地后端实际 `8090`(公开 API) 与 `8091`(SSG 内部端口) 都在 localhost 上。
- 实现(`src/lib/api.ts`):
1. 新增 `isDebugMode()`:读 `import.meta.env.PUBLIC_DEBUG` 或 `process.env.PUBLIC_DEBUG`(`"1"/"true"` 为真),得到顶层常量 `DEBUG`(并 `export const DEBUG_MODE`)。
2. `getBase()`:`if (DEBUG) return "http://localhost:8090"` 置于 `import.meta.env.DEV` 之前(debug 盖掉线上域名)。
3. `publicBase()` / `ssrBase()`:`if (DEBUG) return "http://localhost:8090"`。
4. `BASE_API_URL`(fetchMe 用):`DEBUG ? "http://localhost:8090" : (BASE_API || "")`。
5. `ssgBase()`:`if (DEBUG) return "http://localhost:8091"`(否则 `BASE_API_SSG || http://localhost:8091`)。
- 效果:debug 开启后,SSR/运行期 → localhost:8090、SSG 构建期 → localhost:8091;关闭(默认)走生产配置(DEV→seo.23cm.cn / BASE_API / BASE_API_SSG)。
- 启用方式:构建/开发时设置环境变量 `PUBLIC_DEBUG=1`,如 `PUBLIC_DEBUG=1 npm run build` 或 `PUBLIC_DEBUG=1 npm run dev`。已在 `.env.example` 补 `#PUBLIC_DEBUG=1` 注释。
- 验证(PowerShell):
- `read_lints` api.ts 0 错误;默认构建 `npm run build` BUILD_EXIT=0(无回归)。
- SSG 开关实证:设 `BASE_API_SSG=http://127.0.0.1:9`(死端口) 构建——
- debug 开:`hot-chanel`(FAKE 假 id) 命中 0、真实 hashid `003xOZ9b` 命中 4 → 证明 SSG 强制走 localhost:8091 拉到真实数据;
- debug 关:`hot-chanel` 命中 3 → 证明回落 FAKE(死端口生效)。
- 因 getBase/publicBase/ssrBase/BASE_API_URL 共用同一 `DEBUG` 判定,SSG 侧已证 `DEBUG` 在构建期为真,故 SSR→8090 同步生效。
- ⚠️ SSR(8090) 当前是否运行取决于用户本地后端;debug 构建时若 8090 没跑,SSR 数据取数会 try/catch 回落样例(页面不崩)。SSG(8091) 本地实测可达。
## 前端接口请求接入 i18n(?locale= 透传,13:10)
- 用户疑问:接口返回数据没有语言标识、没法判断该渲染哪种语言。排查结论:**后端早已实现 i18n**,无需前端改契约。
- 后端现状(d:/project/backend_v2):DB 已加 `brand.name_en/name_cn`、`brand_runway.title_en/title_cn/description_en/description_cn`(scripts/i18n/main.go 迁移回填);`service/locale.go` 的 `pickLocale(en,cn,locale)` 按 locale 选列、缺翻译回落另一语言、出参仍是单字段(name/title/brand_name 不变);`handler` 的 `parseLocale(c)` 优先级:`?locale=cn|en` > `Accept-Language` 头 > 默认 `en`。公开接口(/api/public/articles、/articles/:id、/brands) 与 SSG 内部接口(/api/ssg/brands、/brands/popular) 全部读 locale。
- 前端改动(`src/lib/api.ts`):
1. 新增 `setApiLocale(locale)` + 模块级 `_ssrLocale` + `currentLocale()`(浏览器端用 `getLocaleFromUrl(window.location.pathname)` 从 URL path 推断 `/cn/`→cn,其余→en;服务端回落 `_ssrLocale` 或默认 `en`);`withLocale(params, override?)` 统一把 `?locale=` 追加进 query。
2. 所有请求原语统一带 locale:`request()`(getBrands/getArticlesRaw)、`fetchArticleById(id, base?, locale?)`、`getSsrArticle(id, locale?)`、`fetchArticlesList`(客户端)、`fetchColors`(客户端)、`fetchMe`、`ssgRequest`(构建期,token+locale 组合)。删除已内联的 `withToken` 死代码。
3. `currentLocale()` 客户端分支依赖 `astro:i18n` 的 `getLocaleByPath`(经 `@/i18n/utils.getLocaleFromUrl`),客户端 bundle 可用(已 build 通过验证)。
- 注入点:
- `src/layouts/Layout.astro` frontmatter 顶部 `setApiLocale(locale)`(每页经 Layout 渲染,覆盖所有子组件服务端取数)。
- `src/pages/shows.astro` 自身 frontmatter 在取数前 `setApiLocale(locale)`(该页在前置 frontmatter 就 `getBrands`/`getSsgPopularBrands`,早于 Layout,必须单独注入)。
- 4 个 SSR 详情页 `src/pages/{en,cn}/article.astro`、`src/pages/{en,cn}/item/[id].astro`:`getSsrArticle(id, Astro.currentLocale)` 显式传 locale(按需渲染并发安全,避免全局 `_ssrLocale` 串语言)。
- 验证:
- `read_lints` api.ts / Layout.astro / shows.astro 均 0 错误;默认 `npm run build` BUILD_EXIT=0。
- 后端契约实证:本地 8090 品牌接口 `?locale=cn`→`阿德达舍`、默认→`A Detacher`;本地 8091 SSG popular `?locale=cn`→`香奈儿 安普里奥·阿玛尼 克里斯汀·迪奥`、默认→`CHANEL Emporio Armani Christian Dior`。链路打通。
- 约定(重要,跨会话):**全站 API 请求一律带 `?locale=`,语言由 URL path 决定,不依赖浏览器 Accept-Language**(因站点语言由 URL 前缀决定,与浏览器语言无关)。新增任何取数函数都必须经 `request()`/`ssgRequest()`/`fetchArticlesList` 等已带 locale 的原语,不要再裸写 fetch。
## 后端 title_cn 规则回填(14:50)
- 用户批准:把 `brand_runway.title_cn` 从空补齐,让中文走秀页有中文标题(之前 `title_cn` 在迁移里被故意留空,中文页 `pickLocale` 回落英文 title)。
- 探查(脚本 scripts/diag_title,已删):title 本质是「季节/品类描述」(Vogue 格式),品牌名在 JOIN 的 brand_name 单独给;`collection_type`/`season`/`year` 早已解析回填。分布干净:无 year=0、无空 collection_type、无异常 season;合计 25,206 行。
- 方案:**纯规则生成,零外部翻译 API,可幂等重跑**。映射:
- resort→`{year} 度假系列`,pre_fall→`{year} 早秋系列`;
- rtw→`{year} {春夏|秋冬}成衣`,menswear→`{year} {春夏|秋冬}男装`,couture→`{year} {春夏|秋冬}高定`(无 season 的 rtw 回落 `{year} 成衣`)。
- 实现:新增 `d:/project/backend_v2/scripts/backfill_title_cn/main.go`(单条 `UPDATE ... CASE CONCAT(year,...)`,WHERE is_deleted=0,整表覆盖重跑无害);并把该 CASE 语句补进 `scripts/sql/002_i18n_columns.sql` 作为「第 5 步」评审底稿。
- 执行实证:本地 MySQL(root:root@127.0.0.1:3306/db) 跑通,updated rows = 25206,title_cn 非空率 0→100%。
- 端到端验证:临时 `go build` 启动 backend_v2(cmd/server) 于 8090,打 `/api/public/articles`——
- `?locale=cn`:`brand=芬迪 | title=2016 春夏成衣 / 2016 早秋系列 / 2016 秋冬男装 / 2016 秋冬成衣 / 2017 度假系列 / 2017 春夏男装`;
- 默认:`brand=FENDI | title=Spring 2016 Ready-to-Wear / Pre-Fall 2016 / ...`。
- 证明 `article_service.go` 的 `pickLocale(r.TitleEn, r.TitleCn, locale)` 已自动生效,前端 cn 页(articles/article.astro,已带 ?locale=)将直接显示中文标题,无需前端改动。验证后已停掉临时后端、释放 8090。
- 未做(范围外):`description_cn` 仍是空 → 中文页摘要/描述回落英文(描述是散文,规则无法生成,需真实翻译)。
## 品牌搜索「中文昵称搜不到」修复(15:10)
- 现象:中文界面搜「猿人头」找不到 A Bathing Ape。用户期望展示「A Bathing Ape(猿人头)」。
- 根因(已用一次性脚本 scripts/diag_brand 实查,已删):数据其实有 `name_cn="猿人头"`(brand id=3100,`show_name="A Bathing Ape/猿人头"`);后端 `brand_repository.go` 搜索本就匹配 `name_en OR name_cn`。真正卡点:**默认 `only_with_articles=1`** 先用子查询把「无走秀档案的品牌」全滤掉,而 brand 3100 的 `brand_runway` 档案数=0 → 关键词搜索落空。
- 修复(前端+后端):
1. 后端 `brand_repository.go` `brandFilterScope`:关键词搜索时跳过 only_with_articles 子查询(`if q.OnlyWithArticles && q.Keyword == "" && q.Letter != "OTHER"`)。语义:按名字找品牌本就不该被「必须有档案」限制。
2. 前端 `src/lib/api.ts` `getBrands`:关键词搜索时强制 `only_with_articles=0`(双保险,使未重启的后端也立即生效);并改映射返回 `nameCn` / `display`。
3. 后端 `dto.PublicBrand` 新增 `name_en` / `name_cn` 两字段(service 填充 `b.NameEn`/`b.NameCn`),因原 `name` 是 `pickLocale` 二选一、不含双语,前端拼不出「英文(中文)」。
4. 前端 `RunwayLooks.astro` 弹窗品牌名改用 `b.display || b.name`,`display`=中文语境下 `name_en + " (" + name_cn + ")"`,否则 `name_en`。
- 端到端验证:临时 `go build` 起 backend_v2 于 8090,请求 `/api/public/brands?keyword=猿人头&only_with_articles=0&locale=cn` → 返回 `{"name":"猿人头","name_en":"A Bathing Ape","name_cn":"猿人头","article_count":0}`,即搜得到且字段齐备。验证完毕已停后端、释放 8090、清理临时脚本与二进制。
- 注意:brand 3100 档案数=0 是数据现实(爬取遗漏/该品牌无走秀收录),搜索能找到它,但选定后走秀列表会空——属数据层问题,不在本次范围。全站 23,375 品牌仅 2,025 个(8.7%)有 `name_cn`。
## 品牌名不再展示中文(15:49)
- 用户反馈:「前端品牌还是不要展示中文名称了吧,感觉有点low… 标题什么的倒是可以留着」。即品牌名保持英文观感,走秀标题(中文页 title_cn)保留中文。
- 根因:之前品牌名也被后端 `pickLocale` 随 locale 本地化了(`article_service.go` 的 `BrandName`、`brand_service.go` 的 `Brand` 在 `?locale=cn` 时返回中文;前端 `getBrands` 的 `display` 又拼了「英文名 (中文名)」双语)。导致 `/cn/` 页文章卡片、精选品牌、RunwayLooks 弹窗都出现中文品牌名。
- 修改:
1. 后端 `d:/project/backend_v2/internal/service/article_service.go`:列表(`:54`)与详情(`:114`)的 `BrandName` 由 `pickLocale(En, Cn, locale)` 改为 `pickLocale(En, Cn, "en")`——强制优先英文,仅英文缺失时回落中文避免留空;`Title/Summary/Description` 维持按 locale。
2. 后端 `d:/project/backend_v2/internal/service/brand_service.go`:精选 `Brand`(`:87`) 同改为 `pickLocale(En, Cn, "en")`。
3. 前端 `d:/project/frontend_v2/src/lib/api.ts`:`getBrands` 的 `display` 改为 `display = nameEn`(去掉双语拼接),并删除已无用的 `const loc = currentLocale()`。
- 验证:后端 `go build ./internal/service/` 通过(exit 0);前端 `read_lints` api.ts 0 错误。品牌名全站统一英文;标题仍随 locale(中文页显示 title_cn)。
- 边界:`dto.PublicBrand.Name`(品牌列表 `name` 字段)仍为 `pickLocale` 二选一,但前端 `getBrands` 始终用 `nameEn` 覆盖,不显示中文,故未改该字段(避免扩大 API 契约改动面)。
## 后端接口命名复盘(15:59,仅分析未改动)
- 用户觉得后端接口命名乱,要求按现有网站设计给命名方案。已产出提案文档(artifact: api_naming_proposal.md),未改任何代码。
- 现状路由:公开 :8090=`/api/health`、`/api/public/articles`、`/api/public/articles/:id`、`/api/public/brands`、`/api/auth/{register,login,logout,me}`;构建期内部 :8091=`/api/ssg/brands`、`/api/ssg/brands/popular`、`/api/ssg/articles/ids`。
- 识别出的 6 个痛点:① 资源名前后端错位——后端 `articles`=走秀,前端页面已叫 `/shows`;② `public` vs `ssg` 把同类公开数据拆两世界,`ssg` 泄漏 Astro 构建机制;③ 同概念双命名 `popular`(SSG 子路径) vs `featured=1`(public 参数);④ 幽灵接口 `/api/public/colors` 后端无实现、前端靠降级兜底;⑤ 一个 `/public/articles` 端点有 4 个前端 getter(getArticlesRaw/getArticles/fetchArticlesList/getYears);⑥ 缺 `brands/:id` 详情与缺 `/api/v1` 版本前缀。
- 推荐方案:资源名对齐前端路由(`articles`→`shows`)、`ssg`→`internal`、`popular`统一为`featured`、`/api/v1` 前缀、抽 `ENDPOINTS` 常量表、合并 articles getter、删/实现 colors。待用户拍板 5 个问题后落地。
## 落地 /api/v1 前缀(16:13)
- 用户要求:先给所有接口加 `/api/v1` 前缀;并确立命名哲学「一个功能一个接口,尽量不通过参数复用接口」,让我把接口全集确定出来,他逐条确认。
- 改动:
- 后端 `internal/router/router.go`:`r.Group("/api")` → `/api/v1`(连带 health/public/*/auth/* 全部前缀)。
- 后端 `internal/router/ssg.go`:`/api/ssg` → `/api/v1/ssg`。
- 前端 `src/lib/api.ts`:`request()` 原语 `getBase()+"/api"` → `"/api/v1"`;`fetchArticleById`/`fetchArticlesList`/`fetchMe` 3 处手动 fetch 路径加 v1;`getSsgPopularBrands`/`getSsgBrands`/`getSsgArticleIds` 3 处字面量 `/api/ssg` → `/api/v1/ssg`;同步头部/分区注释。
- 前端 `src/components/pages/AuthForm.astro`:登录注册提交 `/api/auth/*` → `/api/v1/auth/*`。
- 部署:`nginx.conf` 404 拦截 `/api/ssg/` → `/api/v1/ssg/`;`docker-compose.yml` 健康检查 `…/api/public/brands` → `…/api/v1/public/brands`;`DEPLOY.md` 同步说明。
- 验证:后端 `go build ./...` 通过(BUILD_EXIT=0);前端 `read_lints` api.ts / AuthForm.astro 均 0 错误。
- 已产出目标接口全集清单(artifact: api_endpoints_catalog.md),按「一功能一接口」原则拆出 shows/brands/内部 各独立端点(A1–A6/B1–B5/C1/D1–D4/E1–E4/F1),并标注旧名作废映射。待用户确认 3 个总决命名(shows vs articles、internal vs ssg、featured vs popular)后逐条落地。
## 前端未用接口清理(16:20)
- 用户要求:先查前端实际没用到的接口,在后端去掉,再列精简清单确认。
- 排查结论(前端调用点全量核对):
- `/api/v1/ssg/articles/ids`:仅 `getSsgArticleIds` 定义,全站无任何 `getStaticPaths`/页面调用(详情页走 `getSsrArticle` 按需 SSR)→ 死端点。
- `/api/v1/auth/logout`:前端退出按钮只做 `clearSession()`+刷新,从不 POST 该接口 → 死端点。
- 附带死代码:前端 `getYears`、`getSsgArticleIds`、`fetchColors`(colors 后端本无实现,已不在 api.ts)三个 getter 从未被调用。
- 已删除:后端 `router.go` 的 `auth.POST("/logout")`、`ssg.go` 的 `ssg.GET("/articles/ids")`;前端 `api.ts` 的 `getYears` 与 `getSsgArticleIds` 函数。
- 验证:后端 `go build ./...` 通过(BUILD_EXIT=0);前端 `read_lints` api.ts 0 错误。
- 剩余后端接口(供用户确认):`/api/v1/health`(infra) + `/api/v1/public/articles`、`/api/v1/public/articles/:id`、`/api/v1/public/brands`、`/api/v1/auth/register|login|me`、`/api/v1/ssg/brands`、`/api/v1/ssg/brands/popular`,共 9 个功能端点。
- 注:后端 `AuthHandler.Logout` 与 `SSGHandler.ArticleIDs` 两个 handler 方法因路由删除已成为孤儿(Go 编译无误),可后续一并删除。
## 重构 SSG 首页接口为 /api/v1/ssg/index/runway(16:48)
- 用户要求:把首页 runway 的 SSG 数据接口从「按品牌热门」改为「首页(/index/)runway 区块多模块数据」,已命名为 `/api/v1/ssg/index/runway`,hotBrand 为其中一个模块;接口尽量写死、避免多余参数。
- 后端:
- `ssg.go`:`ssg.GET("/brands/popular", opt.SSG.Popular)` → `ssg.GET("/index/runway", opt.SSG.IndexRunway)`。
- `ssg_handler.go`:删 `Popular` 方法,新增 `IndexRunwayData{HotBrand []dto.PopularBrand json:"hotBrand"}` 结构体 + `IndexRunway` 方法;写死 `hotBrandLimit=20`、去掉 `limit` 查询参数、不读任何入参;调用 `h.brands.Popular(ctx, 20, parseLocale(c))`。`dto.PopularBrand` 字段 id/article_id/brand/cover/title 与前端 Index.astro 用法吻合。
- 前端:
- `api.ts`:`getSsgPopularBrands(limit)` → 新增 `getSsgIndexRunway(): Promise<{hotBrand: SsgPopularBrand[]}>`,返回 `j?.data ?? {hotBrand:[]}`(沿用 response.Data 的 data 信封)。
- `Index.astro`:改用 `getSsgIndexRunway().hotBrand` 作为 `popular`(galleryItems 映射 b.cover/article_id/brand/title 不变)。
- `RunwayLooks.astro`(/runway-looks 独立页,非首页):因该 SSG 接口已限定 /index/ 专属,改为复用侧栏已拉取的 featured 品牌 `sidebarBrands.slice(0,12)` 派生 hotBrands,移除对首页 SSG 接口的跨页耦合与 `getSsgPopularBrands` 导入。
- 删除旧 `getSsgPopularBrands`(全仓 0 引用)。
- 验证:`go build ./...` 通过(BUILD_EXIT=0);前端 `read_lints` api.ts / RunwayLooks.astro 均 0 错误;全仓 grep 无 `getSsgPopularBrands` / 旧路径残留。
- 设计约定(重要):`/api/v1/ssg/index/runway` 为「首页 runway 区块数据」聚合端点,按模块拆字段(当前仅 hotBrand);后续新增首页 runway 模块(如 featuredShows/latestShows)直接在 `IndexRunwayData` 加字段,前端按需读取,互不干扰。该端点零查询参数、数量写死。
## 删除孤儿 handler(16:32)
- 用户确认「没用到的都删掉」:删掉 `AuthHandler.Logout`(auth_handler.go)与 `SSGHandler.ArticleIDs`(ssg_handler.go)两个方法体及其注释,logout 不恢复。
- 全仓 grep 确认无测试/他处引用后删除;`go build ./...` 通过(BUILD_EXIT=0)。
- 余留:`SSGHandler.articles` 字段在 ArticleIDs 删除后不再被读取(Go 允许未用结构体字段,编译无碍)。如需极致干净,可后续移除该字段及 `NewSSGHandler` 的 articles 参数与 main 装配——属可选清理,未动以免扩大改动面。
## 抽出 IndexService(17:00)
- 用户质疑:`/api/v1/ssg/index/runway` 的实现挂在 `service.BrandService.Popular` 上(ssg_handler.go 调 `h.brands.Popular(...)`),首页区块数据不该借用品牌服务。要求新建专属 service。
- 命名:采用 `IndexService`(对应 URL 的 `/index/` 段「首页区块数据聚合服务」;备选 `HomeService` 偏口语被否)。文件 `internal/service/index_service.go`。
- 改动:
- 新建 `service/index_service.go`:`IndexService` 接口(`Runway(ctx, locale) (IndexRunwayData, error)`)+ `IndexRunwayData{HotBrand []dto.PopularBrand}`(从 handler 迁入)+ `indexService` 结构体(持 `repository.BrandRepository`)+ `NewIndexService(brands)`;`Runway` 写死 `hotBrandLimit=20`,复用 `brands.PopularWithCover` 并保持「品牌名强制英文 / 标题按 locale」映射。
- `handler/ssg_handler.go`:SSGHandler 增 `index service.IndexService` 字段、构造签名 `NewSSGHandler(brands, index)`;删 handler 内 `IndexRunwayData` 定义;`IndexRunway` 改为 `h.index.Runway(...)`;并顺手删掉自 ArticleIDs 删除后空置的 `articles` 字段与参数。
- `service/brand_service.go`:从接口与实现中移除 `Popular`(已无调用方,逻辑整体迁至 IndexService)。
- `cmd/server/main.go`:新增 `indexSvc = service.NewIndexService(brandRepo)`;`NewSSGHandler(brandSvc, articleSvc)` → `NewSSGHandler(brandSvc, indexSvc)`(`articleSvc` 仍被 `NewArticleHandler` 使用,未变未用)。
- 验证:`go build ./...` 通过(exit 0);`read_lints` ssg_handler.go 0 错误;全仓 grep 仅 `index_service.go` 含 `IndexRunwayData`/`Popular` 残留(repository.PopularWithCover 仍被新 service 复用)。
- 架构约定(稳定):首页区块数据由 `IndexService` 专属承载,未来 `/index/*` 各区块(runway/inspiration…)都归它;`IndexRunwayData` 按模块加字段扩展,handler 只做转发。`SSGHandler` 现仅依赖 `BrandService`(供 brands 索引)+ `IndexService`(供 index/runway)。
## SSG 品牌接口重做:/ssg/brands → /ssg/brands/hot(17:45)
- 背景:用户发现 `/api/v1/ssg/brands` 唯一消费者(brands.astro 页面→组件)在 i18n 重构中已成孤儿链 → 确认品牌索引页下线,死端点不改造、直接删。用户改为要求:新写一个 SSG 方法取热门前 30 品牌,替换 RunwayLooks 里的 getBrands;侧栏 filter 展示前 10,弹窗 HOT 展示全部;顺带清掉没用到的 JS 函数。
- 后端(backend_v2):
- `service/brand_service.go`:新增 `Hot(ctx, limit, locale)`——FeaturedIDs("images", limit) 取热度排名 → repo.List(按 id 集合) → **按热度排名重排**(repo.List 固定 name_en ASC,必须重排否则「前 10」不是真前 10 热)→ 映射。抽出公共 `toPublicBrands(items, counts, locale)` helper,List 复用。
- `handler/ssg_handler.go`:删 `Brands`(连带 strings/dto import),新增 `HotBrands`——固定 `hotBrandLimit=30` 写死,**零查询参数**(符合「一个功能一个接口」原则),`response.Data` 返回。
- `router/ssg.go`:`/brands` → `/brands/hot`。SSG 分组现仅 `brands/hot` + `index/runway`。
- 前端(frontend_v2):
- `api.ts`:删 `SsgBrand` + `getSsgBrands`;新增 `getSsgHotBrands(): Promise<BrandEntry[]>`(ssgRequest `/api/v1/ssg/brands/hot`,display=nameEn 保持英文观感)。`getBrands` 保留——弹窗搜索(modalSearch)/字母(modalPickLetter)运行时仍用。
- `RunwayLooks.astro`:frontmatter 数据源换 `getSsgHotBrands()`(30 条热度顺序),删 sidebarBrands/allBrands 数据岛(`showsbrands-data`);删死代码 `visibleBrandItems`/`hasMoreBrands`/`brandsExpanded`(模板零引用);pills findName 改 hotBrands + modalResults 兜底;modalLoadHot 去掉 allBrands.slice(0,50) 兜底直接展示 30 条;侧栏 filter 模板原有 `hotBrands.slice(0,10)` 不变。
- 删除孤儿组件 `src/components/pages/brands.astro`。
- 验证:`go build ./...` exit 0;前端 `npm run build` exit 0;read_lints api.ts / RunwayLooks.astro 均 0;全仓 grep 无 allBrands/getSsgBrands/SsgBrand/sidebarBrands/visibleBrandItems 残留。本地 :8091 跑的是旧进程(新路由 404),重启后端后生效。
- MEMORY.md 本次同步精简:品牌索引页下线、SSG 分组现状、前后端路径修正(frontend_v2 / backend_v2)。
## 修复 Hot 未按热度排名的 bug(19:15)
- 现象:`/api/v1/ssg/brands/hot` 返回 A-Z 字母序、article_count 全 0(用户发现「没按热度排」)。
- 根因:`Hot` 借道 `repo.List(q, restrictIDs)`,但仓储 `brandFilterScope` 里 **restrictIDs 仅在 `q.Featured==true` 时才拼进 WHERE**;Hot 构造的 BrandQuery 没设 Featured → `id IN (热门30)` 完全没生效,实际返回全量品牌字母序第一页;counts map 只含热门品牌 id → 查全空 → article_count 全 0。
- 排查路径:先写 `scripts/diag_hot/main.go` 复现 FeaturedIDs SQL 验证数据源正常(CHANEL 11106 图第一,梯度清晰,排除数据问题)→ 再 curl 8091 实测发现返回与热度排名完全对不上 → 定位到 scope 的隐藏耦合。
- 修复(符合「一个功能一个接口」哲学,不用 featured 假旗标复用 List):
- `repository/brand_repository.go`:接口+实现新增 `ListByIDs(ctx, ids)`(`WHERE is_deleted=0 AND id IN ?`,不保证顺序,调用方重排)。
- `service/brand_service.go` `Hot`:改用 `ListByIDs` 取行 → 按FeaturedIDs 顺序 map 重排 → 映射。删掉 BrandQuery 借道逻辑。
- 验证:`go build ./...` exit 0;重启 8091 dev 进程(go run 临时 exe,杀旧 PID + `Start-Process go run ./cmd/server` 后台拉起,日志 dev-ssg.log/err)后实测返回 CHANEL(151)→Emporio Armani(110)→Dior(147)→Valentino(107)→GUCCI(109)…与热度排名完全一致;8090 公开引擎 200 正常。
- 教训:repo.List 的 restrictIDs 是 featured 专属语义,其他场景要按 id 集合取行一律走 `ListByIDs`,别再踩。

View File

@ -1,58 +1,37 @@
# 项目长期记忆(Project MEMORY.md) # 项目长期记忆(Project MEMORY.md)
## 模块策略(重要) ## 模块策略(重要)
- 站点按模块逐个完善。当前只上线「发布会模块」:首页 index + 品牌索引 brands + 走秀列表 articles + 走秀详情 article。 - 站点按模块逐个完善。当前上线「发布会模块」:首页 index + 走秀列表/详情(RunwayLooks = 走秀档案页,articles/shows 页面已在 i18n 重构中重组)。
- **所有走秀页 `/shows`**(新建 2026-08-08):全局走秀档案(区别于 `/articles` 的品牌档案视图 ?brand_id)。基于 articles.astro,标题「All Runway」,保留系列/季节/品牌筛选 + 三视图(Grid/Magazine/Timeline),新增分页 Load More(page/total/busy,append 模式,后端 `?page=&size=30&with_images=5`)。首页 sec-more「全部走秀 →」指向 /shows。 - **品牌索引页已下线**(2026-08-26 确认):`src/pages/brands.astro` 及孤儿组件 `src/components/pages/brands.astro` 均已删除;品牌数据现在只通过走秀页侧栏 filter + 品牌弹窗呈现(热门 30 条走 SSG 接口,搜索/字母走运行时 `/api/v1/public/brands`)。
- 其余模块(Portfolio / Latest Projects / About / Contact / 登录注册)为「暂未上线」占位页:直接访问旧 URL 或点击导航旧链接均渲染 ComingSoon 占位;**导航(header)保持原样未改动**(用户 2026-08-07 明确「header 别动」),门禁完全靠占位页实现。 - 其余模块(Portfolio / Latest Projects / About / Contact / 根路径登录注册)为 ComingSoon 占位页;**导航(header)保持原样未改动**(用户 2026-08-07 明确「header 别动」),门禁完全靠占位页实现。`/en/login`、`/en/register` 已是真实页面(`AuthForm.astro`,mode login/register)。
- **首页 tabbar**(index.astro, id="tabbar"):仅「发布会(backstage)」上线(默认激活 + Live 徽标,承载 getShows(8) SSR 真实卡片);「灵感(shows)/社媒(events)/街拍(street)」标 `data-soon="true"` + Soon 灰徽标,点击仅显示 ComingSoon 占位、不再请求后端;「品牌」tab 仍为指向 /brands 的有效链接(属已上线模块,未屏蔽)。
- 占位实现:src/components/ComingSoon.astro(编辑风,props: module / moduleZh / note;两个按钮「浏览走秀档案 →」「返回首页」)。portfolio / latest-projects / about / contact 仍为 ComingSoon 占位页。
- **登录/注册(2026-08-25 更新)**:`/en/login`、`/en/register` 已做成真实编辑风页面(套 Layout + 新建 `src/components/pages/AuthForm.astro` 通用表单组件,`mode:'login'|'register'`,客户端 POST `/api/auth/login|register`,用 `saveSession` 写 `fa_token`/`fa_user`)。根 `/login`(src/pages/login.astro) 与 `/cn/login` 等**仍是 ComingSoon 占位**(未动,属当时范围外);导航 Login/Signup 在 en 语境下现指向真实的 /en/login、/en/register。
## 架构总览 ## 架构总览
- 前端:/d/project/test/test — Astro SSG + Tailwind v4。构建 `PUBLIC_API_BASE=http://localhost:8090 npm run build`;预览 `python -m http.server` 托管 dist/。 - 前端:**d:/project/frontend_v2** — Astro SSG + Tailwind v4 + Alpine.js。`npm run build`(SSG 取数走 ssgRequest,失败回落空/假数据不影响构建)。
- 后端:/d/project/admin/backend — Go + Gin + GORM(MySQL),:8090。路由分后台 /api/* 与公开只读 /api/public/*。 - 后端:**d:/project/backend_v2** — Go + Gin + GORM(MySQL),公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`),SSG 内部引擎 :8091(仅 127.0.0.1,`/api/v1/ssg/*`,SSGToken 中间件,nginx 不反代)。
- 数据层:前端 src/lib/data.ts 统一从 /api/public/* 拉数据;后端不可达时回落内置示例。 - **SSG 分组现状(2026-08-26)**:只剩两个端点——`/api/v1/ssg/index/runway`(首页 runway 区块,`IndexService.Runway`,hotBrand 20 条)+ `/api/v1/ssg/brands/hot`(走秀页热门品牌,`BrandService.Hot`,固定 30 条、按图片总数热度顺序,零参数)。`/ssg/brands`(全量 A-Z)与 `/ssg/articles/ids` 均已删除。
- **接口 i18n 约定**:后端按 `?locale=cn|en` 选列返回(pickLocale 缺翻译回落另一语言);前端取数统一复用带 locale 的原语(request/ssgRequest 等),**绝不裸写 fetch;语言以 URL 前缀为准**。
- 走秀标题 `title_cn` 已规则回填(backend_v2 `scripts/backfill_title_cn/main.go`,幂等);`description_cn` 为空白属预期,中文页回落英文。
## 关键约定 ## 关键约定
- 视觉语言:黑白极简编辑风(monospace + Georgia 衬线大标题 + 细线),HBX / 时尚档案调性。 - 视觉语言:黑白极简编辑风(monospace + Georgia 衬线大标题 + 细线),HBX / 时尚档案调性。字体全走 Tailwind 体系,禁止自写字体栈。
- 字体:全站走 Tailwind 体系(font-sans / font-mono / font-serif),禁止自写截断字体栈。新增页面复用项目字体变量。 - **品牌名前端一律只显示英文**(用户偏好 2026-08-26:「前端品牌不要展示中文名称,标题可以留着」):api.ts 映射 `display = nameEn`;走秀标题/描述仍按 locale 选列。
- CSS 写法:能用 @apply 就 @apply;`<style>` 顶部加 `@reference "tailwindcss";`。仅保留手写:@keyframes、渐变 shimmer、!important、双时长 transition。Tailwind v4 已移除零值类(mt-0 等),零值边距保持手写。 - **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`)。
- 导航(Layout.astro):**保持原样未改动**(Portfolio / Latest Projects / About / Contact / Login / Signup),含页脚 Categories + Color Studio Tools + About/Contact 链接。门禁仅由 6 个 ComingSoon 占位页承担,导航不删任何链接(用户要求 header 别动)。 - CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`。Tailwind v4 无零值类。
- 导航(Layout.astro)保持原样,门禁仅由占位页承担。
## Layout 陷阱(重要,2026-08-12 踩坑) ## Layout 陷阱(2026-08-12)
- **Layout.astro** 把 `<slot />` 包裹在 `<div class="flex-1 flex flex-col">`(flex column 容器)。 - Layout 把 `<slot />` 包在 `flex-1 flex flex-col` 容器里;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 内容多少决定宽度、页面抖动)。用 `w-full`(可配 `max-w-[xxx]` + `mx-auto`)。
- **禁用 `m-auto`** 作为页内根容器(`<div class="xxx m-auto">`):它在 flex column 容器中作为 flex item 的 cross-axis `margin: auto`,会触发 **shrink-to-fit**(content 决定宽度),导致:
- 内容空/少时根容器收缩到极窄(骨架 24 张空 div → .showsroot 仅 626px / 主区 350px / 卡片 164px)。
- 内容多时根容器被撑大(真实卡片有 img/文字 → 1042px 「看起来正常」)。
- 同页面在不同内容状态下宽度剧烈变化 = 抖动。
- **正确做法**:用 `w-full`(强制 100% 父容器,常配 `max-w-[xxx]`),或 `w-full mx-auto`(保留大屏居中)。
- 适用范围:所有 Layout 内的页内根容器(包括 showsroot / blogs / about 等),**避免 `m-auto` 单独使用**。
## Astro frontmatter 与 `<script>` 作用域隔离(通用坑) ## Astro `<script>` 两大坑
- **frontmatter 里的 `import` / 变量,在 `<script>`(客户端打包脚本)里拿不到**。两者是分别打包的:frontmatter 是服务端渲染期,`<script>` 是独立客户端 bundle。脚本里直接用 frontmatter 引的 `ROUTES` / `clientHref` 等,esbuild 不报错(当全局变量),但 **TS 语言服务报 `ts(2304) 找不到名称`**,运行时更是 `ReferenceError`。 - **作用域隔离**:frontmatter 的 import/变量在打包型 `<script>` 里拿不到(运行时 ReferenceError);脚本里要用什么就在脚本顶部重新 import。
- **正确做法**:脚本里要用什么,就在该 `<script>` 顶部重新 `import`。例:`import { clientHref, ROUTES } from "@/lib/routes"`。 - **不做模板插值**:打包型 `<script>` 里 `{t('...')}` 不会被执行 → `t is not defined`。预翻译用 `define:vars={{ hiLabel }}` 注入;模板区 `{t()}` 正常。
- 排查方法:在 `<script>` 块里 `import` 行 grep,确认用到的标识符都已在脚本作用域内 import。本仓库曾在 RunwayLooks.astro、pages/shows.astro、pages/cn/shows.astro 三处脚本用了 `clientHref`/`ROUTES` 却只在 frontmatter import,统一补 import 修复(2026-08-24)。 - 打包后脚本在 `_astro/*.js` 而非内联;查 dist 产物用 search_content(ripgrep),PowerShell 无 `Select-String -Recurse`。
## i18n 脚本内翻译陷阱(2026-08-23 踩坑) ## 走秀数据分类(brand_runway)
- **Astro 对「打包型 `<script>`(module)不做 `{...}` 模板插值**。若在前端 `<script>` 里写 `slot.innerHTML = \`...{t('Hi')}...\``,构建后 `t('Hi')` 会原样留存在打包 JS 中 → 运行时 `t is not defined` 报错、脚本中断。 - 三维分类字段:`collection_type`(rtw/menswear/couture/resort/pre_fall)、`season`(spring/fall/null)、`season_code`(SS26/FW25…派生)。靠 title 规则回填(幂等,25,206 行)。
- **正确做法**:在 frontmatter 用 `const hiLabel = t('Hi')` 预翻译,再用 `<script define:vars={{ hiLabel, logoutLabel }}>` 把字符串注入脚本,脚本内用 `${hiLabel}` 引用。define:vars 会把 `const hiLabel="..."` 注入到该 page 的脚本作用域(en 得英文、cn 得中文)。 - 公开列表 `?collection_type=&season=&season_code=&year=` 筛选;前端走秀页有系列/季节/年份 chips。
- 模板(`<template>` HTML 区,非 `<script>`)里的 `{t('...')}` 插值正常可用,仅 `<script>` 受限。 - 地理/城市维度:用户明确暂不做。
- 验证坑:`Select-String -Recurse` 在 PowerShell 不存在(参数名不符);查 dist 产物改用 ripgrep 工具(search_content,递归默认开启)或 `Get-ChildItem -Recurse | Select-String`。 - 分布:rtw 15203 / menswear 4061 / couture 672 / resort 2894 / pre_fall 2376。
- 注意:打包后 `<script>` 内容不在 HTML 内联,而在 `_astro/*.js`;用 `--` 类 UX 标志(如 `activeThemePalette` 控制的 Reset B&W Theme)在对应 prop 为空时不会渲染,静态 HTML 搜不到属正常。
## 部署(Docker + Gitea Actions) ## 部署(Docker + Gitea Actions)
- 仅 brands.astro 构建期 SSR 取数(用 API_SSR);其余页面运行时相对 /api 拉取(nginx 反代 backend:8090)。 - 交付文件:前端 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`。
- 交付文件:前端 Dockerfile / nginx.conf / docker-compose.yml / deploy.sh / .gitea/workflows/deploy.yml / .env.example / DEPLOY.md;后端 Dockerfile。
## 走秀数据分类(brand_runway,已实现 2026-08-08)
- **三维分类字段**(已加,靠 title 解析回填,无需外部数据):
- `collection_type`:rtw / menswear / couture / resort / pre_fall
- `season`:spring / fall / null(Resort/Pre-Fall 为单季,season=null)
- `season_code`:SS26 / FW25 / RES26 / PF25(派生,用于排序/展示)
- **回填**:main.go `backfillRunwayClassification()` 单条 `UPDATE ... CASE` 解析 title(Vogue 格式 `Spring/Fall 2016 Ready-to-Wear`、`Pre-Fall 2016`、`Resort 2017`、`Fall 2016 Couture`、`Spring 2005 Menswear`),仅处理 `collection_type=''` 行(幂等)。实测 25,206 行 ~338ms 完成。
- **接口筛选**:`GET /api/public/articles` 新增 `?collection_type=&season=&season_code=&year=`,响应含三字段。
- **前端筛选**:articles.astro 增加「系列类型 / 季节」两组 chips(`#typefilter` / `#seasonfilter`),点击经 `loadBackend()` 带参重拉。
- **首页 feat 改竖版编辑拼图**(index.astro, 2026-08-08):原 4:3 横图 hero 改为「左侧大竖图(feat-main, aspect 3/4) + 右侧 3 张竖版 rail(feat-rail-item, grid-rows-3)」,**用同一场秀自己的 gallery(最多5张)做多图拼图**(避免横图裁切竖版),保留系列/季节 chip(feat-fill 渐变浮层)。`feat` 整块带 `data-ct` 供系列筛选隐藏。
- **首页「查看更多」内联加载**(index.astro, 2026-08-08):网格下加 `#showmore` 按钮,JS 分页 `fetch ?page=2..&size=8&with_images=5` 追加到 `#showgrid`,带 `homeFType` 系列筛选参数;无更多显示「没有更多了」。系列筛选 IIFE 重构为 `seriesApply()` 暴露 `homeFType`。
- **地理/城市维度:用户明确暂不做**(爬 Vogue 页不可靠;城市方案后续可用「品牌→所属时装周城市」映射表,Resort/Pre-Fall 归巡演桶)。
- 分布实测:rtw 15203 / menswear 4061 / couture 672 / resort 2894 / pre_fall 2376;spring 9140 / fall 9049。

View File

@ -14,9 +14,9 @@
[db] MySQL 8.0 [db] MySQL 8.0
``` ```
- **前端**:Astro 构建为静态 `dist/`,用 nginx 托管;`/api` 由 nginx 反代到 `backend:8090`(同源,无跨域)。 - **前端**:Astro 构建为静态 `dist/`,用 nginx 托管;`/api/v1` 由 nginx 反代到 `backend:8090`(同源,无跨域)。
- **后端**:Go 二进制,监听 `8090`,连 `db` 服务。 - **后端**:Go 二进制,监听 `8090`,连 `db` 服务。
- **构建期取数**:品牌页等通过 SSG 内部接口(`/api/ssg/*`,基址 `BASE_API_SSG`,默认 `http://localhost:8091`)在构建时拉数据;运行期浏览器走同源 `/api`(基址 `BASE_API` 置空)。 - **构建期取数**:品牌页等通过 SSG 内部接口(`/api/v1/ssg/*`,基址 `BASE_API_SSG`,默认 `http://localhost:8091`)在构建时拉数据;运行期浏览器走同源 `/api/v1`(基址 `BASE_API` 置空)。
## 二、服务器目录约定 ## 二、服务器目录约定

View File

@ -41,7 +41,7 @@ services:
db: db:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/public/brands?size=1"] test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/v1/public/brands?size=1"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10

View File

@ -12,7 +12,7 @@ server {
# 网关隔离:SSG 内部接口只存在于 backend 的 8091 端口(回环绑定,nginx 不反代)。 # 网关隔离:SSG 内部接口只存在于 backend 的 8091 端口(回环绑定,nginx 不反代)。
# 若有人从公网域名探测 /api/ssg/*,此处直接 404,确保构建期全量数据绝不从公网出口。 # 若有人从公网域名探测 /api/ssg/*,此处直接 404,确保构建期全量数据绝不从公网出口。
location /api/ssg/ { location /api/v1/ssg/ {
return 404; return 404;
} }

View File

@ -4,15 +4,15 @@ import ComingSoon from "@/components/ComingSoon.astro"
import { toAbs } from "@/lib/api" import { toAbs } from "@/lib/api"
import { getI18n } from "@/i18n/utils" import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
import { getSsgPopularBrands } from "@/lib/api" import { getSsgIndexRunway } from "@/lib/api"
// 国际化:一行取当前语言 + 翻译函数 // 国际化:一行取当前语言 + 翻译函数
const { locale, t } = getI18n(Astro) const { locale, t } = getI18n(Astro)
// 首页「热门品牌」区块:构建期(SSG 内部接口,server-only)拉取 20 个热门品牌, // 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/v1/ssg/index/runway(server-only),
// 封面/标题取自每个品牌代表走秀,直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。 // 首页 runway 数据含多模块,hotBrand 为其中之一;封面/标题取自每个品牌代表走秀,
// 具体 base 地址 + token 拼接已在 @/lib/api 的 SSG 函数内封装。 // 直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
const popular = await getSsgPopularBrands(20) const popular = (await getSsgIndexRunway()).hotBrand
type GalleryItem = { type GalleryItem = {
src: string src: string

View File

@ -1,20 +1,14 @@
--- ---
import Breadcrumb from "@/components/Breadcrumb.astro" import Breadcrumb from "@/components/Breadcrumb.astro"
import { getBrands, getSsgPopularBrands, type BrandEntry } from "@/lib/api" import { getSsgHotBrands } from "@/lib/api"
import { getI18n } from '@/i18n/utils' import { getI18n } from '@/i18n/utils'
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro) const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
let sidebarBrands: BrandEntry[] = [] // 热门品牌:走 SSG 专属接口 /api/v1/ssg/brands/hot,后端固定返回热度前 30 个、按热度顺序排列。
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
try { // 接口不可用时回落假数据先看效果。
sidebarBrands = (await getBrands({ size: 300, only_with_articles: 1, featured: 1 }))
.filter((b) => b.id && b.name)
.sort((a, b) => a.name.localeCompare(b.name))
} catch {}
// 热门品牌:构建期走 SSG 内部接口(/api/ssg/brands/popular),不可用时回落假数据先看效果
const FAKE_HOT_BRANDS = [ const FAKE_HOT_BRANDS = [
{ id: "hot-chanel", name: "Chanel", article_count: 342 }, { id: "hot-chanel", name: "Chanel", article_count: 342 },
{ id: "hot-dior", name: "Dior", article_count: 318 }, { id: "hot-dior", name: "Dior", article_count: 318 },
@ -31,8 +25,9 @@ const FAKE_HOT_BRANDS = [
] ]
let hotBrands: { id: string; name: string; article_count: number }[] = [] let hotBrands: { id: string; name: string; article_count: number }[] = []
try { try {
const ssg = await getSsgPopularBrands(12) hotBrands = (await getSsgHotBrands())
if (ssg.length) hotBrands = ssg.map((b) => ({ id: String(b.id), name: b.brand || "", article_count: 0 })) .filter((b) => b.id && b.name)
.map((b) => ({ id: String(b.id), name: b.name, article_count: b.article_count }))
} catch {} } catch {}
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
@ -284,7 +279,7 @@ const seasonOptions: { value: string; label: string }[] = [
:disabled="!modalSelected.includes(b.id) && modalMaxReached" :disabled="!modalSelected.includes(b.id) && modalMaxReached"
@change="toggleModalBrand(b.id)" @change="toggleModalBrand(b.id)"
/> />
<span class="showsmodal-name" x-text="b.name"></span> <span class="showsmodal-name" x-text="b.display || b.name"></span>
<span class="showsmodal-ct" x-text="b.article_count ? b.article_count : ''"></span> <span class="showsmodal-ct" x-text="b.article_count ? b.article_count : ''"></span>
</label> </label>
</li> </li>
@ -302,7 +297,6 @@ const seasonOptions: { value: string; label: string }[] = [
</div> </div>
<script id="showsbrands-data" type="application/json" set:html={JSON.stringify(sidebarBrands)}></script>
<script id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script> <script id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
<style> <style>
@ -609,14 +603,11 @@ const seasonOptions: { value: string; label: string }[] = [
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => { Alpine.data("showsPage", () => {
const node = document.getElementById("showsbrands-data")
const allBrands: BrandEntry[] = node?.textContent ? JSON.parse(node.textContent) : []
const hotNode = document.getElementById("showshotbrands-data") const hotNode = document.getElementById("showshotbrands-data")
const hotBrands = hotNode?.textContent ? JSON.parse(hotNode.textContent) : [] const hotBrands = hotNode?.textContent ? JSON.parse(hotNode.textContent) : []
return { return {
locale: '', locale: '',
allBrands,
hotBrands, hotBrands,
SPIN_SVG, SPIN_SVG,
collapsedGroups: { collapsedGroups: {
@ -629,7 +620,6 @@ const seasonOptions: { value: string; label: string }[] = [
selectedCt: [] as string[], selectedCt: [] as string[],
selectedSeason: [] as string[], selectedSeason: [] as string[],
selectedYear: [] as string[], selectedYear: [] as string[],
brandsExpanded: false,
// 品牌筛选弹窗状态 // 品牌筛选弹窗状态
modalOpen: false, modalOpen: false,
@ -656,35 +646,6 @@ const seasonOptions: { value: string; label: string }[] = [
this.loadPage(1, false) this.loadPage(1, false)
}, },
// 计算属性:侧边栏设计师列表的分组渲染
get visibleBrandItems() {
const result: any[] = []
const map = new Map<string, BrandEntry[]>()
for (const b of this.allBrands) {
const letter = (b.name[0] || "?").toUpperCase()
if (!map.has(letter)) map.set(letter, [])
map.get(letter)!.push(b)
}
const grouped = [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
let shown = 0
const MAX_INITIAL = 60
for (const [letter, list] of grouped) {
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
result.push({ type: "letter", letter, key: `letter-${letter}` })
for (const b of list) {
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
result.push({ type: "brand", id: b.id, name: b.name, count: b.article_count, key: b.id })
shown++
}
}
return result
},
get hasMoreBrands() {
return this.allBrands.length > 60
},
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动 // 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
get skeletonCount() { get skeletonCount() {
return this.size return this.size
@ -694,7 +655,7 @@ const seasonOptions: { value: string; label: string }[] = [
get pills() { get pills() {
const list: { label: string; clear: () => void }[] = [] const list: { label: string; clear: () => void }[] = []
if (this.selectedBrands.length) { if (this.selectedBrands.length) {
const findName = (id: any) => this.allBrands.find((b) => b.id === id)?.name ?? this.hotBrands.find((b) => b.id === id)?.name ?? `#${id}` const findName = (id: any) => this.hotBrands.find((b) => b.id === id)?.name ?? this.modalResults.find((b) => b.id === id)?.name ?? `#${id}`
const labels = this.selectedBrands const labels = this.selectedBrands
.map((id) => findName(id)) .map((id) => findName(id))
.slice(0, 4) .slice(0, 4)
@ -755,9 +716,9 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalLetter = "HOT" this.modalLetter = "HOT"
this.modalOpen = true this.modalOpen = true
// 锁根滚动并补偿滚动条宽度,避免背景页面横向偏移 // 锁根滚动并补偿滚动条宽度,避免背景页面横向偏移
const sbw = window.innerWidth - document.documentElement.clientWidth // const sbw = window.innerWidth - document.documentElement.clientWidth
document.documentElement.style.overflow = "hidden" // document.documentElement.style.overflow = "hidden"
if (sbw > 0) document.documentElement.style.paddingRight = sbw + "px" // if (sbw > 0) document.documentElement.style.paddingRight = sbw + "px"
await this.modalLoadHot() await this.modalLoadHot()
}, },
closeBrandModal() { closeBrandModal() {
@ -769,9 +730,7 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalLetter = "HOT" this.modalLetter = "HOT"
this.modalLoading = true this.modalLoading = true
try { try {
this.modalResults = this.hotBrands.length this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name, article_count: b.article_count || 0 }))
? this.hotBrands.map((b) => ({ id: b.id, name: b.name, article_count: b.article_count || 0 }))
: this.allBrands.slice(0, 50)
} finally { } finally {
this.modalLoading = false this.modalLoading = false
} }

View File

@ -230,7 +230,7 @@ const registerHref = href(locale, ROUTES.register)
} }
try { try {
const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register" const endpoint = mode === "login" ? "/api/v1/auth/login" : "/api/v1/auth/register"
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

View File

@ -1,240 +0,0 @@
---
import { getSsgBrands, type SsgBrand } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from "@/lib/routes"
type Brand = SsgBrand
// 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开)
// - 数据:一次 SSR 拉完全部精选集合,前端按 name 首字母分桶渲染;数据量小,无需懒加载。
// - 风格:极简编辑风(黑白 + monospace + 细线)。
// · 顶部单行 BRANDS 标题 + 总数
// · sticky A-Z 字母条(点击平滑滚动)
// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
// 构建期 SSR 拉数据走 SSG 内部接口 /api/ssg/brands(不对外暴露,位于独立内部端口 8091);
// base 地址 + token 拼接已在 @/lib/api 的 SSG 函数内封装。后端不可达则回落空数组(页面显示空索引)。
const { locale } = getI18n(Astro)
const brands = await getSsgBrands({ size: 300, only_with_articles: 1, featured: 1 })
const loadError = brands.length === 0
const LETTERS = Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
// 主显示名:拉丁/英文名优先(用于 A-Z 归类与展示),无拉丁名时回退中文
const primaryName = (b: Brand): string => {
const name = (b.name || "").trim()
const show = (b.show_name || "").trim()
if (/^[A-Za-z]/.test(name)) return name
if (show) {
const segs = show.split("/")
const lat = segs.find((s) => /^[A-Za-z]/.test(s.trim()))
if (lat) return lat.trim()
}
return name || show || "Unnamed"
}
// 按首字母归类到 A-Z(中文/符号开头的品牌不再单独成桶,直接忽略)
type Bucket = Record<string, Brand[]>
const buckets: Bucket = {}
LETTERS.forEach((l) => (buckets[l] = []))
for (const b of brands) {
const p = primaryName(b)
const first = (p[0] || "").toUpperCase()
if (first >= "A" && first <= "Z") buckets[first].push(b)
}
// 按字母顺序输出行
const orderedColumns: { key: string; label: string; items: Brand[] }[] = []
LETTERS.forEach((l) => {
if (buckets[l].length > 0) orderedColumns.push({ key: l, label: l, items: buckets[l] })
})
const total = orderedColumns.reduce((s, c) => s + c.items.length, 0)
---
<div class="brands" id="top">
<!-- 页头:单行 BRANDS 标题 + 总数 -->
<header class="brands-head">
<h1 class="brands-title">
<span class="brands-mark">Brands</span>
<span class="brands-count">{total.toLocaleString()}</span>
</h1>
</header>
<!-- A-Z 字母 tabbar(sticky 顶部) -->
<nav class="azbar" id="azbar" aria-label="Brand index by letter">
<div class="azbar-inner">
<a href="#top" class="azbar-logo" id="az-all">All</a>
<ol class="azbar-letters" id="azbar-letters">
{LETTERS.map((l) => (
<li>
<a
href={`#letter-${l}`}
class="az-letter"
data-letter={l}
data-empty={buckets[l].length > 0 ? "false" : "true"}
>{l}</a>
</li>
))}
</ol>
</div>
</nav>
<!-- 每个字母一行:字母标签 + 品牌名横向铺成多列 -->
<main class="brands-main">
{loadError ? (
<p class="brands-error">Failed to load brands. Please try again later.</p>
) : total === 0 ? (
<p class="brands-error">No brands indexed yet.</p>
) : (
<div class="brows">
{orderedColumns.map((col) => (
<section class="brow" id={`letter-${col.key}`} data-letter={col.key}>
<h2 class="brow-letter">{col.label}</h2>
<ul class="brow-items">
{col.items.map((b) => (
<li>
<a href={href(locale, ROUTES.articlesByBrand(b.id))}>{primaryName(b)}</a>
</li>
))}
</ul>
</section>
))}
</div>
)}
</main>
</div>
<style is:global>
@reference "tailwindcss";
/* 布局容器 */
.brands { @apply max-w-[1440px] mx-auto; }
/* ---------- 页头:单行 ---------- */
.brands-head {
margin-top: clamp(48px, 8vw, 110px);
border-top: 1px solid #000;
padding-top: 18px;
}
.brands-title {
@apply flex items-baseline gap-[18px] mb-[14px] font-serif font-black leading-[0.9] tracking-[-0.02em];
margin-top: 0;
font-size: clamp(32px, 5vw, 56px);
}
.brands-mark { @apply text-black; }
.brands-count { @apply font-mono text-[12px] font-normal tracking-[0.06em] text-[#999]; }
/* ---------- A-Z Tabbar(sticky) ---------- */
.azbar {
position: sticky; top: 0; z-index: 30;
background: #fff;
border-bottom: 1px solid #000;
margin-top: 4px;
}
.azbar-inner { @apply flex items-center gap-[14px] py-[10px]; }
.azbar-logo {
@apply font-mono uppercase tracking-[0.18em] text-[11px] font-bold text-black shrink-0 px-[9px] py-[4px] no-underline hover:bg-black hover:text-white;
}
.azbar-logo.is-active { @apply bg-black text-white; }
.azbar-letters { @apply flex flex-wrap list-none flex-1; margin: 0; padding: 0; gap: 0; }
.az-letter {
@apply inline-flex items-center justify-center min-w-[22px] h-[24px] px-[5px] font-mono text-[11px] font-bold tracking-[0.04em] text-black no-underline hover:bg-black hover:text-white;
}
.az-letter[data-empty="true"] { @apply text-[#cfcfcf] cursor-not-allowed pointer-events-none; }
.az-letter.is-active { @apply bg-black text-white; }
@media (max-width: 768px) {
.azbar-inner { gap: 8px; padding: 8px 0; }
.azbar-letters { overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; }
.az-letter { min-width: 20px; height: 22px; font-size: 10px; flex-shrink: 0; }
}
/* ---------- 每个字母一行 ---------- */
.brands-main { @apply pt-[10px] pb-[80px]; }
.brands-error { @apply font-mono uppercase tracking-[0.15em] text-[11px] text-[#999] py-[60px]; }
.brows { @apply border-t border-black; }
.brow {
@apply grid gap-[20px] pt-[14px] pb-[18px] border-b border-[#ececec];
grid-template-columns: 48px 1fr;
scroll-margin-top: 70px;
}
.brow-letter { @apply font-mono uppercase tracking-[0.12em] text-[14px] font-bold text-black; margin: 0; }
.brow-items { @apply list-none grid; margin: 0; padding: 0; grid-template-columns: repeat(4, 1fr); }
.brow-items li { @apply leading-[1.5] pl-[14px] pr-[16px] py-[3px] border-l border-[#ececec]; }
/* 每列首元素去掉左边线,避免与左侧字母标签粘连 */
.brow-items li:nth-child(4n+1) { border-left: none; padding-left: 0; }
.brow-items a {
@apply font-mono uppercase tracking-[0.05em] text-[12px] text-black no-underline inline-block py-[2px] hover:underline underline-offset-[3px];
}
@media (max-width: 1024px) {
.brow-items { grid-template-columns: repeat(3, 1fr); }
.brow-items li:nth-child(4n+1) { border-left: 1px solid #ececec; padding-left: 14px; }
.brow-items li:nth-child(3n+1) { border-left: none; padding-left: 0; }
}
@media (max-width: 768px) {
.brow { grid-template-columns: 36px 1fr; gap: 12px; }
.brow-letter { font-size: 13px; }
.brow-items { grid-template-columns: repeat(2, 1fr); }
.brow-items li:nth-child(3n+1) { border-left: 1px solid #ececec; padding-left: 14px; }
.brow-items li:nth-child(2n+1) { border-left: none; padding-left: 0; }
}
@media (max-width: 480px) {
.brow { grid-template-columns: 1fr; gap: 8px; }
.brow-letter { font-size: 15px; }
.brow-items { grid-template-columns: 1fr; }
.brow-items li { border-left: none !important; padding-left: 0 !important; }
}
</style>
<script>
// 字母点击平滑滚动 + 滚动高亮当前字母
const bar = document.getElementById("azbar")
const links = Array.from(document.querySelectorAll<HTMLAnchorElement>(".az-letter"))
const sections = Array.from(document.querySelectorAll<HTMLElement>(".brow"))
const linkByLetter = new Map(links.map((a) => [a.dataset.letter!, a]))
const sectionByLetter = new Map(sections.map((s) => [s.dataset.letter!, s]))
const barH = () => (bar ? bar.getBoundingClientRect().height + 10 : 70)
links.forEach((a) => {
a.addEventListener("click", (e) => {
if (a.dataset.empty === "true") { e.preventDefault(); return }
const l = a.dataset.letter
if (!l) return
e.preventDefault()
const target = sectionByLetter.get(l)
if (!target) return
const y = target.getBoundingClientRect().top + window.scrollY - barH()
window.scrollTo({ top: y, behavior: "smooth" })
})
})
const allLink = document.getElementById("az-all")
if (allLink) {
allLink.addEventListener("click", (e) => {
e.preventDefault()
window.scrollTo({ top: 0, behavior: "smooth" })
})
}
if ("IntersectionObserver" in window) {
const io = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
if (visible.length) {
const l = (visible[0].target as HTMLElement).dataset.letter
if (l) links.forEach((x) => x.classList.toggle("is-active", x.dataset.letter === l))
}
},
{ rootMargin: "-90px 0px -60% 0px", threshold: 0 }
)
sections.forEach((s) => io.observe(s))
}
</script>

View File

@ -3,6 +3,7 @@ import '@/styles/global.css'
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
import { getI18n } from '@/i18n/utils' import { getI18n } from '@/i18n/utils'
import { LOCALES, LOCALE_NAMES } from '@/i18n/config' import { LOCALES, LOCALE_NAMES } from '@/i18n/config'
import { setApiLocale } from '@/lib/api'
import { i18n } from 'astro:config/client'; import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n'; import { toCodes } from 'astro:i18n';
@ -13,6 +14,10 @@ const siteTitle = title ? `${title} — Portfolio Studio` : "Portfolio Studio"
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro) const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
// 把当前语言注入 API 请求层:所有服务端(SSG/SSR)取数统一带上 ?locale=,
// 后端据此选 *_en / *_cn 列。客户端 fetch 则自行按 URL path 推断,无需此处。
setApiLocale(locale)
// 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性 // 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性
const hiLabel = t('Hi') const hiLabel = t('Hi')
const logoutLabel = t('Logout') const logoutLabel = t('Logout')

View File

@ -1,7 +1,7 @@
// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口) // src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口)
// //
// 集中封装全部 /api 调用:公开查询(/api/public/*)、账号体系(/api/auth/*)、 // 集中封装全部 /api/v1 调用:公开查询(/api/v1/public/*)、账号体系(/api/v1/auth/*)、
// 以及构建期专用的 SSG 接口(/api/ssg/*,仅在 Node 构建期使用)。 // 以及构建期专用的 SSG 接口(/api/v1/ssg/*,仅在 Node 构建期使用)。
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。 // 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。
// //
// base URL 解析规则(全项目统一两个基准地址,命名见下): // base URL 解析规则(全项目统一两个基准地址,命名见下):
@ -18,6 +18,10 @@
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。 // 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
import "@/lib/http" import "@/lib/http"
// i18n:把"当前语言"透传到后端,让 /api 按 locale 选 *_en / *_cn 列。
import { getLocaleFromUrl } from "@/i18n/utils"
import { DEFAULT_LOCALE } from "@/i18n/config"
// 公开/运行期基址 BASE_API:Astro 7 中声明在 env.schema(access:public)的变量, // 公开/运行期基址 BASE_API:Astro 7 中声明在 env.schema(access:public)的变量,
// 浏览器端须从 astro:env/client 读取(不会像 PUBLIC_ 前缀那样自动进 import.meta.env)。 // 浏览器端须从 astro:env/client 读取(不会像 PUBLIC_ 前缀那样自动进 import.meta.env)。
// 该模块在 SSR(服务端)也会回退到 process.env,因此服务端/客户端共用同一份值。 // 该模块在 SSR(服务端)也会回退到 process.env,因此服务端/客户端共用同一份值。
@ -27,7 +31,9 @@ import { BASE_API } from "astro:env/client"
export interface BrandEntry { export interface BrandEntry {
id: string // 后端返回 hashid 编码串(非自增主键),防爬虫顺序枚举 id: string // 后端返回 hashid 编码串(非自增主键),防爬虫顺序枚举
name: string name: string // 英文名(name_en 回落 name)
nameCn?: string // 中文名(name_cn,可能为空)
display?: string // 展示名:中文语境下为 "英文名 (中文名)",否则为英文名
article_count: number article_count: number
} }
@ -97,17 +103,13 @@ export interface SsgPopularBrand {
cover: string cover: string
title: string title: string
} }
export interface SsgBrand {
id: string
name: string
show_name: string
article_count: number
}
// ===================== base 解析 ===================== // ===================== base 解析 =====================
export function getBase(): string { export function getBase(): string {
if (import.meta.env.DEV) return "https://seo.23cm.cn" // if (import.meta.env.DEV) return "https://seo.23cm.cn"
// 运行期/预渲染统一走 BASE_API(空则同源相对 /api) // 运行期/预渲染统一走 BASE_API(空则同源相对 /api)
return (BASE_API || "").replace(/\/$/, "") return (BASE_API || "").replace(/\/$/, "")
} }
@ -134,12 +136,44 @@ export function ssrBase(): string {
return (BASE_API || "http://localhost:8090").replace(/\/$/, "") return (BASE_API || "http://localhost:8090").replace(/\/$/, "")
} }
// ===================== i18n:请求语言透传 =====================
// 后端 /api/v1/public/* 与 /api/v1/ssg/* 都通过 ?locale=cn|en(优先级最高,高于 Accept-Language)
// 决定 name / title / brand_name 选用 *_en 还是 *_cn 列(service.pickLocale 选列并缺翻译回落),
// 出参字段名保持不变,故前端契约无需改动,仅需在请求上带上当前语言。
//
// 当前语言来源分两端:
// - 浏览器端(<script> 里的 fetch):从 URL path 推断(/cn/ → cn,其余 → en),调用方无需关心。
// - 服务端(SSG 构建 / 按需渲染):由各页面/布局在前置 frontmatter 调 setApiLocale() 注入,
// 因绝大多数页面是 SSG 单线程构建,模块级 _ssrLocale 安全;本文件也允许按需渲染时显式传参。
let _ssrLocale: string | null = null
export function setApiLocale(locale: string | null | undefined): void {
_ssrLocale = locale || DEFAULT_LOCALE
}
// 解析当前语言:浏览器端走 URL path,服务端回落注入值或默认语言。
function currentLocale(): string {
if (typeof window !== "undefined" && window.location) {
return getLocaleFromUrl(window.location.pathname)
}
return _ssrLocale || DEFAULT_LOCALE
}
// 把当前语言追加进 query(locale 非空才加,避免污染 URL;DEFAULT_LOCALE 也显式带,语义清晰)。
function withLocale(params?: URLSearchParams, override?: string): URLSearchParams {
const p = params ? new URLSearchParams(params) : new URLSearchParams()
const loc = override || currentLocale()
if (loc) p.set("locale", loc)
return p
}
// ===================== 内部请求原语 ===================== // ===================== 内部请求原语 =====================
// 通用请求原语:走 getBase() + /api + path(公开接口)。GET-only。 // 通用请求原语:走 getBase() + /api/v1 + path(公开接口)。GET-only。
// 自动追加 ?locale=(当前语言),让后端按语言选列返回。
async function request<T>(path: string, params?: URLSearchParams): Promise<T> { async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
const qs = params && params.toString() ? "?" + params.toString() : "" const p = withLocale(params)
const url = getBase() + "/api" + path + qs const qs = p.toString() ? "?" + p.toString() : ""
const url = getBase() + "/api/v1" + path + qs
const res = await fetch(url) const res = await fetch(url)
if (!res.ok) throw new Error(`API ${res.status} ${url}`) if (!res.ok) throw new Error(`API ${res.status} ${url}`)
return (await res.json()) as T return (await res.json()) as T
@ -185,14 +219,18 @@ export async function getArticlesRaw(opts?: {
// 接口:GET /api/public/articles/:id // 接口:GET /api/public/articles/:id
// 文章详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。 // 文章详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
export async function fetchArticleById( export async function fetchArticleById(
id: number | string, id: number | string,
base: string = ssrBase() base: string = ssrBase(),
locale?: string
): Promise<RawArticle | null> { ): Promise<RawArticle | null> {
const b = base.replace(/\/$/, "") const b = base.replace(/\/$/, "")
if (!b) return null if (!b) return null
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try { try {
const res = await fetch(`${b}/api/public/articles/${id}`) const res = await fetch(`${b}/api/v1/public/articles/${id}${qs}`)
if (!res.ok) return null if (!res.ok) return null
const json: any = await res.json() const json: any = await res.json()
return (json?.data ?? null) as RawArticle | null return (json?.data ?? null) as RawArticle | null
@ -204,17 +242,19 @@ export async function fetchArticleById(
// 服务端按需渲染(on-demand)取文章详情:在 fetchArticleById 之上包揽 // 服务端按需渲染(on-demand)取文章详情:在 fetchArticleById 之上包揽
// 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。 // 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。
// 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。 // 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。
// locale 显式传入(如 Astro.currentLocale)以绑定当前语言,避免并发串 locale。
export type SsrArticleResult = { export type SsrArticleResult = {
article: RawArticle | null article: RawArticle | null
error: "missing" | "failed" | null error: "missing" | "failed" | null
} }
export async function getSsrArticle( export async function getSsrArticle(
id: string | null | undefined id: string | null | undefined,
locale?: string
): Promise<SsrArticleResult> { ): Promise<SsrArticleResult> {
if (!id) return { article: null, error: "missing" } if (!id) return { article: null, error: "missing" }
try { try {
const article = await fetchArticleById(id) const article = await fetchArticleById(id, ssrBase(), locale)
return { article, error: article ? null : "failed" } return { article, error: article ? null : "failed" }
} catch { } catch {
return { article: null, error: "failed" } return { article: null, error: "failed" }
@ -256,38 +296,14 @@ export async function fetchArticlesList(opts?: {
if (opts?.collection_type) p.set("collection_type", opts.collection_type) if (opts?.collection_type) p.set("collection_type", opts.collection_type)
if (opts?.season) p.set("season", opts.season) if (opts?.season) p.set("season", opts.season)
if (opts?.brand_id) p.set("brand_id", opts.brand_id) if (opts?.brand_id) p.set("brand_id", opts.brand_id)
const res = await fetch(`${base}/api/public/articles?${p.toString()}`) // 浏览器端按 URL path 自动推断当前语言并追加 ?locale=
p.set("locale", currentLocale())
const res = await fetch(`${base}/api/v1/public/articles?${p.toString()}`)
if (!res.ok) throw new Error(`list ${res.status}`) if (!res.ok) throw new Error(`list ${res.status}`)
const json = await res.json() const json = await res.json()
return json?.data ?? [] return json?.data ?? []
} }
// 接口:GET /api/public/articles(经 getArticlesRaw,sort=year_desc)
// 最近若干条秀场中提取的独特年份(降序去重,最多 6 个),用于筛选年份 chips。
export async function getYears(sample = 30): Promise<number[]> {
const { data } = await getArticlesRaw({ size: sample, sort: "year_desc" })
const ys = Array.from(
new Set(data.map((it: any) => Number(it.year)).filter((y: number) => y > 0))
) as number[]
return ys.slice(0, 6)
}
// 接口:GET /api/public/colors(客户端运行期,base 走 BASE_API)
// 颜色接口(后端暂无独立颜色接口时返回 null,由调用方回落)。
export async function fetchColors(): Promise<any[] | null> {
const base = publicBase()
if (!base) return null
try {
const res = await fetch(`${base}/api/public/colors`)
if (!res.ok) return null
const json: any = await res.json()
const data: any[] = json?.data ?? []
return data.length ? data : null
} catch {
return null
}
}
// ===================== 品牌(/api/public/brands) ===================== // ===================== 品牌(/api/public/brands) =====================
// 接口:GET /api/public/brands // 接口:GET /api/public/brands
@ -300,16 +316,27 @@ export async function getBrands(opts?: {
}): Promise<BrandEntry[]> { }): Promise<BrandEntry[]> {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (opts?.size) qs.set("size", String(opts.size)) if (opts?.size) qs.set("size", String(opts.size))
if (opts?.only_with_articles != null) qs.set("only_with_articles", String(opts.only_with_articles)) // 关键词搜索:用户按名字找品牌,不应被"必须有走秀档案"过滤。
// 后端已对 keyword 跳过该限制,这里再显式置 0 双保险,使正在运行的后端也立即生效。
const onlyWA = opts?.keyword ? 0 : (opts?.only_with_articles ?? 1)
qs.set("only_with_articles", String(onlyWA))
if (opts?.featured != null) qs.set("featured", String(opts.featured)) if (opts?.featured != null) qs.set("featured", String(opts.featured))
if (opts?.keyword) qs.set("keyword", opts.keyword) if (opts?.keyword) qs.set("keyword", opts.keyword)
if (opts?.letter) qs.set("letter", opts.letter) if (opts?.letter) qs.set("letter", opts.letter)
const j = await request<{ data?: any[] }>("/public/brands", qs) const j = await request<{ data?: any[] }>("/public/brands", qs)
return (j.data ?? []).map((b: any) => ({ return (j.data ?? []).map((b: any) => {
id: String(b.id), const nameEn = (b.name_en || b.name || "").toString()
name: (b.name || b.show_name || "").toString(), const nameCn = (b.name_cn || "").toString()
article_count: Number(b.article_count ?? 0), // 品牌名始终展示英文(即便是中文语境),不再拼接中文名,保持英文观感。
})) const display = nameEn
return {
id: String(b.id),
name: nameEn,
nameCn,
display,
article_count: Number(b.article_count ?? 0),
}
})
} }
// ===================== 账号体系(/api/auth/*) ===================== // ===================== 账号体系(/api/auth/*) =====================
@ -343,7 +370,7 @@ export async function fetchMe(): Promise<AuthUser | null> {
if (!token) return null if (!token) return null
try { try {
// 接口:GET /api/auth/me(需 Bearer token,客户端运行期 base 走 BASE_API) // 接口:GET /api/auth/me(需 Bearer token,客户端运行期 base 走 BASE_API)
const res = await fetch(`${BASE_API_URL}/api/auth/me`, { const res = await fetch(`${BASE_API_URL}/api/v1/auth/me?locale=${encodeURIComponent(currentLocale())}`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
if (!res.ok) { if (!res.ok) {
@ -384,14 +411,14 @@ function ssgToken(): string {
return (ssgEnv("SSG_TOKEN") || "").toString().trim() return (ssgEnv("SSG_TOKEN") || "").toString().trim()
} }
function withToken(path: string): string {
const t = ssgToken()
if (!t) return path
return path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t)
}
async function ssgRequest<T>(path: string): Promise<T | null> { async function ssgRequest<T>(path: string): Promise<T | null> {
const url = ssgBase() + withToken(path) // 组合 SSG_TOKEN 与 ?locale=(构建期按当前语言选列,后端 parseLocale 已支持)。
const t = ssgToken()
const parts: string[] = []
if (t) parts.push("token=" + encodeURIComponent(t))
const loc = currentLocale()
if (loc) parts.push("locale=" + encodeURIComponent(loc))
const url = ssgBase() + path + (parts.length ? (path.includes("?") ? "&" : "?") + parts.join("&") : "")
try { try {
const res = await fetch(url) const res = await fetch(url)
if (!res.ok) return null if (!res.ok) return null
@ -401,28 +428,29 @@ async function ssgRequest<T>(path: string): Promise<T | null> {
} }
} }
// 接口:GET /api/ssg/brands/popular?limit=N(SSG 内部端口,构建期 Node 调用) // 接口:GET /api/v1/ssg/index/runway(SSG 内部端口,构建期 Node 调用)
export async function getSsgPopularBrands(limit = 20): Promise<SsgPopularBrand[]> { // 首页(/index/)runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。
const j = await ssgRequest<{ data?: SsgPopularBrand[] }>(`/api/ssg/brands/popular?limit=${limit}`) export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> {
return j?.data ?? [] const j = await ssgRequest<{ data?: { hotBrand?: SsgPopularBrand[] } }>(`/api/v1/ssg/index/runway`)
return j?.data ?? { hotBrand: [] }
} }
// 接口:GET /api/ssg/brands(SSG 内部端口,构建期 Node 调用) // 接口:GET /api/ssg/brands(SSG 内部端口,构建期 Node 调用)
export async function getSsgBrands(opts?: { // SSG 构建期:走秀页热门品牌(按图片总数排名,结果按热度顺序)。
size?: number // 后端固定返回前 30 个;侧栏 filter 展示前 10,品牌弹窗 HOT 标签展示全部。
only_with_articles?: number export async function getSsgHotBrands(): Promise<BrandEntry[]> {
featured?: number const j = await ssgRequest<{ data?: any[] }>("/api/v1/ssg/brands/hot")
}): Promise<SsgBrand[]> { return (j?.data ?? []).map((b: any) => {
const qs = new URLSearchParams() const nameEn = (b.name_en || b.name || "").toString()
if (opts?.size) qs.set("size", String(opts.size)) return {
if (opts?.only_with_articles != null) qs.set("only_with_articles", String(opts.only_with_articles)) id: String(b.id),
if (opts?.featured != null) qs.set("featured", String(opts.featured)) name: nameEn,
const j = await ssgRequest<{ data?: SsgBrand[] }>(`/api/ssg/brands?${qs.toString()}`) nameCn: (b.name_cn || "").toString(),
return j?.data ?? [] display: nameEn,
article_count: Number(b.article_count ?? 0),
}
})
} }
// 接口:GET /api/ssg/articles/ids(SSG 内部端口,构建期 Node 调用,供 getStaticPaths 枚举详情路由)
export async function getSsgArticleIds(): Promise<string[]> {
const j = await ssgRequest<{ data?: string[] }>(`/api/ssg/articles/ids`)
return j?.data ?? []
}

View File

@ -1,289 +0,0 @@
// 网站统一数据层
// 复用 api.ts 的公开接口拉取真实数据,并适配为前端各模块
// (site / shows / trends / colors / street,以及首页 backstage 面板、文章画廊 reports) 所需结构。
//
// 设计原则:
// 1. 单一数据源:所有 getter 共享一次分页拉取 (loadArticles),避免重复请求。
// 2. 优雅回落:后端不可达或返回空时,回落到内置示例,保证 SSG 构建与离线预览不报错。
// 3. 颜色模块:后端暂无独立颜色接口,getColors() 先尝试 /api/public/colors,
// 失败则从 runway 文章封面派生占位(接入颜色接口后即可自动生效)。
//
// 所有 fetch 都已收敛到 @/lib/api,本文件只做数据映射与缓存。
import {
getArticlesRaw,
fetchArticleById,
fetchColors,
toAbs,
type RawArticle,
} from "@/lib/api"
export type { RawArticle }
// 供仍从 data 取详情的函数(ssr.ts 等)透传
export { fetchArticleById }
export function seasonLabel(y: number | string): string {
const n = Number(y)
return n ? `AW${String(n).slice(2)}` : ""
}
// 系列类型 → 展示名(用于首页/列表的系列标签与筛选)
export const SERIES_LABELS: Record<string, string> = {
rtw: "成衣 RTW",
menswear: "男装",
couture: "高定",
resort: "度假",
pre_fall: "早秋",
}
export function seriesLabel(ct?: string): string {
return (ct && SERIES_LABELS[ct]) || "成衣 RTW"
}
export function fmtDate(t: number | string): string {
const n = Number(t)
const d = new Date(n < 1e12 ? n * 1000 : n)
const p = (x: number) => String(x).padStart(2, "0")
return `${d.getUTCFullYear()}.${p(d.getUTCMonth() + 1)}.${p(d.getUTCDate())}`
}
// ---------- 内置示例(后端不可达时回落) ----------
const BRANDS = ["MAISON LUMEN", "STUDIO VANTA", "ATELIER NOIR", "KÔ", "CÉLESTE", "RIVE GAUCHE"]
const SAMPLE: RawArticle[] = Array.from({ length: 24 }, (_, i) => ({
id: String(i + 1),
title: `Collection ${String(i + 1).padStart(2, "0")}`,
brand_name: BRANDS[i % BRANDS.length],
year: 2023 + (i % 4),
cover: `https://picsum.photos/seed/s${i + 1}/800/1000`,
image_count: 3 + (i % 12),
published_at: Math.floor(Date.UTC(2026, 0, 1) / 1000) - i * 86400,
}))
// ---------- 底层:一次性拉取并缓存 ----------
type Loaded = {
items: RawArticle[]
total: number
brandCount: number
yearMin: number
yearMax: number
latest: RawArticle | null
sampled: boolean
}
let _loaded: Promise<Loaded> | null = null
function compute(items: RawArticle[], total: number): Loaded {
const brandCount = new Set(items.map((r) => r.brand_name).filter(Boolean)).size
const years = items.map((r) => Number(r.year)).filter((y) => y > 0)
const yearMin = years.length ? Math.min(...years) : 0
const yearMax = years.length ? Math.max(...years) : 0
const latest =
[...items].sort((a, b) => Number(b.published_at) - Number(a.published_at))[0] || null
return { items, total, brandCount, yearMin, yearMax, latest, sampled: false }
}
function fromSample(): Loaded {
return { ...compute(SAMPLE, SAMPLE.length), sampled: true }
}
export function loadArticles(): Promise<Loaded> {
if (_loaded) return _loaded
_loaded = (async () => {
try {
const { data, total } = await getArticlesRaw({ page: 1, size: 60 })
if (!data.length) return fromSample()
return compute(data, Number(total || data.length))
} catch {
return fromSample()
}
})()
return _loaded
}
// ---------- 模块 getter ----------
export type ArticleCard = {
id: string | number
brand: string
title: string
season: string // 季节码,如 AW16
year: number
img: string
count: number
gallery: string[]
collection_type?: string // rtw/menswear/couture/resort/pre_fall
season_code?: string // SS26/FW25/RES26/PF25
}
function mapArticle(r: RawArticle): ArticleCard {
return {
id: r.id,
brand: r.brand_name || "Unknown",
title: r.title || "",
season: seasonLabel(r.year),
year: Number(r.year) || 0,
img: toAbs(r.cover || ""),
count: r.image_count || 0,
gallery: [],
collection_type: r.collection_type,
season_code: r.season_code,
}
}
export async function getShows(limit = 8): Promise<ArticleCard[]> {
const items = await loadArticlesWithImages(40)
return items.slice(0, limit)
}
export async function getBackstage(limit = 8): Promise<ArticleCard[]> {
const items = await loadArticlesWithImages(40)
return items.slice(8, 8 + limit)
}
export async function getTrends(limit = 9): Promise<ArticleCard[]> {
const items = await loadArticlesWithImages(40)
return items.slice(16, 16 + limit)
}
export async function getStreet(limit = 8): Promise<ArticleCard[]> {
const items = await loadArticlesWithImages(40)
return items.slice(24, 24 + limit)
}
// 列表带图拉取:一次请求即带回每篇文章前 5 张图(后端 with_images 参数),
// 用于首页卡片缩略图,避免此前逐篇详情请求导致的缓慢。
let _loadedWithImages: Promise<ArticleCard[]> | null = null
export function loadArticlesWithImages(size = 40): Promise<ArticleCard[]> {
if (_loadedWithImages) return _loadedWithImages
_loadedWithImages = (async () => {
try {
const { data } = await getArticlesRaw({ page: 1, size, with_images: 5 })
return data.map((r) => {
const base = mapArticle(r)
base.gallery = (r.images || [])
.map((im: any) => toAbs(im.image || ""))
.filter(Boolean)
.slice(0, 5)
return base
})
} catch {
return []
}
})()
return _loadedWithImages
}
// attachGallery 在构建期(SSG)为每个展示文章预取其真实多图(前 3 张),
// 供首页卡片直接渲染"图片堆",无需等到悬停才拉取。后端不可达时 gallery 为空。
async function attachGallery<T extends { id: string | number; gallery: string[] }>(
items: T[]
): Promise<T[]> {
return await Promise.all(
items.map(async (it) => {
let gallery: string[] = []
const art = await fetchArticleById(it.id)
if (art?.images?.length) {
gallery = art.images
.slice(0, 5)
.map((im: any) => toAbs(im.image || ""))
.filter(Boolean)
}
return { ...it, gallery }
})
)
}
export type ColorItem = {
id: string | number
name: string
brand: string
season: string
img: string
}
export async function getColors(limit = 12): Promise<ColorItem[]> {
// 后端暂无独立颜色接口:优先尝试 /api/public/colors,失败则从 runway 文章封面派生占位。
const colors = await fetchColors()
if (colors && colors.length) {
return colors.slice(0, limit).map((c: any) => ({
id: c.id ?? 0,
name: c.name || c.title || "",
brand: c.brand_name || "",
season: seasonLabel(c.year),
img: toAbs(c.cover || c.image || ""),
}))
}
const { items } = await loadArticles()
return items.slice(0, limit).map((r) => ({
id: r.id,
name: r.title,
brand: r.brand_name,
season: seasonLabel(r.year),
img: toAbs(r.cover || ""),
}))
}
export type SiteInfo = {
totalReports: number
brandCount: number
seasonRange: string
latest: { title: string; brand: string; date: string; href: string }
}
export async function getSite(): Promise<SiteInfo> {
const loaded = await loadArticles()
if (loaded.sampled) {
return {
totalReports: 12,
brandCount: 6,
seasonRange: "2023–26",
latest: {
title: "Paper Light",
brand: "MAISON LUMEN",
date: "2026.03.14",
href: "/article?id=1",
},
}
}
const { total, brandCount, yearMin, yearMax, latest } = loaded
const seasonRange =
yearMin && yearMax ? `${yearMin}–${String(yearMax).slice(2)}` : "2023–26"
return {
totalReports: total || 12,
brandCount: brandCount || 6,
seasonRange,
latest: latest
? {
title: latest.title,
brand: latest.brand_name,
date: fmtDate(latest.published_at),
href: `/article?id=${latest.id}`,
}
: { title: "Paper Light", brand: "MAISON LUMEN", date: "2026.03.14", href: "/article?id=1" },
}
}
// ---------- 文章画廊(/articles)所需结构 ----------
export type ReportItem = {
id: string | number
brand: string
title: string
year: number
cover: string
excerpt: string
image_count: number
featured: boolean
ratio: string
created_at: number
}
const RATIOS = ["3 / 4", "4 / 5", "1 / 1", "5 / 4"]
export async function getReports(limit = 30): Promise<ReportItem[]> {
const { items } = await loadArticles()
return items.slice(0, limit).map((r, i) => ({
id: r.id,
brand: r.brand_name || "Unknown",
title: r.title || "",
year: Number(r.year) || 0,
cover: toAbs(r.cover || ""),
excerpt: (r.summary || r.description || r.title || "").slice(0, 120),
image_count: r.image_count || 0,
featured: i % 4 === 0,
ratio: RATIOS[i % RATIOS.length],
created_at: Number(r.published_at) || 0,
}))
}

View File

@ -1,8 +0,0 @@
---
import Layout from "@/layouts/Layout.astro"
import Brands from "@/components/pages/brands.astro"
---
<Layout>
<Brands />
</Layout>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,939 +0,0 @@
---
import Layout from "@/layouts/Layout.astro"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getBrands, getSsgPopularBrands, type BrandEntry } from "@/lib/api"
import { getI18n } from '@/i18n/utils'
import { clientHref, ROUTES, href } from '@/lib/routes'
const { locale } = getI18n(Astro)
let sidebarBrands: BrandEntry[] = []
try {
sidebarBrands = (await getBrands({ size: 300, only_with_articles: 1, featured: 1 }))
.filter((b) => b.id && b.name)
.sort((a, b) => a.name.localeCompare(b.name))
} catch {}
// 热门品牌:构建期走 SSG 内部接口(/api/ssg/brands/popular),不可用时回落假数据先看效果
const FAKE_HOT_BRANDS = [
{ id: "hot-chanel", name: "Chanel", article_count: 342 },
{ id: "hot-dior", name: "Dior", article_count: 318 },
{ id: "hot-gucci", name: "Gucci", article_count: 287 },
{ id: "hot-prada", name: "Prada", article_count: 254 },
{ id: "hot-louisvuitton", name: "Louis Vuitton", article_count: 241 },
{ id: "hot-saintlaurent", name: "Saint Laurent", article_count: 219 },
{ id: "hot-balenciaga", name: "Balenciaga", article_count: 198 },
{ id: "hot-valentino", name: "Valentino", article_count: 176 },
{ id: "hot-givenchy", name: "Givenchy", article_count: 162 },
{ id: "hot-burberry", name: "Burberry", article_count: 153 },
{ id: "hot-fendi", name: "Fendi", article_count: 141 },
{ id: "hot-bottega", name: "Bottega Veneta", article_count: 128 },
]
let hotBrands: { id: string; name: string; article_count: number }[] = []
try {
const ssg = await getSsgPopularBrands(12)
if (ssg.length) hotBrands = ssg.map((b) => ({ id: String(b.id), name: b.brand || "", article_count: 0 }))
} catch {}
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
// 年份筛选:写死,按需增删(数据最早约 2005 年,如需更老年份往数组里加即可)
// 年份:从当今年份往前推 15 年(含),末项加"X年前"兜底
const currentYear = new Date().getFullYear()
const yearFloor = currentYear - 15
const yearOptions: { value: number | string; label: string }[] = []
for (let y = currentYear; y >= yearFloor; y--) {
yearOptions.push({ value: y, label: String(y) })
}
yearOptions.push({ value: `before${yearFloor}`, label: `${yearFloor}年前` })
// 系列类型 / 季节:写死静态数据(先用示例/假数据预览,确认后直接改这两个数组即可)
const collectionOptions: { value: string; label: string }[] = [
{ value: "rtw", label: "Ready-to-Wear" },
{ value: "menswear", label: "Menswear" },
{ value: "womenswear", label: "Womenswear" },
{ value: "couture", label: "Couture" },
{ value: "resort", label: "Resort" },
{ value: "pre_fall", label: "Pre-Fall" },
{ value: "bridal", label: "Bridal" },
{ value: "swimwear", label: "Swimwear" },
]
const seasonOptions: { value: string; label: string }[] = [
{ value: "spring", label: "Spring / Summer" },
{ value: "fall", label: "Fall / Winter" },
{ value: "cruise", label: "Cruise" },
{ value: "holiday", label: "Holiday" },
]
---
<Layout title="全部的 runway — Portfolio Studio">
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
<!-- 顶部:极简眉头 -->
<header class="showshead">
<div>
<Breadcrumb
items={[
{ label: 'Home', href: href(locale, ROUTES.home) },
{ label: 'Runway Archive', href: href(locale, ROUTES.shows) },
]}
>
<span slot="current" x-text="crumbYear">2026 Edition</span>
</Breadcrumb>
<h1 class="showsh1">全部的 runway</h1>
</div>
</header>
<!-- 已激活筛选 pills -->
<div class="showspills" x-show="pills.length > 0" x-cloak>
<template x-for="(p, i) in pills" :key="i">
<span class="showspill">
<span x-text="p.label"></span>
<button class="x" type="button" @click="p.clear()">×</button>
</span>
</template>
</div>
<!-- 主体:左 sidebar + 右 4 列商品 grid -->
<div class="showsbody">
<!-- LEFT SIDEBAR -->
<aside class="showsaside">
<div class="showsaside-head">
<h2 class="showsaside-h">REFINE</h2>
</div>
<div class="showsaside-body">
<!-- Designer 多选 -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('designers')">
<span>DESIGNERS</span>
<span class="showsg-right">
<span class="showsg-count" x-text="selectedBrands.length">0</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.designers">
<div class="showshot" x-show="hotBrands.length">
<div class="mb-2 text-[10px] uppercase tracking-[0.18em] text-black/45">热门品牌 · HOT</div>
<div class="flex flex-wrap gap-1.5">
<template x-for="b in hotBrands" :key="b.id">
<label class="inline-flex items-center gap-1.5 border border-black/15 px-2 py-1 text-[11px] cursor-pointer transition-colors has-[:checked]:border-black has-[:checked]:bg-black has-[:checked]:text-white">
<input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" />
<span x-text="b.name"></span>
</label>
</template>
</div>
</div>
<ul class="showschecks">
<template x-for="item in visibleBrandItems" :key="item.key">
<li>
<!-- Letter Header -->
<template x-if="item.type === 'letter'">
<div class="letter-head" x-text="item.letter"></div>
</template>
<!-- Brand Item -->
<template x-if="item.type === 'brand'">
<label class="flex items-center gap-2 w-full cursor-pointer">
<input
type="checkbox"
:value="item.id"
:checked="selectedBrands.includes(item.id)"
@change="toggleBrand(item.id)"
>
<span x-text="item.name"></span>
<span class="ct" x-text="item.count ? item.count : ''"></span>
</label>
</template>
</li>
</template>
</ul>
<a class="showsmore" @click="openBrandModal()" x-text="'浏览全部品牌 +'"></a>
</div>
</section>
<!-- Collection type -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('collection')">
<span>COLLECTION</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.collection">
<ul class="showschecks showschecks--flat">
{collectionOptions.map((o) => (
<li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<!-- Season -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('season')">
<span>SEASON</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.season">
<ul class="showschecks showschecks--flat">
{seasonOptions.map((o) => (
<li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<!-- Year -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('year')">
<span>YEAR</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.year">
<ul class="showschecks showschecks--flat">
{yearOptions.map((o) => (
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<button class="showsclear" type="button" @click="clearAll()">Clear all ×</button>
</div>
</aside>
<!-- MAIN GRID -->
<main class="showsmain">
<div class="showsgrid" :class="{ 'is-loading': loading }">
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
<template x-if="loading && items.length === 0">
<template x-for="i in skeletonCount" :key="'skel-'+i">
<div class="showscard showscard--skel" aria-hidden="true">
<div class="showscard-left">
<div class="showscard-img"></div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div>
<div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div>
<div class="showsline h-[38px] rounded-[2px]"></div>
</div>
<div class="showscard-strip">
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
</div>
</div>
</div>
</template>
</template>
<!-- Empty State -->
<template x-if="!loading && items.length === 0">
<div class="showsempty">No shows match the current filters</div>
</template>
<!-- Card List -->
<template x-for="it in items" :key="it.id">
<a class="showscard" :href="articleHref(it)" :aria-label="`${it.brand_name} ${it.title}`">
<div class="showscard-left">
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
<img :src="toAbsUrl(it.cover)" :alt="it.title" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
<!-- <span class="showsbadge" x-show="isNewAdded(it.published_at)">New</span> -->
</div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<h3 class="showscard-title" x-text="it.title"></h3>
<p class="showscard-meta" x-text="`${it.season_code || it.year || ''} · ${ctLabel(it.collection_type)} · ${it.brand_name}`"></p>
<div class="showscard-meta-foot">
<span class="showscard-meta-line"><span x-html="ICON_USER"></span><span x-text="it.brand_name"></span></span>
<span class="showscard-meta-line"><span x-html="ICON_DATE"></span><span x-text="fmtDate(it.published_at)"></span></span>
</div>
</div>
<div class="showscard-strip">
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
<div class="showscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG" x-show="slot.src"></span>
<img x-show="slot.src" :src="toAbsUrl(slot.src)" alt="" loading="lazy" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
<span class="showscard-thumb-more" x-show="slot.isLast && slot.moreLabel" x-text="slot.moreLabel"></span>
</div>
</template>
</div>
</div>
</a>
</template>
</div>
<div class="showsloadmore-wrap" x-show="items.length > 0">
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
</div>
</main>
</div>
<!-- 品牌筛选弹窗:搜索 + A-Z 跳转 + 热门推荐,按需加载,避免一次拉全量 -->
<div class="showsmodal" x-show="modalOpen" x-cloak>
<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"
/>
</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="checkbox"
:value="b.id"
:checked="modalSelected.includes(b.id)"
@change="toggleModalBrand(b.id)"
/>
<span class="showsmodal-name" x-text="b.name"></span>
<span class="showsmodal-ct" x-text="b.article_count ? b.article_count : ''"></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} / 1 个品牌`"></span>
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
</footer>
</div>
</div>
</div>
</Layout>
<script id="showsbrands-data" type="application/json" set:html={JSON.stringify(sidebarBrands)}></script>
<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="checkbox"] {
@apply appearance-none w-3 h-3 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-none inline-grid place-content-center checked:bg-black;
}
.showschecks input[type="checkbox"]:checked::after {
content: "";
@apply w-[6px] h-[6px] bg-white;
}
.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 inline-block mt-[10px] text-[10.5px] uppercase tracking-[0.16em] text-[#555] cursor-pointer hover:text-black;
}
.showsclear {
@apply mt-[18px] w-full border-[1.5px] border-black bg-white text-black p-[11px_12px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-150 hover:bg-black hover:text-white;
}
.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;
}
.showsloadmore-wrap {
@apply flex justify-center mt-[38px];
}
.showsloadmore {
@apply border-[1.5px] border-black bg-white text-black p-[13px_36px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-200 hover:bg-black hover:text-white disabled:opacity-40 disabled:cursor-default;
}
.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: transparent;
}
.showsmodal-panel {
@apply relative w-full max-w-[460px] 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-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;
}
.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-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 Alpine from "alpinejs"
import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
const ICON_USER = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><circle cx="6" cy="4" r="2" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 10c0-2 1.8-3 4-3s4 1 4 3" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
const ICON_DATE = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><rect x="2" y="3" width="8" height="7" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 6h8M4 2v2M8 2v2" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
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>`
document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => {
const node = document.getElementById("showsbrands-data")
const allBrands: BrandEntry[] = node?.textContent ? JSON.parse(node.textContent) : []
const hotNode = document.getElementById("showshotbrands-data")
const hotBrands = hotNode?.textContent ? JSON.parse(hotNode.textContent) : []
return {
allBrands,
hotBrands,
locale: '',
ICON_USER,
ICON_DATE,
SPIN_SVG,
collapsedGroups: {
designers: localStorage.getItem("showsg-designers") === "1",
collection: localStorage.getItem("showsg-collection") === "1",
season: localStorage.getItem("showsg-season") === "1",
year: localStorage.getItem("showsg-year") === "1",
},
selectedBrands: [] as number[],
selectedCt: [] as string[],
selectedSeason: [] as string[],
selectedYear: [] as string[],
brandsExpanded: false,
// 品牌筛选弹窗状态
modalOpen: false,
modalKeyword: "",
modalLetter: "",
modalResults: [] as BrandEntry[],
modalLoading: false,
modalSelected: [] as number[],
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
loading: false,
loadingMore: false,
page: 1,
size: 24,
total: 0,
isEnd: false,
reqToken: 0,
init() {
this.locale = this.$el.dataset.locale || 'en'
this.loadPage(1, false)
},
// 计算属性:侧边栏设计师列表的分组渲染
get visibleBrandItems() {
const result: any[] = []
const map = new Map<string, BrandEntry[]>()
for (const b of this.allBrands) {
const letter = (b.name[0] || "?").toUpperCase()
if (!map.has(letter)) map.set(letter, [])
map.get(letter)!.push(b)
}
const grouped = [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
let shown = 0
const MAX_INITIAL = 60
for (const [letter, list] of grouped) {
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
result.push({ type: "letter", letter, key: `letter-${letter}` })
for (const b of list) {
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
result.push({ type: "brand", id: b.id, name: b.name, count: b.article_count, key: b.id })
shown++
}
}
return result
},
get hasMoreBrands() {
return this.allBrands.length > 60
},
// 面包屑末级:未选年份时显示默认的「2026 Edition」,选中后显示对应年份
get crumbYear() {
if (this.selectedYear.length) {
const ys = [...this.selectedYear].sort()
return ys.length === 1 ? `${ys[0]} Edition` : `${ys.join(" / ")}`
}
return "2026 Edition"
},
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
get skeletonCount() {
return this.size
},
// 已筛选选项 Pills 视图数据
get pills() {
const list: { label: string; clear: () => void }[] = []
if (this.selectedBrands.length) {
const findName = (id: any) => this.allBrands.find((b) => b.id === id)?.name ?? this.hotBrands.find((b) => b.id === id)?.name ?? `#${id}`
const labels = this.selectedBrands
.map((id) => findName(id))
.slice(0, 4)
const more = this.selectedBrands.length > 4 ? ` +${this.selectedBrands.length - 4}` : ""
list.push({
label: "DESIGNER · " + labels.join(", ") + more,
clear: () => { this.selectedBrands = []; this.refreshFilter(); }
})
}
if (this.selectedCt.length) {
list.push({
label: "COLLECTION · " + this.selectedCt.join("+").toUpperCase(),
clear: () => { this.selectedCt = []; this.refreshFilter(); }
})
}
if (this.selectedSeason.length) {
list.push({
label: "SEASON · " + this.selectedSeason.join("+").toUpperCase(),
clear: () => { this.selectedSeason = []; this.refreshFilter(); }
})
}
if (this.selectedYear.length) {
list.push({
label: "YEAR · " + [...this.selectedYear].sort().join("+"),
clear: () => { this.selectedYear = []; this.refreshFilter(); }
})
}
return list
},
get loadBtnText() {
if (this.loadingMore) return "Loading…"
if (this.isEnd) return "— End of Archive —"
return "Load More ↓"
},
// 事件处理方法
toggleGroup(group: "designers" | "collection" | "season" | "year") {
this.collapsedGroups[group] = !this.collapsedGroups[group]
localStorage.setItem(`showsg-${group}`, this.collapsedGroups[group] ? "1" : "")
},
toggleBrand(id: number) {
if (this.selectedBrands.includes(id)) this.selectedBrands = []
else this.selectedBrands = [id]
this.refreshFilter()
},
refreshFilter() {
this.loadPage(1, false)
},
// ===== 品牌筛选弹窗方法 =====
async openBrandModal() {
this.modalSelected = [...this.selectedBrands]
this.modalKeyword = ""
this.modalLetter = "HOT"
this.modalOpen = true
await this.modalLoadHot()
},
closeBrandModal() {
this.modalOpen = false
},
async modalLoadHot() {
this.modalLetter = "HOT"
this.modalLoading = true
try {
this.modalResults = this.hotBrands.length
? this.hotBrands.map((b) => ({ id: b.id, name: b.name, article_count: b.article_count || 0 }))
: this.allBrands.slice(0, 50)
} finally {
this.modalLoading = false
}
},
async modalSearch() {
const kw = this.modalKeyword.trim()
if (kw) {
this.modalLetter = ""
this.modalLoading = true
try {
this.modalResults = await getBrands({ keyword: kw, size: 60 })
} catch {
this.modalResults = []
} finally {
this.modalLoading = false
}
} else if (this.modalLetter) {
await this.modalPickLetter(this.modalLetter)
} else {
await this.modalLoadHot()
}
},
async modalPickLetter(l: string) {
if (l === "HOT") {
this.modalKeyword = ""
await this.modalLoadHot()
return
}
this.modalLetter = l
this.modalKeyword = ""
this.modalLoading = true
try {
const letter = l === "#" ? "OTHER" : l
this.modalResults = await getBrands({ letter, size: 200 })
} catch {
this.modalResults = []
} finally {
this.modalLoading = false
}
},
toggleModalBrand(id: number) {
if (this.modalSelected.includes(id)) this.modalSelected = []
else this.modalSelected = [id]
},
confirmBrandModal() {
this.selectedBrands = [...this.modalSelected]
this.modalOpen = false
this.refreshFilter()
},
clearAll() {
this.selectedBrands = []
this.selectedCt = []
this.selectedSeason = []
this.selectedYear = []
this.refreshFilter()
},
// API 加载
async loadPage(page: number, append = false) {
const myToken = ++this.reqToken
if (append) {
this.loadingMore = true
} else {
this.loading = true
this.items = []
}
try {
const json = await getArticles({
page,
size: this.size,
sort: "newest",
brand_ids: this.selectedBrands,
collection_type: this.selectedCt,
season: this.selectedSeason,
year: this.selectedYear,
with_images: 6,
})
if (myToken !== this.reqToken) return
const rows = json.data.map((item: ArticleItem) => ({ ...item, _imgLoaded: false }))
this.total = json.total
this.page = page
this.items = append ? [...this.items, ...rows] : rows
this.isEnd = page >= json.last_page || rows.length < this.size
} catch {
if (myToken !== this.reqToken) return
if (!append) this.items = []
} finally {
if (myToken === this.reqToken) {
this.loading = false
this.loadingMore = false
}
}
},
// 辅助辅助工具函数
toAbsUrl(url: string) { return toAbs(url) },
articleHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.article(it.id)) },
isNewAdded(pubAt?: number) { return Date.now() / 1000 - Number(pubAt || 0) < 60 * 60 * 24 * 14 },
fmtDate(ts: number) {
if (!ts) return ""
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
},
ctLabel(ct?: string) {
const map: Record<string, string> = { rtw: "Ready-to-Wear", menswear: "Menswear", womenswear: "Womenswear", couture: "Couture", resort: "Resort", pre_fall: "Pre-Fall", bridal: "Bridal", swimwear: "Swimwear" }
return ct && map[ct] ? map[ct] : (ct || "")
},
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
}
}
})
})
</script>

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import { getSsrArticle } from "@/lib/api"
export const prerender = false export const prerender = false
const id = Astro.url.searchParams.get("id") const id = Astro.url.searchParams.get("id")
const { article, error } = await getSsrArticle(id) const { article, error } = await getSsrArticle(id, Astro.currentLocale)
--- ---
<Layout> <Layout>

View File

@ -7,7 +7,7 @@ import { getSsrArticle } from "@/lib/api"
export const prerender = false export const prerender = false
const { id } = Astro.params const { id } = Astro.params
const { article, error } = await getSsrArticle(id) const { article, error } = await getSsrArticle(id, Astro.currentLocale)
--- ---
<Layout> <Layout>

View File

@ -1,8 +0,0 @@
---
import Layout from "@/layouts/Layout.astro"
import AuthForm from "@/components/pages/AuthForm.astro"
---
<Layout title="Sign In">
<AuthForm mode="login" />
</Layout>

View File

@ -1,8 +0,0 @@
---
import Layout from "@/layouts/Layout.astro"
import AuthForm from "@/components/pages/AuthForm.astro"
---
<Layout title="Create Account">
<AuthForm mode="register" />
</Layout>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,765 +0,0 @@
---
import Layout from "@/layouts/Layout.astro"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getBrands, getSsgPopularBrands, type BrandEntry } from "@/lib/api"
import { getI18n } from '@/i18n/utils'
import { clientHref, ROUTES, href } from '@/lib/routes'
const { locale } = getI18n(Astro)
let sidebarBrands: BrandEntry[] = []
try {
sidebarBrands = (await getBrands({ size: 300, only_with_articles: 1, featured: 1 }))
.filter((b) => b.id && b.name)
.sort((a, b) => a.name.localeCompare(b.name))
} catch {}
// 热门品牌:构建期走 SSG 内部接口(/api/ssg/brands/popular),不可用时回落假数据先看效果
const FAKE_HOT_BRANDS = [
{ id: "hot-chanel", name: "Chanel", article_count: 342 },
{ id: "hot-dior", name: "Dior", article_count: 318 },
{ id: "hot-gucci", name: "Gucci", article_count: 287 },
{ id: "hot-prada", name: "Prada", article_count: 254 },
{ id: "hot-louisvuitton", name: "Louis Vuitton", article_count: 241 },
{ id: "hot-saintlaurent", name: "Saint Laurent", article_count: 219 },
{ id: "hot-balenciaga", name: "Balenciaga", article_count: 198 },
{ id: "hot-valentino", name: "Valentino", article_count: 176 },
{ id: "hot-givenchy", name: "Givenchy", article_count: 162 },
{ id: "hot-burberry", name: "Burberry", article_count: 153 },
{ id: "hot-fendi", name: "Fendi", article_count: 141 },
{ id: "hot-bottega", name: "Bottega Veneta", article_count: 128 },
]
let hotBrands: { id: string; name: string; article_count: number }[] = []
try {
const ssg = await getSsgPopularBrands(12)
if (ssg.length) hotBrands = ssg.map((b) => ({ id: String(b.id), name: b.brand || "", article_count: 0 }))
} catch {}
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
// 年份筛选:写死,按需增删(数据最早约 2005 年,如需更老年份往数组里加即可)
// 年份:从当今年份往前推 15 年(含),末项加"X年前"兜底
const currentYear = new Date().getFullYear()
const yearFloor = currentYear - 15
const yearOptions: { value: number | string; label: string }[] = []
for (let y = currentYear; y >= yearFloor; y--) {
yearOptions.push({ value: y, label: String(y) })
}
yearOptions.push({ value: `before${yearFloor}`, label: `${yearFloor}年前` })
// 系列类型 / 季节:写死静态数据(先用示例/假数据预览,确认后直接改这两个数组即可)
const collectionOptions: { value: string; label: string }[] = [
{ value: "rtw", label: "Ready-to-Wear" },
{ value: "menswear", label: "Menswear" },
{ value: "womenswear", label: "Womenswear" },
{ value: "couture", label: "Couture" },
{ value: "resort", label: "Resort" },
{ value: "pre_fall", label: "Pre-Fall" },
{ value: "bridal", label: "Bridal" },
{ value: "swimwear", label: "Swimwear" },
]
const seasonOptions: { value: string; label: string }[] = [
{ value: "spring", label: "Spring / Summer" },
{ value: "fall", label: "Fall / Winter" },
{ value: "cruise", label: "Cruise" },
{ value: "holiday", label: "Holiday" },
]
---
<Layout title="全部的 runway — Portfolio Studio">
<!-- 首屏绘制前同步侧边栏折叠状态,杜绝刷新时的闪烁 -->
<script is:inline>
try {
if (localStorage.getItem("showsaside-collapsed") === "1") {
document.documentElement.classList.add("showsaside-collapsed")
}
} catch (e) {}
</script>
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
<!-- 顶部:极简眉头 -->
<header class="showshead">
<div>
<Breadcrumb
items={[
{ label: 'Home', href: href(locale, ROUTES.home) },
{ label: 'Runway Archive', href: href(locale, ROUTES.shows) },
]}
>
<span slot="current" x-text="crumbYear">2026 Edition</span>
</Breadcrumb>
<h1 class="showsh1">全部的 runway</h1>
</div>
</header>
<!-- 已激活筛选 pills -->
<div class="showspills" x-show="pills.length > 0" x-cloak>
<template x-for="(p, i) in pills" :key="i">
<span class="showspill">
<span x-text="p.label"></span>
<button class="x" type="button" @click="p.clear()">×</button>
</span>
</template>
</div>
<!-- 主体:左 sidebar + 右 4 列商品 grid -->
<div class="showsbody">
<!-- LEFT SIDEBAR -->
<aside class="showsaside" :class="{ 'is-collapsed': asideCollapsed }">
<div class="showsaside-head">
<h2 class="showsaside-h">REFINE</h2>
<button class="showsaside-toggle" type="button" aria-label="折叠/展开筛选" @click="toggleAside()">
<svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true">
<path d="M7.5 3l-4 3 4 3" stroke="currentColor" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="showsaside-body" x-show="!asideCollapsed">
<!-- Designer 多选 -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('designers')">
<span>DESIGNERS</span>
<span class="showsg-right">
<span class="showsg-count" x-text="selectedBrands.length">0</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.designers">
<div class="showshot" x-show="hotBrands.length">
<div class="mb-2 text-[10px] uppercase tracking-[0.18em] text-black/45">HOT · Popular</div>
<div class="flex flex-wrap gap-1.5">
<template x-for="b in hotBrands" :key="b.id">
<label class="inline-flex items-center gap-1.5 border border-black/15 px-2 py-1 text-[11px] cursor-pointer transition-colors has-[:checked]:border-black has-[:checked]:bg-black has-[:checked]:text-white">
<input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" />
<span x-text="b.name"></span>
</label>
</template>
</div>
</div>
<ul class="showschecks">
<template x-for="item in visibleBrandItems" :key="item.key">
<li>
<!-- Letter Header -->
<template x-if="item.type === 'letter'">
<div class="letter-head" x-text="item.letter"></div>
</template>
<!-- Brand Item -->
<template x-if="item.type === 'brand'">
<label class="flex items-center gap-2 w-full cursor-pointer">
<input
type="checkbox"
:value="item.id"
:checked="selectedBrands.includes(item.id)"
@change="toggleBrand(item.id)"
>
<span x-text="item.name"></span>
<span class="ct" x-text="item.count ? item.count : ''"></span>
</label>
</template>
</li>
</template>
</ul>
<a class="showsmore" x-show="hasMoreBrands" @click="brandsExpanded = !brandsExpanded" x-text="brandsExpanded ? 'Show fewer ▴' : `Show all ${allBrands.length} designers ▾`"></a>
</div>
</section>
<!-- Collection type -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('collection')">
<span>COLLECTION</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.collection">
<ul class="showschecks showschecks--flat">
{collectionOptions.map((o) => (
<li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<!-- Season -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('season')">
<span>SEASON</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.season">
<ul class="showschecks showschecks--flat">
{seasonOptions.map((o) => (
<li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<!-- Year -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('year')">
<span>YEAR</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.year">
<ul class="showschecks showschecks--flat">
{yearOptions.map((o) => (
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<button class="showsclear" type="button" @click="clearAll()">Clear all ×</button>
</div>
</aside>
<!-- MAIN GRID -->
<main class="showsmain">
<div class="showsgrid" :class="{ 'is-loading': loading }">
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
<template x-if="loading && items.length === 0">
<template x-for="i in skeletonCount" :key="'skel-'+i">
<div class="showscard showscard--skel" aria-hidden="true">
<div class="showscard-left">
<div class="showscard-img"></div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div>
<div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div>
<div class="showsline h-[38px] rounded-[2px]"></div>
</div>
<div class="showscard-strip">
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
</div>
</div>
</div>
</template>
</template>
<!-- Empty State -->
<template x-if="!loading && items.length === 0">
<div class="showsempty">No shows match the current filters · 暂无符合条件的秀场</div>
</template>
<!-- Card List -->
<template x-for="it in items" :key="it.id">
<a class="showscard" :href="articleHref(it)" :aria-label="`${it.brand_name} ${it.title}`">
<div class="showscard-left">
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
<img :src="toAbsUrl(it.cover)" :alt="it.title" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
<!-- <span class="showsbadge" x-show="isNewAdded(it.published_at)">New</span> -->
</div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<h3 class="showscard-title" x-text="it.title"></h3>
<p class="showscard-meta" x-text="`${it.season_code || it.year || ''} · ${ctLabel(it.collection_type)} · ${it.brand_name}`"></p>
<div class="showscard-meta-foot">
<span class="showscard-meta-line"><span x-html="ICON_USER"></span><span x-text="it.brand_name"></span></span>
<span class="showscard-meta-line"><span x-html="ICON_DATE"></span><span x-text="fmtDate(it.published_at)"></span></span>
</div>
</div>
<div class="showscard-strip">
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
<div class="showscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG" x-show="slot.src"></span>
<img x-show="slot.src" :src="toAbsUrl(slot.src)" alt="" loading="lazy" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
<span class="showscard-thumb-more" x-show="slot.isLast && slot.moreLabel" x-text="slot.moreLabel"></span>
</div>
</template>
</div>
</div>
</a>
</template>
</div>
<div class="showsloadmore-wrap" x-show="items.length > 0">
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
</div>
</main>
</div>
</div>
</Layout>
<script id="showsbrands-data" type="application/json" set:html={JSON.stringify(sidebarBrands)}></script>
<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;
}
html.showsaside-collapsed .showsaside,
.showsaside.is-collapsed {
@apply flex-none w-[44px];
}
.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;
}
html.showsaside-collapsed .showsaside-h,
.showsaside.is-collapsed .showsaside-h {
@apply opacity-0 pointer-events-none w-0 overflow-hidden;
}
.showsaside-toggle {
@apply appearance-none bg-white border-[1.5px] border-black w-[22px] h-[22px] inline-flex items-center justify-center cursor-pointer rounded-none text-black p-0 transition-transform duration-250 ease-in-out hover:bg-black hover:text-white;
}
html.showsaside-collapsed .showsaside-toggle,
.showsaside.is-collapsed .showsaside-toggle {
@apply ml-auto rotate-180;
}
.showsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
}
html.showsaside-collapsed .showsaside-body {
@apply hidden;
}
.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="checkbox"] {
@apply appearance-none w-3 h-3 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-none inline-grid place-content-center checked:bg-black;
}
.showschecks input[type="checkbox"]:checked::after {
content: "";
@apply w-[6px] h-[6px] bg-white;
}
.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 inline-block mt-[10px] text-[10.5px] uppercase tracking-[0.16em] text-[#555] cursor-pointer hover:text-black;
}
.showsclear {
@apply mt-[18px] w-full border-[1.5px] border-black bg-white text-black p-[11px_12px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-150 hover:bg-black hover:text-white;
}
.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;
}
.showsloadmore-wrap {
@apply flex justify-center mt-[38px];
}
.showsloadmore {
@apply border-[1.5px] border-black bg-white text-black p-[13px_36px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-200 hover:bg-black hover:text-white disabled:opacity-40 disabled:cursor-default;
}
.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; } }
</style>
<script>
import Alpine from "alpinejs"
import { getArticles, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
const ICON_USER = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><circle cx="6" cy="4" r="2" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 10c0-2 1.8-3 4-3s4 1 4 3" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
const ICON_DATE = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><rect x="2" y="3" width="8" height="7" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 6h8M4 2v2M8 2v2" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
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>`
document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => {
const node = document.getElementById("showsbrands-data")
const allBrands: BrandEntry[] = node?.textContent ? JSON.parse(node.textContent) : []
const hotNode = document.getElementById("showshotbrands-data")
const hotBrands = hotNode?.textContent ? JSON.parse(hotNode.textContent) : []
return {
allBrands,
hotBrands,
locale: '',
ICON_USER,
ICON_DATE,
SPIN_SVG,
asideCollapsed: localStorage.getItem("showsaside-collapsed") === "1",
collapsedGroups: {
designers: localStorage.getItem("showsg-designers") === "1",
collection: localStorage.getItem("showsg-collection") === "1",
season: localStorage.getItem("showsg-season") === "1",
year: localStorage.getItem("showsg-year") === "1",
},
selectedBrands: [] as number[],
selectedCt: [] as string[],
selectedSeason: [] as string[],
selectedYear: [] as string[],
brandsExpanded: false,
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
loading: false,
loadingMore: false,
page: 1,
size: 24,
total: 0,
isEnd: false,
reqToken: 0,
init() {
this.locale = this.$el.dataset.locale || 'en'
this.loadPage(1, false)
},
// 计算属性:侧边栏设计师列表的分组渲染
get visibleBrandItems() {
const result: any[] = []
const map = new Map<string, BrandEntry[]>()
for (const b of this.allBrands) {
const letter = (b.name[0] || "?").toUpperCase()
if (!map.has(letter)) map.set(letter, [])
map.get(letter)!.push(b)
}
const grouped = [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
let shown = 0
const MAX_INITIAL = 60
for (const [letter, list] of grouped) {
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
result.push({ type: "letter", letter, key: `letter-${letter}` })
for (const b of list) {
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
result.push({ type: "brand", id: b.id, name: b.name, count: b.article_count, key: b.id })
shown++
}
}
return result
},
get hasMoreBrands() {
return this.allBrands.length > 60
},
// 面包屑末级:未选年份时显示默认的「2026 Edition」,选中后显示对应年份
get crumbYear() {
if (this.selectedYear.length) {
const ys = [...this.selectedYear].sort()
return ys.length === 1 ? `${ys[0]} Edition` : `${ys.join(" / ")}`
}
return "2026 Edition"
},
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
get skeletonCount() {
return this.size
},
// 已筛选选项 Pills 视图数据
get pills() {
const list: { label: string; clear: () => void }[] = []
if (this.selectedBrands.length) {
const findName = (id: any) => this.allBrands.find((b) => b.id === id)?.name ?? this.hotBrands.find((b) => b.id === id)?.name ?? `#${id}`
const labels = this.selectedBrands
.map((id) => findName(id))
.slice(0, 4)
const more = this.selectedBrands.length > 4 ? ` +${this.selectedBrands.length - 4}` : ""
list.push({
label: "DESIGNER · " + labels.join(", ") + more,
clear: () => { this.selectedBrands = []; this.refreshFilter(); }
})
}
if (this.selectedCt.length) {
list.push({
label: "COLLECTION · " + this.selectedCt.join("+").toUpperCase(),
clear: () => { this.selectedCt = []; this.refreshFilter(); }
})
}
if (this.selectedSeason.length) {
list.push({
label: "SEASON · " + this.selectedSeason.join("+").toUpperCase(),
clear: () => { this.selectedSeason = []; this.refreshFilter(); }
})
}
if (this.selectedYear.length) {
list.push({
label: "YEAR · " + [...this.selectedYear].sort().join("+"),
clear: () => { this.selectedYear = []; this.refreshFilter(); }
})
}
return list
},
get loadBtnText() {
if (this.loadingMore) return "Loading…"
if (this.isEnd) return "— End of Archive —"
return "Load More ↓"
},
// 事件处理方法
toggleAside() {
this.asideCollapsed = !this.asideCollapsed
localStorage.setItem("showsaside-collapsed", this.asideCollapsed ? "1" : "")
document.documentElement.classList.toggle("showsaside-collapsed", this.asideCollapsed)
},
toggleGroup(group: "designers" | "collection" | "season" | "year") {
this.collapsedGroups[group] = !this.collapsedGroups[group]
localStorage.setItem(`showsg-${group}`, this.collapsedGroups[group] ? "1" : "")
},
// 单选:每次只保留一个品牌,再次点击已选项则取消
toggleBrand(id: number) {
if (this.selectedBrands.includes(id)) this.selectedBrands = []
else this.selectedBrands = [id]
this.refreshFilter()
},
refreshFilter() {
this.loadPage(1, false)
},
clearAll() {
this.selectedBrands = []
this.selectedCt = []
this.selectedSeason = []
this.selectedYear = []
this.refreshFilter()
},
// API 加载
async loadPage(page: number, append = false) {
const myToken = ++this.reqToken
if (append) {
this.loadingMore = true
} else {
this.loading = true
this.items = []
}
try {
const json = await getArticles({
page,
size: this.size,
sort: "newest",
brand_ids: this.selectedBrands,
collection_type: this.selectedCt,
season: this.selectedSeason,
year: this.selectedYear,
with_images: 6,
})
if (myToken !== this.reqToken) return
const rows = json.data.map((item: ArticleItem) => ({ ...item, _imgLoaded: false }))
this.total = json.total
this.page = page
this.items = append ? [...this.items, ...rows] : rows
this.isEnd = page >= json.last_page || rows.length < this.size
} catch {
if (myToken !== this.reqToken) return
if (!append) this.items = []
} finally {
if (myToken === this.reqToken) {
this.loading = false
this.loadingMore = false
}
}
},
// 辅助辅助工具函数
toAbsUrl(url: string) { return toAbs(url) },
articleHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.article(it.id)) },
isNewAdded(pubAt?: number) { return Date.now() / 1000 - Number(pubAt || 0) < 60 * 60 * 24 * 14 },
fmtDate(ts: number) {
if (!ts) return ""
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
},
ctLabel(ct?: string) {
const map: Record<string, string> = { rtw: "Ready-to-Wear", menswear: "Menswear", womenswear: "Womenswear", couture: "Couture", resort: "Resort", pre_fall: "Pre-Fall", bridal: "Bridal", swimwear: "Swimwear" }
return ct && map[ct] ? map[ct] : (ct || "")
},
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
}
}
})
})
</script>