commit 5a8dbf41cb54448b41e55bac056f07dd3f860156
Author: toom1996 <23cm.cn@gmail.com>
Date: Tue Aug 11 22:29:47 2026 +0800
update
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..90afc23
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+node_modules
+dist
+.git
+.github
+.gitea
+*.md
+Dockerfile
+docker-compose.yml
+.env
+.env.*
+deploy.sh
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..5a91d60
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+# 复制为 .env 后按需修改(docker-compose 自动读取)
+# 注意:不要提交真实密钥;.env 已在 .dockerignore 中排除
+
+# ---- 后端数据库 ----
+DB_PASSWORD=root
+DB_USER=root
+DB_NAME=fashionadmin
+
+# ---- 后端 JWT 密钥(生产务必改为随机长字符串)----
+JWT_SECRET=change-me-in-prod
+
+# 说明:
+# - 前端构建期通过 compose 的 build.args.API_SSR=http://localhost:8090 拉数据
+# - 前端运行期走同源 /api(nginx 反代到 backend:8090),PUBLIC_API_BASE 在 Dockerfile 中置空
+# - backend 监听 8090;生产建议去掉 ports 暴露,仅由前端 nginx 反代访问
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..8d1d513
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,18 @@
+name: deploy
+on:
+ push:
+ branches: [main, master]
+
+jobs:
+ deploy:
+ runs-on: docker
+ steps:
+ - name: SSH 到服务器执行上线脚本
+ uses: appleboy/ssh-action@v1
+ with:
+ host: ${{ secrets.DEPLOY_HOST }}
+ username: ${{ secrets.DEPLOY_USER }}
+ key: ${{ secrets.DEPLOY_KEY }}
+ script: |
+ cd /opt/fashion/test
+ ./deploy.sh
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..016b59e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# build output
+dist/
+
+# generated types
+.astro/
+
+# dependencies
+node_modules/
+
+# logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# environment variables
+.env
+.env.production
+
+# macOS-specific files
+.DS_Store
+
+# jetbrains setting folder
+.idea/
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..22a1505
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,4 @@
+{
+ "recommendations": ["astro-build.astro-vscode"],
+ "unwantedRecommendations": []
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..d642209
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "command": "./node_modules/.bin/astro dev",
+ "name": "Development server",
+ "request": "launch",
+ "type": "node-terminal"
+ }
+ ]
+}
diff --git a/.workbuddy/memory/2026-08-07.md b/.workbuddy/memory/2026-08-07.md
new file mode 100644
index 0000000..2974646
--- /dev/null
+++ b/.workbuddy/memory/2026-08-07.md
@@ -0,0 +1,21 @@
+# 2026-08-07
+
+## 模块门禁 + 导航收敛(发布会模块聚焦)
+- 新增 src/components/ComingSoon.astro 编辑风占位组件(props:module / moduleZh / note)。
+- portfolio / latest-projects / about / contact / login / register 改为使用 Layout + ComingSoon 的「暂未上线」占位页(旧 URL 仍可访问,导航已移除入口)。
+- Layout.astro 导航收敛为纯发布会模块:仅 Home / Brands / Shows;移除 auth-slot、页脚 Categories、Color Studio Tools、About/Contact 链接;页脚 manifesto 改为走秀档案口径;保留 back-to-top。
+- login/register 由 standalone 页改为套 Layout,顺带修复此前字体变量未定义问题。
+- `npm run build` 通过,10 页全部生成;静态预览 :4400 验证占位页与导航收敛生效。
+- 决策:用户选择「首页 + 品牌 + 走秀」为发布会模块范围,其余「占位页 + 移除导航」。
+
+## 修正:header 不动(23:47)
+- 用户明确「header 别动呀」,故将 Layout.astro 整体还原为原样(顶部导航 Portfolio/Latest/About/Contact + Login/Signup、页脚 Categories/Color Studio Tools/About/Contact 全部恢复)。
+- 门禁改为**仅靠 6 个 ComingSoon 占位页**实现:导航链接保留不动,点击 Portfolio/Latest/About/Contact/Login/Register 才看到「暂未上线」。
+- 重新 `npm run build` 通过。
+
+## 首页 tabbar:仅「发布会」上线(23:5x)
+- 用户澄清前说的「navbar」实为**首页 tabbar**(id="tabbar",不是顶部 header)。
+- index.astro 改造:tabbar 中 **发布会(backstage) 设为默认激活 + Live 徽标**,承载原 shows 的 SSR 真实走秀卡片(getShows(8));灵感(shows)/社媒(events)/街拍(street) 三个 tab 加 `data-soon="true"` + Soon 灰徽标,点击仅切换显示 ComingSoon 占位(不再请求后端切片数据)。
+- JS tab 点击处理:命中 `data-soon` 则跳过 `loadPanel()`,仅切面板;其余逻辑不变。
+- 「品牌」tab 仍为指向 /brands 的有效链接(属于已上线发布会模块,未屏蔽)。
+- `npm run build` 通过,校验:tabbar active/soon 标记正确、发布会面板 8 张真实卡片、其余三块含 Coming Soon。
diff --git a/.workbuddy/memory/2026-08-08.md b/.workbuddy/memory/2026-08-08.md
new file mode 100644
index 0000000..8a0758b
--- /dev/null
+++ b/.workbuddy/memory/2026-08-08.md
@@ -0,0 +1,57 @@
+# 2026-08-08
+
+## 走秀数据分类设计(进行中,尚未落地)
+- 用户希望给 brand_runway 加分类:地理(国家/城市)、系列(季节/类型如 Resort/春季巡游)、时间(year)。
+- 现状:brand_runway 仅有 year,无 city/country/season/type 字段。source_url 形如 `vogue.com/fashion-shows/fall-2017-ready-to-wear/fendi/...` 不含城市,无法自动解析城市。
+- 采样确认 title 为 Vogue 标准格式,系列+季节+年份 100% 可正则解析 `^(Spring|Fall|Resort|Pre-Fall)\s+(\d{4})\s*(.*)$`(已验证 600 条)。
+- 三维模型:① 地理(国家→城市→地区)② 系列(collection_type: rtw/menswear/couture/resort/pre_fall × season: spring/fall)③ 时间(year)。
+- **用户修正:城市 ≠ 品牌总部城市**(Fendi 总部罗马但 RTW 在米兰走;Valentino 意牌在巴黎走;Resort/早秋满世界巡演)。
+ - 主系列(rtw/menswear/couture)城市 = 「品牌所属时装周城市」映射(非总部,更准),内置主流品牌映射表自动填充。
+ - Resort/Pre-Fall 为巡演系列,城市不固定,UI 归「巡演/Cruise」桶,不硬绑城市。
+ - country/region 由 city→country 推导。
+- 待用户确认是否落地(字段+迁移+回填脚本+API 筛选+前端筛选器)。
+
+## 网络抓取城市可行性实测(结论:不现实)
+- 实测拉取 Vogue 真实秀场页(vogue.com/fashion-shows/fall-2017-ready-to-wear/fendi/slideshow/collection, 5.8MB):
+ - title 仅 "Fendi Fall 2017 Ready-to-Wear Fashion Show",无城市;正文 city 命中全是误命中(Romee Strijd 模特名 / model-romee-strijd.jpg / 文案)。
+ - 唯一结构化片段 `{"name":"New York City","score":1}`/`{"name":"Rome","score":1}` 是相关实体联想,非秀场城市,不可信。
+ - Vogue 现 URL 已去掉城市段,无法反推。
+- 数据集 total=25206 条;逐页爬 25k 需 ~14h(礼貌速率)+ Cloudflare/封禁/ToS 风险 → 不现实。
+- 现实方案:**品牌→所属时装周城市映射表**(非总部,准确捕捉 Fendi→Milan 等),主系列秒级批量回填;Resort/Pre-Fall 归「巡演/Cruise」桶不绑城市。
+
+## 落地:三维分类 + 筛选(已完成)
+- 用户选「暂不做城市维度」,只做 系列+季节+年份 三维与筛选。
+- models.go: BrandRunway 加 collection_type/season/season_code 三字段。
+- main.go: migrateSchema 加三列 + backfillRunwayClassification()(单条 UPDATE CASE 解析 title 回填,幂等);仅处理 collection_type='' 行。重启后端实测 25206 行 ~338ms 完成。
+- handlers/public_article.go: PublicListArticles 加 ?collection_type=&season=&season_code=&year= 筛选,响应含三字段。
+- 前端: data.ts RawArticle 增可选三字段; articles.astro 加 #typefilter/#seasonfilter 两组 chips,loadBackend() 带参重拉。
+- `npm run build` 通过;dist/articles 含筛选条,JS 含 collection_type 参数。
+- 分布: rtw 15203 / menswear 4061 / couture 672 / resort 2894 / pre_fall 2376;spring 9140 / fall 9049。
+
+## 首页发布会面板优化(已完成)
+- 用户要求:① 首页也展示「系列 + 季节」标签;② 优化发布会面板更吸引人。
+- data.ts: ArticleCard 增加 `collection_type`/`season_code` 两字段(mapArticle 透传);新增 `SERIES_LABELS` + `seriesLabel()`(rtw→成衣 RTW / menswear→男装 / couture→高定 / resort→度假 / pre_fall→早秋)。
+- index.astro 发布会面板重构:
+ - 区块标题 `RUNWAY ARCHIVE / 走秀档案` + 「全部走秀 →」(链 /articles)。
+ - 系列快速筛选条 `#seriesbar`(全部/成衣/男装/高定/度假/早秋),前端按 `data-ct` 本地过滤 Hero + 网格,纯本地无请求;空结果显示 `#showempty`。
+ - 精选 Hero(取 shows[0]):大图 + 品牌大字 + 系列/季节 chip + 查看全部 N 张,形成视觉焦点。
+ - 网格卡片(剩余 shows)品牌名下方加 `prod-meta` 行: [系列中文] + [season_code 如 FW16] 小 chip。
+ - 卡片底部改纯 CTA(去掉原 AW16 season 文本与 new/hot 徽标,改由系列/季节 chip 表达)。
+ - JS 同步:ensurePanelData 透传 collection_type/season_code;cardHTML 渲染 prod-meta(此路径首页当前为死路径,仅保持一致)。
+- `npm run build` 通过;dist/index.html 校验:sec-head/seriesbar/feat/prod-meta/prod-chip 均在,真实 season_code(SS16/FW16/PF16…)与中文系列标签分布正确,Hero 显示 FENDI。
+- 预览: http://localhost:4420/ (构建需带 PUBLIC_API_BASE 才能让 SSR 首页拉到真实数据;Docker 生产因 PUBLIC_API_BASE 为空、index.astro 用顶层 await getShows 会导致首页空——属既有行为,非本次引入)。
+
+## 首页 feat 竖版拼图 + 查看更多 + /shows 页(已完成)
+- 用户要求:① 首页走秀栏加「查看更多」;② 加一个「所有走秀」页面;③ feat 图都是竖版,问能否改成多竖版或更好创意。
+- **feat 创意**:用同一场秀自己的 gallery(loadArticlesWithImages 带 with_images=5,每秀最多5张 own looks)做「左侧大竖图 + 右侧 3 张竖版 rail」编辑拼图(aspect 3/4),比混排不同秀更好——保留单一焦点秀场且尊重竖版比例。优于用户提的"多竖版混排"。
+- index.astro 改动:
+ - 前端 matter 计算 `feat = shows[0]`、`featRail = gallery.slice(1,4)`(不足则退化为 [img])。
+ - feat 块重写为 feat-main(竖图 + feat-fill 渐变浮层含品牌/系列/季节/CTA) + feat-rail(3 个竖版 rail-item)。
+ - CSS: 删旧 .feat hero(4:3),加 .feat-main/.feat-fill/.feat-ovl/.feat-rail/.feat-rail-item(全部 3:4 竖版)。
+ - 网格下加 #showmore 按钮 + .showmore CSS。
+ - JS: 系列筛选 IIFE 重构为 `seriesApply()` 暴露 `homeFType`;新增 load-more IIFE(fetch ?page=2..&size=8&with_images=5 追加到 #showgrid,带 homeFType,无更多显示「没有更多了」)。
+ - sec-more「全部走秀 →」href 改 /shows。
+- 新建 src/pages/shows.astro(所有走秀页):基于 articles.astro,标题「All Runway」;保留系列/季节/品牌筛选 + 三视图(Grid/Magazine/Timeline);新增分页 Load More(page/total/busy,append 模式,后端 ?page=&size=30&with_images=5);SSR getShows(30) 覆盖;cardTemplate 用 `data-ct` 与 date 字段,去掉 fmtDate 依赖。
+- `npm run build` 通过(11 页);dist 校验:首页 feat-main/feat-rail/3 rail-item/showmore 均在(共4竖图),系列标签分布正确;/shows 含 All Runway + loadmore + 30 SSR 卡片。
+- 后端验证:page=2 size=8 with_images=5 返回 8 行带 images;collection_type=resort 筛选正确。
+- 预览: http://localhost:4430/ 与 http://localhost:4430/shows/ 。
diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md
new file mode 100644
index 0000000..d1fd1c5
--- /dev/null
+++ b/.workbuddy/memory/MEMORY.md
@@ -0,0 +1,37 @@
+# 项目长期记忆(Project MEMORY.md)
+
+## 模块策略(重要)
+- 站点按模块逐个完善。当前只上线「发布会模块」:首页 index + 品牌索引 brands + 走秀列表 articles + 走秀详情 article。
+- **所有走秀页 `/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。
+- 其余模块(Portfolio / Latest Projects / About / Contact / 登录注册)为「暂未上线」占位页:直接访问旧 URL 或点击导航旧链接均渲染 ComingSoon 占位;**导航(header)保持原样未改动**(用户 2026-08-07 明确「header 别动」),门禁完全靠占位页实现。
+- **首页 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;两个按钮「浏览走秀档案 →」「返回首页」)。6 个页面(portfolio / latest-projects / about / contact / login / register)改为 ``。
+- login/register 原 standalone 页(未 import global.css、字体变量未定义),已改为套 Layout + ComingSoon 占位页,顺带修复字体一致性;导航中 Login/Signup 链接保留,点击即见占位。
+
+## 架构总览
+- 前端:/d/project/test/test — Astro SSG + Tailwind v4。构建 `PUBLIC_API_BASE=http://localhost:8090 npm run build`;预览 `python -m http.server` 托管 dist/。
+- 后端:/d/project/admin/backend — Go + Gin + GORM(MySQL),:8090。路由分后台 /api/* 与公开只读 /api/public/*。
+- 数据层:前端 src/lib/data.ts 统一从 /api/public/* 拉数据;后端不可达时回落内置示例。
+
+## 关键约定
+- 视觉语言:黑白极简编辑风(monospace + Georgia 衬线大标题 + 细线),HBX / 时尚档案调性。
+- 字体:全站走 Tailwind 体系(font-sans / font-mono / font-serif),禁止自写截断字体栈。新增页面复用项目字体变量。
+- CSS 写法:能用 @apply 就 @apply;`
+
diff --git a/shot_feat.png b/shot_feat.png
new file mode 100644
index 0000000..574f8d6
Binary files /dev/null and b/shot_feat.png differ
diff --git a/shot_feat2.png b/shot_feat2.png
new file mode 100644
index 0000000..04c8619
Binary files /dev/null and b/shot_feat2.png differ
diff --git a/shot_hbx.png b/shot_hbx.png
new file mode 100644
index 0000000..801a956
Binary files /dev/null and b/shot_hbx.png differ
diff --git a/shot_hbx2.png b/shot_hbx2.png
new file mode 100644
index 0000000..4b27b66
Binary files /dev/null and b/shot_hbx2.png differ
diff --git a/shot_hbx_v10.png b/shot_hbx_v10.png
new file mode 100644
index 0000000..5af77af
Binary files /dev/null and b/shot_hbx_v10.png differ
diff --git a/shot_hbx_v11.png b/shot_hbx_v11.png
new file mode 100644
index 0000000..0dc2e9f
Binary files /dev/null and b/shot_hbx_v11.png differ
diff --git a/shot_hbx_v12.png b/shot_hbx_v12.png
new file mode 100644
index 0000000..539285f
Binary files /dev/null and b/shot_hbx_v12.png differ
diff --git a/shot_hbx_v13.png b/shot_hbx_v13.png
new file mode 100644
index 0000000..b34f877
Binary files /dev/null and b/shot_hbx_v13.png differ
diff --git a/shot_hbx_v14.png b/shot_hbx_v14.png
new file mode 100644
index 0000000..eb06dbc
Binary files /dev/null and b/shot_hbx_v14.png differ
diff --git a/shot_hbx_v15.png b/shot_hbx_v15.png
new file mode 100644
index 0000000..7210332
Binary files /dev/null and b/shot_hbx_v15.png differ
diff --git a/shot_hbx_v3.png b/shot_hbx_v3.png
new file mode 100644
index 0000000..a8d88de
Binary files /dev/null and b/shot_hbx_v3.png differ
diff --git a/shot_hbx_v4.png b/shot_hbx_v4.png
new file mode 100644
index 0000000..ab5eabc
Binary files /dev/null and b/shot_hbx_v4.png differ
diff --git a/shot_hbx_v5.png b/shot_hbx_v5.png
new file mode 100644
index 0000000..06f92a9
Binary files /dev/null and b/shot_hbx_v5.png differ
diff --git a/shot_hbx_v6.png b/shot_hbx_v6.png
new file mode 100644
index 0000000..5260ab8
Binary files /dev/null and b/shot_hbx_v6.png differ
diff --git a/shot_hbx_v7.png b/shot_hbx_v7.png
new file mode 100644
index 0000000..d1c16be
Binary files /dev/null and b/shot_hbx_v7.png differ
diff --git a/shot_hbx_v8.png b/shot_hbx_v8.png
new file mode 100644
index 0000000..2b94679
Binary files /dev/null and b/shot_hbx_v8.png differ
diff --git a/shot_hbx_v9.png b/shot_hbx_v9.png
new file mode 100644
index 0000000..2156431
Binary files /dev/null and b/shot_hbx_v9.png differ
diff --git a/shot_hero.png b/shot_hero.png
new file mode 100644
index 0000000..574f8d6
Binary files /dev/null and b/shot_hero.png differ
diff --git a/src/assets/astro.svg b/src/assets/astro.svg
new file mode 100644
index 0000000..8cf8fb0
--- /dev/null
+++ b/src/assets/astro.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/background.svg b/src/assets/background.svg
new file mode 100644
index 0000000..4b2be0a
--- /dev/null
+++ b/src/assets/background.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/ComingSoon.astro b/src/components/ComingSoon.astro
new file mode 100644
index 0000000..08090bb
--- /dev/null
+++ b/src/components/ComingSoon.astro
@@ -0,0 +1,77 @@
+---
+interface Props {
+ module?: string
+ moduleZh?: string
+ note?: string
+}
+const { module = "This module", moduleZh = "", note = "" } = Astro.props
+---
+
+
+
+ Coming Soon
+ 暂未上线
+
+ 暂未上线
+ {module}{moduleZh && ` · ${moduleZh}`}
+
+ {note || "该模块正在筹备中,尚未对外开放。站点正按模块逐个完善,你可以先浏览已上线的走秀档案。"}
+
+
+
+
+
diff --git a/src/components/Welcome.astro b/src/components/Welcome.astro
new file mode 100644
index 0000000..1b2cf9c
--- /dev/null
+++ b/src/components/Welcome.astro
@@ -0,0 +1,210 @@
+---
+import astroLogo from '../assets/astro.svg';
+import background from '../assets/background.svg';
+---
+
+
+
+
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
new file mode 100644
index 0000000..74d0cdd
--- /dev/null
+++ b/src/layouts/Layout.astro
@@ -0,0 +1,228 @@
+---
+import '../styles/global.css'
+
+const {
+ title = 'Portfolio Studio',
+ activeCategory = 'Shows',
+ activeNavTab,
+ activeThemePalette = null,
+} = Astro.props;
+
+const categories: { label: string; value: PortfolioCategory | 'Trending Colors' }[] = [
+ { label: '灵感', value: '灵感' },
+ { label: 'Backstage', value: 'Backstage' },
+ { label: 'Events', value: 'Events' },
+ { label: 'Street', value: 'Street' },
+ { label: 'Trending Colors', value: 'Trending Colors' },
+];
+---
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..558a8fe
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,115 @@
+// src/lib/api.ts — 后端 API 统一客户端
+// 集中所有 /api 调用:base URL 解析、错误处理、端点封装。
+//
+// base URL 解析规则:
+// - DEV(astro dev,前端 4345 + 后端 8090):一律回落 http://localhost:8090(CORS=*)。
+// - SSR(构建/预渲染期):走 server-only 的 API_SSR,回落 PUBLIC_API_BASE。
+// - 客户端(浏览器运行期):走 PUBLIC_API_BASE,置空时同源相对 /api(由 nginx 反代到 backend)。
+
+export interface BrandEntry {
+ id: number
+ name: string
+ article_count: number
+}
+
+export interface ArticleItem {
+ id: number
+ brand_id: number
+ title: string
+ cover: string
+ brand_name: string
+ year: number
+ image_count: number
+ published_at: number
+ collection_type?: string
+ season?: string
+ season_code?: string
+ gallery: string[]
+}
+
+export interface ArticleQuery {
+ page?: number
+ size?: number
+ sort?: string
+ brand_ids?: number[]
+ collection_type?: string[]
+ season?: string[]
+ year?: string[]
+ with_images?: number
+}
+
+export interface ArticlePage {
+ data: ArticleItem[]
+ total: number
+ last_page: number
+}
+
+export function getBase(): string {
+ const env = import.meta.env as any
+ if (env.DEV) return "http://localhost:8090"
+ if (env.SSR) return (env.API_SSR || env.PUBLIC_API_BASE || "").replace(/\/$/, "")
+ return (env.PUBLIC_API_BASE || "").replace(/\/$/, "")
+}
+
+// 把相对图片路径补成绝对(仅客户端渲染卡片时用;空 base 时保留相对路径走同源)
+export function toAbs(url: string): string {
+ if (!url) return ""
+ return /^https?:\/\//i.test(url) ? url : getBase() + url
+}
+
+async function request(path: string, params?: URLSearchParams): Promise {
+ const qs = params && params.toString() ? "?" + params.toString() : ""
+ const url = getBase() + "/api" + path + qs
+ const res = await fetch(url)
+ if (!res.ok) throw new Error(`API ${res.status} ${url}`)
+ return (await res.json()) as T
+}
+
+// 侧边栏品牌清单(含 article_count 计数)
+export async function getBrands(opts?: {
+ size?: number
+ only_with_articles?: number
+ featured?: number
+}): Promise {
+ const qs = new URLSearchParams()
+ if (opts?.size) qs.set("size", String(opts.size))
+ if (opts?.only_with_articles) qs.set("only_with_articles", String(opts.only_with_articles))
+ if (opts?.featured) qs.set("featured", String(opts.featured))
+ const j = await request<{ data?: any[] }>("/public/brands", qs)
+ return (j.data ?? []).map((b: any) => ({
+ id: Number(b.id),
+ name: (b.name || b.show_name || "").toString(),
+ article_count: Number(b.article_count ?? 0),
+ }))
+}
+
+// 最近若干条秀场中提取的独特年份(降序去重,最多 6 个),用于筛选年份 chips
+export async function getYears(sample = 30): Promise {
+ const qs = new URLSearchParams({ size: String(sample), sort: "year_desc" })
+ const j = await request<{ data?: any[] }>("/public/articles", qs)
+ const ys = Array.from(
+ new Set((j.data ?? []).map((it: any) => Number(it.year)).filter((y: number) => y > 0))
+ ) as number[]
+ return ys.slice(0, 6)
+}
+
+// 文章/秀场列表:统一构造 QueryArray 参数(多选用 repeated params),
+// 并把后端 images[] 映射成 gallery 字符串数组(缩略图条用)。
+export async function getArticles(q: ArticleQuery): Promise {
+ const p = new URLSearchParams()
+ p.set("page", String(q.page ?? 1))
+ p.set("size", String(q.size ?? 24))
+ if (q.sort) p.set("sort", q.sort)
+ if (q.with_images) p.set("with_images", String(q.with_images))
+ if (q.brand_ids?.length) p.set("brand_ids", q.brand_ids.join(","))
+ // 多选:同一字段用 repeated params(后端 QueryArray 收到数组后 1 个走 = / 多个走 IN)
+ for (const v of q.collection_type ?? []) p.append("collection_type", v)
+ for (const v of q.season ?? []) p.append("season", v)
+ for (const v of q.year ?? []) p.append("year", v)
+ const j = await request<{ data?: any[]; total?: number; last_page?: number }>("/public/articles", p)
+ const data: ArticleItem[] = (j.data ?? []).map((it: any) => ({
+ ...it,
+ gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
+ }))
+ return { data, total: Number(j.total ?? data.length), last_page: Number(j.last_page ?? 1) }
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..d450e6c
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,87 @@
+// 账号体系前端封装:token 存储 + 注册/登录/当前用户
+// 与后端 /api/auth/* 交互(JWT 无状态,token 存 localStorage)
+
+const API_BASE = ((import.meta as any).env?.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
+const TOKEN_KEY = "fa_token"
+const USER_KEY = "fa_user"
+
+export type AuthUser = { id: number; username: string; email: string }
+
+export function getToken(): string | null {
+ return localStorage.getItem(TOKEN_KEY)
+}
+
+export function getUser(): AuthUser | null {
+ const raw = localStorage.getItem(USER_KEY)
+ if (!raw) return null
+ try {
+ return JSON.parse(raw) as AuthUser
+ } catch {
+ return null
+ }
+}
+
+export function setSession(token: string, user: AuthUser) {
+ localStorage.setItem(TOKEN_KEY, token)
+ localStorage.setItem(USER_KEY, JSON.stringify(user))
+}
+
+export function clearSession() {
+ localStorage.removeItem(TOKEN_KEY)
+ localStorage.removeItem(USER_KEY)
+}
+
+export async function login(account: string, password: string): Promise {
+ const res = await fetch(`${API_BASE}/api/auth/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ account, password }),
+ })
+ const json: any = await res.json().catch(() => ({}))
+ if (!res.ok) throw new Error(json?.error || "登录失败")
+ setSession(json.token, json.user)
+ return json.user as AuthUser
+}
+
+export async function register(
+ username: string,
+ email: string,
+ password: string
+): Promise {
+ const res = await fetch(`${API_BASE}/api/auth/register`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, email, password }),
+ })
+ const json: any = await res.json().catch(() => ({}))
+ if (!res.ok) throw new Error(json?.error || "注册失败")
+ setSession(json.token, json.user)
+ return json.user as AuthUser
+}
+
+// 用本地 token 拉取最新用户信息(校验有效性);无效则清空会话
+export async function fetchMe(): Promise {
+ const token = getToken()
+ if (!token) return null
+ try {
+ const res = await fetch(`${API_BASE}/api/auth/me`, {
+ headers: { Authorization: `Bearer ${token}` },
+ })
+ if (!res.ok) {
+ clearSession()
+ return null
+ }
+ const json: any = await res.json()
+ const user = json?.user as AuthUser
+ if (user) localStorage.setItem(USER_KEY, JSON.stringify(user))
+ return user
+ } catch {
+ return getUser()
+ }
+}
+
+// 带鉴权头的请求头(供将来受保护接口使用)
+export function authHeaders(): Record {
+ const t = getToken()
+ return t ? { Authorization: `Bearer ${t}` } : {}
+}
diff --git a/src/lib/data.ts b/src/lib/data.ts
new file mode 100644
index 0000000..8a01d6a
--- /dev/null
+++ b/src/lib/data.ts
@@ -0,0 +1,332 @@
+// 网站统一数据层
+// 从后端公开只读接口 (/api/public/...) 拉取真实数据,并适配为前端各模块
+// (site / shows / trends / colors / street,以及首页 backstage 面板、文章画廊 reports) 所需结构。
+//
+// 设计原则:
+// 1. 单一数据源:所有 getter 共享一次分页拉取 (loadArticles),避免重复请求。
+// 2. 优雅回落:后端不可达或返回空时,回落到内置示例,保证 SSG 构建与离线预览不报错。
+// 3. 颜色模块:后端暂无独立颜色接口,getColors() 先尝试 /api/public/colors,
+// 失败则从 runway 文章封面派生占位(接入颜色接口后即可自动生效)。
+
+const API_BASE = ((import.meta as any).env?.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
+
+export type RawArticle = {
+ id: number
+ title: string
+ summary?: string
+ description?: string
+ cover: string
+ brand_name: string
+ year: number
+ image_count: number
+ published_at: number
+ source_url?: string
+ collection_type?: string
+ season?: string
+ season_code?: string
+ images?: { id: number; image: string; name?: string }[]
+}
+
+// ---------- 通用辅助 ----------
+export function toAbs(url: string): string {
+ if (!url) return ""
+ return /^https?:\/\//i.test(url) ? url : API_BASE + url
+}
+
+export function seasonLabel(y: number | string): string {
+ const n = Number(y)
+ return n ? `AW${String(n).slice(2)}` : ""
+}
+
+// 系列类型 → 展示名(用于首页/列表的系列标签与筛选)
+export const SERIES_LABELS: Record = {
+ 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: 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 | 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 {
+ if (_loaded) return _loaded
+ _loaded = (async () => {
+ if (!API_BASE) return fromSample()
+ try {
+ const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=60`)
+ if (!res.ok) return fromSample()
+ const json: any = await res.json()
+ const items: RawArticle[] = json?.data ?? []
+ if (!items.length) return fromSample()
+ return compute(items, Number(json?.total ?? items.length))
+ } catch {
+ return fromSample()
+ }
+ })()
+ return _loaded
+}
+
+export async function fetchArticleById(id: number | string): Promise {
+ if (!API_BASE) return null
+ try {
+ const res = await fetch(`${API_BASE}/api/public/articles/${id}`)
+ if (!res.ok) return null
+ const json: any = await res.json()
+ return json?.data ?? null
+ } catch {
+ return null
+ }
+}
+
+// ---------- 模块 getter ----------
+// 统一映射:四块面板共用同一真实文章结构,字段含义清晰、不再错配
+// (接口仅暴露 runway 文章一类数据,四块面板取真实列表的不同区段,避免重复同一批内容)
+
+export type ArticleCard = {
+ id: 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 {
+ const items = await loadArticlesWithImages(40)
+ return items.slice(0, limit)
+}
+
+export async function getBackstage(limit = 8): Promise {
+ const items = await loadArticlesWithImages(40)
+ return items.slice(8, 8 + limit)
+}
+
+export async function getTrends(limit = 9): Promise {
+ const items = await loadArticlesWithImages(40)
+ return items.slice(16, 16 + limit)
+}
+
+export async function getStreet(limit = 8): Promise {
+ const items = await loadArticlesWithImages(40)
+ return items.slice(24, 24 + limit)
+}
+
+// 列表带图拉取:一次请求即带回每篇文章前 5 张图(后端 with_images 参数),
+// 用于首页卡片缩略图,避免此前逐篇详情请求导致的缓慢。
+let _loadedWithImages: Promise | null = null
+export function loadArticlesWithImages(size = 40): Promise {
+ if (_loadedWithImages) return _loadedWithImages
+ _loadedWithImages = (async () => {
+ if (!API_BASE) return []
+ try {
+ const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=${size}&with_images=5`)
+ if (!res.ok) return []
+ const json: any = await res.json()
+ const items: RawArticle[] = json?.data ?? []
+ return items.map((r: RawArticle) => {
+ 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(items: T[]): Promise {
+ 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: number
+ name: string
+ brand: string
+ season: string
+ img: string
+}
+export async function getColors(limit = 12): Promise {
+ // 后端暂无独立颜色接口:优先尝试 /api/public/colors,失败则从 runway 文章封面派生占位。
+ if (API_BASE) {
+ try {
+ const res = await fetch(`${API_BASE}/api/public/colors`)
+ if (res.ok) {
+ const json: any = await res.json()
+ const data: any[] = json?.data ?? []
+ if (data.length) {
+ return data.slice(0, limit).map((c) => ({
+ id: c.id ?? 0,
+ name: c.name || c.title || "",
+ brand: c.brand_name || "",
+ season: seasonLabel(c.year),
+ img: toAbs(c.cover || c.image || ""),
+ }))
+ }
+ }
+ } catch {
+ /* 回落到封面派生 */
+ }
+ }
+ 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 {
+ 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: 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 {
+ 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,
+ }))
+}
diff --git a/src/pages/about.astro b/src/pages/about.astro
new file mode 100644
index 0000000..d46787b
--- /dev/null
+++ b/src/pages/about.astro
@@ -0,0 +1,8 @@
+---
+import Layout from "../layouts/Layout.astro"
+import ComingSoon from "../components/ComingSoon.astro"
+---
+
+
+
+
diff --git a/src/pages/article.astro b/src/pages/article.astro
new file mode 100644
index 0000000..e27aabe
--- /dev/null
+++ b/src/pages/article.astro
@@ -0,0 +1,463 @@
+---
+import Layout from "../layouts/Layout.astro"
+
+// 示例数据:字段严格对齐后端 BrandRunway 模型 + 图片集接口
+const article = {
+ id: 1,
+ title: "Maison Lumen — Paper Light",
+ description:
+ "巴黎,2026 秋冬。Maison Lumen 以“Paper Light(纸光)”为题,将薄纱、宣纸与压褶工艺叠合成一种近乎透明的体积感。\n\n系列延续了工作室对“轻”的执念:肩线被刻意放低,廓形在行走中随风摆动,像被光穿透的纸。色彩仅保留骨白、炭灰与一抹雾蓝。\n\n这不是关于廓形的宣言,而是关于 material 的克制——用最少的手段,制造最大的呼吸感。",
+ created_at: 1711948800,
+ updated_at: 1711948800,
+ year: 2026,
+ source_url: "https://example.com/runway/maison-lumen-paper-light",
+ brand_id: 12,
+ brand_name: "Maison Lumen",
+ images: [
+ { id: 1, image: "https://picsum.photos/seed/art1/1200/1500", name: "Look 01", sort_order: 1 },
+ { id: 2, image: "https://picsum.photos/seed/art2/1200/1500", name: "Look 02", sort_order: 2 },
+ { id: 3, image: "https://picsum.photos/seed/art3/1200/1500", name: "Look 03", sort_order: 3 },
+ { id: 4, image: "https://picsum.photos/seed/art4/1200/1500", name: "Look 04", sort_order: 4 },
+ ],
+}
+
+const fmt = (ts: number) => {
+ const d = new Date(ts * 1000)
+ const y = d.getFullYear()
+ const m = String(d.getMonth() + 1).padStart(2, "0")
+ const day = String(d.getDate()).padStart(2, "0")
+ return `${y}.${m}.${day}`
+}
+
+const body = article.description.split("\n\n")
+---
+
+
+
+
+
+
← Index
+
Runway · {article.brand_name}
+
+
+
+
+ {article.title}
+
+
+
+
+
{fmt(article.created_at)}
+
{article.year}
+
Source ↗
+
+
+
+
+
+ Report
+
+
+ {body.map((p) =>
{p}
)}
+
+
+
+
+
+
+
+
+
+ {article.images.map((im) => (
+
+
+ {im.name}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
点击图片放大 · 上下滚动查看 · Esc 关闭
+
+
+
+
+
+
+
+
diff --git a/src/pages/articles.astro b/src/pages/articles.astro
new file mode 100644
index 0000000..071601e
--- /dev/null
+++ b/src/pages/articles.astro
@@ -0,0 +1,474 @@
+---
+import Layout from "../layouts/Layout.astro"
+import { getReports, fmtDate } from "../lib/data"
+
+// 文章展示创意布局页:复用你现有的 Layout 外壳。
+// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts);
+// 后端不可达时回落到内置示例,非对称创意布局结构完全不变。
+
+const reports = await getReports(30)
+const fmt = fmtDate
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/brands.astro b/src/pages/brands.astro
new file mode 100644
index 0000000..1344999
--- /dev/null
+++ b/src/pages/brands.astro
@@ -0,0 +1,258 @@
+---
+import Layout from "../layouts/Layout.astro"
+
+// 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开)
+// - 数据:一次 SSR 拉完全部精选集合(?size=300&only_with_articles=1&featured=1),
+// 前端按 name 首字母分桶渲染;198 个品牌数据量小,无需懒加载。
+// - 风格:极简编辑风(黑白 + monospace + 细线)。
+// · 顶部单行 BRANDS 标题 + 总数
+// · sticky A-Z 字母条(点击平滑滚动)
+// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
+// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
+// 构建期 SSR 拉数据用 API_SSR(容器内部后端地址,如 http://backend:8090);
+// 运行期浏览器走同源 /api(由 nginx 反代),PUBLIC_API_BASE 置空即可。
+const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
+
+interface Brand {
+ id: number
+ name: string
+ show_name: string
+ article_count: number
+}
+
+let brands: Brand[] = []
+let loadError = false
+try {
+ const res = await fetch(`${API}/api/public/brands?size=300&only_with_articles=1&featured=1`)
+ if (res.ok) {
+ const j = await res.json()
+ brands = (j?.data ?? []) as Brand[]
+ } else {
+ loadError = true
+ }
+} catch {
+ loadError = true
+}
+
+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
+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)
+---
+
+
+
+
+
+
+ Brands
+ {total.toLocaleString()}
+
+
+
+
+
+
+
+
+ {loadError ? (
+ Failed to load brands. Please try again later.
+ ) : total === 0 ? (
+ No brands indexed yet.
+ ) : (
+
+ {orderedColumns.map((col) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
diff --git a/src/pages/contact.astro b/src/pages/contact.astro
new file mode 100644
index 0000000..e1ad78b
--- /dev/null
+++ b/src/pages/contact.astro
@@ -0,0 +1,8 @@
+---
+import Layout from "../layouts/Layout.astro"
+import ComingSoon from "../components/ComingSoon.astro"
+---
+
+
+
+
diff --git a/src/pages/index.astro b/src/pages/index.astro
new file mode 100644
index 0000000..07ad1b4
--- /dev/null
+++ b/src/pages/index.astro
@@ -0,0 +1,865 @@
+---
+import Layout from "../layouts/Layout.astro"
+import ComingSoon from "../components/ComingSoon.astro"
+import { getShows, seriesLabel } from "../lib/data"
+
+// 默认 tab(Shows)走 SSR:构建期从接口预取真实数据与多图,首屏立即可见。
+// 其余 tab(Backstage / Events / Street)点击时才由前端请求接口动态渲染,避免一次性拉全量拖慢首屏。
+const shows = await getShows(20)
+// 精选轮播:取前 5 场秀,做成「左图右文」卡片式轮播(Featured Carousel)
+const slides = shows.slice(0, 5)
+// 右侧品牌栏:跨分页拉取「与轮播不同品牌」的其他精选品牌,按品牌去重取 2 个
+const API_BASE = (import.meta.env.PUBLIC_API_BASE || "").replace(/\/$/, "")
+const slideBrands = new Set(slides.map(s => s.brand))
+const rail: typeof shows = []
+const railSeen = new Set()
+if (API_BASE) {
+ for (let pg = 1; pg <= 6 && rail.length < 6; pg++) {
+ try {
+ const rr = await fetch(`${API_BASE}/api/public/articles?page=${pg}&size=40&with_images=5`)
+ if (!rr.ok) break
+ const rj = await rr.json()
+ const items: any[] = rj?.data ?? []
+ for (const r of items) {
+ const b = r.brand_name || "Unknown"
+ if (slideBrands.has(b) || railSeen.has(b)) continue
+ railSeen.add(b)
+ rail.push({
+ id: r.id, brand: b, title: r.title || "",
+ collection_type: r.collection_type, season_code: r.season_code,
+ year: Number(r.year) || 0,
+ count: r.image_count || 0, img: r.cover || "",
+ gallery: (r.images || []).map((im: any) => im.image || "").filter(Boolean).slice(0, 5),
+ })
+ if (rail.length >= 2) break
+ }
+ } catch {}
+ }
+}
+const railHot = [...rail].sort((a, b) => (b.count || 0) - (a.count || 0))
+// 最新取前 2;最热取 count 最高的 2;若两者完全相同(库内品牌少),改取最新之外的其它品牌,保证切换有可见差异
+const latestSet = rail.slice(0, 2)
+let hotSet = railHot.slice(0, 2)
+if (hotSet.length === 2 && latestSet.length === 2 &&
+ hotSet[0].brand === latestSet[0].brand && hotSet[1].brand === latestSet[1].brand) {
+ const lb = new Set(latestSet.map((r) => r.brand))
+ const others = rail.filter((r) => !lb.has(r.brand)).slice(0, 2)
+ if (others.length === 2) hotSet = others
+}
+
+// 拼装右侧卡片的文案字段
+function railEyebrow(r: typeof shows[number]) {
+ const m = (r.title || "").match(/(\d{4}).*?(Spring|Summer|Fall|Autumn|Winter|Resort|Pre-Fall|Cruise)/i)
+ const season = m ? `${m[1]} ${m[2]}` : (r.season_code || "")
+ const yr = r.year ? `${r.year}` : ""
+ return [yr || (m ? m[1] : ""), season].filter(Boolean).join(" · ").toUpperCase() || "Featured Runway"
+}
+function railTagline(r: typeof shows[number]) {
+ return `${seriesLabel(r.collection_type)}${r.season_code ? " · " + r.season_code : ""}`
+}
+function railDesc(r: typeof shows[number]) {
+ const yr = r.year || ((r.title || "").match(/\b(19|20)\d{2}\b/) || [""])[0]
+ const seasonTxt = (() => {
+ const m = (r.title || "").match(/(Spring|Summer|Fall|Autumn|Winter|Resort|Pre-Fall|Cruise)/i)
+ if (m) {
+ const map: Record = { Spring: "春", Summer: "夏", Fall: "秋冬", Autumn: "秋冬", Winter: "冬", Resort: "度假", "Pre-Fall": "早秋", Cruise: "度假" }
+ return (map[m[1].toLowerCase().replace(/(^|\s)\w/g, (s) => s.toUpperCase())] || map[m[1]] || "") + (yr || "")
+ }
+ return yr ? `${yr} 发布` : ""
+ })()
+ return { seasonTxt: seasonTxt || (yr ? `${yr} 发布` : "") }
+}
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
RUNWAY ARCHIVE
+
走秀档案 · 精选最新上架的秀场
+
+
全部走秀 →
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {slides.length > 0 && (
+
+
+
+ {slides.map((s, i) => (
+
+
+
+ Featured
+
+
+
+
{((s.title || "").match(/(\d{4}).*?(Spring|Summer|Fall|Autumn|Winter|Resort|Pre-Fall|Cruise)/i) || []).slice(1,3).join(" ") || s.season_code || "Featured Runway"}
+
+
{s.brand}
+
{seriesLabel(s.collection_type)}{s.season_code ? " · " + s.season_code : ""}
+
+
+
+
+
{String(i + 1).padStart(2, "0")} / {String(slides.length).padStart(2, "0")}
+
+
+
+ {slides.map((_, di) => (
+
+ ))}
+
+
+
+
+
+
+ ))}
+
+ {rail.length > 0 && (
+
+ )}
+
+
+ )}
+
+
+
+ {shows.slice(5).map((s) => (
+
+
+

+
Runway
+ {s.count > 1 &&
{s.count} Pics}
+
查看全部 {s.count} 张 →
+
+
+
+
{s.brand}
+
+ {seriesLabel(s.collection_type)}
+ {s.season_code && {s.season_code}}
+
+
{s.title}
+
+
+
+ ))}
+
+
+
+
+
该系列暂无可展示的走秀
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/latest-projects.astro b/src/pages/latest-projects.astro
new file mode 100644
index 0000000..03baa65
--- /dev/null
+++ b/src/pages/latest-projects.astro
@@ -0,0 +1,8 @@
+---
+import Layout from "../layouts/Layout.astro"
+import ComingSoon from "../components/ComingSoon.astro"
+---
+
+
+
+
diff --git a/src/pages/login.astro b/src/pages/login.astro
new file mode 100644
index 0000000..c4e24f1
--- /dev/null
+++ b/src/pages/login.astro
@@ -0,0 +1,8 @@
+---
+import Layout from "../layouts/Layout.astro"
+import ComingSoon from "../components/ComingSoon.astro"
+---
+
+
+
+
diff --git a/src/pages/portfolio.astro b/src/pages/portfolio.astro
new file mode 100644
index 0000000..c122097
--- /dev/null
+++ b/src/pages/portfolio.astro
@@ -0,0 +1,8 @@
+---
+import Layout from "../layouts/Layout.astro"
+import ComingSoon from "../components/ComingSoon.astro"
+---
+
+
+
+
diff --git a/src/pages/register.astro b/src/pages/register.astro
new file mode 100644
index 0000000..d181355
--- /dev/null
+++ b/src/pages/register.astro
@@ -0,0 +1,8 @@
+---
+import Layout from "../layouts/Layout.astro"
+import ComingSoon from "../components/ComingSoon.astro"
+---
+
+
+
+
diff --git a/src/pages/shows.astro b/src/pages/shows.astro
new file mode 100644
index 0000000..72d89c5
--- /dev/null
+++ b/src/pages/shows.astro
@@ -0,0 +1,1007 @@
+---
+import Layout from "../layouts/Layout.astro"
+
+// 全部秀场档案库:HBX 风左筛选 + 右 4 列商品卡。
+// 筛选交互(设计师多选 / 系列类型 / 季节 / 年份 / 排序)全部通过 querystring 同步到后端,
+// 真正由 `/api/public/articles` 的 brand_ids / collection_type / season / year / sort 参数承担。
+
+// 侧边栏品牌列表:featured=1 取 ~198 个有走秀档案的代表品牌,含 article_count 作为计数。
+// dev 期(import.meta.env.DEV)自动兜底到本地后端,避免每次手敲环境变量;生产走 API_SSR 构建参数。
+import { getBrands, getYears, type BrandEntry } from "../lib/api"
+
+let sidebarBrands: BrandEntry[] = []
+let yearChips: number[] = []
+
+// SSR 期从后端拉精选品牌(有走秀档案的代表品牌,含计数),运行期前端用同一封装再拉。
+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 {}
+
+// 年份 chips:从 back-end 拉最近 30 条秀场,提取独特年份(按降序去重),构造最近 6 年。
+// 为 SSR 提供稳健的初始值,运行期会按筛选再补;不可达时给一组 fallback 区间。
+let yearSeed = [2026, 2025, 2024, 2023, 2022, 2021]
+try {
+ const ys = await getYears(30)
+ if (ys.length) yearChips = ys
+} catch {}
+if (!yearChips.length) yearChips = yearSeed
+
+// URL 链接:保留当前路径以便 client-side filter 同步到地址栏
+const pathname = "/shows"
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 12 }, () => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 0000000..f1d8c73
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..8bf91d3
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "include": [".astro/types.d.ts", "**/*"],
+ "exclude": ["dist"]
+}