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'; +--- + +
+ +
+
+ Astro Homepage +

+ To get started, open the
src/pages
directory in your project. +

+ +
+
+ + + +

What's New in Astro 6.0?

+

+ Redesigned dev server, fonts, live collections, built-in CSP support, and more! Click to + explore Astro 6.0's new features. +

+
+
+ + 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}

)} +
+
+ + +
+
+
+ Gallery +
+ +
+ + + +
+ + +
+ + + +
+ + + + + + 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 +--- + + +
+ +
+
+ Runway Archive + SS23 — AW26 +
+

The Reports

+
+

+ 走秀趋势报告档案库。支持网格 / 杂志 / 时间轴三种创意视图自由切换,彩色配图由后端接口动态渲染。 +

+ ← Index +
+
+ + +
+
+ + + +
+ {reports.length} Reports +
+ + +
+ + +
+ + + + + + +
+
+ + + +
+ + + +
+
+ + + + 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) => ( +
+

{col.label}

+ +
+ ))} +
+ )} +
+
+
+ + + + 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} 发布` : "") } +} +--- + + + +
+
    +
  • 发布会 + Live +
  • +
  • 灵感 + Soon +
  • +
  • 社媒 + Soon +
  • +
  • 街拍 + Soon +
  • + 品牌 +
+
+ + +
+ +
+ +
+
+

RUNWAY ARCHIVE

+

走秀档案 · 精选最新上架的秀场

+
+ 全部走秀 → +
+ + +
+ + + + + + +
+ + + {slides.length > 0 && ( + + )} + + +
+ {shows.slice(5).map((s) => ( + + ))} +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
Previous
+
Next
+
+
+ + + + + + 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" +--- + + + + +
+ + +
+
+ Runway Archive · 2026 Edition +

All 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"] +}