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

This commit is contained in:
toom1996
2026-08-11 22:29:47 +08:00
commit 5a8dbf41cb
62 changed files with 9514 additions and 0 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
node_modules
dist
.git
.github
.gitea
*.md
Dockerfile
docker-compose.yml
.env
.env.*
deploy.sh

15
.env.example Normal file
View File

@ -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 拉数据
# - 前端运行期走同源 /apinginx 反代到 backend:8090PUBLIC_API_BASE 在 Dockerfile 中置空
# - backend 监听 8090生产建议去掉 ports 暴露,仅由前端 nginx 反代访问

View File

@ -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

24
.gitignore vendored Normal file
View File

@ -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/

4
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@ -0,0 +1,21 @@
# 2026-08-07
## 模块门禁 + 导航收敛(发布会模块聚焦)
- 新增 src/components/ComingSoon.astro 编辑风占位组件propsmodule / 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。

View File

@ -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/ 。

View File

@ -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 Morepage/total/busyappend 模式,后端 `?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)改为 `<Layout><ComingSoon/></Layout>`
- 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`<style>` 顶部加 `@reference "tailwindcss";`。仅保留手写:@keyframes、渐变 shimmer、!important、双时长 transition。Tailwind v4 已移除零值类mt-0 等),零值边距保持手写。
- 导航Layout.astro**保持原样未改动**Portfolio / Latest Projects / About / Contact / Login / Signup含页脚 Categories + Color Studio Tools + About/Contact 链接。门禁仅由 6 个 ComingSoon 占位页承担,导航不删任何链接(用户要求 header 别动)。
## 部署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。
## 走秀数据分类brand_runway已实现 2026-08-08
- **三维分类字段**(已加,靠 title 解析回填,无需外部数据):
- `collection_type`rtw / menswear / couture / resort / pre_fall
- `season`spring / fall / nullResort/Pre-Fall 为单季season=null
- `season_code`SS26 / FW25 / RES26 / PF25派生用于排序/展示)
- **回填**main.go `backfillRunwayClassification()` 单条 `UPDATE ... CASE` 解析 titleVogue 格式 `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张)做多图拼图**(避免横图裁切竖版),保留系列/季节 chipfeat-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 2376spring 9140 / fall 9049。

22
AGENTS.md Normal file
View File

@ -0,0 +1,22 @@
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
AGENTS.md

78
DEPLOY.md Normal file
View File

@ -0,0 +1,78 @@
# 部署与 CI/CD 说明
项目结构:前端 Astro 静态站 + 后端 Go 服务 + MySQL。采用 **Docker 容器化 + Gitea Actions** 上线。
## 一、架构
```
浏览器
│ HTTP (80)
[frontend] nginx:80 ── 静态资源 / , 反代 /api ──▶ [backend] Go :8090
[db] MySQL 8.0
```
- **前端**Astro 构建为静态 `dist/`,用 nginx 托管;`/api` 由 nginx 反代到 `backend:8090`(同源,无跨域)。
- **后端**Go 二进制,监听 `8090`,连 `db` 服务。
- **构建期 SSR**`brands.astro` 在构建时拉数据,地址用 `API_SSR`(容器内部 `http://localhost:8090`,配合 `build.network: host` 访问已启动的 backend 容器);运行期浏览器走同源 `/api`
## 二、服务器目录约定
```bash
/opt/fashion/
├── test/ # 前端仓 git clone含 docker-compose.yml / deploy.sh / Dockerfile / nginx.conf
└── admin/
└── backend/ # 后端仓 git clone含 Dockerfile
```
初始化(在服务器上):
```bash
mkdir -p /opt/fashion && cd /opt/fashion
git clone <前端仓地址> test
git clone <后端仓地址> admin/backend
cd test
cp .env.example .env # 按需修改 JWT_SECRET / DB_PASSWORD
```
前置:服务器已安装 Docker Engine + Docker Compose v2且当前用户可免 sudo 执行 docker。
## 三、手动一键上线
在服务器上(或本地 SSH 过去)执行:
```bash
cd /opt/fashion/test
./deploy.sh
```
`deploy.sh` 会:① 拉两个仓库最新代码 → ② `docker compose up -d db backend` → ③ 轮询等待 backend 健康 → ④ `docker compose build frontend`(构建期 SSR 拉数据)→ ⑤ `docker compose up -d frontend`
完成后访问服务器 `http://<服务器IP>/` 即可。
## 四、CI/CDGitea Actions
仓库根已放入 `.gitea/workflows/deploy.yml`。push 到 `main`/`master` 即自动 SSH 到服务器执行 `./deploy.sh`
前置Gitea 仓库 → Settings → Secrets / Actions
| Secret | 说明 |
| ------------- | ---------------------------- |
| `DEPLOY_HOST` | 服务器 IP / 域名 |
| `DEPLOY_USER` | SSH 用户名 |
| `DEPLOY_KEY` | 服务器私钥(对应公钥已放入 `~/.ssh/authorized_keys` |
> 后端仓库也放一份相同的 `.gitea/workflows/deploy.yml`,这样前后端任意仓库 push 都会触发一次完整上线deploy.sh 会同时 pull 两个仓)。
## 五、HTTPS可选
在服务器前置一个反向代理(如 Caddy / 已有 nginx终止 TLS把 80 转发到 frontend 容器,并加 `X-Forwarded-Proto`。或把 frontend 的 `ports` 换成 `443` + 挂载证书。此处从简,未内置。
## 六、注意事项
- 后端环境变量名(`DB_HOST`/`JWT_SECRET` 等)需与实际 `config` 读取一致;如后端用别的变量名,改 `docker-compose.yml``environment` 即可。
- `JWT_SECRET` 生产务必改成随机长串;默认值是占位的。
- 生产可去掉 `backend.ports``8090:8090` 暴露,仅由前端 nginx 在同一 compose 网络内反代访问,更安全。
- 前端 `PUBLIC_API_BASE` 在 Dockerfile 中置空(同源 `/api`);如需独立域名,改为绝对地址并在后端已开启 CORS`*`)。

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
# ---- 前端构建阶段 ----
FROM node:22-alpine AS build
WORKDIR /app
# 构建期 SSR 拉数据用的「后端内部地址」。
# 在 docker-compose 里通过 build.args.API_SSR 传入(= http://localhost:8090
# 配合 build.network: host让前端构建容器能访问已启动的 backend 容器)。
ARG API_SSR=http://localhost:8090
ENV API_SSR=$API_SSR
# 运行期浏览器调用地址:置空 = 同源相对路径 /api由下方 nginx 反代到 backend:8090
ENV PUBLIC_API_BASE=""
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- 静态托管阶段 ----
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

46
README.md Normal file
View File

@ -0,0 +1,46 @@
# Astro Starter Kit: Basics
```sh
npm create astro@latest -- --template basics
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
│ └── favicon.svg
├── src
│   ├── assets
│   │   └── astro.svg
│   ├── components
│   │   └── Welcome.astro
│   ├── layouts
│   │   └── Layout.astro
│   └── pages
│   └── index.astro
└── package.json
```
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

11
astro.config.mjs Normal file
View File

@ -0,0 +1,11 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
// https://astro.build/config
export default defineConfig({
vite: {
plugins: [tailwindcss()]
}
});

38
deploy.sh Normal file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
#
# 一键上线脚本(在服务器上执行,或本地 SSH 调它)
# 服务器目录约定(见 DEPLOY.md
# /opt/fashion/test -> 前端仓(本脚本所在)
# /opt/fashion/admin/backend -> 后端仓
#
# 流程:拉代码 -> 起 db+backend -> 等后端健康 -> 构建前端(SSR 拉数据) -> 起前端
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
echo "==> [1/5] 拉取最新代码"
git pull --ff-only
git -C ../admin/backend pull --ff-only || true
echo "==> [2/5] 启动 db + backend供前端构建期 SSR 取数)"
docker compose up -d db backend
echo "==> [3/5] 等待 backend :8090 健康"
for i in $(seq 1 60); do
if curl -fsS "http://localhost:8090/api/public/brands?size=1" >/dev/null 2>&1; then
echo " backend 已就绪"
break
fi
echo " 等待中... ($i/60)"
sleep 2
done
echo "==> [4/5] 构建前端镜像API_SSR=http://localhost:8090构建期网络=host"
docker compose build frontend
echo "==> [5/5] 启动前端"
docker compose up -d frontend
echo "==> 部署完成,当前状态:"
docker compose ps

60
docker-compose.yml Normal file
View File

@ -0,0 +1,60 @@
# 生产部署 compose放在前端仓库根服务器结构见 DEPLOY.md
# 前端仓: /opt/fashion/test (本文件所在)
# 后端仓: /opt/fashion/admin/backend
services:
# ---- 数据库 ----
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-root}
MYSQL_DATABASE: ${DB_NAME:-fashionadmin}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_PASSWORD:-root}"]
interval: 10s
timeout: 5s
retries: 10
# ---- 后端 (Go) ----
backend:
build:
context: ../admin/backend
restart: unless-stopped
environment:
SERVER_PORT: 8090
GIN_MODE: release
JWT_SECRET: ${JWT_SECRET:-change-me-in-prod}
DB_HOST: db
DB_PORT: 3306
DB_USER: ${DB_USER:-root}
DB_PASSWORD: ${DB_PASSWORD:-root}
DB_NAME: ${DB_NAME:-fashionadmin}
ports:
- "8090:8090" # 仅构建期前端拉数据用;生产可去掉,改由前端 nginx 反代
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/public/brands?size=1"]
interval: 10s
timeout: 5s
retries: 10
# ---- 前端 (Astro 静态 + nginx) ----
frontend:
build:
context: .
args:
API_SSR: http://localhost:8090 # 构建期 SSR 拉数据:配合 network: host 访问已起的 backend
network: host
restart: unless-stopped
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
db_data:

1
footertail.txt Normal file
View File

@ -0,0 +1 @@
y-2.5 px-4 flex items-center justify-between transition-colors cursor-pointer" data-astro-cid-ju4pidww><div class="flex items-center gap-2" data-astro-cid-ju4pidww><!-- <Sparkles class="w-3.5 h-3.5 text-amber-300" /> --><span data-astro-cid-ju4pidww>AI Color Trend Generator</span></div><span class="text-[10px] text-zinc-400 font-normal" data-astro-cid-ju4pidww>Gemini 2026</span></button><button id="btn-extractor" class="w-full bg-zinc-100 hover:bg-zinc-200 border border-zinc-300 text-black text-xs font-mono font-bold uppercase tracking-wider py-2.5 px-4 flex items-center justify-between transition-colors cursor-pointer" data-astro-cid-ju4pidww><div class="flex items-center gap-2" data-astro-cid-ju4pidww><!-- <Layers class="w-3.5 h-3.5 text-zinc-600" /> --><span data-astro-cid-ju4pidww>Photo Swatch Extractor</span></div><span class="text-[10px] text-zinc-500 font-normal" data-astro-cid-ju4pidww>Upload</span></button></div></div></div><div class="flex flex-col sm:flex-row justify-between items-center text-[12px] font-mono text-zinc-500 gap-4" data-astro-cid-ju4pidww><div data-astro-cid-ju4pidww><span data-astro-cid-ju4pidww>© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.</span></div><div class="flex items-center gap-6" data-astro-cid-ju4pidww><button data-nav="About" class="nav-btn hover:text-black cursor-pointer uppercase font-bold" data-astro-cid-ju4pidww>About</button><button data-nav="Contact Us" class="nav-btn hover:text-black cursor-pointer uppercase font-bold" data-astro-cid-ju4pidww>Contact</button><button id="back-to-top" class="flex items-center gap-1 font-bold text-black hover:opacity-60 transition-opacity cursor-pointer uppercase" data-astro-cid-ju4pidww><span data-astro-cid-ju4pidww>Back to top</span><!-- <ArrowUp class="w-3.5 h-3.5" /> --></button></div></div>

21
nginx.conf Normal file
View File

@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 前端静态资源 / SPA 回退
location / {
try_files $uri $uri/ /index.html;
}
# 反向代理后端 API浏览器走同源 /api由 nginx 转发到 backend 容器
location /api/ {
proxy_pass http://backend:8090/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

4814
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "test",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"astro": "^7.1.6",
"tailwindcss": "^4.3.3"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

9
public/favicon.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

BIN
shot_feat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
shot_feat2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

BIN
shot_hbx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
shot_hbx2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
shot_hbx_v10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
shot_hbx_v11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
shot_hbx_v12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
shot_hbx_v13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
shot_hbx_v14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
shot_hbx_v15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
shot_hbx_v3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
shot_hbx_v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
shot_hbx_v5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
shot_hbx_v6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
shot_hbx_v7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
shot_hbx_v8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
shot_hbx_v9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
shot_hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

1
src/assets/astro.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,77 @@
---
interface Props {
module?: string
moduleZh?: string
note?: string
}
const { module = "This module", moduleZh = "", note = "" } = Astro.props
---
<section class="cs-wrap">
<div class="cs-kicker">
<span>Coming Soon</span>
<span>暂未上线</span>
</div>
<h1 class="cs-title">暂未上线</h1>
<p class="cs-mod">{module}{moduleZh && ` · ${moduleZh}`}</p>
<p class="cs-desc">
{note || "该模块正在筹备中,尚未对外开放。站点正按模块逐个完善,你可以先浏览已上线的走秀档案。"}
</p>
<div class="cs-actions">
<a class="cs-btn" href="/articles">浏览走秀档案 →</a>
<a class="cs-btn-ghost" href="/">返回首页</a>
</div>
</section>
<style>
@reference "tailwindcss";
.cs-wrap {
max-width: 760px;
margin: 0 auto;
padding: 48px 4px 60px;
@apply font-mono text-black;
}
.cs-kicker {
display: flex;
justify-content: space-between;
border-top: 1px solid #000;
padding-top: 18px;
@apply text-[11px] tracking-[0.25em] uppercase text-[#666];
}
.cs-title {
@apply font-serif font-black tracking-[-0.02em];
line-height: 0.9;
font-size: clamp(56px, 13vw, 128px);
margin: 0.16em 0 0.3em;
}
.cs-mod {
@apply text-[14px] uppercase tracking-[0.14em] text-[#111];
margin: 0 0 18px;
}
.cs-desc {
@apply text-[13px] text-[#666];
line-height: 1.7;
max-width: 52ch;
margin: 0 0 28px;
}
.cs-actions {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.cs-btn {
@apply inline-block bg-black text-white border border-black px-[18px] py-[12px] text-[12px] uppercase tracking-[0.18em] font-bold no-underline;
transition: background 0.2s, color 0.2s;
}
.cs-btn:hover {
@apply bg-white text-black;
}
.cs-btn-ghost {
@apply inline-block bg-white text-black border border-black px-[18px] py-[12px] text-[12px] uppercase tracking-[0.18em] font-bold no-underline;
transition: background 0.2s, color 0.2s;
}
.cs-btn-ghost:hover {
@apply bg-black text-white;
}
</style>

View File

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

228
src/layouts/Layout.astro Normal file
View File

@ -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' },
];
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans">
<!-- 顶部主导航栏 -->
<nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase">
<div class="flex flex-wrap items-center gap-6 md:gap-8">
<a href="/portfolio" class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">Portfolio<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
2026
</span></a>
<a href="/shows" class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">All Shows</a>
<a href="/latest-projects" class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">Latest Projects</a>
<a href="/about" class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">About</a>
<a href="/contact" class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">Contact Us</a>
</div>
<div class="flex items-center gap-3" id="auth-slot">
<a href="/login" class="hover:opacity-60 transition-opacity duration-200">Login</a>
<a href="/register" class="hover:opacity-60 transition-opacity duration-200">Signup</a>
</div>
</nav>
<div class="flex-1 flex flex-col">
<slot />
</div>
<footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans">
<script>
import { getUser, fetchMe, clearSession } from "../lib/auth.ts"
const slot = document.getElementById("auth-slot")
function render(user) {
if (user) {
slot.innerHTML = `<span class="opacity-70">Hi, ${user.username}</span><button id="logout-btn" class="hover:opacity-60 transition-opacity cursor-pointer bg-transparent border-0 p-0 font-bold text-[13px] uppercase tracking-[0.5px]">Logout</button>`
const btn = slot.querySelector("#logout-btn")
if (btn) btn.addEventListener("click", () => { clearSession(); location.reload() })
} else {
slot.innerHTML = `<a href="/login" class="hover:opacity-60 transition-opacity duration-200">Login</a><a href="/register" class="hover:opacity-60 transition-opacity duration-200">Signup</a>`
}
}
const cached = getUser()
if (cached) render(cached)
fetchMe().then((user) => render(user)).catch(() => {})
</script>
{/* Top Footer Grid */}
<div class="grid grid-cols-1 md:grid-cols-12 gap-8 mb-10 pb-10 border-b border-zinc-200">
{/* Column 1: Brand & Manifesto */}
<div class="md:col-span-5 space-y-4">
<div class="flex items-center gap-2">
<span class="text-base font-black tracking-wider uppercase border-b-2 border-black pb-0.5">
Portfolio Studio
</span>
<span class="text-[10px] font-mono bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest">
2026 Edition
</span>
</div>
<p class="text-xs text-zinc-600 leading-relaxed max-w-md">
Minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. Integrated with 2026 color forecast intelligence and AI palette generator.
</p>
{/* Location Badge */}
<div class="flex flex-wrap gap-3 text-[11px] font-mono text-zinc-500 uppercase tracking-widest pt-1">
<span>Paris</span>
<span>•</span>
<span>Milan</span>
<span>•</span>
<span>Tokyo</span>
<span>•</span>
<span>New York</span>
</div>
</div>
{/* Column 2: Quick Navigation */}
<div class="md:col-span-3 space-y-3">
<span class="text-[11px] font-mono uppercase tracking-widest text-zinc-400 font-bold block mb-2">
Categories
</span>
<ul class="space-y-2 list-none p-0 m-0 text-xs font-mono font-bold uppercase tracking-wider">
{categories.map((cat) => (
<li>
<button
data-category={cat.value}
class={`category-btn hover:opacity-60 transition-opacity cursor-pointer flex items-center gap-2 ${
activeCategory === cat.value ? 'underline font-black text-black' : 'text-zinc-600'
}`}
>
<span>{cat.label}</span>
{cat.value === 'Trending Colors' && (
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
)}
</button>
</li>
))}
</ul>
</div>
{/* Column 3: Color Studio & Actions */}
<div class="md:col-span-4 space-y-3">
<span class="text-[11px] font-mono uppercase tracking-widest text-zinc-400 font-bold block mb-2">
Color Studio Tools
</span>
<div class="flex flex-col gap-2">
<button
id="btn-ai-generator"
class="w-full bg-black text-white hover:bg-zinc-800 text-xs font-mono font-bold uppercase tracking-wider py-2.5 px-4 flex items-center justify-between transition-colors cursor-pointer"
>
<div class="flex items-center gap-2">
<!-- <Sparkles class="w-3.5 h-3.5 text-amber-300" /> -->
<span>AI Color Trend Generator</span>
</div>
<span class="text-[10px] text-zinc-400 font-normal">Gemini 2026</span>
</button>
<button
id="btn-extractor"
class="w-full bg-zinc-100 hover:bg-zinc-200 border border-zinc-300 text-black text-xs font-mono font-bold uppercase tracking-wider py-2.5 px-4 flex items-center justify-between transition-colors cursor-pointer"
>
<div class="flex items-center gap-2">
<!-- <Layers class="w-3.5 h-3.5 text-zinc-600" /> -->
<span>Photo Swatch Extractor</span>
</div>
<span class="text-[10px] text-zinc-500 font-normal">Upload</span>
</button>
{activeThemePalette && (
<button
id="btn-reset-theme"
class="w-full bg-red-50 hover:bg-red-100 border border-red-200 text-red-700 text-xs font-mono font-bold uppercase tracking-wider py-2 px-4 flex items-center justify-between transition-colors cursor-pointer"
>
<div class="flex items-center gap-2">
{/* <RefreshCw class="w-3.5 h-3.5" /> */}
<span>Reset B&amp;W Theme</span>
</div>
<span class="text-[10px] opacity-80">Default</span>
</button>
)}
</div>
</div>
</div>
{/* Active Theme Strip */}
{activeThemePalette && (
<div class="mb-6 p-4 bg-zinc-50 border border-zinc-200 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div class="flex items-center gap-2 text-xs font-mono">
<span class="w-2.5 h-2.5 rounded-full bg-emerald-500 animate-pulse"></span>
<span class="font-bold uppercase">Active Site Theme:</span>
<span class="text-zinc-600">{activeThemePalette.name}</span>
</div>
<div class="flex gap-1 h-6 w-48 border border-zinc-300 overflow-hidden">
{activeThemePalette.colors.map((c) => (
<div
style={`background-color: ${c.hex}`}
class="h-full flex-1"
title={`${c.name} (${c.hex})`}
></div>
))}
</div>
</div>
)}
{/* Bottom Legal & Back To Top */}
<div class="flex flex-col sm:flex-row justify-between items-center text-[12px] font-mono text-zinc-500 gap-4">
<div>
<span>© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.</span>
</div>
<div class="flex items-center gap-6">
<a
href="/about"
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
>
About
</a>
<a
href="/contact"
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
>
Contact
</a>
<button
id="back-to-top"
class="flex items-center gap-1 font-bold text-black hover:opacity-60 transition-opacity cursor-pointer uppercase"
>
<span>Back to top</span>
<!-- <ArrowUp class="w-3.5 h-3.5" /> -->
</button>
</div>
</div>
</footer>
</body>
</html>
<style>
html,
body {
margin: 0;
}
</style>

115
src/lib/api.ts Normal file
View File

@ -0,0 +1,115 @@
// src/lib/api.ts — 后端 API 统一客户端
// 集中所有 /api 调用base URL 解析、错误处理、端点封装。
//
// base URL 解析规则:
// - DEVastro dev前端 4345 + 后端 8090一律回落 http://localhost:8090CORS=*)。
// - 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<T>(path: string, params?: URLSearchParams): Promise<T> {
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<BrandEntry[]> {
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<number[]> {
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<ArticlePage> {
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) }
}

87
src/lib/auth.ts Normal file
View File

@ -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<AuthUser> {
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<AuthUser> {
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<AuthUser | null> {
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<string, string> {
const t = getToken()
return t ? { Authorization: `Bearer ${t}` } : {}
}

332
src/lib/data.ts Normal file
View File

@ -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<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: 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 () => {
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<RawArticle | null> {
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<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 () => {
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<T extends { id: 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: number
name: string
brand: string
season: string
img: string
}
export async function getColors(limit = 12): Promise<ColorItem[]> {
// 后端暂无独立颜色接口:优先尝试 /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<SiteInfo> {
const loaded = await loadArticles()
// 落回到示例时,沿用与首页原本一致的示例展示值
if (loaded.sampled) {
return {
totalReports: 12,
brandCount: 6,
seasonRange: "202326",
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)}` : "202326"
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<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,
}))
}

8
src/pages/about.astro Normal file
View File

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

463
src/pages/article.astro Normal file
View File

@ -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")
---
<Layout title={`${article.title} — Runway Report`}>
<article class="mx-auto max-w-[1100px] px-5 md:px-10 py-12 md:py-16">
<!-- 面包屑 / 品牌 -->
<div class="flex items-center justify-between text-[11px] font-mono uppercase tracking-[0.25em] text-black border-b border-black pb-4">
<a href="/" class="hover:opacity-60 transition-opacity">← Index</a>
<span id="art-brand">Runway · {article.brand_name}</span>
</div>
<!-- 标题 -->
<h1 class="font-serif font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{article.title}
</h1>
<!-- 元信息 -->
<div class="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-[12px] font-mono uppercase tracking-widest text-[#666]">
<span id="art-date">{fmt(article.created_at)}</span>
<span id="art-year" class="text-black">{article.year}</span>
<a href={article.source_url} target="_blank" rel="noopener" class="hover:text-black underline underline-offset-4">Source ↗</a>
</div>
<!-- 正文 -->
<div class="mt-12 grid md:grid-cols-12 gap-8">
<div class="md:col-span-2 text-[11px] font-mono uppercase tracking-widest text-[#999] pt-1">
Report
</div>
<div id="art-body" class="md:col-span-10 max-w-[68ch] space-y-5 text-[16px] leading-[1.9] text-[#222]">
{body.map((p) => <p>{p}</p>)}
</div>
</div>
<!-- 图片集 -->
<section class="mt-16">
<div class="flex items-end justify-between border-b border-[#eee] pb-3 mb-6">
<div class="text-[11px] font-mono uppercase tracking-[0.25em] text-[#999]">
Gallery <span id="art-gallery-count" class="ml-2 text-black"></span>
</div>
<button type="button" id="art-viewall" class="text-[11px] font-mono uppercase tracking-widest border border-black px-3 py-1.5 hover:bg-black hover:text-white transition-colors">⤢ 全屏查看</button>
</div>
<!-- 图片网格 -->
<div id="art-gallery" class="grid grid-cols-2 md:grid-cols-3 gap-4">
{article.images.map((im) => (
<figure class="g-item bg-[#f2f2f2] cursor-zoom-in">
<img src={im.image} alt={im.name} loading="lazy" class="w-full aspect-[3/4] object-cover" />
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">{im.name}</figcaption>
</figure>
))}
</div>
</section>
<div class="mt-16 pt-6 border-t border-black">
<a href="/" class="text-[12px] font-mono uppercase tracking-widest hover:opacity-60 transition-opacity">← Back to Index</a>
</div>
</article>
<!-- 灯箱:左侧竖排缩略图条 + 中央大图(点击放大→宽度最大+纵向滚动) -->
<div id="lb" class="lb" aria-hidden="true">
<div class="lb-counter" id="lb-counter"></div>
<button class="lb-btn lb-close" id="lb-close" aria-label="关闭">✕</button>
<div class="lb-stage" id="lb-stage">
<div class="lb-thumbs" id="lb-thumbs"></div>
<button class="lb-tbtn lb-tup" id="lb-tup" type="button" aria-label="上一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 14l6-6 6 6"/></svg>
</button>
<button class="lb-tbtn lb-tdown" id="lb-tdown" type="button" aria-label="下一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 10l6 6 6-6"/></svg>
</button>
<button class="lb-btn lb-prev" id="lb-prev" type="button" aria-label="上一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<button class="lb-btn lb-next" id="lb-next" type="button" aria-label="下一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</button>
<div class="lb-main" id="lb-main">
<img id="lb-img" class="lb-img" alt="" />
</div>
</div>
<div class="lb-caption" id="lb-caption"></div>
<div class="lb-hint" id="lb-hint">点击图片放大 · 上下滚动查看 · Esc 关闭</div>
</div>
</Layout>
<style is:global>
@reference "tailwindcss";
/* 灯箱:左侧竖排缩略图条 + 中央大图;点击放大→宽度最大+纵向滚动 */
.lb {
position: fixed; inset: 0; z-index: 100;
@apply bg-white;
display: none; flex-direction: column;
opacity: 0; transition: opacity .2s ease;
-webkit-tap-highlight-color: transparent;
}
.lb.is-open { display: flex; opacity: 1; }
.lb-stage {
flex: 1 1 auto; min-height: 0;
display: flex; align-items: stretch; justify-content: stretch;
overflow: hidden; position: relative;
animation: lbIn .2s ease both;
}
@keyframes lbIn { from { opacity: 0; } to { opacity: 1; } }
.lb-thumbs {
flex: 0 0 118px; overflow-y: auto; overflow-x: hidden; position: relative; scroll-behavior: smooth;
padding: 38px 18px 38px 20px;
border-right: 1px solid rgba(0,0,0,.08);
background: linear-gradient(180deg, rgba(0,0,0,.02), rgba(0,0,0,0));
scrollbar-width: none; -ms-overflow-style: none;
}
.lb-thumbs::-webkit-scrollbar { width: 0; height: 0; }
.lb-thumb {
position: relative; display: block; width: 100%; aspect-ratio: 3 / 4;
padding: 0; margin: 0 0 8px; border: 0;
@apply bg-[#ebebeb] rounded-[2px];
overflow: hidden; cursor: pointer;
opacity: .42; transition: opacity .2s ease, transform .2s ease;
}
.lb-thumb:last-child { margin-bottom: 0; }
.lb-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lb-thumb::after {
content: ""; position: absolute; inset: 0; border-radius: 2px;
box-shadow: inset 0 0 0 0 transparent; pointer-events: none;
transition: box-shadow .2s ease;
}
.lb-thumb:hover { opacity: .85; }
.lb-thumb.is-active { opacity: 1; transform: scale(1.03); }
.lb-thumb.is-active::after { box-shadow: inset 0 0 0 2px #111; }
.lb-thumb-idx {
position: absolute; left: 6px; top: 6px; z-index: 1;
font: 600 9px/1 ui-monospace, SFMono-Regular, monospace;
@apply tracking-[.12em] text-white px-[5px] py-[3px] rounded-[3px];
background: rgba(0,0,0,.55);
backdrop-filter: blur(4px); pointer-events: none;
}
.lb-thumb.is-active .lb-thumb-idx { @apply bg-[#111] text-white; }
.lb-main {
flex: 1 1 auto; min-width: 0; min-height: 0;
display: flex; align-items: center; justify-content: center;
position: relative; padding: 0 72px;
}
.lb-img {
max-width: 100%; max-height: 100%; display: block;
object-fit: contain; user-select: none; -webkit-user-drag: none;
transition: opacity .2s ease; cursor: zoom-in;
}
/* 放大态:宽度最大化 + 纵向滚动(无滚轮缩放、无拖拽、无特效) */
.lb-main.is-zoomed { align-items: stretch; display: block; overflow-y: auto; overflow-x: hidden; }
.lb-main.is-zoomed .lb-img { width: 100%; max-width: 100%; height: auto; max-height: none; cursor: zoom-out; }
.lb-btn {
position: absolute; z-index: 3;
width: 42px; height: 42px;
@apply rounded-full;
border: 1px solid rgba(0,0,0,.14); background: rgba(255,255,255,.72);
@apply text-[#111];
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s, transform .15s, border-color .15s; backdrop-filter: blur(10px);
}
.lb-btn svg, .lb-tbtn svg {
display: block; fill: none; stroke: currentColor;
stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
}
.lb-btn svg { width: 20px; height: 20px; }
.lb-tbtn svg { width: 16px; height: 16px; }
.lb-btn:hover { background: rgba(0,0,0,.06); border-color: rgba(0,0,0,.24); }
.lb-close { top: 18px; right: 18px; }
.lb-prev { left: 114px; top: 50%; transform: translateY(-50%); }
.lb-next { right: 18px; top: 50%; transform: translateY(-50%); }
.lb-prev:hover, .lb-next:hover { transform: translateY(-50%) scale(1.08); }
/* 左栏上下翻页按钮(浮在缩略图条上、禁手动滚动时的翻页入口) */
.lb-tbtn {
position: absolute; z-index: 4;
left: 20px; width: 72px; height: 34px;
border: 1px solid rgba(0,0,0,.12); background: rgba(255,255,255,.82);
@apply rounded-[6px];
cursor: pointer;
display: flex; align-items: center; justify-content: center;
@apply text-[#111];
backdrop-filter: blur(8px);
transition: background .15s, transform .15s, border-color .15s;
}
.lb-tbtn svg { width: 16px; height: 16px; display: block; }
.lb-tbtn:hover { background: rgba(0,0,0,.06); border-color: rgba(0,0,0,.22); }
.lb-tup { top: 8px; }
.lb-tdown { bottom: 8px; }
.lb-counter {
position: absolute; top: 20px; left: 50%; transform: translateX(-50%); z-index: 3;
font: 600 12px/1 ui-monospace, monospace;
@apply tracking-[.14em] text-[#111];
background: rgba(255,255,255,.75); border: 1px solid rgba(0,0,0,.1);
@apply px-[11px] py-[7px] rounded-[6px];
backdrop-filter: blur(6px);
}
.lb-caption {
position: absolute; left: 0; right: 0; bottom: 44px; z-index: 3;
text-align: center; color: rgba(0,0,0,.62); pointer-events: none;
font: 11px/1.4 ui-monospace, monospace;
@apply tracking-[.18em] uppercase;
padding: 0 24px; text-shadow: 0 1px 3px rgba(255,255,255,.7);
}
.lb-hint {
position: absolute; left: 0; right: 0; bottom: 16px; z-index: 3;
text-align: center; color: rgba(0,0,0,.45); pointer-events: none;
font: 11px/1 ui-monospace, monospace;
@apply tracking-[.14em];
padding: 0 24px; transition: opacity .2s ease; text-shadow: 0 1px 3px rgba(255,255,255,.7);
}
.lb-hint.is-hidden { opacity: 0; }
@media (max-width: 640px) {
.lb-thumbs { flex: 0 0 76px; padding: 32px 8px 32px 12px; }
.lb-thumb { margin-bottom: 8px; }
.lb-thumb-idx { font-size: 8px; left: 4px; top: 4px; padding: 2px 4px; }
.lb-main { padding: 0 18px; }
.lb-btn { width: 38px; height: 38px; }
.lb-prev { left: 82px; } .lb-next { right: 8px; }
.lb-tbtn { left: 12px; width: 52px; height: 30px; }
.lb-caption { bottom: 40px; }
}
</style>
<script>
// 真实数据接入:按 ?id= 拉取后端接口,失败时保留上方示例布局
const API = import.meta.env.PUBLIC_API_BASE || ""
const toAbs = (url: string) =>
!url ? "" : /^https?:\/\//i.test(url) ? url : API + url
const id = new URLSearchParams(location.search).get("id")
if (id) {
;(async () => {
try {
const res = await fetch(`${API}/api/public/articles/${id}`)
if (!res.ok) return
const d = (await res.json()).data
if (!d) return
const titleEl = document.querySelector("h1")
if (titleEl && d.title) titleEl.textContent = d.title
const bodyEl = document.getElementById("art-body")
if (bodyEl && d.description) {
bodyEl.innerHTML = d.description
.split("\n\n")
.map((p: string) => `<p>${p}</p>`)
.join("")
}
const brandEl = document.getElementById("art-brand")
if (brandEl && d.brand_name) brandEl.textContent = `Runway · ${d.brand_name}`
const yearEl = document.getElementById("art-year")
if (yearEl && d.year) yearEl.textContent = String(d.year)
const dateEl = document.getElementById("art-date")
if (dateEl && d.published_at) {
const dt = new Date(Number(d.published_at) * 1000)
dateEl.textContent = `${dt.getFullYear()}.${String(dt.getMonth() + 1).padStart(2, "0")}.${String(dt.getDate()).padStart(2, "0")}`
}
const imgs = d.images
if (imgs && Array.isArray(imgs)) {
const gal = document.getElementById("art-gallery")
if (gal) {
gal.innerHTML = imgs
.map(
(im: any) =>
`<figure class="bg-[#f2f2f2]"><img src="${toAbs(im.image)}" alt="${im.name || ""}" loading="lazy" class="w-full aspect-[3/4] object-cover" /><figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">${im.name || ""}</figcaption></figure>`
)
.join("")
}
}
} catch {
/* 后端不可达时,使用示例数据 */
}
})()
}
</script>
<script>
(function () {
const gallery = document.getElementById("art-gallery");
const lb = document.getElementById("lb");
const lbStage = document.getElementById("lb-stage");
const lbMain = document.getElementById("lb-main");
const lbImg = document.getElementById("lb-img");
const lbCounter = document.getElementById("lb-counter");
const lbCaption = document.getElementById("lb-caption");
const lbThumbs = document.getElementById("lb-thumbs");
const lbHint = document.getElementById("lb-hint");
const btnClose = document.getElementById("lb-close");
const btnPrev = document.getElementById("lb-prev");
const btnNext = document.getElementById("lb-next");
const btnTup = document.getElementById("lb-tup");
const btnTdown = document.getElementById("lb-tdown");
const viewall = document.getElementById("art-viewall");
let items = [];
let index = 0;
let zoomed = false;
/* 放大态:宽度最大化 + 纵向滚动(无滚轮缩放 / 无拖拽 / 无特效) */
function zoomIn() {
zoomed = true;
if (lbMain) { lbMain.classList.add("is-zoomed"); lbMain.scrollTop = 0; }
if (lbHint) lbHint.classList.add("is-hidden");
}
function resetZoom() {
zoomed = false;
if (lbMain) { lbMain.classList.remove("is-zoomed"); lbMain.scrollTop = 0; }
if (lbHint) lbHint.classList.remove("is-hidden");
}
function toggleZoom() { zoomed ? resetZoom() : zoomIn(); }
/* 收集画廊图片 */
function collect() {
const list = [];
if (gallery) gallery.querySelectorAll("img").forEach((img) => {
const fig = img.closest("figure");
const cap = fig && fig.querySelector("figcaption");
list.push({ src: img.currentSrc || img.src, name: (cap && cap.textContent || "").trim() });
});
return list;
}
/* 渲染左侧竖排缩略图条 */
function renderStrip() {
if (!lbThumbs) return;
lbThumbs.innerHTML = items
.map((it, i) => {
const n = String(i + 1).padStart(2, "0");
return '<button type="button" class="lb-thumb' + (i === index ? ' is-active' : '') + '" data-i="' + i + '">' +
'<img src="' + it.src + '" alt="" />' +
'<span class="lb-thumb-idx">' + n + '</span>' +
'</button>';
})
.join("");
}
function show(i) {
if (!items.length) return;
index = (i + items.length) % items.length;
const it = items[index];
resetZoom();
lbImg.style.opacity = "0";
const loader = new Image();
loader.onload = function () { lbImg.src = it.src; lbImg.style.opacity = "1"; };
loader.src = it.src;
if (lbCounter) lbCounter.textContent = (index + 1) + " / " + items.length;
if (lbCaption) lbCaption.textContent = it.name || "";
if (lbThumbs) lbThumbs.querySelectorAll(".lb-thumb").forEach((t, k) => {
t.classList.toggle("is-active", k === index);
if (k === index) {
// 自动居中当前张(左栏禁用手动滚动,改用程序化 scrollTo
const target = t.offsetTop - (lbThumbs.clientHeight - t.clientHeight) / 2;
lbThumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
}
});
}
function open(i) {
items = collect();
if (!items.length) return;
if (lb) { lb.classList.add("is-open"); lb.setAttribute("aria-hidden", "false"); }
document.body.style.overflow = "hidden";
renderStrip();
show(i || 0);
}
function close() {
if (lb) { lb.classList.remove("is-open"); lb.setAttribute("aria-hidden", "true"); }
document.body.style.overflow = "";
}
function next() { show(index + 1); }
function prev() { show(index - 1); }
/* 事件绑定 */
if (gallery) gallery.addEventListener("click", (e) => {
const fig = e.target.closest("figure");
if (!fig) return;
const imgs = Array.prototype.slice.call(gallery.querySelectorAll("img"));
open(Math.max(0, imgs.indexOf(fig.querySelector("img"))));
});
if (btnClose) btnClose.addEventListener("click", close);
if (btnNext) btnNext.addEventListener("click", (e) => { e.stopPropagation(); next(); });
if (btnPrev) btnPrev.addEventListener("click", (e) => { e.stopPropagation(); prev(); });
if (btnTup) btnTup.addEventListener("click", (e) => { e.stopPropagation(); prev(); });
if (btnTdown) btnTdown.addEventListener("click", (e) => { e.stopPropagation(); next(); });
if (lb) lb.addEventListener("click", (e) => {
const t = e.target;
if (t === lb || (t.classList && t.classList.contains("lb-stage"))) close();
});
if (lbThumbs) lbThumbs.addEventListener("click", (e) => {
const t = e.target.closest(".lb-thumb");
if (t) show(Number(t.dataset.i));
});
if (lbImg) lbImg.addEventListener("click", toggleZoom);
document.addEventListener("keydown", (e) => {
if (!lb || !lb.classList.contains("is-open")) return;
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") next();
else if (e.key === "ArrowLeft") prev();
});
if (viewall) viewall.addEventListener("click", () => open(0));
/* 移动端单指滑动切换(仅在未放大时,放大态交给纵向滚动) */
let tx0 = 0, ty0 = 0, two = false;
if (lbStage) {
lbStage.addEventListener("touchstart", (e) => {
if (e.touches.length === 1) { tx0 = e.touches[0].clientX; ty0 = e.touches[0].clientY; }
else if (e.touches.length === 2) two = true;
}, { passive: true });
lbStage.addEventListener("touchend", (e) => {
if (two) { two = false; return; }
if (zoomed) return;
const t = e.changedTouches[0];
const dx = t.clientX - tx0, dy = t.clientY - ty0;
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) (dx < 0 ? next() : prev());
}, { passive: true });
}
/* 计数 + 画廊重渲染后同步 */
function syncCount() {
const c = document.getElementById("art-gallery-count");
const n = gallery ? gallery.querySelectorAll("img").length : 0;
if (c) c.textContent = n ? "· 共 " + n + " 张" : "";
}
syncCount();
if (gallery && "MutationObserver" in window) {
new MutationObserver(syncCount).observe(gallery, { childList: true });
}
})();
</script>

474
src/pages/articles.astro Normal file
View File

@ -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
---
<Layout>
<div class="archive">
<!-- 页头(极简黑白编辑风,与首页 Hero 一致的排版语言) -->
<header class="archive-head">
<div class="archive-kicker">
<span>Runway Archive</span>
<span>SS23 — AW26</span>
</div>
<h1 class="archive-title">The Reports</h1>
<div class="archive-sub">
<p>
走秀趋势报告档案库。支持网格 / 杂志 / 时间轴三种创意视图自由切换,彩色配图由后端接口动态渲染。
</p>
<a href="/" class="archive-back">← Index</a>
</div>
</header>
<!-- 工具栏:布局切换 + 结果计数 -->
<div class="archive-toolbar">
<div class="viewswitch" role="tablist" aria-label="Layout mode">
<button data-layout="grid" class="on" type="button">Grid</button>
<button data-layout="magazine" type="button">Magazine</button>
<button data-layout="timeline" type="button">Timeline</button>
</div>
<span class="archive-count" id="count">{reports.length} Reports</span>
</div>
<!-- 分类筛选(品牌),由脚本按数据动态生成 -->
<div class="filterbar" id="filterbar"></div>
<!-- 系列类型 / 季节筛选后端分类字段collection_type / season -->
<div class="filterbar" id="typefilter">
<button class="chip on" data-type="">全部系列</button>
<button class="chip" data-type="rtw">成衣 RTW</button>
<button class="chip" data-type="menswear">男装</button>
<button class="chip" data-type="couture">高定</button>
<button class="chip" data-type="resort">度假 / 早春</button>
<button class="chip" data-type="pre_fall">早秋</button>
</div>
<div class="filterbar" id="seasonfilter">
<button class="chip on" data-season="">全部季节</button>
<button class="chip" data-season="spring">Spring 春夏</button>
<button class="chip" data-season="fall">Fall 秋冬</button>
</div>
<!-- 画廊容器data-layout 决定当前视图;局部切换不产生整页刷新 -->
<section class="gallery" id="gallery" data-layout="grid">
{reports.map((s) => (
<article
class="card"
data-brand={s.brand}
data-featured={s.featured ? "true" : "false"}
data-year={s.year}
data-archive={s.year < 2025 ? "true" : "false"}
style={`--ratio:${s.ratio}`}
>
<div class="card-datecol"><span class="card-date">{fmt(s.created_at)}</span></div>
<a class="card-media" href={`/article?id=${s.id}`} aria-label={s.title}>
<img src={s.cover} alt={s.title} loading="lazy" />
{s.featured && <span class="card-flag">Featured</span>}
</a>
<div class="card-body">
<span class="card-brand">{s.brand}</span>
<h3 class="card-title">{s.title}</h3>
<p class="card-excerpt">{s.excerpt}</p>
<div class="card-foot">
<span class="card-date2">{fmt(s.created_at)}</span>
<span class="card-read">Read →</span>
</div>
</div>
</article>
))}
</section>
</div>
</Layout>
<style is:global>
@reference "tailwindcss";
/* 仅作用于本页(.archive 前缀),不影响其它页面与你的原有样式 */
.archive { max-width: 1440px; margin: 0 auto; }
/* ---------- 页头 ---------- */
.archive-head { margin-top: clamp(48px, 8vw, 110px); }
.archive-kicker {
@apply font-mono uppercase tracking-[0.25em] text-[11px] text-black;
display: flex; justify-content: space-between;
border-top: 1px solid #000; padding-top: 18px;
}
.archive-title {
@apply font-serif font-black tracking-[-0.02em];
line-height: 0.85;
font-size: clamp(48px, 11vw, 150px); margin: 0.28em 0 0;
}
.archive-sub {
display: flex; justify-content: space-between; align-items: flex-end;
gap: 24px; flex-wrap: wrap; border-bottom: 1px solid #000;
padding-bottom: 22px; margin-top: 22px;
}
.archive-sub p { max-width: 580px; @apply text-[#444] text-[15px]; line-height: 1.6; margin: 0; }
.archive-back {
@apply font-mono uppercase tracking-[0.15em] text-[11px] border border-black px-[16px] py-[10px] hover:bg-black hover:text-white;
white-space: nowrap;
transition: background 0.25s, color 0.25s;
}
/* ---------- 工具栏 ---------- */
.archive-toolbar {
display: flex; justify-content: space-between; align-items: center;
gap: 16px; flex-wrap: wrap; margin: 28px 0 22px;
}
.viewswitch { display: inline-flex; @apply border border-black; }
.viewswitch button {
@apply font-mono uppercase tracking-[0.15em] text-[11px] px-[18px] py-[10px] bg-white text-black;
border: none; border-right: 1px solid #000;
cursor: pointer; transition: background 0.25s, color 0.25s;
}
.viewswitch button:last-child { border-right: none; }
.viewswitch button.on { @apply bg-black text-white; }
.archive-count {
@apply font-mono text-[11px] tracking-[0.15em] uppercase text-[#666];
}
/* ---------- 筛选 chips ---------- */
.filterbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 34px; }
.chip {
@apply font-mono uppercase tracking-[0.12em] text-[11px] px-[14px] py-[7px] border border-[#ccc] bg-white text-[#333] rounded-full;
cursor: pointer; transition: all 0.2s;
}
.chip:hover { border-color: #000; }
.chip.on { @apply bg-black text-white border-black; }
/* ---------- 画廊基础 + 入场/切换过渡 ---------- */
.gallery { transition: opacity 0.24s ease, transform 0.24s ease; }
.gallery.switching { opacity: 0; transform: translateY(10px); }
.gallery .card { position: relative; @apply bg-white; }
/* 无 JS 时直接可见;有 JS 时做交错淡入 */
.archive.js .gallery .card {
opacity: 0; transform: translateY(16px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.archive.js .gallery .card.in { opacity: 1; transform: none; }
.card-media {
position: relative; display: block; overflow: hidden;
aspect-ratio: var(--ratio, 3 / 4); background: #f2f2f2;
}
.card-media img {
width: 100%; height: 100%; object-fit: cover;
opacity: 0; transform: scale(1.03);
transition: opacity 0.7s ease, transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card-media img.loaded { opacity: 1; transform: none; }
.card-media::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0) 42%, rgba(0,0,0,0.30));
opacity: 0; transition: opacity 0.5s;
}
.card:hover .card-media img { transform: scale(1.06); }
.card:hover .card-media::after { opacity: 1; }
.card-flag {
position: absolute; top: 12px; left: 12px; z-index: 2;
@apply font-mono uppercase tracking-[0.15em] text-[10px] font-bold bg-black text-white px-[9px] py-[4px];
}
.card-body { padding: 14px 2px 0; }
.card-brand {
display: block;
@apply font-mono uppercase tracking-[0.14em] text-[10.5px] text-black font-bold;
}
.card-title {
@apply font-serif font-extrabold;
font-size: clamp(18px, 2vw, 26px); line-height: 1.05; margin: 8px 0 0;
transition: color 0.3s;
}
.card:hover .card-title { color: #cf5a44; }
.card-excerpt { @apply text-[#555] text-[13.5px]; line-height: 1.55; margin: 8px 0 0; max-width: 48ch; }
.card-foot {
display: flex; justify-content: space-between; align-items: center;
margin-top: 12px;
@apply font-mono uppercase tracking-[0.1em] text-[10.5px] text-[#999];
}
.card-read {
@apply text-black;
opacity: 0; transform: translateX(-6px);
transition: opacity 0.35s, transform 0.35s;
}
.card:hover .card-read { opacity: 1; transform: none; }
/* 视觉层次Featured = 黑色粗边框 + 大标题Archive(年份<2025) = 虚线灰边框 */
.gallery .card[data-featured="true"] .card-media { border: 2px solid #000; }
.gallery .card[data-featured="true"] .card-title { font-size: clamp(22px, 2.6vw, 34px); }
.gallery .card[data-archive="true"] .card-media { border: 1px dashed #bbb; }
/* ---------- 视图 1Grid瀑布流非对称高低错落 ---------- */
.gallery[data-layout="grid"] { column-count: 3; column-gap: 26px; }
.gallery[data-layout="grid"] .card { break-inside: avoid; display: block; margin: 0 0 30px; }
.gallery[data-layout="grid"] .card-datecol { display: none; }
@media (max-width: 1024px) { .gallery[data-layout="grid"] { column-count: 2; } }
@media (max-width: 640px) { .gallery[data-layout="grid"] { column-count: 1; } }
/* ---------- 视图 2Magazine非对称网格Featured 跨栏放大) ---------- */
.gallery[data-layout="magazine"] {
display: grid; grid-template-columns: repeat(6, 1fr);
gap: 26px; grid-auto-flow: dense;
}
.gallery[data-layout="magazine"] .card { grid-column: span 2; display: block; }
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 4; grid-row: span 2; }
.gallery[data-layout="magazine"] .card[data-featured="true"] .card-media { aspect-ratio: 16 / 10; }
.gallery[data-layout="magazine"] .card-datecol { display: none; }
@media (max-width: 1024px) {
.gallery[data-layout="magazine"] { grid-template-columns: repeat(4, 1fr); }
.gallery[data-layout="magazine"] .card { grid-column: span 2; }
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 4; }
}
@media (max-width: 640px) {
.gallery[data-layout="magazine"] { grid-template-columns: 1fr; }
.gallery[data-layout="magazine"] .card,
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 1; grid-row: auto; }
}
/* ---------- 视图 3Timeline时间轴左侧日期 + 缩略图 + 正文) ---------- */
.gallery[data-layout="timeline"] { display: block; position: relative; }
.gallery[data-layout="timeline"]::before {
content: ""; position: absolute; left: 188px; top: 6px; bottom: 6px; width: 2px; background: #000;
}
.gallery[data-layout="timeline"] .card {
display: grid; grid-template-columns: 170px 220px 1fr; gap: 26px;
align-items: start; margin: 0 0 30px; position: relative;
}
.gallery[data-layout="timeline"] .card::before {
content: ""; position: absolute; left: 182px; top: 8px;
width: 14px; height: 14px; border-radius: 50%;
background: #fff; border: 2px solid #000; z-index: 2;
}
.gallery[data-layout="timeline"] .card-datecol { display: block; }
.gallery[data-layout="timeline"] .card-date {
@apply font-mono text-[13px] tracking-[0.08em] text-black;
}
.gallery[data-layout="timeline"] .card-media { aspect-ratio: auto; height: 150px; }
.gallery[data-layout="timeline"] .card-excerpt { display: none; }
.gallery[data-layout="timeline"] .card-foot { display: none; }
@media (max-width: 768px) {
.gallery[data-layout="timeline"]::before { left: 8px; }
.gallery[data-layout="timeline"] .card { grid-template-columns: 1fr; gap: 12px; padding-left: 34px; }
.gallery[data-layout="timeline"] .card::before { left: 2px; }
.gallery[data-layout="timeline"] .card-media { height: 200px; width: 100%; }
}
/* 筛选隐藏 */
.archive .card[data-hidden="true"] { display: none !important; }
</style>
<script>
const archive = document.querySelector<HTMLElement>(".archive")
const gallery = document.getElementById("gallery") as HTMLElement | null
const countEl = document.getElementById("count")
const filterbar = document.getElementById("filterbar")
const viewBtns = Array.from(document.querySelectorAll<HTMLButtonElement>(".viewswitch button"))
const API_BASE = (import.meta.env.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
// 封面可能是完整外链(https://...),也可能是相对路径(/uploads/...),统一处理
const toAbs = (url: string) => {
if (!url) return ""
return /^https?:\/\//i.test(url) ? url : API_BASE + url
}
// 来自品牌索引的聚焦筛选(/brands 的品牌链接:?brand_id= 或 ?brand=
const _urlParams = new URLSearchParams(location.search)
const _brandId = _urlParams.get("brand_id")
const _brandName = _urlParams.get("brand")
if (archive && gallery) archive.classList.add("js")
const fmt = (t: number | 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())}`
}
type Item = {
id: number | string
title: string
brand: string
year: number | string
created_at: number | string
cover: string
excerpt: string
image_count: number
featured: boolean
ratio?: string
}
// 卡片模板SSR 示例与后端渲染共用同一套结构,保证三种视图样式一致
const cardTemplate = (it: Item) => {
const archiveFlag = Number(it.year) < 2025 ? "true" : "false"
const ratio = it.ratio ?? "3 / 4"
return `
<article class="card" data-brand="${it.brand}" data-featured="${it.featured ? "true" : "false"}"
data-year="${it.year}" data-archive="${archiveFlag}" style="--ratio:${ratio}">
<div class="card-datecol"><span class="card-date">${fmt(it.created_at)}</span></div>
<a class="card-media" href="/article?id=${it.id}" aria-label="${it.title}">
<img src="${it.cover}" alt="${it.title}" loading="lazy" />
${it.featured ? '<span class="card-flag">Featured</span>' : ""}
</a>
<div class="card-body">
<span class="card-brand">${it.brand}</span>
<h3 class="card-title">${it.title}</h3>
<p class="card-excerpt">${it.excerpt}</p>
<div class="card-foot">
<span class="card-date2">${fmt(it.created_at)}</span>
<span class="card-read">Read →</span>
</div>
</div>
</article>`
}
const ratios = ["3 / 4", "4 / 5", "1 / 1", "5 / 4", "3 / 4", "4 / 3"]
const setupCards = () => {
if (!gallery) return
const cards = Array.from(gallery.querySelectorAll<HTMLElement>(".card"))
// 配图渐显
cards.forEach((c) => {
c.querySelectorAll("img").forEach((img) => {
if (img.complete) img.classList.add("loaded")
else img.addEventListener("load", () => img.classList.add("loaded"), { once: true })
})
})
// 交错入场
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add("in")
io.unobserve(e.target)
}
})
},
{ threshold: 0.08 }
)
cards.forEach((c, i) => {
c.style.transitionDelay = (i % 8) * 0.04 + "s"
io.observe(c)
})
buildChips(cards)
}
// 品牌筛选 chips按当前数据动态生成
let currentFilter: string | null = null
const buildChips = (cards: HTMLElement[]) => {
if (!filterbar) return
const brands = Array.from(new Set(cards.map((c) => c.dataset.brand).filter(Boolean))) as string[]
filterbar.innerHTML = ""
const mk = (label: string, brand: string | null) => {
const b = document.createElement("button")
b.className = "chip" + (brand === currentFilter ? " on" : "")
b.textContent = label
b.dataset.brand = brand ?? ""
b.addEventListener("click", () => {
currentFilter = currentFilter === brand ? null : brand
filterbar!.querySelectorAll(".chip").forEach((x) => x.classList.toggle("on", (x as HTMLElement).dataset.brand === (currentFilter ?? "")))
applyFilter()
})
return b
}
filterbar.appendChild(mk("All", null))
brands.forEach((br) => filterbar.appendChild(mk(br, br)))
}
const applyFilter = () => {
if (!gallery) return
const cards = Array.from(gallery.querySelectorAll<HTMLElement>(".card"))
let visible = 0
cards.forEach((c) => {
const show = !currentFilter || c.dataset.brand === currentFilter
c.toggleAttribute("data-hidden", !show)
if (show) visible++
})
if (countEl) countEl.textContent = `${visible} Reports`
}
// 布局切换:局部更新,带淡入淡出过渡,不刷新整页
viewBtns.forEach((btn) => {
btn.addEventListener("click", () => {
if (!gallery) return
const mode = btn.dataset.layout as string
if (gallery.dataset.layout === mode) return
viewBtns.forEach((x) => x.classList.toggle("on", x === btn))
gallery.classList.add("switching")
window.setTimeout(() => {
gallery!.dataset.layout = mode
gallery!.classList.remove("switching")
}, 240)
})
})
// 后端数据接入:拉取真实列表,失败则保留示例(布局不变)
const render = (items: Item[]) => {
if (!gallery) return
gallery.dataset.layout = gallery.dataset.layout || "grid"
gallery.innerHTML = items.map(cardTemplate).join("")
if (countEl) countEl.textContent = `${items.length} Reports`
setupCards()
}
const loadBackend = async () => {
if (!API_BASE) return
try {
const size = _brandId ? 100 : 30
const p = new URLSearchParams({ page: "1", size: String(size) })
if (fType) p.set("collection_type", fType)
if (fSeason) p.set("season", fSeason)
if (_brandId) p.set("brand_id", _brandId)
const url = `${API_BASE}/api/public/articles?${p.toString()}`
const res = await fetch(url)
if (!res.ok) throw new Error("bad status")
const json = await res.json()
const rows = json?.data ?? []
if (!Array.isArray(rows) || rows.length === 0) return
let items: Item[] = rows.map((r: any, i: number) => ({
id: r.id,
title: r.title || "Untitled",
brand: r.brand_name || "Unknown",
year: r.year || "",
created_at: r.published_at,
cover: toAbs(r.cover || ""),
excerpt: r.summary || "",
image_count: Number(r.image_count || 0),
featured: false,
ratio: ratios[i % ratios.length],
}))
// 以图片数量最高的前 3 篇标记为 Featured视觉层次
const top = [...items].sort((a, b) => b.image_count - a.image_count).slice(0, 3).map((x) => x.id)
items.forEach((it) => (it.featured = top.includes(it.id as any)))
// 来自品牌索引的聚焦:高亮该品牌、改写页头标题
const focus = _brandName || (_brandId && items.length ? items[0].brand : null)
if (focus) {
currentFilter = focus
render(items)
filterbar?.querySelectorAll(".chip").forEach((x) =>
x.classList.toggle("on", (x as HTMLElement).dataset.brand === focus)
)
const t = document.querySelector(".archive-title")
if (t) t.textContent = focus
const k = document.querySelector(".archive-kicker span")
if (k) k.textContent = "Brand Archive"
if (countEl) countEl.textContent = `${items.length} Reports`
} else {
render(items)
}
} catch {
/* 保持示例数据 */
}
}
setupCards()
loadBackend()
</script>

258
src/pages/brands.astro Normal file
View File

@ -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<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)
---
<Layout title="Brands — HBX">
<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 字母 tabbarsticky 顶部) -->
<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={`/articles?brand_id=${b.id}`}>{primaryName(b)}</a>
</li>
))}
</ul>
</section>
))}
</div>
)}
</main>
</div>
</Layout>
<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 Tabbarsticky ---------- */
.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>

8
src/pages/contact.astro Normal file
View File

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

865
src/pages/index.astro Normal file
View File

@ -0,0 +1,865 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
import { getShows, seriesLabel } from "../lib/data"
// 默认 tabShows走 SSR构建期从接口预取真实数据与多图首屏立即可见。
// 其余 tabBackstage / 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<string>()
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<string, string> = { 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} 发布` : "") }
}
---
<Layout>
<!-- 二级大字号分类(标签页):仅「发布会」上线,其余标注 Coming Soon -->
<div id="tabbar" class="mt-16 md:mt-24">
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
<li class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" data-tab="backstage" data-active="true">发布会
<span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span>
</li>
<li class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" data-tab="shows" data-soon="true">灵感
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" data-tab="events" data-soon="true">社媒
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" data-tab="street" data-soon="true">街拍
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<a href="/brands" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap hover:opacity-80">品牌</a>
</ul>
</div>
<!-- 中间内容展示区:标签内容区,点击标签仅局部更新此区域 -->
<main class="grow min-h-[400px] mt-10">
<!-- 发布会 面板默认激活SSR 预渲染真实走秀内容) -->
<div class="panel" data-panel="backstage" data-show="true" data-loaded="true">
<!-- 区块标题 -->
<div class="sec-head">
<div>
<h2 class="sec-title">RUNWAY ARCHIVE</h2>
<p class="sec-sub">走秀档案 · 精选最新上架的秀场</p>
</div>
<a href="/shows" class="sec-more">全部走秀 →</a>
</div>
<!-- 系列快速筛选前端本地过滤data-ct 对应 collection_type -->
<div class="seriesbar" id="seriesbar">
<button class="schip on" data-ct="">全部</button>
<button class="schip" data-ct="rtw">成衣</button>
<button class="schip" data-ct="menswear">男装</button>
<button class="schip" data-ct="couture">高定</button>
<button class="schip" data-ct="resort">度假</button>
<button class="schip" data-ct="pre_fall">早秋</button>
</div>
<!-- 精选:左轮播(图上文下)+ 右品牌栏(横版卡片 ×2 -->
{slides.length > 0 && (
<section class="hero-carousel" id="heroCarousel" data-count={slides.length}>
<div class="hc-stage">
<div class="hc-rotator">
{slides.map((s, i) => (
<div class="hc-slide" data-index={i} data-ct={s.collection_type || "rtw"} style={i === 0 ? "" : "display:none"}>
<a class="hc-media" href={`/article?id=${s.id}`} aria-label={s.brand}>
<img src={s.gallery[0] || s.img} alt={s.brand} class="hc-img" loading={i === 0 ? "eager" : "lazy"} />
<span class="prod-tag tl">Featured</span>
</a>
<div class="hc-text">
<div class="hc-info">
<span class="hc-eyebrow">{((s.title || "").match(/(\d{4}).*?(Spring|Summer|Fall|Autumn|Winter|Resort|Pre-Fall|Cruise)/i) || []).slice(1,3).join(" ") || s.season_code || "Featured Runway"}</span>
<div class="hc-head">
<h2 class="hc-brand">{s.brand}</h2>
<p class="hc-tagline">{seriesLabel(s.collection_type)}{s.season_code ? " · " + s.season_code : ""}</p>
</div>
<div class="hc-bar-row">
<a class="hc-bar" href={`/article?id=${s.id}`}>
<span>Brand Analysis</span>
<span class="hc-bar-plus">+</span>
</a>
<span class="hc-meta-count">{s.count} Look{s.count > 1 ? "s" : ""}</span>
</div>
</div>
<div class="hc-controls">
<span class="hc-index"><span class="hc-cur">{String(i + 1).padStart(2, "0")}</span> / {String(slides.length).padStart(2, "0")}</span>
<div class="hc-nav">
<button class="hc-arrow hc-prev" type="button" aria-label="上一张">←</button>
<div class="hc-dots">
{slides.map((_, di) => (
<button class="hc-dot" type="button" data-i={di} aria-label={`第 ${di + 1} 张`} class:list={["hc-dot", di === 0 && "on"]}></button>
))}
</div>
<button class="hc-arrow hc-next" type="button" aria-label="下一张">→</button>
</div>
</div>
</div>
</div>
))}
</div>
{rail.length > 0 && (
<aside class="hc-rail">
<div class="hc-rail-head">
<div class="hc-rail-tabs">
<button type="button" class="on" data-sort="latest">最新</button>
<button type="button" data-sort="hot">最热</button>
</div>
</div>
<div class="hc-rail-list" id="railList">
<div class="rail-set" data-set="latest">
{latestSet.map((r) => (
<article class="rail-card">
<div class="rail-text">
<span class="rail-eyebrow">{railEyebrow(r)}</span>
<h3 class="rail-brand">{r.brand}</h3>
<p class="rail-tag">{railTagline(r)}</p>
<a class="rail-bar" href={`/article?id=${r.id}`}>
<span>Brand Analysis</span>
<span class="rail-bar-plus">+</span>
</a>
<div class="rail-desc">{r.brand}{railDesc(r).seasonTxt || "近期新作"} <span class="rail-dot">·</span> {r.count} 张</div>
</div>
<a class="rail-thumb" href={`/article?id=${r.id}`} aria-label={r.brand}>
<img src={r.gallery[0] || r.img} alt={r.brand} loading="lazy" />
</a>
</article>
))}
</div>
<div class="rail-set" data-set="hot" style="display:none">
{hotSet.map((r) => (
<article class="rail-card">
<div class="rail-text">
<span class="rail-eyebrow">{railEyebrow(r)}</span>
<h3 class="rail-brand">{r.brand}</h3>
<p class="rail-tag">{railTagline(r)}</p>
<a class="rail-bar" href={`/article?id=${r.id}`}>
<span>Brand Analysis</span>
<span class="rail-bar-plus">+</span>
</a>
<div class="rail-desc">{r.brand}{railDesc(r).seasonTxt || "近期新作"} <span class="rail-dot">·</span> {r.count} 张</div>
</div>
<a class="rail-thumb" href={`/article?id=${r.id}`} aria-label={r.brand}>
<img src={r.gallery[0] || r.img} alt={r.brand} loading="lazy" />
</a>
</article>
))}
</div>
</div>
</aside>
)}
</div>
</section>
)}
<!-- 卡片网格(轮播之外的剩余 shows -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6" id="showgrid">
{shows.slice(5).map((s) => (
<article class="prod-card group" data-article-id={s.id} data-ct={s.collection_type || "rtw"}>
<div class="prod-main">
<img src={s.gallery[0] || s.img} alt={s.brand} class="prod-main-img" loading="lazy" />
<span class="prod-tag tl">Runway</span>
{s.count > 1 && <span class="prod-tag tr">{s.count} Pics</span>}
<a href={`/article?id=${s.id}`} class="prod-viewall">查看全部 {s.count} 张 →</a>
</div>
<div class="prod-thumbs">
{( s.gallery.length ? s.gallery : [s.img] ).map((g, gi) => (
<button class="prod-thumb" type="button" aria-label={`查看第 ${gi + 1} 张`}>
<img src={g} alt="" loading="lazy" />
</button>
))}
{s.count > (s.gallery.length || 1) && (
<a href={`/article?id=${s.id}`} class="prod-more">+{s.count - (s.gallery.length || 1)}</a>
)}
</div>
<div class="prod-info">
<div class="prod-name">{s.brand}</div>
<div class="prod-meta">
<span class="prod-chip">{seriesLabel(s.collection_type)}</span>
{s.season_code && <span class="prod-chip">{s.season_code}</span>}
</div>
<div class="prod-sub">{s.title}</div>
<div class="prod-foot">
<a href={`/article?id=${s.id}`} class="prod-cta">查看 {s.count} 张 →</a>
</div>
</div>
</article>
))}
</div>
<div class="showmore-wrap">
<button id="showmore" class="showmore" type="button">查看更多走秀 ↓</button>
</div>
<div class="empty" id="showempty" hidden>该系列暂无可展示的走秀</div>
</div>
<!-- 灵感 面板(暂未上线) -->
<div class="panel" data-panel="shows" data-show="false" data-loaded="true">
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 社媒 面板(暂未上线) -->
<div class="panel" data-panel="events" data-show="false" data-loaded="true">
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 街拍 面板(暂未上线) -->
<div class="panel" data-panel="street" data-show="false" data-loaded="true">
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
</main>
<!-- 底部/两侧切换按钮(保持你原来的) -->
<div class="flex justify-between items-center text-[13px] font-bold mt-12">
<div class="cursor-pointer hover:opacity-60 transition-opacity duration-200">Previous</div>
<div class="cursor-pointer hover:opacity-60 transition-opacity duration-200">Next</div>
</div>
</Layout>
<style is:global>
@reference "tailwindcss";
/* 卡片及缩略图样式改为全局is:global默认 Shows 走 SSR 由 Astro 作用域正常生效,
但其余 tab 的卡片由前端 JS 在运行时注入 DOM缺 data-astro-cid 属性,
原组件作用域样式无法命中。改为全局后所有 tab 的卡片展示完全一致3:4。 */
/* 标签配色:沿用你原来的 #bbb / #666 hover / 黑 激活 */
.tab { @apply text-[#bbb] transition-colors duration-200 no-underline; }
.tab:hover { @apply text-[#666]; }
.tab[data-active="true"] { @apply text-black; }
.tab[data-active="true"]:hover { @apply text-black; }
/* 面板:仅显示当前标签对应的内容区,局部切换 */
.panel { @apply hidden; }
.panel[data-show="true"] {
@apply block;
animation: tabfade 0.3s ease;
}
@keyframes tabfade {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
/* 卡片悬停上浮 + 阴影(恢复此前被误删的规则) */
.prod-card:hover { @apply -translate-y-1 shadow-[0_16px_38px_rgba(0,0,0,0.13)]; }
/* 主图容器:固定 3:4 比例 + 骨架占位,图片未加载时尺寸恒定、显示加载动画 */
.prod-main {
@apply relative w-full aspect-[3/4] overflow-hidden bg-[#f0f0f0];
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%;
animation: img-shimmer 1.3s ease-in-out infinite;
}
.prod-main.is-loaded { animation: none; }
/* 主图:绝对铺满容器并裁切,保证不同分辨率图展示一致、容器高度恒定 */
.prod-main-img {
@apply absolute inset-0 w-full h-full object-cover block origin-center;
transition: transform .55s ease, opacity .22s ease;
}
/* 缩略图容器:固定 3:4 比例 + 骨架占位 */
.prod-thumb {
@apply bg-[#f0f0f0];
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%;
animation: img-shimmer 1.3s ease-in-out infinite;
}
.prod-thumb.is-loaded { animation: none; }
@keyframes img-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
/* .js 门控的淡入与 is-error 占位规则已移至下方 <style is:global>,避免组件作用域破坏 .js 选择器) */
/* ===== 卡片 + 缩略图轮播 混合布局 ===== */
:root { --brand: #cf5a44; }
.prod-card {
@apply flex flex-col bg-white border border-[#ececec] rounded-[6px] overflow-hidden transition-[transform,box-shadow] duration-300 will-change-transform;
}
.prod-card:hover .prod-main-img { @apply scale-105; }
.prod-card:hover .prod-main { @apply shadow-[0_12px_26px_rgba(0,0,0,0.18)]; }
.prod-tag {
@apply absolute z-[2] font-mono text-[10px] uppercase tracking-[.18em] px-[8px] py-[4px];
}
.prod-tag.tl { @apply top-[10px] left-[10px] bg-white text-black; }
.prod-tag.tr { @apply top-[10px] right-[10px] bg-black text-white flex gap-[5px] items-center; }
.prod-tag.tr::before { content: ""; @apply w-[6px] h-[6px] rounded-full bg-white; }
.prod-thumbs {
@apply flex gap-2 px-[10px] pt-[10px] pb-[6px] overflow-x-auto;
scrollbar-width: none; -ms-overflow-style: none;
}
.prod-thumbs::-webkit-scrollbar { display: none; }
.prod-thumb {
@apply flex-none w-[78px] aspect-[3/4] h-auto p-0 m-0 border-2 border-transparent bg-[#f2f2f2] cursor-pointer rounded-[4px] overflow-hidden opacity-75 transition-[border-color,opacity,transform] duration-200;
}
.prod-thumb img { @apply w-full h-full object-cover block; }
.prod-thumb:hover { @apply opacity-100; }
.prod-thumb.active { @apply border-[var(--brand)] opacity-100; }
.prod-info { @apply px-[12px] pt-[8px] pb-[14px] flex flex-col gap-[6px] flex-1; }
.prod-name { @apply text-[16px] font-extrabold leading-[1.2] text-[#111]; }
.prod-sub { @apply text-[11px] uppercase tracking-[.14em] text-[#999] font-mono; }
.prod-tags { @apply flex gap-[6px] flex-wrap; }
.prod-badge {
@apply text-[10px] uppercase tracking-[.08em] px-[8px] py-[3px] border border-[#ddd] rounded-full text-[#666] bg-white;
}
.prod-badge.new { @apply bg-[#111] text-white border-[#111]; }
.prod-badge.hot { @apply bg-[var(--brand)] text-white border-[var(--brand)]; }
.prod-foot { @apply flex items-center justify-between mt-auto pt-[4px]; }
.prod-price { @apply text-[15px] font-extrabold tracking-[.02em] text-[#111]; }
.prod-cta { @apply text-[11px] uppercase tracking-[.14em] font-bold no-underline; color: var(--brand); }
.prod-card:hover .prod-cta { @apply underline; }
/* 缩略图条末尾的"更多"入口:提示还有更多图片,点击进入详情看全部 */
.prod-more {
@apply flex-none w-[78px] aspect-[3/4] h-auto flex items-center justify-center bg-[var(--brand)] text-white no-underline rounded-[4px] text-[13px] font-extrabold tracking-[.02em] transition-[filter,transform] duration-200;
}
.prod-more:hover { @apply brightness-[1.08] -translate-y-[2px]; }
/* 主图悬停时浮现"查看全部 N 张"浮层(桌面 hover 生效,移动端不拦截 pinch */
.prod-viewall {
@apply absolute inset-0 z-[3] flex items-end justify-center pb-[16px] text-white text-[12px] font-bold tracking-[.14em] uppercase no-underline opacity-0 pointer-events-none transition-opacity duration-[250ms] ease-[ease];
background: linear-gradient(to top, rgba(0,0,0,.6), rgba(0,0,0,0) 55%);
}
.prod-card:hover .prod-viewall { @apply opacity-100 pointer-events-auto; }
/* 标签页激活态:品牌色下划线动画 */
.tab { @apply relative pb-[6px]; }
.tab::after {
content: "";
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-[ease];
}
.tab[data-active="true"]::after { @apply scale-x-100; }
/* 懒加载占位骨架卡 */
.skel-card { @apply border border-[#ececec] rounded-[6px] overflow-hidden bg-white; }
.skel-main {
@apply aspect-[3/4] bg-[#f0f0f0];
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%; animation: img-shimmer 1.3s ease-in-out infinite;
}
.skel-line {
@apply h-[12px] mt-[10px] mx-[12px] rounded-[3px] bg-[#f0f0f0];
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%; animation: img-shimmer 1.3s ease-in-out infinite;
}
.skel-line.short { @apply w-1/2; }
/* 区块标题 */
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
.sec-more:hover { @apply opacity-60; }
/* 系列快速筛选 */
.seriesbar { @apply flex items-center gap-2 overflow-x-auto mb-7 pb-1; scrollbar-width: none; -ms-overflow-style: none; }
.seriesbar::-webkit-scrollbar { display: none; }
.schip {
@apply flex-none font-mono text-[11px] uppercase tracking-[.12em] px-[14px] py-[6px] rounded-full border border-[#ddd] bg-white text-[#666] cursor-pointer transition-colors duration-200;
}
.schip:hover { @apply text-[#111] border-[#111]; }
.schip.on { @apply bg-[#111] text-white border-[#111]; }
/* 精选 feat group橄榄绿 editorial 重排(保留左大图轮播 + 右品牌栏) */
.hero-carousel { @apply relative mb-9; }
.hc-stage { @apply relative flex flex-col; }
@media (min-width: 768px) { .hc-stage { flex-direction: row; gap: 32px; align-items: stretch; } }
.hc-rotator { display: flex; flex-direction: column; min-height: 0; }
@media (min-width: 768px) { .hc-rotator { flex: 1 1 0%; min-height: 560px; } }
.hc-slide { display: flex; flex-direction: column; flex: 1 1 0%; min-height: 0; gap: 20px; }
.hc-media {
position: relative; flex: 1 1 0%; min-height: 320px; width: 100%; overflow: hidden;
border-radius: 2px; background: #f0f0f0; display: block; text-decoration: none;
}
@media (min-width: 768px) { .hc-media { min-height: 480px; } }
.hc-img { @apply absolute inset-0 w-full h-full object-cover block origin-center transition-transform duration-[600ms] ease-out; }
.hc-media:hover .hc-img { @apply scale-[1.03]; }
.hc-text { @apply flex flex-col gap-[18px] flex-shrink-0; }
.hc-info { @apply flex flex-col gap-3; }
.hc-eyebrow { @apply block font-mono text-[11px] uppercase tracking-[.24em] text-[#8a8455]; }
.hc-head { @apply flex items-baseline gap-4 flex-wrap; }
.hc-brand { @apply font-serif font-extrabold text-[#14110d] m-0 tracking-[-.02em] leading-[1.0]; font-size: 34px; }
@media (min-width: 768px) { .hc-brand { font-size: 46px; } }
.hc-tagline { @apply font-mono text-[11px] uppercase tracking-[.18em] text-[#8a8455] m-0; }
.hc-bar-row { @apply flex items-center gap-4 pt-[14px] border-t border-[#e6e2d8]; }
.hc-bar {
@apply inline-flex items-center justify-between gap-4 px-4 py-[10px] text-white font-mono text-[10px] uppercase tracking-[.2em] font-bold no-underline border transition-colors duration-200;
background: #8a8455; border-color: #8a8455;
}
.hc-bar:hover { @apply no-underline; background: #fff; color: #8a8455; }
.hc-bar-plus { @apply text-[16px] leading-none font-light; }
.hc-meta-count { @apply font-mono text-[11px] uppercase tracking-[.16em] text-[#9a937f]; }
.hc-controls { @apply flex items-center justify-between pt-[14px] border-t border-[#e6e2d8]; }
.hc-index { @apply font-mono text-[12px] tracking-[.2em] text-[#9a937f]; }
.hc-index .hc-cur { @apply font-bold; color: #8a8455; }
.hc-nav { @apply flex items-center gap-4; }
.hc-arrow {
@apply w-9 h-9 rounded-full flex items-center justify-center cursor-pointer bg-white no-underline select-none transition-colors duration-200;
border: 1px solid #8a8455; color: #8a8455;
}
.hc-arrow:hover { background: #8a8455; color: #fff; }
.hc-dots { @apply flex gap-2 items-center; }
.hc-dot { @apply w-2 h-2 rounded-full border-0 p-0 cursor-pointer transition-colors duration-200; background: #dcd8cc; }
.hc-dot.on { background: #8a8455; }
/* 右侧品牌栏横版卡片图左文右×2 等高) */
.hc-rail { @apply flex flex-col min-h-0; }
@media (min-width: 768px) { .hc-rail { width: 34%; flex-shrink: 0; border-left: 1px solid #e6e2d8; padding-left: 24px; } }
.hc-rail-head { @apply mb-4; }
.hc-rail-tabs { @apply flex gap-3; }
.hc-rail-tabs button { @apply font-mono text-[11px] uppercase tracking-[.14em] bg-none border-0 p-0 cursor-pointer transition-colors duration-200; color: #b8b2a2; }
.hc-rail-tabs button.on { @apply font-bold pb-[2px] border-b; color: #8a8455; border-color: #8a8455; }
.hc-rail-list { @apply flex flex-col flex-1 overflow-y-auto min-h-0; scrollbar-width: thin; scrollbar-color: #dcd8cc #fff; }
.hc-rail-list::-webkit-scrollbar { @apply w-[6px]; }
.hc-rail-list::-webkit-scrollbar-thumb { @apply bg-[#dcd8cc] rounded-full; }
.rail-set { @apply list-none m-0 p-0 flex flex-col flex-1 min-h-0 gap-4; }
.rail-card { @apply flex flex-row-reverse flex-1 min-h-0 gap-3 items-stretch; }
.rail-text { @apply w-[42%] flex flex-col justify-between min-w-0 py-[2px]; }
.rail-eyebrow { @apply block font-mono text-[10px] uppercase tracking-[.16em] text-[#8a8455]; }
.rail-brand { @apply font-serif font-extrabold text-[#14110d] m-0 mt-[2px] leading-[1.02] tracking-[-.01em]; font-size: 22px; }
.rail-tag { @apply font-mono text-[10px] uppercase tracking-[.12em] m-0 mt-[2px]; color: #9a937f; }
.rail-bar {
@apply flex items-center justify-between w-full mt-3 px-3 py-[8px] text-white font-mono text-[10px] uppercase tracking-[.16em] font-bold no-underline border transition-colors duration-200;
background: #8a8455; border-color: #8a8455;
}
.rail-bar:hover { @apply no-underline; background: #fff; color: #8a8455; }
.rail-bar-plus { @apply text-[14px] leading-none font-light; }
.rail-desc { @apply font-mono text-[10px] mt-2 leading-[1.5] tracking-[.02em]; color: #9a937f; }
.rail-dot { @apply mx-[3px]; color: #ccc; }
.rail-thumb {
position: relative; flex: 1; min-height: 160px; border-radius: 2px; overflow: hidden;
background: #f0f0f0; display: block; text-decoration: none;
}
@media (min-width: 768px) { .rail-thumb { min-height: 0; } }
.rail-thumb img { @apply absolute inset-0 w-full h-full object-cover transition-transform duration-[600ms] ease-out; }
.rail-thumb:hover img { @apply scale-[1.04]; }
/* 卡片上的系列 / 季节标签 */
.prod-meta { @apply flex gap-2 flex-wrap mt-1; }
.prod-chip {
@apply font-mono text-[10px] uppercase tracking-[.1em] px-[8px] py-[3px] rounded-full border border-[#ddd] text-[#666] bg-white;
}
.empty { @apply text-center text-[13px] text-[#999] py-16 font-mono uppercase tracking-[.12em]; }
/* 查看更多(首页走秀栏内联分页) */
.showmore-wrap { @apply flex justify-center mt-10; }
.showmore {
@apply font-mono uppercase tracking-[.16em] text-[12px] px-[28px] py-[14px] border border-[#111] bg-white text-[#111] rounded-full cursor-pointer transition-colors duration-200;
}
.showmore:hover { @apply bg-[#111] text-white; }
.showmore[disabled] { @apply opacity-40 cursor-default; }
/* (图片堆方案已移除,改由卡片内缩略图条展示多图) */
</style>
<style is:global>
/* JS 启用时主图/缩略图先透明加载完成淡入失败保留占位骨架is:global 避免组件作用域误伤 .js 选择器) */
.js .prod-main-img { @apply opacity-0; }
.js .prod-main-img.is-loaded { @apply opacity-100; }
.js .prod-thumb img { @apply opacity-0; }
.js .prod-thumb img.is-loaded { @apply opacity-100; }
.prod-main-img.is-error, .prod-thumb img.is-error { opacity: 0 !important; }
</style>
<script>
// 标签页交互:默认 Shows 为 SSR点击其它标签时按需请求接口渲染懒加载
const API_BASE = (import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "");
const tabs = Array.from(document.querySelectorAll<HTMLElement>("#tabbar .tab"));
const panels = Array.from(document.querySelectorAll<HTMLElement>(".panel"));
function toAbs(u: string): string {
if (!u) return "";
return /^https?:\/\//i.test(u) ? u : API_BASE + u;
}
// 面板数据区段(与后端真实列表对齐):各面板取不同区段,互不重复
const PANEL_SLICE: Record<string, [number, number]> = {
backstage: [8, 16],
events: [16, 25],
street: [24, 32],
};
const LABEL: Record<string, string> = { shows: "Show", backstage: "Backstage", events: "Trend", street: "Street" };
const SERIES_LABELS: Record<string, string> = { rtw: "成衣 RTW", menswear: "男装", couture: "高定", resort: "度假", pre_fall: "早秋" };
let panelCache: any[] | null = null;
async function ensurePanelData(): Promise<any[]> {
if (panelCache) return panelCache;
const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=32&with_images=5`);
const json = await res.json();
const raw: any[] = json?.data ?? [];
panelCache = raw.map((r) => ({
id: r.id,
brand: r.brand_name || "Unknown",
title: r.title || "",
season: r.year ? "AW" + String(r.year).slice(-2) : "",
season_code: r.season_code || "",
collection_type: r.collection_type || "",
year: r.year,
img: toAbs(r.cover || ""),
count: r.image_count || 0,
gallery: (r.images || []).map((im: any) => toAbs(im.image || "")).filter(Boolean).slice(0, 5),
}));
return panelCache;
}
function skeletonHTML(n = 8): string {
let s = "";
for (let i = 0; i < n; i++) {
s += `<div class="skel-card"><div class="skel-main"></div><div class="skel-line"></div><div class="skel-line short"></div></div>`;
}
return s;
}
function cardHTML(it: any, i: number, label: string): string {
const g = it.gallery && it.gallery.length ? it.gallery : [it.img];
const thumbs = g.map((src: string, gi: number) =>
`<button class="prod-thumb" type="button" aria-label="查看第 ${gi + 1} 张"><img src="${src}" alt="" loading="lazy" /></button>`
).join("");
const more = it.count > g.length
? `<a href="/article?id=${it.id}" class="prod-more">+${it.count - g.length}</a>`
: "";
const badges =
(i === 0 ? `<span class="prod-badge new">新品</span>` : "") +
(it.count > 3 ? `<span class="prod-badge hot">热门</span>` : "");
return `
<article class="prod-card group" data-article-id="${it.id}">
<div class="prod-main">
<img src="${g[0]}" alt="${it.brand}" class="prod-main-img" loading="lazy" />
<span class="prod-tag tl">${label}</span>
${it.count > 1 ? `<span class="prod-tag tr">${it.count} Pics</span>` : ""}
<a href="/article?id=${it.id}" class="prod-viewall">查看全部 ${it.count} 张 →</a>
</div>
<div class="prod-thumbs">${thumbs}${more}</div>
<div class="prod-info">
<div class="prod-name">${it.brand}</div>
<div class="prod-meta">
<span class="prod-chip">${SERIES_LABELS[it.collection_type] || "成衣 RTW"}</span>
${it.season_code ? `<span class="prod-chip">${it.season_code}</span>` : ""}
</div>
<div class="prod-sub">${it.title}</div>
<div class="prod-tags">${badges}</div>
<div class="prod-foot">
<span class="prod-price">${it.season}</span>
<a href="/article?id=${it.id}" class="prod-cta">查看 →</a>
</div>
</div>
</article>`;
}
function initCardInteractions(root: ParentNode) {
root.querySelectorAll<HTMLElement>(".prod-card").forEach((card) => {
const main = card.querySelector<HTMLImageElement>(".prod-main-img");
const thumbs = Array.from(card.querySelectorAll<HTMLElement>(".prod-thumb"));
if (!main || !thumbs.length) return;
thumbs[0].classList.add("active");
thumbs.forEach((t) => {
t.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const img = t.querySelector("img");
const src = img?.getAttribute("src") || "";
if (!src || main.getAttribute("src") === src) return;
main.classList.remove("is-loaded");
main.parentElement?.classList.remove("is-loaded");
main.style.opacity = "0";
window.setTimeout(() => {
main.setAttribute("src", src);
main.style.opacity = "1";
}, 180);
thumbs.forEach((x) => x.classList.remove("active"));
t.classList.add("active");
});
});
main.addEventListener("load", () => {
main.classList.add("is-loaded");
main.parentElement?.classList.add("is-loaded");
});
enablePinch(card.querySelector<HTMLElement>(".prod-main"));
});
root.querySelectorAll<HTMLImageElement>(".prod-main-img, .prod-thumb img").forEach(watchImg);
}
async function loadPanel(name: string) {
const panel = panels.find((p) => p.getAttribute("data-panel") === name);
if (!panel || panel.getAttribute("data-loaded") === "true" || panel.getAttribute("data-loading") === "true") return;
panel.setAttribute("data-loading", "true");
const grid = panel.querySelector<HTMLElement>(".grid");
if (grid) grid.innerHTML = skeletonHTML(8);
try {
const all = await ensurePanelData();
const [a, b] = PANEL_SLICE[name] || [0, 8];
const items = all.slice(a, b);
if (grid) {
grid.innerHTML = items.map((it, i) => cardHTML(it, i, LABEL[name] || "Show")).join("");
initCardInteractions(grid);
}
panel.setAttribute("data-loaded", "true");
} catch {
if (grid) grid.innerHTML = `<div class="text-sm text-gray-400 p-6">内容加载失败,请重试</div>`;
}
panel.setAttribute("data-loading", "false");
}
tabs.forEach((tab) => {
// Brands 是导航型标签(<a href="/brands">):点击直接跳转品牌档案页,不切换面板
if (tab.tagName.toLowerCase() === "a") return;
tab.addEventListener("click", () => {
const target = tab.getAttribute("data-tab");
if (!target) return;
tabs.forEach((t) => t.setAttribute("data-active", String(t === tab)));
panels.forEach((p) => {
p.setAttribute("data-show", String(p.getAttribute("data-panel") === target));
});
// 暂未上线标签:仅切换面板显示占位内容,不请求数据
if (tab.getAttribute("data-soon") === "true") return;
if (target !== "shows") loadPanel(target);
});
});
// 首页发布会面板:系列快速筛选(前端本地过滤,按 data-ct 显示/隐藏)
let homeFType = ""
const seriesApply = (function () {
const bar = document.getElementById("seriesbar");
if (!bar) return () => {};
const grid = document.getElementById("showgrid");
const empty = document.getElementById("showempty");
const chips = Array.from(bar.querySelectorAll<HTMLButtonElement>(".schip"));
function apply(ct: string) {
homeFType = ct;
const ok = (el: Element | null) => !!el && (!ct || (el.getAttribute("data-ct") || "") === ct);
let vis = 0;
if (grid) grid.querySelectorAll<HTMLElement>(".prod-card").forEach((c) => {
const m = ok(c); c.style.display = m ? "" : "none"; if (m) vis++;
});
// 精选轮播Featured始终展示不参与系列筛选隐藏
if (empty) empty.hidden = vis > 0;
}
chips.forEach((b) => b.addEventListener("click", () => {
chips.forEach((x) => x.classList.toggle("on", x === b));
apply(b.getAttribute("data-ct") || "");
}));
return apply;
})();
// 首页发布会面板:查看更多(前端分页追加,带系列筛选参数)
(function () {
let homePage = 2;
let homeBusy = false;
const btn = document.getElementById("showmore") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
if (homeBusy) return;
homeBusy = true;
const orig = btn.textContent;
btn.textContent = "加载中…";
btn.disabled = true;
try {
const p = new URLSearchParams({ page: String(homePage), size: "8", with_images: "5" });
if (homeFType) p.set("collection_type", homeFType);
const res = await fetch(`${API_BASE}/api/public/articles?${p.toString()}`);
const json = await res.json();
const rows = json?.data ?? [];
const grid = document.getElementById("showgrid");
if (grid && rows.length) {
const html = rows.map((r: any) => {
const g = (r.images || []).map((im: any) => toAbs(im.image || "")).filter(Boolean).slice(0, 5);
const it = {
id: r.id, brand: r.brand_name || "Unknown", title: r.title || "",
season: r.year ? "AW" + String(r.year).slice(-2) : "",
season_code: r.season_code || "", collection_type: r.collection_type || "",
year: r.year, img: toAbs(r.cover || ""), count: r.image_count || 0, gallery: g,
};
return cardHTML(it, 99, "Runway");
}).join("");
grid.insertAdjacentHTML("beforeend", html);
initCardInteractions(grid);
seriesApply(homeFType);
homePage++;
if (rows.length < 8) { btn.textContent = "没有更多了"; return; }
} else {
btn.textContent = "没有更多了"; return;
}
btn.textContent = orig;
btn.disabled = false;
} catch {
btn.textContent = orig;
btn.disabled = false;
} finally {
homeBusy = false;
}
});
})();
// 启用 JS 标记:用于渐进增强的占位符淡入(无 JS 时图片直接显示,不影响可用性)
document.documentElement.classList.add("js");
// 图片占位符:加载完成淡入并停止骨架动画;失败保留占位骨架
function watchImg(img: HTMLImageElement) {
const done = () => {
img.classList.add("is-loaded");
img.parentElement?.classList.add("is-loaded");
};
const fail = () => {
img.classList.add("is-error");
img.removeAttribute("src");
};
if (img.complete) {
if (img.naturalWidth > 0) done();
else fail();
} else {
img.addEventListener("load", done, { once: true });
img.addEventListener("error", fail, { once: true });
}
}
// 初始化 SSR 的 Shows 面板卡片交互(缩略图切换 / 占位淡入 / 双指缩放)
initCardInteractions(document);
// 移动端双指缩放主图pinch-to-zoom+ 单指平移
function enablePinch(box: HTMLElement | null) {
if (!box) return;
const img = box.querySelector<HTMLImageElement>(".prod-main-img");
if (!img) return;
let scale = 1, startDist = 0, startScale = 1, panX = 0, panY = 0, sx = 0, sy = 0;
const dist = (a: Touch, b: Touch) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
box.addEventListener("touchstart", (e: TouchEvent) => {
if (e.touches.length === 2) {
startDist = dist(e.touches[0], e.touches[1]);
startScale = scale;
img.style.transition = "none";
} else if (e.touches.length === 1 && scale > 1) {
sx = e.touches[0].clientX - panX;
sy = e.touches[0].clientY - panY;
}
}, { passive: true });
box.addEventListener("touchmove", (e: TouchEvent) => {
if (e.touches.length === 2 && startDist) {
e.preventDefault();
scale = Math.min(4, Math.max(1, startScale * dist(e.touches[0], e.touches[1]) / startDist));
panX = 0; panY = 0;
} else if (e.touches.length === 1 && scale > 1) {
e.preventDefault();
panX = e.touches[0].clientX - sx;
panY = e.touches[0].clientY - sy;
} else {
return;
}
img.style.transform = `scale(${scale}) translate(${panX}px, ${panY}px)`;
}, { passive: false });
box.addEventListener("touchend", () => {
if (scale <= 1) {
scale = 1; panX = 0; panY = 0;
img.style.transition = "";
img.style.transform = "";
} else {
img.style.transition = "transform .2s ease";
}
});
}
// 首页精选轮播(左图右文卡片式)
(function () {
const root = document.getElementById("heroCarousel");
if (!root) return;
const slides = Array.from(root.querySelectorAll<HTMLElement>(".hc-slide"));
const total = slides.length;
if (total <= 1) {
root.querySelectorAll<HTMLElement>(".hc-controls").forEach((c) => (c.style.display = "none"));
return;
}
let idx = 0;
let timer: number | undefined;
const dots = Array.from(root.querySelectorAll<HTMLButtonElement>(".hc-dot"));
function go(n: number) {
idx = (n + total) % total;
slides.forEach((s, i) => { s.style.display = i === idx ? "" : "none"; });
// 每屏各自带一套 controls只高亮当前可见 slide 内的圆点
const visDots = slides[idx].querySelectorAll<HTMLElement>(".hc-dot");
visDots.forEach((d, i) => d.classList.toggle("on", i === idx));
}
function start() { stop(); timer = window.setInterval(() => go(idx + 1), 5000); }
function stop() { if (timer) window.clearInterval(timer); }
root.querySelectorAll<HTMLButtonElement>(".hc-prev").forEach((b) =>
b.addEventListener("click", () => { go(idx - 1); start(); })
);
root.querySelectorAll<HTMLButtonElement>(".hc-next").forEach((b) =>
b.addEventListener("click", () => { go(idx + 1); start(); })
);
dots.forEach((d) =>
d.addEventListener("click", () => { go(Number(d.dataset.i)); start(); })
);
root.addEventListener("mouseenter", stop);
root.addEventListener("mouseleave", start);
start();
})();
// 右侧品牌栏:最新 / 最热 切换(事件委托,稳健绑定)
(function () {
const wrap = document.querySelector(".hc-rail-tabs");
const railList = document.getElementById("railList");
if (!wrap || !railList) return;
const sets = railList.querySelectorAll<HTMLElement>(".rail-set");
wrap.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest("button");
if (!btn) return;
const sort = btn.dataset.sort;
if (!sort) return;
wrap.querySelectorAll("button").forEach((b) => b.classList.toggle("on", b === btn));
sets.forEach((s) => { s.style.display = s.dataset.set === sort ? "" : "none"; });
});
})();
</script>

View File

@ -0,0 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
---
<Layout title="Latest Projects — 暂未上线">
<ComingSoon module="Latest Projects" moduleZh="最新项目" />
</Layout>

8
src/pages/login.astro Normal file
View File

@ -0,0 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
---
<Layout title="Sign In — 暂未上线">
<ComingSoon module="Sign In" moduleZh="登录" note="账户系统正在筹备中,暂未开放注册与登录。" />
</Layout>

View File

@ -0,0 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
---
<Layout title="Portfolio — 暂未上线">
<ComingSoon module="Portfolio" moduleZh="作品集" />
</Layout>

8
src/pages/register.astro Normal file
View File

@ -0,0 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
---
<Layout title="Create Account — 暂未上线">
<ComingSoon module="Create Account" moduleZh="注册" note="账户系统正在筹备中,暂未开放注册与登录。" />
</Layout>

1007
src/pages/shows.astro Normal file

File diff suppressed because it is too large Load Diff

1
src/styles/global.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

5
tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}