Compare commits

...

12 Commits

Author SHA1 Message Date
19fb597bb4 update
Some checks failed
deploy / deploy (push) Has been cancelled
2026-09-06 00:50:52 +08:00
36f3ad039f update
Some checks failed
deploy / deploy (push) Has been cancelled
2026-09-03 19:45:36 +08:00
6902376bc2 update
Some checks failed
deploy / deploy (push) Has been cancelled
2026-09-03 00:32:17 +08:00
64af75c0a9 update
Some checks failed
deploy / deploy (push) Has been cancelled
2026-09-02 21:50:30 +08:00
50e5775dc8 update
Some checks failed
deploy / deploy (push) Has been cancelled
2026-09-01 00:08:05 +08:00
97b023bbbd update
Some checks failed
deploy / deploy (push) Has been cancelled
2026-08-31 19:59:36 +08:00
0d13748d7e update
Some checks failed
deploy / deploy (push) Has been cancelled
2026-08-31 00:43:39 +08:00
662b12c612 update
Some checks failed
deploy / deploy (push) Has been cancelled
2026-08-30 22:49:56 +08:00
dd66e5b693 update
Some checks failed
deploy / deploy (push) Has been cancelled
2026-08-30 10:28:22 +08:00
e24bfe164d update
Some checks failed
deploy / deploy (push) Has been cancelled
2026-08-29 20:17:13 +08:00
a133413d74 Merge branch 'main' of https://git.23cm.cn/toom1996/frontend_v2
Some checks failed
deploy / deploy (push) Has been cancelled
2026-08-29 15:52:55 +08:00
3c7c5d8855 update 2026-08-29 15:47:58 +08:00
59 changed files with 5520 additions and 2422 deletions

View File

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

9
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"css.lint.unknownAtRules": "ignore",
"scss.lint.unknownAtRules": "ignore",
"tailwindCSS.experimental.configFile": "./src/styles/global.css",
"tailwindCSS.validate": true,
"files.associations": {
"*.css": "tailwindcss"
}
}

View File

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

View File

@ -0,0 +1,43 @@
# 2026-08-30 工作日志
## 首页 Street Style 图片过大调整(11:33)
- 用户反馈首页 Street Style 区块图片太大。定位到 `src/components/Index.astro` 的 `<style>`:
- `.ss-banner` 原 `aspect-ratio:16/9` + `w-full` → 桌面端整宽约 675px 高(巨型 hero);
- `.ss-grid` 原仅 `sm:grid-cols-2` + `.ss-card` `aspect-ratio:3/4` → 桌面两列每张卡约 1000px 高,竖排 11 张占空间极大。
- 调整(保守,不改图片比例、保持编辑风):
- `.ss-banner` 加 `max-height: 440px;`(仅桌面端收窄 hero)。
- `.ss-grid` 改为 `grid-cols-1 sm:grid-cols-2 md:grid-cols-3`(中等屏起加第 3 列,单图立刻变小)。
- 验证:`npm run build` BUILD_EXIT=0;read_lints Index.astro 0;dev 服务 http://localhost:4321 已起(HMR 生效)。
- 待定:若用户仍嫌大,可进一步把 `.ss-card` 比例从 3/4 改为 4/5 或 1/1 直接缩高度。
## Runway 头部下拉:方案 A 落地(16:26)
- 用户拍板:header 的 Runway 下拉按「真实 collection_type」分 5 类(方案 A),不做婚纱等无数据类。
- 关键发现:Layout.astro 早就有 `runways` 下拉数组,但里面 8 项全是占位垃圾(Spring/Summer 2026…Menswear FW25),且全指向死路由 `ROUTES.shows`(`/shows`);下拉父链接 `<a>` 也错链到 `/shows`。
- 改动:
- `src/i18n/dictionary.ts`:新增 5 个分类 cn 翻译(`ready-to-wear`→女装成衣、`menswear`→男装、`couture`→高级定制、`resort`→度假系列、`pre-fall`→早秋系列)。标签用 `t('Ready-to-Wear')` 等英文 key(英文环境回退显示英文,与列表页 COLLECTION 筛选项 label 一致)。
- `Layout.astro`:`runways` 数组换成 全部 + 5 类,href 用 `href(locale, ROUTES.runwayLooks) + '?collection=xxx'`;父链接 `ROUTES.shows`→`ROUTES.runwayLooks`(修死路由)。
- `RunwayLooks.astro`:`init()` 读 `URLSearchParams(window.location.search).get('collection')`,命中白名单(rtw/menswear/womenswear/couture/resort/pre_fall/bridal/swimwear)则赋值 `selectedCt`,从而深链自动选中筛选(复用现有 loadPage→getArticles({collection}) 链路)。
- 关键事实:`getArticles`(api.ts) 把 `collection` 映射成后端查询参数 `?collection=`(不是 `collection_type`),故深链参数名用 `collection`。
- 验证:`npm run build` BUILD_EXIT=0;read_lints Layout/RunwayLooks 均 0。
- 待用户预览确认;深链真实筛选需后端(:8090)在跑。
## 街拍按地区分类(全链路,18:30 收尾)
- 用户拍板「全链路做」:SQL 加 city 列 + 从 title/URL 回填 + 后端 city 筛选 + 前端 header 下拉 + 列表筛选。
- 后端:model 加 `City` 字段;dto `StreetSnapQuery.City` + `Normalize`;repo `List` 加 `WHERE city = ?`(精确、大小写敏感);handler `parseListQuery` 解析 `c.DefaultQuery("city","")`;SQL `scripts/sql/004_add_streetsnap_city.sql` + 幂等回填 `scripts/backfill_city/main.go`(首选 title 第一段 location 关键词匹配,兜底 URL slug)。
- 前端:`Layout.astro` streets 下拉 18 城(全部 + Paris/New York/Milan/London/Copenhagen/Florence/Berlin/Tokyo/Seoul/Shanghai/Beijing/Rome/Madrid/Amsterdam/Antwerp/Los Angeles/Moscow/Mumbai),href 带 `?city=规范大写名`;`StreetSnaps.astro` 侧栏 City pills + `init()` 读 `?city` 深链自动选中;`api.ts.getStreetSnaps` 透传 `city`;`dictionary.ts` 有各城 cn 翻译(`t('Paris')` 经 `translate` 自动 `toLowerCase` 查到「巴黎」)。
- 关键约定:**city 规范值首字母大写英文**(Paris / New York / Copenhagen…),前后端必须一致且大小写敏感。
- DB+验证:dev 库 db_dev.street_snap 仅 12 行(Paris 8 / Copenhagen 4),其余城本地预览为空属预期(生产真实爬取数据才有完整覆盖)。重启后端 :8090 后 Node 直连验证:`?city=Paris`→8、`?city=Copenhagen`→4、`?city=London`→0、无参→12,全链路通过(client_sign 本地 enabled:false,无需签名)。
- 复盘:标题实际格式为「Street Style 2027 Day 12」而非 `location\tseason\tevent`,city 由旧回填按 URL 预置;生产真实数据靠 title/URL 解析归类。dev 环境 `go run` 可正常编译(仅 `go build ./...` 全量链接会 OOM,非代码问题)。
## Header 精简:移除死链 + street style 减为 3 下拉(18:57)
- 用户要求:去掉 Latest Projects / About / Contact Us;street style 下拉只留三个。
- 现状确认:`Layout.astro` 主导航早已无此三项(仅 Portfolio / Runway(下拉) / Street Style(下拉) / 语言 / Login / Signup);`streets` 下拉当前已是 3 项(`全部 / Paris / New York`)。
- 清理:`routes.ts` 删 `latestProjects`/`about`/`contact` 键;`dictionary.ts` 删 about/contact/latest projects 翻译键;全 src 搜索确认无 `latestProjects|about|contact` 残留引用。
- 验证:`npm run build` exit 0(日志仅 npm warn 包装的 NativeCommandError,非构建错误)。
## Header 下拉数量微调(19:00)
- 用户:street style 下拉再加一个、runway 下拉去掉一个。
- Street:加 **Milan** → `全部 / Paris / New York / Milan` = 4 项(时尚三都补齐;dictionary 已有 `milan→米兰`)。
- Runway:去 **Pre-Fall**(早秋系列,过渡线、数据最少 2376 行),保留 `全部 / rtw / menswear / couture / resort` = 5 项。
- 两个下拉均保留"全部"重置项(结构对称);Runway 父链接仍指向无参列表。
- 验证:待 build。

View File

@ -0,0 +1,29 @@
# 2026-08-31 工作日志
## 账号体系:登录/注册/个人中心/硬门禁(全链路落地)
- 用户决策:①视觉沿用站点黑白极简编辑风;②硬门禁(未登录一碰筛选/翻页弹登录框);③仅预置内部账号,不开放注册。
- 现状诊断:此前登录注册是半成品——前端 Login/Signup 链接与 `#auth-slot` 被注释、无 login/register 页面、api.ts 删了 `saveSession`;后端 `/auth/register|/login` 已下线、JWT 签发器被注释、`users` 表从未创建。
- 后端:恢复 `jwt.Manager.Generate`;`user_repository` 加 `FindByAccount`(username|email,忽略 is_deleted);`auth_service` 加 `Login`(bcrypt 校验→签发);`auth_handler` 加 `Login`;路由挂 `POST /api/v1/auth/login`(公开)。`go build ./internal/...` 通过;seed 建 users 表 + 预置 admin,实测登录返回 token(180) 成功(LOGIN_OK)。
- 前端:api.ts 恢复 `saveSession` + 新增 `login()`;routes 加 `account`;dictionary 加登录/账户文案;`Layout` 接回 `#auth-slot`(仅 Login 链接,去 Signup,footer 已登录显示 Hi+Logout);新建 `login.astro` / `account.astro`(en/cn,黑白极简,Alpine 表单);RunwayLooks/StreetSnaps 加 `requireLogin` 拦截 + 内嵌登录弹窗,首屏 init 不拦。
- 验证:`npm run build` EXIT=0;后端 login 实测 LOGIN_OK;seed 创建 admin / admin@studio.local。
- 默认账号 admin / Studio#2026!Admin(env `SEED_ADMIN_PASSWORD` 可改,务必改默认密码)。
## 收尾验证(17:40 续)
- 启动 Astro dev server(npx astro dev --background,http://localhost:4321),预览 /en/login 渲染正常。
- 复查 account.astro:未登录重定向走 Alpine `init()` 客户端 `getUser()`(非服务端),无"服务端读不到 localStorage 导致永远重定向"的坑。
- i18n 键核对齐全(login prompt / please log in / sign out / my collections / my history / coming soon 等均在 dictionary.ts)。
- 后端现状::8090 已有实例在跑(端口被占,新 go run 起不来属预期)。实测登录 POST /api/v1/auth/login(account=admin)→ STATUS=200 返回合法 JWT + user{id:7,username:admin,email:admin@studio.local}。此前 curl 报 400 是 PowerShell 把密码里 `#`/`!` 吞掉所致,非代码问题。
- 结论:登录/门禁全链路验证通过(构建过、页面渲染、API 200 签 token、BASE_API=localhost:8090 已就位)。硬门禁 UX 行为按 requireLogin 代码逻辑确认,未做浏览器点击自动化。
## JWT 双令牌 + 踢下线(19:35 续)
- 用户问"JWT 怎么做刷新 token、能不能踢下线"→ 落地双令牌方案:access 2h(无状态)+ refresh 30d(落库 refresh_tokens,SHA256 存哈希)。
- 后端:`config.JWT` 加 `RefreshExpireHours`;`AuthService` 加 `Refresh/Logout/RevokeAllByRefresh`;新增 `RefreshTokenRepository` + `model.RefreshToken`;`dto.LoginResponse` 改 `{access_token,refresh_token,expires_in,user}`;handler 加 `Refresh/Logout/LogoutAll`;router 挂 `/auth/refresh|/logout|/logout-all`;`main` 装配 `refreshRepo`。建表 `scripts/sql/006_create_refresh_tokens.sql` + 迁移脚本 `scripts/migrate_refresh/main.go`(go run 建表成功)。
- 前端 `api.ts`:saveSession 存 fa_token/fa_refresh/fa_user;新增 refreshSession、authedFetch(fetchMe 遇 401 静默刷新)、logout(吊销+清态)、logoutAll(踢下线+清态);Layout/account 登出改调 logout()。组件 login() 调用兼容(仅 await 成功)。
- 验证:`go build -o bin/server.exe ./cmd/server` EXIT=0;杀旧 8090 进程、起新后端;Node 端到端脚本 ALL_OK:login(200,expires_in=7200) → refresh(200) → logout-all(revoked:1) → 再 refresh=401。前端 `npm run build` EXIT=0。
- 说明:refresh 复用不轮换;"普通踢"=吊销 refresh,已签发 access 2h 内仍有效(非即时);即时吊销需另加 token_version,未做。默认账号仍为 admin/Studio#2026!Admin。
## 登录互斥(同账号单会话,19:59 续)
- 用户澄清动机:做踢下线是因为"不希望同一账号被不同的人同时登录"。原 `Login` 只新发 refresh、不吊销旧的,导致同账号可并存多条会话,与诉求相反。
- 改动 `auth_service.Login`:签发前先 `RevokeAllByUser(userID)` 吊销该用户全部既有 refresh,仅保留本次新签发的这一条 → 同账号同一时刻只有一条活性会话。
- 重建 `bin/server.exe` EXIT=0;重启 :8090(杀旧进程 PID 7368 → 起新 PID 1776)。Node 互斥测试 MUTEX_OK:同账号二次 login 后,首次 refresh=401(被顶掉)、二次 refresh=200(有效)。
- 残留边界:redis/access 为无状态 JWT(2h),被顶掉的旧会话其已签发的 access 在 2h 内仍有效,到期后才被弹回登录;要访问令牌即时失效需 token_version 方案(未做)。logout-all 手动端点保留(可用于"登出其他设备"按钮/管理员强踢)。

View File

@ -0,0 +1,87 @@
# 2026-09-01 工作日志
## 登录页样式丢失(17:30 修复)
- 现象:用户反馈 login 页样式没了。排查发现 `src/pages/{en,cn}/login.astro` 是**裸 `<body>` 片段页**(没有 `<html>/<head>`),仅靠前端 `import '@/styles/global.css'` 引入样式。Astro 在构建时对片段页不会把 import 的 CSS 注入 head,导致构建产物 `dist/.../login/index.html` 完全没有 `<link rel=stylesheet>`,整页无样式(dev 模式下 Vite HMR 偶尔能补上,故时有时无)。
- 修复:给两个 login 页补上完整 `<!doctype html><html><head>(charset/viewport/title)</head><body>...`,前端 `import '@/styles/global.css` 随即被注入 head;`<script>` 移入 body 内。复用 Layout 同款写法。
- 验证:`npx astro build` EXIT=0,`dist/.../en/login/index.html` 头部出现 `<link rel="stylesheet" href="/_astro/...css">`,确认样式回归。其它页面走 Layout 不受影响;全仓仅 login 两页存在此片段页 import CSS 问题。
## 新增 root 测试账号
- 用户要求加一个账号/密码都是 root 的测试账号。改 `scripts/seed_users/main.go`:把单账号 upsert 重构为账号列表循环,`admin`(强密码) + `root`(密码 root,明文弱密码,仅本地联调)。
- 运行 `go run ./scripts/seed_users`(EXIT=0):`✓ 内部账号已存在,已刷新密码哈希: root`;`ℹ 账号 root 登录方式: root / 密码: root`。
- 实测登录:`Invoke-RestMethod POST /api/v1/auth/login {account:root,password:root}` → `STATUS_OK user=root`,access_len=177、refresh_len=64。登录链路通,用户可直接用 root/root 测试。
## 重要纠正:access token 实际是 7 天,不是 2h
- 之前记忆/口述误记 access=2h。核对 `configs/config.yml` 第 40 行 `expire_hours: 168`(=7 天,注释"与原项目一致");`internal/config/config.go` 默认回落也是 168h;refresh 720h=30 天。
- 实测 root 登录 `expires_in=604800`(7 天)佐证。
## 个人中心页面重设计(企业级,设计优先)
- 用户反馈现 account.astro「根本不像企业级页面」,要求先把视觉/结构做好,功能可先不实现。
- 重写 `src/pages/{en,cn}/account.astro`:黑白极简编辑风(与站点一致)。结构 = ① 黑底 Hero(字母头像 monogram + 用户名/邮箱 + 角色 chip「Internal · Portfolio Studio」+ 加入于 2026 + Sign out);② 四宫格数据概览(Saved looks / Collections / History / Active sessions,占位 0/0/0/1);③ 左栏 tab 导航 + 右栏内容面板,tab = Overview / My Collections / My History / Sessions & Security / Settings;④ 收藏/历史为空状态(细线 SVG + CTA 去浏览档案);⑤ Sessions 面板含「当前设备」会话卡 + 单会话互斥说明 + 「退出所有设备」按钮(占位,带 Soon 标);⑥ Settings 偏好行(语言/外观/通知,均 Soon 占位)。
- 功能约定:仅 `logout()`(调 api.logout 吊销 refresh)与未登录重定向真实可用;编辑资料/收藏/历史/会话管理/偏好均为占位(disabled 或 Soon 标),待后续实现。
- 词典:新增一批账户中心键(overview / sessions & security / settings / saved looks / active sessions / member since / role / internal member / account overview / account details / username / edit profile / view archive / no collections yet / no history yet / browse the archive / current session / this device / last active / sign out all devices / single session note / preferences / appearance / notifications / soon / quick stats / now),en 即键本身、cn 提供翻译。
- 验证:`npm run build` EXIT=0,`/en/account`、`/cn/account` 均预渲染成功;read_lints 0 错误。dev server 仍在 4321,需用 root/root 登录后访问 /en/account 看完整效果(未登录仅见登录引导卡)。
- 影响:互斥登录"踢人"只吊销 refresh,而**已签发的 access 还能活 7 天**——即被顶掉的会话最多要等 7 天才失效,2h 窗口的说法是错的。要让同账号互斥真正即时生效,必须加 `users.token_version`(签发时嵌版本、middleware.Auth 每次比对),或把 access TTL 调短(如 2h)。已修正 MEMORY.md。
## 账户中心 v1:做成真实可用的客户端版(16:30 续)
- 用户要求把个人中心做成"像 B 站/论坛那种有功能的",不要占位。结合"一点一点完善",本轮交付**客户端存储版**(数据存浏览器、按登录用户名隔离),零后端风险,登录即可用;函数签名预留替换服务端。
- 新增前端库:`src/lib/favorites.ts`(收藏:getFavorites/isFavorited/toggleFavorite/removeFavorite/clearFavorites)、`src/lib/history.ts`(浏览历史:recordHistory/getHistory/clearHistory,去重+上限60)、`src/lib/profile.ts`(昵称/简介:getProfile/saveProfile)。均按 `fa_fav_<user>`/`fa_hist_<user>`/`fa_profile_<user>` 隔离。
- 详情页 `src/pages/{en,cn}/item/[id].astro`:新增右下角浮动「收藏」按钮(fab,点击 toggle 收藏、已收藏反白),并在加载时 `recordHistory` 记录浏览历史(仅登录态);meta 由服务端 frontmatter 透传,define:vars 注入脚本。
- 重写 `src/pages/{en,cn}/account.astro`:Hero(昵称可编辑、取 profile 存储)、四宫格真实计数(收藏数/历史数)、Tab = Overview / Saved looks / History / Sessions & Security / Settings 全部接真实数据:
- Saved looks:真实收藏列表(卡片+封面+移除+跳详情),空状态引导。
- History:真实浏览历史列表+清空按钮。
- Sessions:解析 UA 显示「当前设备」(浏览器·OS),「退出所有设备」接 `logoutAll()`(真实)。
- Settings:语言切换(EN/中文 跳对应 locale 账户页,真实)、外观(浅色/深色 toggle,theme-dark 切内容面板与统计卡,真实)、通知(开/关 本地存储)。
- 编辑资料弹窗:修改昵称/简介,存 profile 本地,Hero 即时反映。
- 词典 `dictionary.ts` 补 cn 翻译:member profile/save/saved/remove/cancel/save changes/display name/bio/clear history/no saved looks yet/light/dark/on/off。
- 验证:`npm run build` EXIT=0,`/en/account`、`/cn/account` 预渲染成功;read_lints 0 错误。dev server :4321 运行中,用 root/root 登录后访问 /en/account 可见完整功能(未登录自动跳 /en/login)。
- **待办(下一轮,服务端化)**:收藏/历史/资料/会话列表需后端化以支持跨设备——需新增 `favorites` 表+接口、`users` 加 display_name/bio 列、`refresh_tokens` 列表接口(登录时补 user_agent/ip)、`/me/profile` 更新接口。现前端 lib 函数签名已设计为可平滑替换实现。
## 个人中心视觉重构(18:00)
- 用户反馈 account 页「太难看了」。排查:页面样式其实正常(Layout 注入 CSS 正常),丑点是**设计语言不统一**——原稿用暖米色 `bg-[#f7f5f1]` 面板 + 大柔影 `shadow-[0_18px_50px_rgba(0,0,0,.04)]` + `rounded-[30px]` 大圆角,与站点纯黑白极简编辑风(thin border、`rounded-none`、mono 标签、serif 大标题、`shadow-sm`)冲突。
- 重写 `src/pages/{en,cn}/account.astro`(与 v1 功能/Alpine 逻辑完全一致,仅重排版面):报头改白底细线(去掉黑底大卡 + 方块 monogram 头像);统计卡改 `border-black/15` 网格、无柔影无大圆角;Tab 导航激活态改「黑底白字」实心(原 border-b-2 下划线);内容面板 `border border-black/15 bg-white`、`rounded-none`;收藏/历史卡片改 `border-black/15` + `hover:border-black` + `bg-[#f1f1f1]` 占位(对齐 look-grid.css);编辑弹窗去圆角去阴影;新增 `js-surface/js-line/js-chip/accent-btn/ghost-btn` class 钩子支撑深色模式反相(`.account-root.theme-dark` 全局 `border-color` 反白 + 主按钮反白)。cn/en 两文件共用同一套 `t()` 文案,新增词典键 `items`(cn:条)。
- 验证:`npx astro build` EXIT=0;dist 产物确认 stylesheet 链接 + 新 class + 深色 `<style>` 均注入;read_lints 0 错误。
## 个人中心大幅精简(18:47)
- 用户要求:去掉 Active Sessions / My Collections(认为无用,或换功能)、去掉 Sign out、section 内容简洁点"有用户名就够了"。
- 改动 `src/pages/{en,cn}/account.astro`:
- 报头:删邮箱行、角色 chip、Sign out 按钮、编辑资料入口;仅留「方块字母头像 + 用户名」大标题(member profile 小标签保留)。
- 删整块「四宫格数据概览」(含 Saved looks / My Collections / My History / Active Sessions 占位卡)。
- Tab 导航:删 Overview、Sessions & Security 两项;保留 Saved looks / My History / Settings 三个。默认 tab 改为 `saved`。
- 删编辑资料弹窗及 displayName/bio 相关逻辑(profile.ts 不再接入)。
- Sign out:报头按钮移除,仅在 Settings 底部保留一个**低调文字链接**(underline hover)`logout()`,避免彻底无法登出(切账号卡死)。已告知用户若想彻底删除可再提。
- 脚本同步精简:删 `editing/displayName/bio/device/parseUA`、`saveProfileEdit/logoutAllDevices`、移除 `logoutAll/getProfile/saveProfile` 导入;保留 favorites/history/settings 逻辑。
- 验证:`npx astro build` EXIT=0;dev server :4321 运行中,root/root 登录访问 /en/account 看效果。
- 遗留:dictionary.ts 中部分键(my collections / active sessions / sessions & security / sign out all devices / account overview / username / email / display name / bio / role / internal member / member since / edit profile / view archive / save changes / cancel 等)已无人引用,属数据冗余,未删(不影响构建)。
## 个人中心再精简:去掉偏好设置(19:10)
- 用户:「偏好设置也不要了」。结合上轮已去掉 sign out 报头按钮,现整体移除 Settings tab。
- 改 `src/pages/{en,cn}/account.astro`:删导航里的 Settings 按钮、删整个 Settings 面板(语言/外观/通知/退出登录全部移除);脚本移除 `themeDark/notif` 状态、`toggleTheme/toggleNotif/switchLang/logout` 方法,及 `logout` 导入;移除 `<main>` 上的 `:class="theme-dark"` 绑定与深色模式 `<style>` 块(js-surface/accent-btn 等 class 留作空操作,无害)。
- 结果:个人中心现仅 = 报头(方块头像+用户名)+ 两个 tab(Saved looks / My History),无偏好、无退出登录入口。
- 影响:登录后页面无任何「退出/切换账号」入口,需清理浏览器 localStorage 才能换号。已向用户说明,如需保留可再加。
- 验证:`npm run build` EXIT=0;read_lints 0 错误。
## 个人中心:加宽容器 + 收藏假数据(19:15)
- 用户:① 收藏搞个假数据看效果;② 觉得 main 容器太窄,加宽。
- 改动 `src/pages/{en,cn}/account.astro`:
- 主容器 `max-w-5xl` → `max-w-7xl`(1280px),收藏/历史卡片网格 `grid-cols-2 sm:grid-cols-3` → `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4`(间距 gap-4→gap-5)。
- 收藏假数据:脚本新增 `demoFavorites`(8 条,含 Maison Margiela/Prada/Bottega/Loewe/Saint Laurent/Celine/Balenciaga/Jacquemus 的 FW25/SS26 RTW,标 `demo:true`)。`init()` 中 `getFavorites()` 为空时回退到 demoFavorites,保证有内容可预览;一旦用户存入真实收藏即不再显示 demo。
- 占位图:新增 `PLACEHOLDER`(内联 SVG data URI,灰底 "LOOK" 字样),`coverUrl(f)` 方法在 `f.cover` 为空时返回占位(真实收藏无封面也不再裂图)。
- demo 项处理:点击 demo 卡片 `itemHref` 跳走秀档案(不进 404 详情);点 REMOVE 仅从内存 `favorites` 移除 demo 项、不写 localStorage。
- 验证:`npm run build` EXIT=0;read_lints 0 错误。
- 注:若账号之前已存真实收藏,收藏 tab 显示真实数据而非 demo(布局同样可见);要看纯 demo 需先清空该用户收藏的 localStorage。
## 列表卡片收藏按钮 + 压黑底白字(19:24)
- 用户三点:① 图片应可收藏,且抱怨找不到收藏/取消入口;② 整体偏无背景色,尽量少用黑底白字组件。
- 改动:
- `src/components/RunwayLooks.astro` / `StreetSnaps.astro`:卡片 `<a>` 外包 `relative` 容器并加 `x-data="{ fav: isFav(it.id) }"`,右上角加白底细边心形按钮 `.fav-toggle`:`@click.prevent="fav = toggleFav(it)"`;未登录点按由 `toggleFav` 弹登录框(requireLogin)。脚本引入 `isFavorited/toggleFavorite`,新增 `isFav(id)`、`toggleFav(it)`(runway→type:'runway'/brand=brand_name;street→type:'street'/brand=''),`toggleFavorite` 返回布尔驱动 `fav` 视觉。
- `src/styles/look-grid.css`:新增 `.fav-toggle`(白底 `border-black/15` 圆钮,`hover:scale-110`)与 `.fav-toggle.is-on svg { fill:#000 }`(已收藏仅实心黑心,不出现黑底块)。
- `src/pages/{en,cn}/item/[id].astro`:详情页浮窗收藏按钮由黑底白字改为白底描边(`hover:bg-black` 反白),图标改心形 + `#fav-fab.is-active .fav-heart{fill:currentColor}`;脚本去掉 `btn.style.background/color` 内联黑底,改用 `label.textContent` + `is-active` 类切换。
- `src/pages/{en,cn}/account.astro`:tab 激活态由「黑底块 bg-black text-white」改为编辑风细线(黑字 + `border-b-2 border-black` 移动端下划线 / `lg:border-l-2 lg:border-l-black` 桌面左竖线);两个 `browse the archive` 按钮由 `bg-black text-white` 改为 `bg-white text-black`(保留 hover 反白)。cn 版 tab 含 `font-mono`(en 无),已分别适配。
- 验证:`npm run build` EXIT=0;read_lints 0 错误。
- 遗留黑底白字(用户说"尽可能少"而非全删,保留作小强调):分页 `.pg.is-current`、卡片 `.showsbadge`(RTW 角标)、导航 `2026` 小药丸、登录提交按钮 hover 反白——均为功能性/极小强调,未改。若用户要更彻底再动。
## 收藏爱心图标修正(19:39)
- 用户:"你现在看下,收藏按钮的logo、"(话未说完,但意图是检查/修图标)。
- 发现:列表页(RunwayLooks / StreetSnaps)与详情页(item/[id] en+cn)四个位置的收藏按钮共用同一段**手写爱心 SVG path**,坐标不对称——叶瓣中心 x=5.5/14.5(均=10),底部尖端却在 x=12,导致整颗心向左偏、视觉是斜的。
- 修复:四处理所当然统一替换为严格对称爱心 path `M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z`(叶瓣中心 x=8.7/15.3、尖端 x=12、凹点 x=12,左右镜像)。
- 验证:`npm run build` EXIT=0;read_lints 0 错误。

View File

@ -0,0 +1,125 @@
# 2026-09-02 工作记录
## 收藏功能后端化(服务端持久化)✅ 完成并端到端验证
用户指出:此前收藏只有前端 localStorage("纯前端自慰"),后端没有对应接口。本次补齐后端 + 前端接入。
### 后端(d:/project/backend_v2)
新增文件:
- `internal/model/favorite.go`:`Favorite` 模型(user_id / target_type / target_uid / title / cover / brand),表 `favorites`,唯一索引 `(user_id, target_uid)`。
- `internal/dto/favorite.go`:`FavoriteItem`(id 即 target_uid)、`FavoriteAdd`(`target_type`+`target_uid` 必填 + 可选快照)。
- `internal/repository/favorite_repository.go`:`ListByUser` / `Add`(OnConflict DoNothing 幂等)/ `Remove`。
- `internal/service/favorite_service.go`:`List` / `Add`(校验 type∈{runway,street})/ `Remove`。
- `internal/handler/favorite_handler.go`:`List`(GET) / `Add`(POST) / `Remove`(DELETE `/:target_uid`),均 `middleware.Auth` 鉴权,返回 `{data:...}`。
- `internal/router/router.go`:在 `api.Group("/auth")` 下注册三接口(含 middleware.Auth)。
- `internal/main.go`:装配 `favRepo` / `favSvc` / `Favorite` handler。
- `scripts/sql/007_create_favorites.sql` + `scripts/migrate_refresh/main.go`:建表(幂等)。
约定:沿用项目「一个功能一个接口」哲学,收藏独立路径 `/auth/favorites`,不挂查询参数。target_uid 直接用前端卡片编码串(r=/s=),避免解码。
### 前端(d:/project/frontend_v2)
- `src/lib/api.ts`:新增 `authJson<T>(method, path, body?)`(带 Bearer、401 静默 refresh、返回 `data`、抛出后端 message)。
- `src/lib/favorites.ts`:重写为 server 模式门面——`serverMode()`(getUser+access token);`toggleFavorite` 本地乐观更新 + 后台 POST/DELETE 同步(失败不回滚本地);`removeFavorite` 乐观清 serverList + 后台 DELETE;`fetchServerFavorites()` 拉服务端权威列表(account 页用)。对外签名(getFavorites/isFavorited/toggleFavorite/removeFavorite)保持不变。
- `src/pages/{en,cn}/account.astro`:移除假数据 demo(demoFavorites 整段删除);init 改调 `fetchServerFavorites()` 取服务端收藏;`itemHref/removeFav` 去掉 demo 分支;登录态展示真实收藏,空则显示空态。
- 列表卡片(RunwayLooks/StreetSnaps)无需改:仍用 `isFavorited/toggleFavorite`,新增的 server 同步在 lib 内部完成。
### 验证
- 后端 `go build ./...` EXIT=0;`go run ./scripts/migrate_refresh` MIG_EXIT=0(favorites 表建好)。
- 重启本地后端(杀旧 PID 11160,`go build -o bin/server.exe` 后启动,HEALTH=200)。
- 用 PowerShell `Invoke-RestMethod` 跑通全生命周期(curl.exe -d 在 Windows 下 body 发不出,改用 Invoke-RestMethod 才成功):
admin 登录 → POST /auth/favorites(返回新建项)→ GET(含该项)→ DELETE → GET 返回 `[]`。真实落库,无残留。
- 注意:本地后端种子账号是 `admin / Studio#2026!Admin`;用户预览用的 `root/root` 走线上后端,本地调试用 admin。
- 前端 `npx astro build` EXIT=0;read_lints 0 错误。
### 遗留 / 未做
- 跨设备列表卡片心形初始态:列表卡片初始仍读本地 localStorage(保证即时);登录态服务端同步在后台,换设备首次进列表可能短暂不一致(同设备因本地镜像一致)。如需严格一致,列表页 loadPage 后需 bootstrap serverList 并触发 Alpine 重算(当前未做)。
- users.display_name/bio、refresh_tokens 列表接口仍待办。
- 历史(history)仍为纯本地,未服务端化(用户未要求)。
## 收藏态跨浏览器/设备一致性修复(11:1x)✅ 完成 + 构建验证
用户提供担忧:换浏览器收藏列表是否不展示(数据绑账号应不丢,但发现列表卡片初始态只从本地读、列表页从不拉服务端,导致新浏览器进列表爱心全空);并问展示收藏态是否压垮数据库。
结论与改动:
- 性能:后端 `ListByUser` = `WHERE user_id=?` 索引查询,每页加载 1 次(非每卡片 1 次),千人并发仅几十 QPS 微查询,**不会崩**;唯一会崩的「每卡片 1 查询」写法未采用。
- 修复方案:列表/详情页进入时**先本地即时填充 + 异步拉服务端校正**,用响应式 `favMap`(`Record<id,boolean>`)替代原 `x-data="{ fav: isFav(it.id) }"` 的一次性捕获(后者服务端异步返回后不会刷新卡片)。
- `src/lib/favorites.ts`:新增 `loadServerFavIds(): Promise<string[]>`(调 `fetchServerFavorites` 回填 serverList 并返 id 数组)。
- `RunwayLooks.astro` / `StreetSnaps.astro`:data 增 `favMap`/`favTouched`;`init()` 调 `loadFavs()`(本地即时 → 登录态 `loadServerFavIds` 校正,`favTouched` 为真时跳过覆盖避免丢刚点的收藏);卡片按钮改 `:class="{ 'is-on': favState(it.id) }"` + `@click.prevent="toggleFav(it)"`(toggleFav 内乐观更新 favMap);删除原 `isFav` 方法。
- `src/pages/{en,cn}/item/[id].astro`:详情页 `sync()` 初始后,登录态 `loadServerFavIds().then(sync)` 异步校正(跨浏览器详情页爱心一致)。
- 验证:`npx astro build` EXIT=0;read_lints 0 错误。
- 注:预览 `:4321` 走线上后端(root/root,线上 favorites 表需部署 007 迁移才有);要本地直接验证跨浏览器,把前端 BASE_API 指向本地 `:8090` 用 admin 登录即可(用户未要求切,保持现状)。
## 收藏海量场景优化(10万级):服务端分页 + 批量校验(12:0x)✅ 完成并端到端验证
用户追问:若用户收藏极多(2000、甚至 10 万)怎么办。结论与改动:
- 性能论证:DB 层 `WHERE user_id=?` 索引查询,2000/10万行均毫秒级,不会崩;favMap(id→bool) 为 O(1)。真痛点在①账户页一次性 `x-for` 全量渲染(10万 DOM 爆炸)②列表标记此前拉全量完整对象(2000≈500KB/次)。
- 改动:
- 后端:新增 `POST /api/v1/auth/favorites/check`(body `{ids:[...]}` → 返回已收藏子集);`GET /auth/favorites` 改服务端分页(`?page&per_page`,默认24/上限100,返回 `{data:{items,total,last_page}}`)。repository 加 `ListPaged/CountByUser/FilterExisting`;service `List`→`ListPaged`+`Check`;dto 加 `FavoriteCheck`;router 注册 check;favorites 表加复合索引 `idx_user_created(user_id, created_at)`(007 sql + migrate_refresh 幂等 ALTER ADD INDEX)。
- 前端 `favorites.ts`:新增 `fetchFavoritesPage`(账户分页)+ `checkFavorited(ids)`(列表仅问当前页可见 id);删除 `loadServerFavIds`/`fetchServerFavorites`。
- 列表组件(RunwayLooks/StreetSnaps):`loadPage` 后调 `markFavs()` 用 check 校正当前页 hearts(与收藏总量解耦,常数级);删 `favTouched`。
- 账户页(en/cn):Saved looks 服务端分页 + 翻页器(`favPageList` 窗口化页码:首尾页+当前页±1+…折叠);`removeFav` 刷新当前页。
- 验证:后端 `go build ./cmd/server` EXIT=0;本地后端重启新二进制 HEALTH_OK;PowerShell 端到端:加3收藏 → P1(total3,last2,count2)/P2(count1) 分页正确 → check 返回3命中 → 删除后 total0。前端 `astro build` EXIT=0、lint 0。
- 注:预览 `:4321` 仍指向线上后端(root/root,需部署 007 迁移才有 favorites 表);本地验证用 admin/Studio#2026!Admin 走 :8090。
## 浏览历史服务端化(图集 + 图片)✅ 完成 + 端到端验证(15:1x)
用户拍板:历史也走**服务端**(跨设备一致,尽管每次浏览都写库写入量比收藏高一个数量级);图集 = **列表页整体**(进 Runway Looks / Street Snaps 整页记一条)。
### 后端
- `scripts/sql/008_create_histories.sql` + `migrate_refresh`:建 `histories` 表,`uniq(user_id,kind,target_uid)`;kind∈{image,gallery},target_type∈{runway,street,runway_looks,street_snaps}。
- `internal/model/history.go` / `dto/history.go`(`HistoryItem`/`HistoryAdd`)/ `repository/history_repository.go` / `service/history_service.go` / `handler/history_handler.go` / router 注册(GET/POST/DELETE `/auth/history` + DELETE `/auth/history/:target_uid`)/ main.go 装配。
- **关键膨胀控制(与收藏的本质差异)**:`Upsert` 用 `OnConflict DoUpdates`——同 (user_id,kind,target_uid) 反复浏览只刷 `viewed_at` 不新增行;每用户硬上限 `HISTORY_CAP=2000`,超量用子查询双嵌套(规避 MySQL 同表子查删除限制)FIFO 删最旧。
- **修了一个真 bug**:首版 `ListPaged` 的 `total` 调 `CountByUser(ctx,userID)` 忽略 kind,导致按类型过滤时分页计数错(items 过滤了但 total 算全量)。已改 `CountByUser(ctx,userID,kind)` 一并过滤。
### 前端
- `src/lib/history.ts` 重写为服务端优先:`recordHistory({kind,type,id,title,cover,brand})`(登录态本地+后台 POST upsert;未登录仅本地);`fetchHistoryPage(page,perPage,kind)` 服务端分页(kind 空=全部);`removeHistory`/`clearHistory`。
- 埋点:`src/pages/{en,cn}/item/[id].astro` 详情页 `recordHistory` 加 `kind:"image"`;`RunwayLooks.astro`/`StreetSnaps.astro` 的 `init()` 进页面记一条 `kind:"gallery"`(target_uid `runway-looks`/`street-snaps`,仅登录态;翻页走 loadPage 不触发,符合「进页面就记」)。
- 账户页(en/cn)History tab:`全部/图片/图集` 切换(setHistKind→重载)、服务端分页 + `histPageList` 窗口化页码、`histHref`(image→item 详情,gallery→对应列表页)、图集/图片徽标、单条删除/清空。字典加 `all/image/gallery/no history yet/viewed gallery`。
### 验证
- 后端 `go build ./...` EXIT=0;`migrate_refresh` 建表 OK;重启本地后端新二进制(taskkill 杀 7912 后启动,HEALTH=200)。
- PowerShell 端到端:登录 admin → 记 gallery+image → 重复记同 image **upsert 不新增(total 恒 2)** → `?kind=gallery`(total1)/`?kind=image`(total1)/全部(total2) 计数正确 → DELETE 单条 → CLEAR 归零。修复后复测 GAL_TOTAL=1 IMG_TOTAL=1 ALL=2 通过。
- 前端 `npx astro build` EXIT=0;read_lints 0 错误。
- 注:预览 `:4321` 走线上后端(root/root,需部署 008 迁移才有 histories 表);本地真实验证用 admin/Studio#2026!Admin 走 :8090。
## 后端连接池僵死导致「历史/收藏/登录全失效」(15:2x)✅ 修复
用户反馈浏览历史「没生效」。排查:前端代码与历史接口均正常,但本地后端 :8090 的 `/api/v1/public/brands` 返回 `{"error":"invalid connection"}`,`/health` 正常。
根因:后端(`server-bin`,当时 PID 18604)的 MySQL 连接池连接僵死——GORM 此前只设 `ConnMaxLifetime=3600`、**未设空闲回收时间**,空闲连接被 MySQL 回收后池子仍持有死连接,查询即 `invalid connection` / `bad connection`(server.err 见 14:45、14:52 已报 bad connection)。MySQL 自 8/26 起未重启,故非 MySQL 重启,而是空闲连接被回收。
修复:
- 后端加 `conn_max_idle_time`(config.yml + DatabaseConfig + mysql.go `SetConnMaxIdleTime`,默认 60s,须 < MySQL wait_timeout),空闲连接主动回收,从源头避免死连接。
- `go build -o server-bin ./cmd/server` 后杀旧 PID 18604、重启新二进制;重启后 `/public/brands` 恢复正常,历史接口端到端全过(ALL=2 / GALLERY=1 / IMAGE=1,upsert 不增总量,清空归零)。
- 临时验证脚本 `_tmp_e2e.mjs` 用完已删。
教训:本地后端长时间空闲后接口再访问报 invalid connection,**先重启后端**;已加空闲回收,复现概率大降。前端 `BASE_API=http://localhost:8090`(.env),故本地 dev 直接打本地后端,无需切线上。线上后端(root/root)仍需部署 007(favorites)/008(histories) 迁移才有这两张表。
## 浏览历史「看不到」排查 + 登录后补记修复(16:4x)✅
用户反馈浏览记录看不到,且「runway 文章数据库没新增」。排查结论:
- **历史写在 `histories` 表,不是 `brand_runway`**(走秀主数据表仅由爬虫/种子导入,浏览永不写入)。用户查错表,非 bug。
- 历史**按用户隔离**:local 8090 实测 `histories` 表 `root` 有 1 条(`KIND=gallery TYPE=street_snaps TITLE='Street Snaps'`),`admin` 有 0 条。账户页登录 root 才能看到 root 的记录。
- 已实证登录态写入正常(root 那条即用户用 root 登录进 Street Snaps 列表页所记)。
修复:未登录进列表页→登录后不补记图集的边界 bug。`src/lib/api.ts` 的 `login()` 成功后派发 `window` 事件 `auth:login`;`RunwayLooks.astro`/`StreetSnaps.astro` 的 `init()` 监听该事件补记对应 `gallery`(type=runway_looks / street_snaps)。dev server 已重编译,三页 HTTP=200。
## item 页浏览历史不记录:根因=define:vars+import 致命组合(17:0x)✅
用户访问 /en/item/r000AhByP 后个人中心与 histories 表都无记录。排查:/auth/history 后端经 curl 实测可写(root POST 成功落库),排除后端问题。抓渲染 HTML 发现 item 页 <script define:vars={{meta...}}> 被 Astro 包成内联 IIFE,原脚本顶部的 import 被塞进函数体内 → SyntaxError 整段不执行,故
ecordHistory/收藏绑定全失效。修复:en/cn item/[id].astro 去掉 define:vars,改由按钮 data-id/type/title/cover/brand/save-label/saved-label 属性透传,模块脚本内 tn.dataset 读取。dev server 已重编译,抓模块源码确认 import 已在顶层。清理了测试写入的 r000AhByP 记录。教训已写入 MEMORY.md Astro script 坑。
## 浏览历史设计纠正:仅图集记录,单张 item 不记(17:3x)✅
用户纠正:浏览历史应「进图集(RunwayLooks/StreetSnaps)才记」,单张 item 详情页不该记。此前我给 item 页加了 image 历史记录,属理解错误。修复:删 en/cn item/[id].astro 里的
ecordHistory({kind:'image',...}) 调用与导入(收藏逻辑不动);账户页 History 区移除无用的「图片」筛选 tab(仅留 全部/图集),卡片角标固定 gallery。图集页 gallery 记录逻辑(RunwayLooks/StreetSnaps 的 init() 调 recordHistory)保持不变,已抓打包脚本确认
ecordHistory/kind:gallery 仍在。MEMORY.md 同步更新:浏览历史仅记图集。
## 个人中心「没有」根因定位(17:4x)✅
用户报「个人中心还是没有」。排查:后端 history 全链路实测通过(Node 脚本 login admin → POST /auth/history ok → GET total=1,histories 表存在);前端 recordHistory/fetchHistoryPage/account 渲染逻辑均正确。根因在登录态:.env 当前 BASE_API=http://localhost:8090,本地库只有 admin、无 root,用户用 root/root 登录失败 → getUser() 为空 → 进 account 被踢登录页、图集不记历史。结论:本地验证一律用 admin/Studio#2026!Admin;要用 root 须改 .env 回线上域名并重启 dev server+硬刷新+重登录。MEMORY 账号体系段已补此坑。
## 纠正:user 表确有 root + 个人中心空的真因(18:4x)✅
实测 db_dev.users 经 scripts/seed_users 创建 admin(强密码 Studio#2026!Admin)+root/root(uid 8)。
oot/root 登录本地 :8090 返回 200;GET /auth/history 显示 root 账号下已有 street-snaps/runway-looks 两条真实图集历史(viewed_at≈当前)。此前'本地无 root、root 登录必失败'为误判,已纠正 MEMORY 账号体系段。
个人中心'看不到'两主因:(1) account.astro 默认 tab='saved'(收藏,常空),浏览历史在 'My History' tab,需点击;(2) 前端 dev server 若连线上而非本地 8090,则 root 登录线上、看不到本地这 2 条。已 stro dev stop+stro dev --background 重启使 .env 的 BASE_API=本地 8090 生效。验证:硬刷新→root/root 登录→点 My History→应见 2 条。
## 实锤根因:账户页历史空 = kind=all 被后端按字面过滤(18:5x)✅
带 root token 实测后端 /auth/history 三种查询:无 kind → total=2;kind=all → total=0(后端把 all 当字面 kind 值 WHERE kind='all');kind=gallery → total=2。根因:账户页 History「全部/默认」把 kind='all' 拼进 query,后端无该语义 → 恒空;数据其实一直在 root 下(2 条 gallery)。用户直接 curl 不带 token 返回 0 属正常未鉴权空返回。修复:src/lib/history.ts fetchHistoryPage 把 'all'/空 归一为不带 kind 参数(只传 image|gallery 这类具体值)。MEMORY 浏览历史条目已记坑。另:后端对无效 kind 值返回空而非全部,属脆弱设计(可选后续加固)。
## 浏览历史语义纠正与收敛:图集→文章级(19:4x-20:0x)✅
用户澄清:他说「进到图集里才有浏览记录」指的是点开一篇文章(详情页看整组图),不是进 RunwayLooks/StreetSnaps 列表页——此前按列表页 gallery 实现是理解反了。已按用户拍板重构:
- 语义:浏览历史=点开过的文章;item 详情页 init
ecordHistory(meta.id);列表页记录与 auth:login 补记全删(en/cn item + RunwayLooks + StreetSnaps)。
- 表收敛:histories 改 (user_id,target_uid,viewed_at),DROP kind/target_type/title/cover/brand;唯一键(user_id,target_uid);保留 HISTORY_CAP=2000 FIFO(文章会积累)。后端 model/dto/handler/service/repository 同步删字段;dto.HistoryAdd 仅 target_uid。008 迁移脚本改新表结构,存量库跑 migrate_refresh ALTER(实测生效:GET items 仅 {id,viewed_at},POST {target_uid}→200)。
- 展示:账户页先服务端分页取 id,再逐篇 getHistoryMeta(id) 回查公开详情拿 title/cover/brand(api.ts 新增,s= 前缀判 street 否则 runway);卡片跳文章详情;删 kind 筛选 tab/histHref/gallery 徽标;标题回查失败用 '…'。
- 验证:POST/GET 全绿;/public/runway-looks/r0042h1mx 回查字段齐全;account、item 页面 SSR 200。dev server 已重启(PID 31792)、后端 server-bin 已重建。MEMORY 浏览历史条目已整体改写。

View File

@ -0,0 +1,71 @@
# 2026-09-03 工作日志
## Header 下拉 FOUC + 加载屏接入(12:0x)✅
用户反馈 header 下拉在 CSS 未加载时很丑(裸奔成大块列表),并给了一份 loading 动画页(Raleway 彩虹 text-shadow + Lorem ipsum 文案)要求接入。
- **根因**:Layout 里 Runway/Street/语言/账号 下拉与未登录 Login 链接均用 `x-show` 但**没加 `x-cloak`**,Alpine 接管前 `x-show` 未生效 → 菜单裸奔。global.css 已有 `[x-cloak]{display:none!important}`,补 `x-cloak` 即解决。
- **改动**:
- `src/layouts/Layout.astro`:head 引入 Raleway(`<link>` preconnect+stylesheet);body 顶部插入 `#loading-screen` 动画屏(用户给的 HTML/CSS 还原,h1 文案保留 Lorem ipsum);所有 `x-show` 下拉补 `x-cloak`(含 login.astro 语言 ul);footer 后加 `is:inline` 淡出脚本(`window.load` 后淡出,最少 500ms / 最多 3s 兜底)。
- `src/styles/global.css`:追加**作用域隔离**的 loading 样式(全部在 `#loading-screen` 内;`@keyframes` 改名 `ls-text-shadow`;`prefers-reduced-motion` 仅限加载屏;避免 `#fdf9fd` 背景/`Raleway`/`a` 颜色污染整站黑白调性)。
- **验证**:lint 0;`GET /en` 200,输出含 `id="loading-screen"`、`x-cloak`(×13)、淡出脚本、Raleway link 均到位。
- **提醒点**:loading 屏用彩色(与站点纯黑白调性冲突),是用户明确要求;若要统一风格可改 `#loading-screen` 背景为白/黑。h1 文案是占位 Lorem ipsum,用户可能想换成品牌名。
## 加载屏黑白化 + 文案替换(12:1x)✅
用户要求加载屏改成黑白(与站点纯黑白调性一致)并换文案。改动:
- Layout.astro 加载屏 h1 Lorem ipsum dolor sit amet. → Portfolio Studio(取自 siteTitle 品牌名),删掉 demo 残留(Hover over text to pause / Inspired by Tomas Brunsdon dribbble 链接),加小号副标 <p class=ls-sub>Loading</p>。
- global.css 加载屏配色:背景 #fdf9fd→#fff、文字 #011a32→#0a0a0a、链接 #024794→#0a0a0a;动画 keyframes 彩色 shadow(#0c2ffb/#2cfcfd/#fb203b/#fefc4b)改灰阶(#000/#3a3a3a/#777/#bbb),保留偏移运动;h1 字号 5em→clamp(2.5rem,8vw,5rem) 防移动端溢出;新增 .ls-sub 等宽大写灰字样式。
- 验证:GET /en 200,输出含 Portfolio Studio + ls-sub Loading,已无 Lorem/dribbble;CSS 黑白校验全通过(无 fdf9fd/0c2ffb/024794/011a32)。lint 0。
## 加载屏重构:is:inline → 独立组件 + 打包脚本(12:2x)✅
用户认为 is:inline 不合理,要求优化(Alpine 或组件化)。结论:抽 src/components/LoadingScreen.astro 组件,最合理——不需为小事引入 Alpine(且有 window.load 竞态),普通 Astro 打包 <script> 是 module/defer,在 load 前执行无竞态,比 is:inline 更工程化。改动:
- 新建 LoadingScreen.astro:标记(h1 Portfolio Studio + p.ls-sub Loading) + <style is:global>(原 global.css 加载屏 CSS 整段移入) + 打包 <script> 隐藏逻辑(原 is:inline IIFE 照搬,去掉 is:inline)。Raleway <link> 从 Layout head 移入组件(Astro 自动 hoist 到 head)。
- Layout.astro:引入组件、用 <LoadingScreen /> 替换内联标记、删除底部 is:inline 脚本、删 head Raleway 链。
- global.css:删除加载屏 CSS 块,仅留 @import tailwindcss + [x-cloak]。
- 验证:lint 0;GET /en 200,loading-screen 标记在、无 is:inline、旧 IIFE 已移除、Raleway 仍在。教训:Astro 中全局一次性 UI(loading/overlay)应封装为组件而非散在 Layout+global.css。
## 未登录预览门禁:列表仅显前5张+登录提示(14:2x)✅
需求:未登录用户只能看图集前5张图片,其余提示登录。实现:RunwayLooks/StreetSnaps 两列表页统一加未登录预览门禁。
- 新增 i18n 键 login to view all photos(cn: 登录查看全部图片)。
- 脚本加常量 PREVIEW_LIMIT=5;Alpine data 加 getter visibleItems(getUser() 返回全部,否则 slice(0,5))、showLoginGate(!getUser(); items.length>5)。
- 模板 x-for 源 items→visibleItems;卡片列表后插门禁块(x-show=showLoginGate,含 Preview 标题+loginGateText+登录按钮开 showLoginModal);分页器 x-show 加 getUser() 条件。
- 验证:lint 0;GET /en/runway-looks 与 /en/street-snaps 均 200,含 visibleItems x-for、showLoginGate div、x-text=loginGateText、pager x-show=getUser(); lastPage>1(HTML 转义为 &amp;&amp; &gt;)。登录后门禁消失、分页器出现、显示全部。
## 未登录预览门禁后端硬截断 + 重启踩坑(15:0x)✅
需求:前端门禁只是障眼法,接口一抓全露馅——后端也得改。改动:
- 后端新增 dto.PreviewLimit=5;response.PagePreview 支持 preview 标记;runway/street 列表 handler 未登录时强制 Page=1,Size=5,且 total>5 才回 preview:true。
- 关键修复 PublicFirstPageAuth:原仅 page>1 解析 token,导致 page=1 已登录用户无 Context、被误判匿名全截 5 条;改为始终解析 Bearer(有效则写入 Context),仅 page>1 强制。新增 isAnon(c) 辅助(同包 handler)。
- 前端:getArticles/getStreetSnaps 透传 preview;Alpine loadPage 存 this.preview,showLoginGate 改用 !getUser() && (preview===true || items.length>PREVIEW_LIMIT),visibleItems 仍 slice(0,5) 防御。
- 验证(node 脚本):anon runway/street 各 5 条+preview:true、page=2→401;root 登录回 24/12 条无 preview。前端 /en/runway-looks、/en/street-snaps 均 200,api.ts lint 0。
- 重启大坑(重要):go build -o server-bin 在 Windows 产出无扩展名 server-bin,但运行的是 server-bin.exe(旧)。Start-Process server-bin 被 Windows 解析成旧 .exe,导致首轮没生效。修正:改用 go build -o server-bin.exe 覆盖 .exe 并启 .exe。已记 MEMORY。
## 预览门禁纠正:列表页限制 → 详情页限制(16:3x)✅
用户澄清:之前把"未登录最多看 5 张"做成了列表页限制(RunwayLooks/StreetSnaps 列表只显 5 个图集)是误读。真实意图是**列表页照常展示全部图集,限制发生在点进图集详情后——未登录仅看前 5 张图片**。改动:
- 前端列表页回退:StreetSnaps.astro 删 visibleItems/showLoginGate/preview 字段 + 删门禁块 + x-for 改回 items + 清 PREVIEW_LIMIT/loginGateText 残留常量;RunwayLooks.astro 清 PREVIEW_LIMIT/loginGateText 残留常量(列表逻辑上一轮已回退)。lint 0。
- 详情页(Item.astro runway / StreetSnap.astro street)本就已是「前5张 SSR + extraImages 登录后补拉 + 登录门禁」结构,无需改。
- 后端:源码早已把截断从列表接口移到详情接口(列表 response.Page 全量;详情未登录截断 Images[:5]+preview:true)。但**运行中的二进制是旧的列表截断版**(验证显示列表只回 5 条、详情回全量 6 张且无 preview)→ 重编译 `go build -o server-bin.exe`、杀旧 PID 18912、启新 .exe。
- 验证(node+SSR):anon 列表全量无 preview;anon 详情 5 张+preview:true;root 登录详情完整 6 张无 preview;`/en/item/s000zAwwQ` 与 `/en/item/r0042h1mx` SSR 200、`data-preview="true"`、画廊 5 张图、含 Preview 门禁块;两个列表页已无 visibleItems/showLoginGate。
## 详情页「剩余 N 张」占位遮罩(17:0x)✅
用户补充:详情页未登录只显前 5 张,但用户会以为图集就 5 张——要求对剩余图片做空图遮罩并写"登录后查看"之类提示。改动:
- 后端 `response.DataPreview` 新增参数 `imageTotal int`,响应包写入 `image_total`(截断前的图片真实总数);走秀/街拍两详情 handler 在截断前 `imageTotal:=len(detail.Images)` 透传。前端 `getSsrArticle`/`getSsrStreetSnap` 读 `json.image_total`,`SsrArticleResult`/`SsrStreetSnapResult` 增 `imageTotal?` 字段;en/cn `item/[id].astro` 把 `imageTotal` 传给 `<Item>`/`<StreetSnap>`。
- 详情组件 `Item.astro`/`StreetSnap.astro` 增 `imageTotal` prop,算 `lockedCount=preview?max(0,imageTotal-previewImages.length):0`;画廊在 previewImages 之后、extraImages 之前插入 `lockedCount` 个空图 `<figure>`(锁图标 + "login to view" + "+N photos",`x-show="!getUser()&&preview"`+`x-cloak`,点击 `goLogin()`)。已登录 `getUser()` 为真 → 遮罩隐藏、由 `extraImages` 真实图替补。
- i18n 新增 `login to view`(cn 登录查看)/`photos`(cn 张照片)。
- 后端重编译 `go build -o server-bin.exe`、杀旧 PID 32508、启新 .exe(PID 29468);`Invoke-RestMethod` 验证 anon runway image_total=8 preview=true 返5、street image_total=6 preview=true 返5。
- 验证(node 解析 SSR HTML):走秀页画廊 5 张真实图 + 3 个"login to view +3 photos"遮罩 + 底部 Preview 门禁块;街拍页 5 张 + 1 个"login to view +1 photos"遮罩。功能对任意 image_total 通用(含用户举例的 20→15)。
## 遮罩运行时不显示:Alpine 表达式作用域坑(18:4x)🔧
用户反馈"还是只展示 5 张图"——SSR 静态 HTML 确有遮罩,但浏览器运行时看不到。排查:
- 后端 verified:`GET /api/v1/public/runway-looks/r0042h1mx`(anon)返 `image_total=8, preview:true` 且 images 截断 5 张;已登录(带 Bearer + 前端签名)返 `image_total=8, images.length=8, preview=undefined` → 后端全量正常。
- **根因**:遮罩与底部门禁用 `x-show="!getUser() && preview"`,但 `getUser` 是 `.astro` `<script>` 里 `import` 进来的模块符号——Alpine 属性表达式作用域访问不到它(只认组件 data 属性 / `$magic` / global),求值抛 ReferenceError → 绑定失效、元素卡在 `x-cloak` 的 `display:none`。**上一条"验证"只看 SSR 静态 HTML,没跑 Alpine,所以误判成功**。列表分页器 `StreetSnaps.astro:215` / `RunwayLooks.astro:253` 的 `x-show="getUser() && lastPage>1"` 同病。
- **修复**:详情组件 `Item.astro`/`StreetSnap.astro` 与列表组件 `RunwayLooks.astro`/`StreetSnaps.astro` 各增响应式 data 属性 `isAuthed:false`;`init()` 用 `this.isAuthed = !!getUser()` 初始化、`auth:login` 事件置 `this.isAuthed = true`;所有相关 `x-show` 改用 `isAuthed`(遮罩 `!isAuthed && preview`,分页器 `isAuthed && lastPage>1`)。`getUser()` 仍在组件 method 内调用(作用域OK)。
- 验证:`fetch` 详情页 HTML 确认 `x-show="!isAuthed &amp;&amp; preview"` 出现 4 次(3 遮罩+1 门禁)、`getUser` 在 x-show 中 0 次。dev server (astro dev, :4321) 已热更新新代码。
## 遮罩与真实图不等高:遮罩缺 figcaption 行(18:5x)✅
用户反馈未登录时遮罩图高度与正常图不一样。根因:正常图 `<figure>` = `img aspect-[3/4]` + 底部 `figcaption`(图片 name 如 look01/img01 灰字一行,实测数据每张图都有 name);而遮罩 figure 直接把 `aspect-[3/4]` 套在 figure 上、无 figcaption → 总高比正常图矮一行 caption 高度,grid stretch 行为也不一致。修复:`Item.astro`/`StreetSnap.astro` 遮罩重构为与正常图**同构**——figure 不再带 aspect,内部先放 `div.w-full aspect-[3/4] flex items-center justify-center`(锁图标+login to view 居中的"图位"),再放与正常 figcaption 同 class 的 `figcaption`(灰字 `+N photos`,替代原图内那行 +N 小字,保证 caption 行高一致)。验证:SSR figcaption 顺序 = look01..look05 5 个正常 + "+3 photos"×3 遮罩、遮罩 figure 含 aspect-[3/4] 图位 div(×3),结构已同构。dev server 热更新生效。
## 去掉底部 Preview 大框门禁(19:1x)✅
用户觉得画廊后整块「Preview / login to view all photos + Login 按钮」门禁意义不大(画廊遮罩已有提示),要求去掉。改动:
- `Item.astro`/`StreetSnap.astro`:删除画廊后 `x-show="!isAuthed && preview"` 整块门禁 div(font-serif "Preview" + 副文案 + Login 按钮)及顶部仅供该块使用的 `loginGateText`/`loginModalSubmitLabel` 常量(列表组件 RunwayLooks/StreetSnaps 的 loginModalSubmitLabel 用于内嵌登录弹窗,保留)。
- `dictionary.ts`:删 `'login to view all photos'` key(无引用)。
- 验证:两详情页 SSR 无 gateText/gateDiv、画廊遮罩 x-show 仍在(走秀 3/街拍 1);lint 0。
- **编辑教训**:删除整块时两次把 replace_in_file 的 old/new_str 弄反导致重复插入门禁块,须先读文件确认再一次性删除所有重复块;改文件先想清楚替换方向再提交。

View File

@ -1,37 +1,55 @@
# 项目长期记忆(Project MEMORY.md) # 项目长期记忆(Project MEMORY.md)
## 模块策略(重要)
- 站点按模块逐个完善。当前上线「发布会模块」:首页 index + 走秀列表/详情(RunwayLooks = 走秀档案页,articles/shows 页面已在 i18n 重构中重组)。
- **品牌索引页已下线**(2026-08-26 确认):`src/pages/brands.astro` 及孤儿组件 `src/components/pages/brands.astro` 均已删除;品牌数据现在只通过走秀页侧栏 filter + 品牌弹窗呈现(热门 30 条走 SSG 接口,搜索/字母走运行时 `/api/v1/public/brands`)。
- 其余模块(Portfolio / Latest Projects / About / Contact / 根路径登录注册)为 ComingSoon 占位页;**导航(header)保持原样未改动**(用户 2026-08-07 明确「header 别动」),门禁完全靠占位页实现。`/en/login`、`/en/register` 已是真实页面(`AuthForm.astro`,mode login/register)。
## 架构总览 ## 架构总览
- 前端:**d:/project/frontend_v2** — Astro SSG + Tailwind v4 + Alpine.js。`npm run build`(SSG 取数走 ssgRequest,失败回落空/假数据不影响构建)。 - 前端 `d:/project/frontend_v2`:Astro SSG + Tailwind v4 + Alpine.js。构建 `npm run build`(SSG 取数走 ssgRequest,失败回落不影响构建)。
- 后端:**d:/project/backend_v2** — Go + Gin + GORM(MySQL),公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`),SSG 内部引擎 :8091(仅 127.0.0.1,`/api/v1/ssg/*`,SSGToken 中间件,nginx 不反代)。 - 后端 `d:/project/backend_v2`:Go + Gin + GORM(MySQL)。公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`);SSG 引擎 :8091(仅 127.0.0.1,nginx 不反代)。
- **SSG 分组现状(2026-08-26)**:只剩两个端点——`/api/v1/ssg/index/runway`(首页 runway 区块,`IndexService.Runway`,hotBrand 20 条)+ `/api/v1/ssg/brands/hot`(走秀页热门品牌,`BrandService.Hot`,固定 30 条、按图片总数热度顺序,零参数)。`/ssg/brands`(全量 A-Z)与 `/ssg/articles/ids` 均已删除。 - 接口 i18n:后端按 `?locale=cn|en` 选列;前端取数复用带 locale 原语(request/ssgRequest),**绝不裸写 fetch**;语言以 URL 前缀为准。
- **接口 i18n 约定**:后端按 `?locale=cn|en` 选列返回(pickLocale 缺翻译回落另一语言);前端取数统一复用带 locale 的原语(request/ssgRequest 等),**绝不裸写 fetch;语言以 URL 前缀为准**。 - **后端编译产物坑(Windows)**:`go build -o server-bin ./cmd/server` 实际产出无扩展名 `server-bin`,但运行的是 `server-bin.exe`(旧)。必须 `go build -o server-bin.exe` 显式覆盖 `.exe`,再 `Start-Process server-bin.exe`;杀进程用 `(Get-NetTCPConnection -LocalPort 8090).OwningProcess` 拿 PID。本地后端长时间空闲后报 `invalid connection` 先重启后端(连接池 `conn_max_idle_time` 已设,须 < MySQL wait_timeout)。
- 走秀标题 `title_cn` 已规则回填(backend_v2 `scripts/backfill_title_cn/main.go`,幂等);`description_cn` 为空白属预期,中文页回落英文。 - 前端 dev server **不热加载 `.env`**:改 BASE_API 后需 `astro dev stop`+`astro dev --background` 重启;浏览器旧 bundle 需硬刷新、旧 token 失效需重登录。
## 关键约定 ## 关键约定
- 视觉语言:黑白极简编辑风(monospace + Georgia 衬线大标题 + 细线),HBX / 时尚档案调性。字体全走 Tailwind 体系,禁止自写字体栈。 - 视觉:黑白极简编辑风(monospace 小标签 + Georgia 衬线大标题 + 细线),HBX/时尚档案调性。**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系。
- **品牌名前端一律只显示英文**(用户偏好 2026-08-26:「前端品牌不要展示中文名称,标题可以留着」):api.ts 映射 `display = nameEn`;走秀标题/描述仍按 locale 选列。 - 品牌名前端只显英文(`display=nameEn`);走秀标题/描述按 locale 选列。
- **API 命名哲学(用户原则)**:「每一个地方尽可能不要通过参数来复用接口,尽量一个功能一个接口」。查询参数仅限「同一资源的列表筛选/分页」;跨资源/独立功能一律独立路径(如 `brands/hot` 而非 `?featured=1`)。待办:公开侧 `/api/v1/public/brands` 的 featured/keyword/letter 参数拆分、`/articles` 的 `brand_id` 拆 `brands/:id/shows`、资源命名 `shows` vs `articles`、`popular` vs `featured`(目标清单见 artifact `api_endpoints_catalog.md`)。 - API 命名:一个功能一个接口,跨资源/独立功能独立路径(如 `brands/hot` 而非 `?featured=1`)。
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`。Tailwind v4 无零值类。 - CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`;Tailwind v4 无零值类。
- 导航(Layout.astro)保持原样,门禁仅由占位页承担。 - Layout `<slot/>` 包 `flex-1 flex flex-col`;页内根容器**禁用 `m-auto`**(触发 shrink-to-fit 抖动),用 `w-full`+`max-w-[x] mx-auto`。
- **Header/下拉 FOUC**:所有 `x-show` 下拉(Runway/Street/语言/账号/未登录 Login)一律带 `x-cloak`(global.css 已 `[x-cloak]{display:none!important}`)。
- **加载屏组件化**:`src/components/LoadingScreen.astro` 封装标记+样式+打包 `<script>`(**无 `is:inline`**,module/defer 在 window.load 前执行无竞态),Raleway 字体链随组件 hoist 到 head。文案 Portfolio Studio + 副标 Loading,黑白配色。改加载屏只动这一个文件。
## Layout 陷阱(2026-08-12) ## Astro `<script>` 坑
- Layout 把 `<slot />` 包在 `flex-1 flex flex-col` 容器里;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 内容多少决定宽度、页面抖动)。用 `w-full`(可配 `max-w-[xxx]` + `mx-auto`)。 - 作用域隔离:打包型 `<script>` 拿不到 frontmatter 的 import/变量,需脚本顶部重新 import。
- **`define:vars` + `import` = 致命**(已踩坑):`define:vars` 把脚本包成 IIFE 注入 `const meta=...`,原顶部 `import` 被塞进函数体 → 非模块上下文非法 → 整段 SyntaxError 不执行。凡脚本要 import,改用 `data-*` 属性透传(Layout.astro 已用)。en/cn `item/[id].astro` 曾因此详情页历史/收藏全失效,已修。
- 片段页(无 `<html>/<head>` 的 .astro)的 `import '@/styles/global.css'` build 时不注入 head → 整页无样式。login.astro 曾因此丢样式,已修。
- **Alpine `x-show`/`x-on` 等属性表达式访问不到模块 `import` 的符号**:`@astrojs/alpinejs` 下,`.astro` `<script>` 里 `import { getUser } from "@/lib/api"` 的函数,在 `x-show="!getUser() && preview"` 这类属性表达式里是**未定义自由变量**(Alpine 只认组件 data 属性 / `$magic` / global),求值抛 ReferenceError → 绑定失效、元素卡在 `x-cloak` 的 `display:none`。**已踩坑**:详情页「剩余 N 张」占位遮罩与底部门禁用 `x-show="!getUser()&&preview"`,SSR 静态 HTML 有遮罩但运行时全隐藏(误判成功只因验证看了静态 HTML)。修复:组件 data 增 `isAuthed:false`,`init()` 用 `this.isAuthed = !!getUser()` 初始化、`auth:login` 事件置 `true`,`x-show` 改用 `!isAuthed && preview`(列表分页器 `x-show="getUser() && lastPage>1"` 同病,已一并改 `isAuthed`)。**铁律:Alpine 属性表达式里只写组件 data 属性或 `$magic`,绝不直接调模块 import 函数;要读外部状态先存进 data 属性。**
- 验证 Alpine 运行时务必真跑浏览器(或至少确认表达式可解析),**只看 SSR 静态 HTML 会漏掉运行时绑定失效**。
## Astro `<script>` 两大坑 ## 模块策略
- **作用域隔离**:frontmatter 的 import/变量在打包型 `<script>` 里拿不到(运行时 ReferenceError);脚本里要用什么就在脚本顶部重新 import。 - 主线:首页 index + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。品牌索引页已下线(2026-08-26):品牌仅经走秀页侧栏 filter + 品牌弹窗(热门 30 SSG,搜索/字母运行时 `/api/v1/public/brands`)。
- **不做模板插值**:打包型 `<script>` 里 `{t('...')}` 不会被执行 → `t is not defined`。预翻译用 `define:vars={{ hiLabel }}` 注入;模板区 `{t()}` 正常。 - 导航死链清理(2026-08-30):删 `/latest-projects`/`/about`/`/contact`(Layout/routes/dictionary 同步)。保留未动死链:`/portfolio`/`/login`/`/register`。「header 别动」= 不擅改导航结构/视觉;用户已批的调整(Runway 下拉分类、Street Style 减城、删死链)属授权。
- 打包后脚本在 `_astro/*.js` 而非内联;查 dist 产物用 search_content(ripgrep),PowerShell 无 `Select-String -Recurse`。
## 走秀数据分类(brand_runway) ## 未登录预览门禁(详情页,2026-09-03 定稿)
- 三维分类字段:`collection_type`(rtw/menswear/couture/resort/pre_fall)、`season`(spring/fall/null)、`season_code`(SS26/FW25…派生)。靠 title 规则回填(幂等,25,206 行)。 - 用户真实意图:**列表页照常展示全部图集**,限制只发生在**点进图集详情后**——未登录仅看前 5 张图,其余提示登录(此前误做成列表页限制已回退)。
- 公开列表 `?collection_type=&season=&season_code=&year=` 筛选;前端走秀页有系列/季节/年份 chips。 - 后端硬截断(真防护,落详情接口):`GET /api/v1/public/runway-looks/:id` 与 `/street-snaps/:id` 判断 `middleware.UserIDFrom(c)`——未登录且 `len(Images)>dto.PreviewLimit(=5)` 时 `Images=Images[:5]` 并回 `preview:true`。`response.DataPreview` 包新增带 `image_total`(截断前图片总数),供前端渲染剩余占位遮罩。已登录(带 Bearer)返回完整图片集、无 preview。列表接口 `response.Page` 全量,翻页 page>1 仍 401 由 PublicFirstPageAuth 兜底。
- 地理/城市维度:用户明确暂不做。 - `PublicFirstPageAuth` 始终解析 Bearer(有效写 Context),仅 page>1 强制;详情路由无 page → 匿名放行、已登录写态。
- 分布:rtw 15203 / menswear 4061 / couture 672 / resort 2894 / pre_fall 2376。 - 前端:详情组件 `Item.astro`(runway)/`StreetSnap.astro`(street) 由 SSR `getSsr*` 透传 `preview` 与 `imageTotal` prop;画廊渲染 `previewImages=SSR.images.slice(0,5)`(SSR 渲染前 5 张)+ **剩余 `(imageTotal-5)` 张空图遮罩**(锁图标 + "login to view" + "+N photos",`x-show="!isAuthed && preview"`+`x-cloak`,点击 `goLogin()`)+ 底部 Preview 门禁块。`isAuthed` 是组件 data 属性(`init()` 用 `getUser()` 初始化、`auth:login` 置真),因 `getUser` 是模块 import 符号、不能直接用于 Alpine 表达式(见 Astro `<script>` 坑)。已登录或 `auth:login` 触发 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed`(带 Bearer 取全量)把第 6 张起追加进 `extraImages`,此时 `isAuthed` 为真、遮罩 `x-show` 为假自动隐藏。`getUser()` 读 localStorage `fa_user`。
- 列表页已回退全量展示,仅保留筛选/翻页 `requireLogin()` 硬门禁。
- i18n:`login to view`(cn 登录查看)/`photos`(cn 张照片)/`login to view all photos`(cn 登录查看全部图片,底部门禁用)。
## 部署(Docker + Gitea Actions) ## 账号体系
- 交付文件:前端 Dockerfile / nginx.conf / docker-compose.yml / deploy.sh / .gitea/workflows/deploy.yml / .env.example / DEPLOY.md;后端 Dockerfile。nginx 拦截 `/api/v1/ssg/` 404;健康检查 `/api/v1/public/brands`。 - 仅预置内部账号不开放注册。`seed_users/main.go` 建 `admin`(`SEED_ADMIN_PASSWORD`,缺省 `Studio#2026!Admin`) + `root`/`root`(测试弱密码,uid 通常 8)。**本地 `db_dev` 确有 root/root**,打 `http://localhost:8090` 登录 200——勿再误判"本地无 root"。前端 `.env` 当前 `BASE_API=http://localhost:8090`。
- 双令牌 JWT:access 默认 7d(168h) 无状态 + refresh 默认 30d(720h) 仅存 SHA256 落库;登录即吊销该用户全部 refresh(互斥登录)。接口 `/api/v1/auth/`:`/login` `/refresh`(复用不轮换) `/logout` `/logout-all` `/me`(Bearer)。前端 `api.ts`:`saveSession/clearSession/refreshSession/fetchMe/authedFetch(401 静默 refresh 一次)/logout/logoutAll`。
- 硬门禁:RunwayLooks/StreetSnaps 筛选与翻页入口 `requireLogin()`,未登录弹内嵌登录表单,登录成功 `loadPage(1)` 刷新;首屏 `init()` 的 `loadPage(1)` 不拦。
- 收藏服务端化(2026-09-02):表 `favorites` + `/api/v1/auth/favorites`(GET 分页/POST 幂等/DELETE/`/check` 批量校验当前页收藏态),按 `user_id` 隔离,`target_uid` 存编码串(runway `r=`/street `s=`)。前端 `src/lib/favorites.ts`:账户页 `fetchFavoritesPage()` 服务端分页;列表卡片 `checkFavorited(ids)` 仅发当前页可见 id;收藏/取消本地乐观+后台同步。
- 浏览历史文章级(2026-09-02 收敛):记录时机在 item 详情页 `recordHistory(meta.id)`(编码串自带类型,无需 kind);列表页进页不再记。后端 `histories` 表 `(user_id,target_uid,viewed_at)`,唯一键 `uniq(user_id,target_uid)`,`HISTORY_CAP=2000` FIFO。前端 `src/lib/history.ts`:账户页 My History 服务端分页+逐篇回查 `getHistoryMeta(id)` 取 title/cover/brand。`recordHistory` 仅在 `getUser()` 时调用。
- 个人中心 `account.astro`:报头 + 两 tab(Saved looks / My History),已精简移除 Overview/Sessions 等。
## 部署
- Docker + Gitea Actions:前端 Dockerfile/nginx.conf/docker-compose.yml/deploy.sh;nginx 拦截 `/api/v1/ssg/` 404,健康检查 `/api/v1/public/brands`。
## 数据分类
- 走秀 `brand_runway`:`collection_type`(rtw/menswear/couture/resort/pre_fall)+`season`+`season_code`(SS26…),title 规则回填(幂等)。公开列表 `?collection_type=&season=&season_code=&year=` 筛选。
- 街拍 `street_snap.city`(2026-08-30):VARCHAR(128) 精确匹配 `WHERE city=?`;前端 header 下拉 `?city=` + 侧栏 City pills。规范值「首字母大写英文」(Paris/New York/Copenhagen…),大小写敏感须一致;dev 库仅 12 行样本属预期。
## 前端复用
- 详情页统一 `/item`:`src/pages/{locale}/item/[id].astro`,SSR `getSsrArticle`→`getSsrStreetSnap` 回落;`ROUTES.article`/`streetSnap` 指向 `/item/${id}`。
- 列表组件同构,抽 `src/styles/look-grid.css`+`src/lib/looks-grid.ts`;品牌筛选弹窗 `BrandModal.astro`(仅 RunwayLooks)。卡片标记在 Alpine `x-for` 内客户端渲染,不能提成 Astro 组件。

Binary file not shown.

26
_tmp_verify2.mjs Normal file
View File

@ -0,0 +1,26 @@
const BASE = "http://localhost:8090";
const login = await (await fetch(BASE + "/api/v1/auth/login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account: "root", password: "root" }),
})).json();
const tok = login.access_token;
const H = { "Content-Type": "application/json", Authorization: "Bearer " + tok };
if (!tok) { console.log("LOGIN_FAIL", JSON.stringify(login)); process.exit(1); }
const get = async () => {
const r = await fetch(BASE + "/api/v1/auth/history?page=1&per_page=24", { headers: H });
const j = await r.json();
return { status: r.status, items: j?.data?.items };
};
const post = async (body) => {
const r = await fetch(BASE + "/api/v1/auth/history", { method: "POST", headers: H, body: JSON.stringify(body) });
return { status: r.status, text: (await r.text()).slice(0, 120) };
};
console.log("1 GET empty =>", JSON.stringify(await get()));
console.log("2 NEW fmt POST =>", JSON.stringify(await post({ target_uid: "r0042h1mx" })));
console.log("3 GET after =>", JSON.stringify(await get()));
console.log("4 OLD fmt POST =>", JSON.stringify(await post({ kind: "gallery", target_type: "runway_looks", target_uid: "s0042h1my", title: "T", cover: "C", brand: "B" })));
console.log("5 GET after2 =>", JSON.stringify(await get()));
process.exit(0);

60
_tmp_verify_hist.mjs Normal file
View File

@ -0,0 +1,60 @@
const BASE = "http://localhost:8090";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function jsonFetch(path, opts = {}, retries = 8) {
let lastErr;
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(BASE + path, opts);
if (res.ok) return { status: res.status, data: await res.json() };
return { status: res.status, data: await res.json().catch(() => null) };
} catch (e) {
lastErr = e;
await sleep(800);
}
}
throw lastErr;
}
// 1) 登录 root
const login = await jsonFetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account: "root", password: "root" }),
});
const tok = login?.data?.access_token;
if (!tok) { console.log("LOGIN_FAIL", JSON.stringify(login)); process.exit(1); }
console.log("LOGIN_OK uid=", login.data.user?.id);
const auth = (m, p, b) => jsonFetch(p, {
method: m,
headers: { "Content-Type": "application/json", Authorization: "Bearer " + tok },
body: b ? JSON.stringify(b) : undefined,
});
// 2) 取一篇真实走秀文章的对外编码 id
const list = await jsonFetch("/api/v1/public/runway-looks?page=1&size=1&locale=en");
const art = list?.data?.data?.[0];
if (!art?.id) { console.log("NO_ARTICLE", JSON.stringify(list).slice(0, 300)); process.exit(1); }
const articleId = String(art.id);
console.log("ARTICLE id=", articleId, "title=", art.title?.slice(0, 30));
// 3) 记录一次浏览(文章级)
const post1 = await auth("POST", "/api/v1/auth/history", { target_uid: articleId });
console.log("POST1 status=", post1.status, JSON.stringify(post1.data));
// 4) 列表应含该文章
const get1 = await auth("GET", "/api/v1/auth/history?page=1&per_page=24");
const g1 = get1.data?.data;
console.log("GET1 total=", g1?.total, "items=", JSON.stringify(g1?.items?.map((x) => x.id)));
// 5) 再次记录同一篇(应 upsert,total 不增)
await auth("POST", "/api/v1/auth/history", { target_uid: articleId });
const get2 = await auth("GET", "/api/v1/auth/history?page=1&per_page=24");
console.log("GET2(after dup POST) total=", get2.data?.data?.total, "(应为 1,upsert 生效)");
// 6) 回查详情接口(账户页同款)——确认能取到封面/标题
const detail = await jsonFetch("/api/v1/public/runway-looks/" + articleId + "?locale=en");
console.log("DETAIL_OK title=", detail.data?.data?.title?.slice(0, 30), "cover=", detail.data?.data?.cover?.slice(0, 40));
process.exit(0);

BIN
dev.log

Binary file not shown.

View File

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

594
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/alpinejs": "^3.13.11", "@types/alpinejs": "^3.13.11",
"alpinejs": "^3.16.1", "alpinejs": "^3.16.1",
"astro": "^7.2.4", "astro": "^7.2.9",
"swiper": "^11.2.10", "swiper": "^11.2.10",
"tailwindcss": "^4.3.3" "tailwindcss": "^4.3.3"
} }

9
server.err Normal file
View File

@ -0,0 +1,9 @@
2026/09/02 15:03:50 ✓ 配置文件: D:\project\backend_v2\configs\config.yml
2026/09/02 15:03:50 ✓ MySQL 已连接: 127.0.0.1:3306/db_dev
2026/09/02 15:03:50 ✓ 静态资源: /uploads → D:\project\backend_v2\uploads
2026/09/02 15:03:50 🚀 公开服务已启动: http://localhost:8090
2026/09/02 15:03:50 🔒 SSG 内部服务已启动: http://localhost:8091 (仅构建期/回环可达)
[mysql] 2026/09/02 15:15:12 connection.go:49: bad connection
[mysql] 2026/09/02 15:22:42 connection.go:49: bad connection
[mysql] 2026/09/02 15:27:04 connection.go:49: read tcp 127.0.0.1:60958->127.0.0.1:3306: wsarecv: An established connection was aborted by the software in your host machine.
[mysql] 2026/09/02 15:27:43 connection.go:49: read tcp 127.0.0.1:55938->127.0.0.1:3306: wsarecv: An established connection was aborted by the software in your host machine.

View File

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

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,150 @@
---
import { getI18n } from "@/i18n/utils"
// 全站统一登录门禁,两种语义属性(按使用场景二选一,不要混用):
// - login-required:需要登录的「动作」(收藏/翻页/看完整图…)。点击时若未登录 →
// 就地弹出本登录层并记住该元素;登录成功后自动重放这次点击(继续原操作)。
// - login-redirect:需要登录的「完整入口」(想直接去账户/详情受限区…)。未登录 →
// 跳独立登录页(自动带 redirect 回跳当前页),登录完成后回原处。
// 已登录时两者都直接放行。本组件挂在 Layout 一次,全站共用同一 store。
const { t } = getI18n(Astro)
const title = t('login')
const prompt = t('login to continue')
const accountLabel = t('username or email')
const passwordLabel = t('password')
const failLabel = t('login failed')
const closeLabel = t('close')
const fullLoginLabel = t('go to full login')
---
<!-- 全局登录层:fixed 覆盖层,所有页面生效 -->
<div
x-data="$store.auth"
x-show="isOpen"
x-cloak
data-auth-fail={failLabel}
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 px-5"
role="dialog"
aria-modal="true"
aria-label={title}
@click.self="close()"
@keydown.escape.window="close()"
>
<div class="w-full max-w-sm border-2 border-black bg-white p-8">
<div class="mb-6 flex items-start justify-between border-b-2 border-black pb-4">
<h3 class="font-serif text-2xl font-black uppercase leading-none tracking-tight">{title}</h3>
<button type="button" @click="close()" class="-mt-1 text-[11px] uppercase tracking-widest opacity-60 transition-opacity hover:opacity-100">{closeLabel}</button>
</div>
<p class="mb-7 font-mono text-[11px] uppercase tracking-widest text-zinc-400">{prompt}</p>
<form @submit.prevent="submit()" class="space-y-6" novalidate>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-widest text-zinc-500">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-2 text-sm outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-widest text-zinc-500">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-2 text-sm outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<p x-show="error" x-text="error" x-cloak class="text-xs uppercase tracking-wide text-red-600"></p>
<button type="submit" :disabled="loading"
class="w-full border border-black bg-black py-3.5 font-mono text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:opacity-40">
<span x-show="!loading">{title}</span>
<span x-show="loading" x-cloak>…</span>
</button>
</form>
<div class="mt-6 border-t border-black/10 pt-4 text-center">
<button type="button" @click="goFullLogin()" class="font-mono text-[10px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">{fullLoginLabel}</button>
</div>
</div>
</div>
<script>
import { getUser, login } from "@/lib/api"
document.addEventListener("alpine:init", () => {
// 全局登录 store:request(el) 返回是否放行;未登录记录 pending 并开层
Alpine.store("auth", {
isOpen: false,
pending: null as HTMLElement | null,
account: "",
password: "",
error: "",
loading: false,
request(el: HTMLElement): boolean {
if (getUser()) return true // 已登录:放行,交还原生点击
this.pending = el
this.error = ""
this.isOpen = true
document.body.style.overflow = "hidden" // 锁背景滚动
return false
},
close() {
this.isOpen = false
this.pending = null
this.error = ""
document.body.style.overflow = ""
},
async submit() {
this.error = ""
this.loading = true
try {
await login({ account: this.account, password: this.password })
const el = this.pending
this.pending = null
this.isOpen = false
this.account = ""
this.password = ""
document.body.style.overflow = ""
// 登录成功后自动重放刚才被拦截的那次点击,让用户操作无缝继续
if (el) requestAnimationFrame(() => { try { el.click() } catch { /* 忽略 */ } })
} catch {
const host = document.querySelector<HTMLElement>("[data-auth-fail]")
this.error = host?.dataset.authFail || "login failed"
} finally {
this.loading = false
}
},
// 弹窗内兜底:前往独立完整登录页(带回跳当前页)
goFullLogin() {
const p = window.location.pathname + window.location.search
const loc = p.startsWith("/cn") ? "cn" : "en"
this.close()
window.location.assign(`/${loc}/login?redirect=${encodeURIComponent(p)}`)
},
})
// 全站统一登录门禁:两种语义属性(capture 阶段,先于元素自身 @click 处理)
// login-required → 就地弹窗(登录后自动重放原点击)
// login-redirect → 跳独立登录页(带回跳)
function fullLoginUrl(): string {
const p = window.location.pathname + window.location.search
const loc = p.startsWith("/cn") ? "cn" : "en"
return `/${loc}/login?redirect=${encodeURIComponent(p)}`
}
document.addEventListener("click", (e) => {
const hit = e.target as HTMLElement | null
if (!hit) return
const gate = hit.closest?.("[login-required], [login-redirect]") as HTMLElement | null
if (!gate) return
if (getUser()) return // 已登录:放行,交还原生点击
e.preventDefault()
e.stopPropagation()
if (gate.hasAttribute("login-redirect")) {
window.location.assign(fullLoginUrl())
return
}
;(Alpine.store("auth") as any).request(hit) // 记住实际点击目标,登录后重放
}, true)
})
</script>

View File

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

View File

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

View File

@ -5,11 +5,12 @@ import { toAbs } from "@/lib/api"
import { getI18n } from "@/i18n/utils" import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
import { getSsgIndexRunway } from "@/lib/api" import { getSsgIndexRunway } from "@/lib/api"
import { getSsgStreetSnapPopular } from "@/lib/api"
// 国际化:一行取当前语言 + 翻译函数 // 国际化:一行取当前语言 + 翻译函数
const { locale, t } = getI18n(Astro) const { locale, t } = getI18n(Astro)
// 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/v1/ssg/index/runway(server-only), // 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/internal/ssg/home/runway(server-only),
// 首页 runway 数据含多模块,hotBrand 为其中之一;封面/标题取自每个品牌代表走秀, // 首页 runway 数据含多模块,hotBrand 为其中之一;封面/标题取自每个品牌代表走秀,
// 直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。 // 直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
const popular = (await getSsgIndexRunway()).hotBrand const popular = (await getSsgIndexRunway()).hotBrand
@ -29,6 +30,19 @@ const galleryItems: GalleryItem[] = popular
articleId: b.article_id, articleId: b.article_id,
title: b.title, title: b.title,
})) }))
// 首页「Street Style」热门街拍:构建期走 SSG 内部接口 /api/internal/ssg/street-snaps/popular(server-only),
// 返回按图片数前 N 的街拍(含封面),直接烧入静态 HTML;后端不可达则静默留空(区块回退 ComingSoon)。
const streetPopular = (await getSsgStreetSnapPopular(12)).filter((b) => b.cover && b.id)
const streetItems: { src: string; loc: string; event: string; id: string }[] = streetPopular.map((b) => {
const parts = (b.title || "").split("\t")
return {
src: toAbs(b.cover || ""),
loc: parts[0] || b.title || "",
event: parts.slice(1).join(" ").trim(),
id: b.id,
}
})
--- ---
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen"> <div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
<!-- 二级分类标签页 --> <!-- 二级分类标签页 -->
@ -44,8 +58,8 @@ const galleryItems: GalleryItem[] = popular
</li> </li>
<li <li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'shows' }" :class="{ 'is-active': activeTab === 'street' }"
@click="activeTab = 'shows'" @click="activeTab = 'street'"
> >
Street Style Street Style
</li> </li>
@ -136,9 +150,36 @@ const galleryItems: GalleryItem[] = popular
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" /> <ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div> </div>
<!-- 街拍 面板 --> <!-- 街拍 面板(构建期 SSG 拉取热门街拍封面;去掉 swiper,改为 HBX 大图网格) -->
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak> <div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" /> {
streetItems.length > 0 ? (
<section class="ss-section">
<!-- 街拍大图卡片网格 -->
<div class="ss-grid">
{
streetItems.map((g) => (
<a class="ss-card" href={href(locale, ROUTES.streetSnap(g.id))}>
<div class="ss-img-box" x-data="{ loaded: false, error: false, init() { const i = this.$el.querySelector('img'); if (i && i.complete) { this.loaded = i.naturalWidth > 0; this.error = i.naturalWidth === 0; } } }" :class="{ 'is-loaded': loaded || error }">
<div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="ss-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="ss-ring" /></svg>
</span>
<img src={g.src} alt={g.loc} loading="lazy" decoding="async" :class="{ 'is-error': error }" @load="loaded = true; $dispatch('img-ready')" @error="error = true; $dispatch('img-ready')" />
<span class="ss-cap">
<span class="ss-cap-loc">{g.loc}</span>
<span class="ss-cap-event">{g.event}</span>
</span>
</div>
</a>
))
}
</div>
</section>
) : (
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
)
}
</div> </div>
</main> </main>
</div> </div>
@ -310,6 +351,66 @@ const galleryItems: GalleryItem[] = popular
@media (max-width: 768px) { @media (max-width: 768px) {
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); } .hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
} }
/* ===== 首页 Street Style 网格(去掉 swiper,改为 HBX 大图卡片,与街拍列表页一致) ===== */
.ss-section { @apply mb-9; }
.ss-grid {
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[14px] md:gap-[18px];
}
.ss-card {
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit;
aspect-ratio: 3 / 4;
}
.ss-img-box {
@apply absolute inset-0 w-full h-full overflow-hidden;
background: #f0f0f0;
}
.ss-img-box > img {
@apply absolute inset-0 w-full h-full object-cover;
opacity: 0;
transition: opacity 0.45s ease, transform 0.7s ease;
}
.ss-img-box.is-loaded > img { opacity: 1; }
.ss-img-box > img.is-error { opacity: 0 !important; }
.ss-card:hover .ss-img-box > img { transform: scale(1.04); }
/* 骨架屏 + spinner(与首页 hs-* 平行) */
.ss-skel {
position: absolute; inset: 0; z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease;
}
.ss-img-box.is-loaded .ss-skel { opacity: 0; }
.ss-spin {
position: absolute; inset: 0; z-index: 2;
display: flex; align-items: center; justify-content: center;
color: #bdbdbd; pointer-events: none;
transition: opacity 0.3s ease;
}
.ss-img-box.is-loaded .ss-spin { opacity: 0; }
.ss-spin svg { display: block; }
.ss-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
/* 底部叠层白字(loc / event) */
.ss-cap {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 28px 20px 18px;
background: linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.34) 55%, transparent 100%);
color: white; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-end;
gap: 5px; z-index: 3; pointer-events: none;
}
.ss-cap-loc {
font-size: clamp(18px, 2vw, 26px);
font-weight: 700; line-height: 1.1; letter-spacing: -0.01em;
text-shadow: 0 1px 4px rgba(0,0,0,0.55);
}
.ss-cap-event {
font-size: clamp(10px, 0.8vw, 12px);
font-weight: 500; text-transform: uppercase; letter-spacing: 0.16em; line-height: 1.4;
opacity: 0.85; text-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
</style> </style>
<script> <script>
@ -319,9 +420,9 @@ const galleryItems: GalleryItem[] = popular
import "swiper/css/navigation"; import "swiper/css/navigation";
import "swiper/css/free-mode"; import "swiper/css/free-mode";
// 初始化 Swiper 轮播 // 初始化 Swiper 轮播(首页可能有多个:Hot Brand / Street Style)
const el = document.getElementById("hsSwiper"); const swipers: Swiper[] = []
if (el) { document.querySelectorAll<HTMLElement>(".hs-swiper").forEach((el) => {
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev"); const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next"); const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
@ -335,12 +436,21 @@ const galleryItems: GalleryItem[] = popular
keyboard: { enabled: true }, keyboard: { enabled: true },
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" }, navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
observer: true, observer: true,
observeParents: true,
observeSlideChildren: true, observeSlideChildren: true,
resizeObserver: true,
}); });
swipers.push(swiper);
});
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁 // Street Style 等面板默认 x-show 隐藏,Swiper 在 display:none 下初始化会把 slide 算成 0 宽,
window.addEventListener("img-ready", () => { // 切到对应 tab 后必须 update() 一次重算尺寸,否则图片虽在但宽度 0 不可见。
requestAnimationFrame(() => swiper.update()); const updateAllSwipers = () =>
}); requestAnimationFrame(() => swipers.forEach((s) => s.update()));
} document.querySelectorAll<HTMLElement>(".tab").forEach((tab) => {
tab.addEventListener("click", () => setTimeout(updateAllSwipers, 60));
});
window.addEventListener("resize", updateAllSwipers);
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
window.addEventListener("img-ready", updateAllSwipers);
</script> </script>

View File

@ -1,5 +1,5 @@
--- ---
import type { RawArticle } from "@/lib/data" import type { RawArticle } from "@/lib/api"
import Breadcrumb from "@/components/Breadcrumb.astro" import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from "@/i18n/utils" import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
@ -8,9 +8,11 @@ import { ssrBase } from "@/lib/api"
interface Props { interface Props {
article: RawArticle | null article: RawArticle | null
error?: "missing" | "failed" | null error?: "missing" | "failed" | null
preview?: boolean // 后端未登录截断图片集时返回 true,渲染「剩余 N 张」占位遮罩
imageTotal?: number // 图片集真实总数(后端截断前),渲染「剩余 N 张」占位遮罩
} }
const { article, error = null } = Astro.props const { article, error = null, preview = false, imageTotal = 0 } = Astro.props
const { locale, t } = getI18n(Astro) const { locale, t } = getI18n(Astro)
const full = (u: string): string => { const full = (u: string): string => {
@ -23,10 +25,20 @@ const images = (article?.images ?? [])
.map((im) => ({ .map((im) => ({
id: im.id, id: im.id,
url: full(im.image || ""), url: full(im.image || ""),
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
name: im.name || "", name: im.name || "",
})) }))
.filter((im) => im.url) .filter((im) => im.url)
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5
const previewImages = images.slice(0, PREVIEW_LIMIT)
// 未登录预览占位遮罩数:补足「剩余 N 张」空图,提示登录后可见,避免误以为图集只有预览上限那几张。
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
const descParas = (article?.description || "") const descParas = (article?.description || "")
.split(/\n\n+/) .split(/\n\n+/)
.map((s) => s.trim()) .map((s) => s.trim())
@ -53,6 +65,11 @@ if (article?.brand_id) {
<div <div
id="article-view" id="article-view"
x-data="articleView()" x-data="articleView()"
data-fav-type="runway_image"
data-gallery-uid={article?.id ?? ""}
data-gallery-title={article?.title ?? ""}
data-gallery-brand={article?.brand_name ?? ""}
data-preview={preview ? "true" : "false"}
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white" class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
> >
{ {
@ -68,19 +85,21 @@ if (article?.brand_id) {
{article.title} {article.title}
</h1> </h1>
{images.length > 0 && ( {previewImages.length > 0 && (
<section class="mt-16"> <section class="mt-16">
<div <div
id="article-gallery" id="article-gallery"
class="grid grid-cols-2 md:grid-cols-3 gap-4" class="grid grid-cols-2 md:grid-cols-3 gap-4"
> >
{images.map((im, i) => ( {previewImages.map((im, i) => (
<figure <figure
data-image-index={i} data-image-index={i}
data-image-url={im.url} data-image-url={im.url}
data-image-name={im.name} data-image-name={im.name}
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`} @click={`openLb(${i})`}
class="bg-[#f2f2f2] cursor-zoom-in group" class="relative bg-[#f2f2f2] cursor-zoom-in group"
> >
<img <img
src={im.url} src={im.url}
@ -90,13 +109,94 @@ if (article?.brand_id) {
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity" class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/> />
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
<button
login-required
type="button"
data-fav-id={im.id}
data-fav-url={im.raw}
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
{im.name && ( {im.name && (
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2"> <figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
{im.name} {im.name}
</figcaption> </figcaption>
)} )}
</figure> </figure>
))} ) )}
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
{lockedCount > 0 &&
Array.from({ length: lockedCount }).map(() => (
<figure
x-show="!isAuthed && preview"
x-cloak
login-redirect
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[3/4] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
<div class="flex flex-col items-center gap-2">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
</svg>
<span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
</div>
</div>
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 look01 等小字):
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption>
</figure>
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url">
<figure
:data-image-index="5 + i"
:data-image-url="im.url"
:data-image-name="im.name"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click="openLb(5 + i)"
class="relative bg-[#f2f2f2] cursor-zoom-in group"
>
<img
:src="im.url"
:alt="im.name"
loading="lazy"
decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
<button
login-required
type="button"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
<template x-if="im.name">
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption>
</template>
</figure>
</template>
</div> </div>
</section> </section>
)} )}
@ -125,7 +225,7 @@ if (article?.brand_id) {
) )
} }
{article && images.length > 0 && ( {article && previewImages.length > 0 && (
<div <div
x-show="lbOpen" x-show="lbOpen"
x-cloak x-cloak
@ -140,6 +240,19 @@ if (article?.brand_id) {
x-text="`${lbIndex + 1} / ${imageCount}`" x-text="`${lbIndex + 1} / ${imageCount}`"
></div> ></div>
<button
login-required
type="button"
@click.stop="toggleFav(currentFavId, currentFavUrl)"
:class="isFav(currentFavId) ? 'is-fav' : ''"
class="fav-btn absolute top-[18px] right-[70px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
<button <button
type="button" type="button"
@click="closeLb()" @click="closeLb()"
@ -154,7 +267,7 @@ if (article?.brand_id) {
id="lb-thumbs" id="lb-thumbs"
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar" class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar"
> >
{images.map((im, i) => ( {previewImages.map((im, i) => (
<button <button
type="button" type="button"
data-lb-index={i} data-lb-index={i}
@ -171,6 +284,22 @@ if (article?.brand_id) {
</span> </span>
</button> </button>
))} ))}
<template x-for="(im, i) in extraImages" :key="im.url">
<button
type="button"
:data-lb-index="5 + i"
:data-thumb-url="im.url"
@click="showLb(5 + i)"
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
: ''"
:aria-label="String(5 + i + 1)"
>
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
</button>
</template>
</div> </div>
<button <button
@ -282,6 +411,9 @@ if (article?.brand_id) {
// handleTouchEnd(event: TouchEvent): void // handleTouchEnd(event: TouchEvent): void
// } // }
import { getUser, getArticleDetailAuthed, toAbs } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("articleView", () => ({ Alpine.data("articleView", () => ({
lbOpen: false, lbOpen: false,
@ -292,12 +424,108 @@ if (article?.brand_id) {
imageCount: 0, imageCount: 0,
currentImageName: "", currentImageName: "",
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
favIds: [],
favType: "runway_image",
galleryUid: "",
galleryTitle: "",
galleryBrand: "",
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
isAuthed: false,
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; raw: string; name: string }[],
showAll: false,
init() { init() {
this.favType = this.$el.dataset.favType || "runway_image"
this.galleryUid = this.$el.dataset.galleryUid || ""
this.galleryTitle = this.$el.dataset.galleryTitle || ""
this.galleryBrand = this.$el.dataset.galleryBrand || ""
this.preview = this.$el.dataset.preview === "true"
this.isAuthed = !!getUser()
this.imageCount = document.querySelectorAll( this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]" "#article-gallery [data-image-index]"
).length ).length
this.initThumbLazyLoading() this.initThumbLazyLoading()
this.initImageFavs()
this.maybeLocateImageFromUrl()
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染
if (getUser() && this.preview) this.loadFull()
// 登录后(如从门禁跳登录回跳)自动补全全量
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() })
},
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。
// 后端详情接口对已登录(带 Bearer)返回完整图片集,故此处能拿到全量。
async loadFull() {
if (!this.preview) return
try {
const full = await getArticleDetailAuthed(this.galleryUid)
if (!full || !Array.isArray(full.images)) return
const mapped = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "",
url: toAbs(im.image || ""),
raw: im.image || "",
name: im.name || "",
}))
this.extraImages = mapped
this.showAll = true
// VIP:前 PREVIEW_LIMIT 张是 SSR 静态渲染的普通样式图(SSR 无用户令牌,
// 后端回落 normal)。VIP 已登录后此处把首屏图也替换为高清原图(后端对 VIP 返回 HD URL),
// 否则 VIP 仅第 6 张起看高清,首屏仍是普通图,体验不一致。
const me = getUser()
if (me && me.tier === "vip") {
const hd = (full.images || []).slice(0, 5).map((im: any) => toAbs(im.image || ""))
hd.forEach((url: string, i: number) => {
if (!url) return
const fig = document.querySelector<HTMLElement>(`#article-gallery [data-image-index="${i}"]`)
if (!fig) return
fig.dataset.imageUrl = url
const img = fig.querySelector("img")
if (img) img.setAttribute("src", url)
})
}
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]"
).length
this.initImageFavs()
this.initThumbLazyLoading()
this.maybeLocateImageFromUrl()
} catch {
/* 失败维持截断态,门禁仍在 */
}
},
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
maybeLocateImageFromUrl() {
if (typeof window === "undefined") return
const q = new URLSearchParams(window.location.search)
const want = q.get("img")
if (!want) return
const figures = Array.from(
document.querySelectorAll<HTMLElement>("#article-gallery [data-fav-id]"),
)
const idx = figures.findIndex((el) => el.dataset.favId === want)
if (idx < 0) return
const fig = figures[idx] as HTMLElement
requestAnimationFrame(() => {
if (q.get("lb") === "1") {
this.openLb(idx)
return
}
fig.scrollIntoView({ behavior: "smooth", block: "center" })
fig.classList.add("locate-flash")
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
})
}, },
getImageElement(index: number) { getImageElement(index: number) {
@ -314,6 +542,65 @@ if (article?.brand_id) {
return this.getImageElement(index)?.dataset.imageName || "" return this.getImageElement(index)?.dataset.imageName || ""
}, },
getImageFavId(index: number) {
return this.getImageElement(index)?.dataset.favId || ""
},
// 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主)
getImageFavUrl(index: number) {
return this.getImageElement(index)?.dataset.favUrl || ""
},
// ===== 单图收藏 =====
// 只把「当前这篇图集的全部图片 id」发给后端问哪些已收藏,
// 复杂度与图片数成正比,与用户收藏总量无关。
allImageFavIds(): string[] {
return Array.from(
document.querySelectorAll<HTMLElement>("#article-gallery [data-fav-id]"),
)
.map((el) => el.dataset.favId || "")
.filter(Boolean)
},
initImageFavs() {
const ids = this.allImageFavIds()
if (!ids.length) return
// 本地先即时填充,避免爱心闪烁
this.favIds = ids.filter((id) => isFavorited(id))
// 登录态用服务端校正(换设备后与服务端一致)
checkFavorited(ids).then((hit) => {
this.favIds = ids.filter((id) => hit.has(id))
})
},
isFav(id: string) {
return !!id && this.favIds.includes(id)
},
toggleFav(id: string, url: string) {
if (!id) return
toggleImageFavorite({
imageId: id,
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
imageUrl: url,
parentUid: this.galleryUid,
title: this.galleryTitle,
brand: this.galleryBrand,
})
this.favIds = this.isFav(id)
? this.favIds.filter((x: string) => x !== id)
: [...this.favIds, id]
},
// 灯箱里的收藏按钮跟随当前大图
get currentFavId() {
return this.getImageFavId(this.lbIndex)
},
get currentFavUrl() {
return this.getImageFavUrl(this.lbIndex)
},
openLb(index = 0) { openLb(index = 0) {
if (!this.imageCount) return if (!this.imageCount) return
@ -488,4 +775,25 @@ if (article?.brand_id) {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
} }
/* 单图收藏爱心:默认描边,已收藏实心 */
.fav-btn svg {
fill: none;
}
.fav-btn.is-fav svg {
fill: currentColor;
}
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
.locate-flash {
outline: 3px solid #111;
outline-offset: 4px;
}
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
@media (hover: none) {
.fav-btn {
opacity: 1 !important;
}
}
</style> </style>

View File

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

View File

@ -0,0 +1,112 @@
---
// 加载屏:首屏/整页切换时遮盖未渲染内容,避免 FOUC。
// 轻量原则(用户要求"朴实流畅"):
// - 只用静态大字 + mono 小标 + 一条 2px 细线进度(transform/opacity 动画,最便宜);
// - 去掉昂贵的多层 text-shadow 位移动画与外部字体;最短展示时间压缩到 ~350ms,跟手不拖沓。
---
<div id="loading-screen" aria-hidden="true">
<div id="ls-box">
<h1>Portfolio Studio</h1>
<p class="ls-sub">Loading</p>
<div class="ls-bar" aria-hidden="true"></div>
</div>
</div>
<style>
#loading-screen {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #ffffff;
color: #0a0a0a;
transition: opacity 0.4s ease;
}
#loading-screen.is-hidden {
opacity: 0;
pointer-events: none;
}
#loading-screen h1 {
margin: 0;
font-family: ui-serif, Georgia, Cambria, 'Times New Roman', serif;
font-size: clamp(2.2rem, 6vw, 4rem);
font-weight: 900;
line-height: 1;
letter-spacing: -0.02em;
text-transform: uppercase;
}
#loading-screen .ls-sub {
margin: 1.1em 0 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.72rem;
letter-spacing: 0.34em;
text-transform: uppercase;
color: #888;
animation: ls-pulse 1.4s ease-in-out infinite;
}
#loading-screen .ls-bar {
margin-top: 2em;
width: min(180px, 60vw);
height: 2px;
background: #e6e6e6;
overflow: hidden;
}
#loading-screen .ls-bar::before {
content: "";
display: block;
width: 100%;
height: 100%;
background: #0a0a0a;
transform-origin: left center;
animation: ls-bar 1.1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes ls-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
@keyframes ls-bar {
0% { transform: scaleX(0.08); }
45% { transform: scaleX(0.72); }
100% { transform: scaleX(1); }
}
/* 尊重系统"减少动画"偏好 */
@media (prefers-reduced-motion: reduce) {
#loading-screen .ls-sub,
#loading-screen .ls-bar::before {
animation: none;
}
#loading-screen .ls-bar::before { transform: scaleX(1); }
}
</style>
<script>
// load 后淡出;最少展示 350ms 防闪烁,最多 2s 兜底。
(function () {
var el = document.getElementById('loading-screen');
if (!el) return;
var hidden = false;
function hide() {
if (hidden) return;
hidden = true;
el.classList.add('is-hidden');
setTimeout(function () {
el.style.display = 'none';
}, 500);
}
var start = Date.now();
function tryHide() {
var waited = Date.now() - start;
if (waited >= 350) hide();
else setTimeout(hide, 350 - waited);
}
if (document.readyState === 'complete') tryHide();
else window.addEventListener('load', tryHide);
setTimeout(hide, 2000);
})();
</script>

View File

@ -1,33 +1,35 @@
--- ---
import Breadcrumb from "@/components/Breadcrumb.astro" import Breadcrumb from "@/components/Breadcrumb.astro"
import BrandModal from "@/components/BrandModal.astro"
import { getSsgHotBrands } from "@/lib/api" import { getSsgHotBrands } from "@/lib/api"
import { getI18n } from '@/i18n/utils' import { getI18n } from '@/i18n/utils'
import "@/styles/look-grid.css"
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro) const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
// 热门品牌:走 SSG 专属接口 /api/v1/ssg/brands/hot,后端固定返回热度前 30 个、按热度顺序排列。 // 热门品牌:走 SSG 专属接口 /api/internal/ssg/brands/popular,后端固定返回热度前 30 个、按热度顺序排列。
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。 // 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
// 接口不可用时回落假数据先看效果。 // 接口不可用时回落假数据先看效果。
const FAKE_HOT_BRANDS = [ const FAKE_HOT_BRANDS = [
{ id: "hot-chanel", name: "Chanel", article_count: 342 }, { id: "hot-chanel", name: "Chanel" },
{ id: "hot-dior", name: "Dior", article_count: 318 }, { id: "hot-dior", name: "Dior" },
{ id: "hot-gucci", name: "Gucci", article_count: 287 }, { id: "hot-gucci", name: "Gucci" },
{ id: "hot-prada", name: "Prada", article_count: 254 }, { id: "hot-prada", name: "Prada" },
{ id: "hot-louisvuitton", name: "Louis Vuitton", article_count: 241 }, { id: "hot-louisvuitton", name: "Louis Vuitton" },
{ id: "hot-saintlaurent", name: "Saint Laurent", article_count: 219 }, { id: "hot-saintlaurent", name: "Saint Laurent" },
{ id: "hot-balenciaga", name: "Balenciaga", article_count: 198 }, { id: "hot-balenciaga", name: "Balenciaga" },
{ id: "hot-valentino", name: "Valentino", article_count: 176 }, { id: "hot-valentino", name: "Valentino" },
{ id: "hot-givenchy", name: "Givenchy", article_count: 162 }, { id: "hot-givenchy", name: "Givenchy" },
{ id: "hot-burberry", name: "Burberry", article_count: 153 }, { id: "hot-burberry", name: "Burberry" },
{ id: "hot-fendi", name: "Fendi", article_count: 141 }, { id: "hot-fendi", name: "Fendi" },
{ id: "hot-bottega", name: "Bottega Veneta", article_count: 128 }, { id: "hot-bottega", name: "Bottega Veneta" },
] ]
let hotBrands: { id: string; name: string; article_count: number }[] = [] let hotBrands: { id: string; name: string }[] = []
try { try {
hotBrands = (await getSsgHotBrands()) hotBrands = (await getSsgHotBrands())
.filter((b) => b.id && b.name) .filter((b) => b.id && b.name)
.map((b) => ({ id: String(b.id), name: b.name, article_count: b.article_count })) .map((b) => ({ id: String(b.id), name: b.name }))
} catch {} } catch {}
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
@ -107,15 +109,14 @@ const seasonOptions: { value: string; label: string }[] = [
<ul class="showschecks"> <ul class="showschecks">
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id"> <template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
<li> <li>
<label class="flex items-center gap-2 w-full cursor-pointer"> <label login-required class="flex items-center gap-2 w-full cursor-pointer">
<input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" /> <input type="radio" name="designer" :value="b.id" :checked="selectedBrand === b.id" :class="{ 'is-checked': selectedBrand === b.id }" @click.prevent="toggleBrand(b.id)" />
<span x-text="b.name"></span> <span x-text="b.name"></span>
<span class="ct" x-text="b.article_count ? b.article_count : ''"></span>
</label> </label>
</li> </li>
</template> </template>
</ul> </ul>
<a class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a> <a login-required class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
</div> </div>
</section> </section>
@ -130,7 +131,7 @@ const seasonOptions: { value: string; label: string }[] = [
<div class="showsg-b" x-show="!collapsedGroups.collection"> <div class="showsg-b" x-show="!collapsedGroups.collection">
<ul class="showschecks showschecks--flat"> <ul class="showschecks showschecks--flat">
{collectionOptions.map((o) => ( {collectionOptions.map((o) => (
<li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li> <li><label login-required><input type="radio" name="collection" value={o.value} :checked={'selectedCt === "' + o.value + '"'} :class={`{'is-checked': selectedCt === '${o.value}'}`} @click.prevent={'selectedCt = selectedCt === "' + o.value + '" ? "" : "' + o.value + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
))} ))}
</ul> </ul>
</div> </div>
@ -147,7 +148,7 @@ const seasonOptions: { value: string; label: string }[] = [
<div class="showsg-b" x-show="!collapsedGroups.season"> <div class="showsg-b" x-show="!collapsedGroups.season">
<ul class="showschecks showschecks--flat"> <ul class="showschecks showschecks--flat">
{seasonOptions.map((o) => ( {seasonOptions.map((o) => (
<li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li> <li><label login-required><input type="radio" name="season" value={o.value} :checked={'selectedSeason === "' + o.value + '"'} :class={`{'is-checked': selectedSeason === '${o.value}'}`} @click.prevent={'selectedSeason = selectedSeason === "' + o.value + '" ? "" : "' + o.value + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
))} ))}
</ul> </ul>
</div> </div>
@ -164,13 +165,13 @@ const seasonOptions: { value: string; label: string }[] = [
<div class="showsg-b" x-show="!collapsedGroups.year"> <div class="showsg-b" x-show="!collapsedGroups.year">
<ul class="showschecks showschecks--flat"> <ul class="showschecks showschecks--flat">
{yearOptions.map((o) => ( {yearOptions.map((o) => (
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li> <li><label login-required><input type="radio" name="year" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'}`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
))} ))}
</ul> </ul>
</div> </div>
</section> </section>
<button class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button> <button login-required class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
</div> </div>
</aside> </aside>
@ -210,7 +211,8 @@ const seasonOptions: { value: string; label: string }[] = [
<!-- Card List --> <!-- Card List -->
<template x-for="it in items" :key="it.id"> <template x-for="it in items" :key="it.id">
<a class="showscard" :href="itemHref(it)" :aria-label="`${it.brand_name} ${it.title}`"> <div class="showscard-wrap relative group">
<a class="showscard" :href="itemHref(it)" :aria-label="cardTitle(it)">
<div class="showscard-left"> <div class="showscard-left">
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }"> <div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span> <span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
@ -219,7 +221,7 @@ const seasonOptions: { value: string; label: string }[] = [
</div> </div>
<div class="showscard-right"> <div class="showscard-right">
<div class="showscard-info"> <div class="showscard-info">
<h3 class="showscard-title" x-text="`${it.brand_name} / ${it.title}`"></h3> <h3 class="showscard-title" x-text="cardTitle(it)"></h3>
</div> </div>
<div class="showscard-strip"> <div class="showscard-strip">
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx"> <template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
@ -231,375 +233,42 @@ const seasonOptions: { value: string; label: string }[] = [
</template> </template>
</div> </div>
</div> </div>
</a> </a>
<button login-required type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
</button>
</div>
</template> </template>
</div> </div>
<div class="showsloadmore-wrap" x-show="items.length > 0"> <nav class="showspager" x-show="lastPage > 1" aria-label="Pagination">
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button> <button login-required class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
</div> <template x-for="(p, i) in pageList" :key="i">
<span class="pg-wrap">
<span class="pg-ellipsis" x-show="p === '...'" x-text="'…'"></span>
<button login-required class="pg" type="button" x-show="p !== '...'" :class="{ 'is-current': p === page }" :disabled="p === page" @click="goPage(p)" x-text="p"></button>
</span>
</template>
<button login-required class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
</nav>
</main> </main>
</div> </div>
<!-- 品牌筛选弹窗:搜索 + A-Z 跳转 + 热门推荐,按需加载,避免一次拉全量 --> <!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) -->
<div class="showsmodal" x-show="modalOpen" x-cloak @keydown.escape.window="closeBrandModal()"> <BrandModal />
<div class="showsmodal-mask" @click="closeBrandModal()"></div>
<div class="showsmodal-panel">
<header class="showsmodal-head">
<h3>全部品牌</h3>
<button class="showsmodal-x" type="button" @click="closeBrandModal()" aria-label="关闭">✕</button>
</header>
<div class="showsmodal-search">
<input
class="showsmodal-input"
type="text"
x-model="modalKeyword"
@input.debounce.300ms="modalSearch()"
placeholder="搜索品牌名…"
autocomplete="off"
/>
</div>
<div class="showsmodal-az">
<button class="az az-hot" type="button" :class="{ active: modalLetter === 'HOT' }" @click="modalPickLetter('HOT')">HOT</button>
<template x-for="l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split('')" :key="l">
<button class="az" type="button" :class="{ active: modalLetter === l }" @click="modalPickLetter(l)" x-text="l"></button>
</template>
</div>
<div class="showsmodal-body">
<div class="showsmodal-hint" x-show="!modalKeyword && modalLetter === 'HOT'">热门推荐</div>
<div class="showsmodal-loading" x-show="modalLoading" x-html="SPIN_SVG"></div>
<ul class="showsmodal-list">
<template x-for="b in modalResults" :key="b.id">
<li>
<label class="showsmodal-row">
<input
type="checkbox"
:value="b.id"
:checked="modalSelected.includes(b.id)"
:disabled="!modalSelected.includes(b.id) && modalMaxReached"
@change="toggleModalBrand(b.id)"
/>
<span class="showsmodal-name" x-text="b.display || b.name"></span>
<span class="showsmodal-ct" x-text="b.article_count ? b.article_count : ''"></span>
</label>
</li>
</template>
<li class="showsmodal-empty" x-show="!modalLoading && modalResults.length === 0">未找到匹配的品牌</li>
</ul>
</div>
<footer class="showsmodal-foot">
<!-- <span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 最多 5 个`"></span> -->
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
</footer>
</div>
</div>
</div> </div>
<script id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script> <script id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
<style>
@reference "tailwindcss";
[x-cloak] { display: none !important; }
/* 使用 @apply 精简并重构样式 */
.showsroot {
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
}
.showshead {
@apply flex justify-between items-end gap-6 py-5 pb-[26px] ;
}
.showsh1 {
@apply text-[clamp(40px,6vw,72px)] leading-[0.9] font-black tracking-[-0.02em] my-0 mb-[10px];
}
.showspills {
@apply flex flex-wrap gap-2 pt-3.5;
}
.showspill {
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
}
.showspill .x {
@apply inline-flex w-3.5 h-3.5 items-center justify-center border-none bg-black text-white cursor-pointer text-xs leading-none;
}
.showsbody {
@apply flex gap-[36px] items-start max-md:flex-col max-md:gap-[18px];
}
.showsaside {
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] rounded-none transition-[flex-basis] duration-250 ease-in-out flex flex-col max-md:static max-md:max-h-none max-md:w-full;
}
.showsaside-head {
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
}
.showsaside-h {
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none transition-opacity duration-200;
}
.showsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
}
.showsg {
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
}
.showsg-h {
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
}
.showsg-right {
@apply inline-flex items-center gap-2.5;
}
.showsg-chev {
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
}
.showsg-chev.is-collapsed {
@apply -rotate-90;
}
.showsg-count {
@apply text-[#999] font-normal text-[10px] tracking-[0.14em];
}
.showschecks {
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin] overscroll-contain;
}
.showschecks li {
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
}
.showschecks li.letter-head {
@apply p-[8px_0_3px] text-[10px] tracking-[0.22em] text-[#8a8a8a] cursor-default pointer-events-none border-t border-dotted border-[#e6e6e6] first:border-t-0 first:pt-1;
}
.showschecks input[type="checkbox"] {
@apply appearance-none w-3 h-3 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-none inline-grid place-content-center checked:bg-black;
}
.showschecks input[type="checkbox"]:checked::after {
content: "";
@apply w-[6px] h-[6px] bg-white;
}
.showschecks .ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showschecks--flat li {
@apply py-[4px];
}
.showschecks--flat li label {
@apply inline-flex items-center gap-2 cursor-pointer w-full;
}
.showsmore {
@apply block w-full mt-[12px] text-center text-[11px] uppercase tracking-[0.22em] text-black font-bold cursor-pointer;
}
.showsclear {
@apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[#666] cursor-pointer hover:text-black transition-colors duration-150;
}
.showsmain {
@apply flex-1 min-w-0;
}
.showsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
}
.showsgrid.is-loading::before {
content: "";
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
animation: hbxProg 1s ease-in-out infinite;
}
.showscard {
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px];
}
.showscard:hover .showscard-img img {
@apply scale-104;
}
.showscard-left {
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
}
.showscard-img {
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
}
.showscard-img img {
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
}
.showscard-img img.is-loaded {
@apply opacity-100;
}
.showsspin {
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
}
.showscard-img.is-loaded .showsspin,
.showscard-thumb.is-loaded .showsspin {
@apply opacity-0;
}
.showsbadge {
@apply absolute top-[6px] right-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-white bg-black p-[3px_6px];
}
.showsflag-featured {
@apply absolute top-[6px] left-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-black bg-white p-[3px_6px] border border-black;
}
.showscard-right {
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
}
.showscard-info {
@apply flex-none min-w-0 flex flex-col p-[2px_2px_0];
}
.showscard-title {
@apply text-[15.5px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] tracking-[-0.01em] line-clamp-2 h-[41px] overflow-hidden;
}
.showscard-meta {
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
}
.showscard-meta-foot {
@apply flex flex-col gap-[6px] h-[38px] overflow-hidden;
}
.showscard-meta-line {
@apply inline-flex items-center gap-[6px] text-[10.5px] tracking-[0.06em] uppercase text-[#444] h-[16px] leading-[16px] overflow-hidden whitespace-nowrap;
}
.showscard-strip {
@apply grid grid-cols-5 gap-1 mt-[10px];
}
.showscard-thumb {
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
}
.showscard-thumb img {
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
}
.showscard-thumb img.is-loaded {
@apply opacity-100;
}
.showscard-thumb.placeholder {
@apply bg-[#f6f6f6];
}
.showscard-thumb-more {
@apply absolute inset-0 bg-black/55 text-white text-[11px] font-bold tracking-[0.04em] flex items-center justify-center;
}
.showscard--skel .showscard-img,
.showscard--skel .showscard-thumb,
.showscard--skel .showsline {
@apply bg-[#efefef];
animation: hbxShim 1.6s ease infinite;
}
.showsloadmore-wrap {
@apply flex justify-center mt-[38px];
}
.showsloadmore {
@apply border-[1.5px] border-black bg-white text-black p-[13px_36px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-200 hover:bg-black hover:text-white disabled:opacity-40 disabled:cursor-default;
}
.showsempty {
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
}
@keyframes hbxProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes hbxShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }
/* ===== 品牌筛选弹窗(语言切换器风格:无 mask、顶部小箭头、干净白底列表) ===== */
.showsmodal {
@apply fixed inset-0 z-[60] flex items-start justify-center pt-[14vh] pointer-events-none;
}
.showsmodal-mask {
@apply absolute inset-0 pointer-events-auto cursor-default;
background: rgba(0, 0, 0, 0.4);
}
.showsmodal-panel {
@apply relative w-full max-w-[680px] max-h-[72vh] bg-white flex flex-col border border-black/10 pointer-events-auto;
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
}
/* 顶部小三角箭头(语言切换器同款:白底 + 浅边框) */
.showsmodal-panel::before {
content: "";
position: absolute;
top: -5px;
left: 50%;
width: 10px;
height: 10px;
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
transform: translateX(-50%) rotate(45deg);
}
.showsmodal-head {
@apply flex items-center justify-between px-4 pt-4 pb-2 shrink-0;
}
.showsmodal-head h3 {
@apply m-0 text-[12px] tracking-[0.22em] uppercase font-bold;
}
.showsmodal-x {
@apply appearance-none bg-transparent border-0 w-6 h-6 inline-flex items-center justify-center cursor-pointer text-[14px] leading-none text-[#999] transition-colors hover:text-black;
}
.showsmodal-search {
@apply px-4 pt-1 pb-3 shrink-0;
}
.showsmodal-input {
@apply w-full border border-black/15 px-3 py-2 text-[13px] outline-none bg-[#fafafa] focus:bg-white focus:border-black;
}
.showsmodal-az {
@apply flex flex-wrap gap-1 px-4 pb-3 shrink-0;
}
.showsmodal-az .az {
@apply appearance-none bg-white border border-black/10 w-6 h-6 text-[10px] cursor-pointer leading-none font-mono transition-colors hover:bg-black/5;
}
.showsmodal-az .az.active {
@apply bg-black text-white border-black;
}
.showsmodal-az .az-hot {
@apply w-auto px-2 font-sans font-bold tracking-[0.12em] text-[9px];
}
.showsmodal-body {
@apply flex-1 min-h-0 overflow-y-auto px-2 py-1 overscroll-contain;
}
.showsmodal-hint {
@apply text-[10px] tracking-[0.22em] uppercase text-[#999] px-3 py-2;
}
.showsmodal-loading {
@apply flex justify-center py-6 text-[#bdbdbd];
}
.showsmodal-list {
@apply list-none m-0 p-0;
}
.showsmodal-row {
@apply flex items-center gap-2 w-full cursor-pointer px-3 py-2 text-[13px] transition-colors hover:bg-black/5;
}
.showsmodal-name {
@apply flex-1 truncate;
}
.showsmodal-ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showsmodal-empty {
@apply py-6 text-center text-[#999] text-[13px];
}
.showsmodal-foot {
@apply flex items-center justify-between px-4 py-3 border-t border-black/5 shrink-0 text-[11px] text-[#888];
}
.showsmodal-counter {
@apply tracking-[0.06em];
}
.showsmodal-ok {
@apply appearance-none bg-black text-white border border-black px-4 py-1.5 cursor-pointer text-[12px] tracking-[0.1em] transition-colors hover:bg-black/85;
}
</style>
<script> <script>
import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api" import { getArticles, getBrands, toAbs, getUser, type ArticleItem, type BrandEntry } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
const SPIN_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block"><g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g><g opacity="0.55" fill="none" stroke="currentColor" stroke-width="1"><rect x="8" y="9.6" width="8" height="5.4"></rect><circle cx="12" cy="12.3" r="1.6"></circle></g></svg>` import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => { Alpine.data("showsPage", () => {
@ -610,16 +279,17 @@ const seasonOptions: { value: string; label: string }[] = [
locale: '', locale: '',
hotBrands, hotBrands,
SPIN_SVG, SPIN_SVG,
// 侧栏分组折叠状态:默认全部收起(不做 localStorage 持久化,每次进页都是关着的)。
collapsedGroups: { collapsedGroups: {
designers: localStorage.getItem("showsg-designers") === "1", designers: true,
collection: localStorage.getItem("showsg-collection") === "1", collection: true,
season: localStorage.getItem("showsg-season") === "1", season: true,
year: localStorage.getItem("showsg-year") === "1", year: true,
}, },
selectedBrands: [] as number[], selectedBrand: null as (string | null),
selectedCt: [] as string[], selectedCt: "" as string,
selectedSeason: [] as string[], selectedSeason: "" as string,
selectedYear: [] as string[], selectedYear: "" as string,
// 品牌筛选弹窗状态 // 品牌筛选弹窗状态
modalOpen: false, modalOpen: false,
@ -627,93 +297,84 @@ const seasonOptions: { value: string; label: string }[] = [
modalLetter: "", modalLetter: "",
modalResults: [] as BrandEntry[], modalResults: [] as BrandEntry[],
modalLoading: false, modalLoading: false,
modalSelected: [] as number[], modalSelected: null as (string | null),
get modalMaxReached() {
return this.modalSelected.length >= 1
},
items: [] as (ArticleItem & { _imgLoaded?: boolean })[], items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
loading: false, loading: false,
loadingMore: false,
page: 1, page: 1,
size: 24, lastPage: 1,
total: 0,
isEnd: false,
reqToken: 0, reqToken: 0,
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
favMap: {} as Record<string, boolean>,
init() { init() {
this.locale = this.$el.dataset.locale || 'en'; this.locale = this.$el.dataset.locale || 'en';
this.loadPage(1, false) // 支持从 header 下拉深链 ?collection=xxx 直接进入对应分类(与侧栏 COLLECTION 筛选一致)
const ct = new URLSearchParams(window.location.search).get('collection')
const ALLOWED = ['rtw', 'menswear', 'womenswear', 'couture', 'resort', 'pre_fall', 'bridal', 'swimwear']
if (ct && ALLOWED.includes(ct)) this.selectedCt = ct
// 首页(含筛选后的首页)公开:未登录也直接加载首屏,不弹登录框
// 收藏态在 loadPage 内按当前页校正(markFavs),与收藏总量解耦,10万也常数级
this.loadPage(1)
// 浏览历史:改为「点开文章详情」时记录(见 item 详情页),列表页不再记。
}, },
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动 // 骨架屏数量与首屏每页条数一致(后端固定 ArticleFixedSize=24),消除加载抖动
get skeletonCount() { get skeletonCount() {
return this.size return 24
}, },
// 已筛选选项 Pills 视图数据 // 已筛选选项 Pills 视图数据
get pills() { get pills() {
const list: { label: string; clear: () => void }[] = [] const list: { label: string; clear: () => void }[] = []
if (this.selectedBrands.length) { if (this.selectedBrand) {
const findName = (id: any) => this.hotBrands.find((b) => b.id === id)?.name ?? this.modalResults.find((b) => b.id === id)?.name ?? `#${id}` const findName = (id: any) => this.hotBrands.find((b) => b.id === id)?.name ?? this.modalResults.find((b) => b.id === id)?.name ?? `#${id}`
const labels = this.selectedBrands const name = findName(this.selectedBrand)
.map((id) => findName(id))
.slice(0, 4)
const more = this.selectedBrands.length > 4 ? ` +${this.selectedBrands.length - 4}` : ""
list.push({ list.push({
label: "DESIGNER · " + labels.join(", ") + more, label: "DESIGNER · " + name,
clear: () => { this.selectedBrands = []; this.refreshFilter(); } clear: () => { this.selectedBrand = null; this.refreshFilter(); }
}) })
} }
if (this.selectedCt.length) { if (this.selectedCt) {
list.push({ list.push({
label: "COLLECTION · " + this.selectedCt.join("+").toUpperCase(), label: "COLLECTION · " + this.selectedCt.toUpperCase(),
clear: () => { this.selectedCt = []; this.refreshFilter(); } clear: () => { this.selectedCt = ""; this.refreshFilter(); }
}) })
} }
if (this.selectedSeason.length) { if (this.selectedSeason) {
list.push({ list.push({
label: "SEASON · " + this.selectedSeason.join("+").toUpperCase(), label: "SEASON · " + this.selectedSeason.toUpperCase(),
clear: () => { this.selectedSeason = []; this.refreshFilter(); } clear: () => { this.selectedSeason = ""; this.refreshFilter(); }
}) })
} }
if (this.selectedYear.length) { if (this.selectedYear) {
list.push({ list.push({
label: "YEAR · " + [...this.selectedYear].sort().join("+"), label: "YEAR · " + this.selectedYear,
clear: () => { this.selectedYear = []; this.refreshFilter(); } clear: () => { this.selectedYear = ""; this.refreshFilter(); }
}) })
} }
return list return list
}, },
get loadBtnText() { // 点击抽屉开关:仅切换内存状态,刷新后回到默认(全部收起)
if (this.loadingMore) return "Loading…"
if (this.isEnd) return "— End of Archive —"
return "Load More ↓"
},
// 点击抽屉开关
toggleGroup(group: "designers" | "collection" | "season" | "year") { toggleGroup(group: "designers" | "collection" | "season" | "year") {
this.collapsedGroups[group] = !this.collapsedGroups[group] this.collapsedGroups[group] = !this.collapsedGroups[group]
localStorage.setItem(`showsg-${group}`, this.collapsedGroups[group] ? "1" : "")
}, },
// 选中热门品牌
toggleBrand(id: number) {
const idx = this.selectedBrands.indexOf(id)
if (idx >= 0) this.selectedBrands.splice(idx, 1)
else this.selectedBrands.push(id)
this.refreshFilter()
},
// 清除筛选条件
refreshFilter() { refreshFilter() {
this.loadPage(1, false) // 首页公开:筛选切换后重新加载第 1 页,未登录也可查看
this.loadPage(1)
},
toggleBrand(id: string) {
this.selectedBrand = this.selectedBrand === id ? null : id
this.refreshFilter()
}, },
// ===== 品牌筛选弹窗方法 ===== // ===== 品牌筛选弹窗方法 =====
async openBrandModal() { async openBrandModal() {
this.modalSelected = [...this.selectedBrands] this.modalSelected = this.selectedBrand
this.modalKeyword = "" this.modalKeyword = ""
this.modalLetter = "HOT" this.modalLetter = "HOT"
this.modalOpen = true this.modalOpen = true
@ -732,7 +393,7 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalLetter = "HOT" this.modalLetter = "HOT"
this.modalLoading = true this.modalLoading = true
try { try {
this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name, article_count: b.article_count || 0 })) this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name }))
} finally { } finally {
this.modalLoading = false this.modalLoading = false
} }
@ -744,7 +405,7 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalLoading = true this.modalLoading = true
this.modalResults = [] this.modalResults = []
try { try {
this.modalResults = await getBrands({ keyword: kw, size: 60 }) this.modalResults = await getBrands({ keyword: kw })
} catch { } catch {
this.modalResults = [] this.modalResults = []
} finally { } finally {
@ -768,23 +429,15 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalResults = [] this.modalResults = []
try { try {
const letter = l === "#" ? "OTHER" : l const letter = l === "#" ? "OTHER" : l
this.modalResults = await getBrands({ letter, size: 200 }) this.modalResults = await getBrands({ letter })
} catch { } catch {
this.modalResults = [] this.modalResults = []
} finally { } finally {
this.modalLoading = false this.modalLoading = false
} }
}, },
toggleModalBrand(id: number) {
const idx = this.modalSelected.indexOf(id)
if (idx >= 0) this.modalSelected.splice(idx, 1)
else {
if (this.modalSelected.length >= 5) return
this.modalSelected.push(id)
}
},
confirmBrandModal() { confirmBrandModal() {
this.selectedBrands = [...this.modalSelected] this.selectedBrand = this.modalSelected
this.modalOpen = false this.modalOpen = false
document.documentElement.style.overflow = "" document.documentElement.style.overflow = ""
document.documentElement.style.paddingRight = "" document.documentElement.style.paddingRight = ""
@ -792,73 +445,121 @@ const seasonOptions: { value: string; label: string }[] = [
}, },
clearAll() { clearAll() {
this.selectedBrands = [] this.selectedBrand = null
this.selectedCt = [] this.selectedCt = ""
this.selectedSeason = [] this.selectedSeason = ""
this.selectedYear = [] this.selectedYear = ""
this.refreshFilter() this.refreshFilter()
}, },
// API 加载 // API 加载(分页:每次整页替换,不追加)
async loadPage(page: number, append = false) { async loadPage(page: number) {
const myToken = ++this.reqToken const myToken = ++this.reqToken
if (append) { this.loading = true
this.loadingMore = true this.items = []
} else {
this.loading = true
this.items = []
}
try { try {
const json = await getArticles({ const json = await getArticles({
page, page,
size: this.size,
sort: "newest", sort: "newest",
brand_ids: this.selectedBrands, brandId: this.selectedBrand || undefined,
collection_type: this.selectedCt, collection: this.selectedCt || undefined,
season: this.selectedSeason, season: this.selectedSeason || undefined,
year: this.selectedYear, year: this.selectedYear || undefined,
with_images: 6,
}) })
if (myToken !== this.reqToken) return if (myToken !== this.reqToken) return
const rows = json.data.map((item: ArticleItem) => ({ ...item, _imgLoaded: false })) const rows = json.data.map((item: ArticleItem) => ({ ...item, _imgLoaded: false }))
this.total = json.total this.lastPage = json.last_page
this.page = page this.page = page
this.items = append ? [...this.items, ...rows] : rows this.items = rows
this.markFavs() // 当前页可见 id 走服务端校验收藏态(与收藏总量解耦)
this.isEnd = page >= json.last_page || rows.length < this.size
} catch { } catch {
if (myToken !== this.reqToken) return if (myToken !== this.reqToken) return
if (!append) this.items = [] this.items = []
} finally { } finally {
if (myToken === this.reqToken) { if (myToken === this.reqToken) this.loading = false
this.loading = false
this.loadingMore = false
}
} }
}, },
// ===== 分页导航 =====
// 生成窗口化的页码列表:始终含首尾页,当前页 ±1,其余用 "..." 折叠。
get pageList() {
return buildPageList(this.lastPage, this.page)
},
goPage(p: number) {
if (this.loading) return
if (p < 1 || p > this.lastPage || p === this.page) return
this.loadPage(p)
const main = this.$el.querySelector(".showsmain")
if (main) main.scrollIntoView({ behavior: "smooth", block: "start" })
else window.scrollTo({ top: 0, behavior: "smooth" })
},
prevPage() { this.goPage(this.page - 1) },
nextPage() { this.goPage(this.page + 1) },
// 辅助辅助工具函数 // 辅助辅助工具函数
toAbsUrl(url: string) { return toAbs(url) }, toAbsUrl(url: string) { return toAbs(url) },
// 渲染文章url // 渲染文章url
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) }, itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
// 渲染thumb图片列表 // 卡片标题:后端 title 通常已含品牌(如 "Etro Spring 2024"),若再拼 brand_name 会重复
// (Etro / Etro Spring 2024)。title 已以品牌开头则只显示 title,否则 "Brand / Title"。
cardTitle(it: any) {
const t = String(it?.title || "")
const b = String(it?.brand_name || "")
if (b && t.toLowerCase().startsWith(b.toLowerCase())) return t
return b ? `${b} / ${t}` : t
},
// 渲染 thumb 图片列表(复用共享工具):固定 5 个槽位 + 末位叠加「共 N 张」遮罩。
//
// 注意参数顺序:makeCardSlots(images, count, opts) —— 第二个参数是「槽位数量」不是图片总数。
// 曾误把 it.image_count(如 31)传成 count,导致每张卡片生成 31 个槽位(大量灰色占位块)。
// 图片总数只用于末位遮罩文案,通过 moreLabel 回调传入。
getCardSlots(it: ArticleItem) { getCardSlots(it: ArticleItem) {
const gallery = it.gallery || [] const total = it.image_count || 0
const totalCount = it.image_count || 0 // 缩略图条带跳过主图(it.cover 即 gallery[0]),从第二张起,避免首张缩略图与主图重复。
const moreLabel = totalCount > 30 ? "30+" : `${totalCount || 0}+` return makeCardSlots(it.gallery || [], 5, {
const slots = [] moreLabel: () => (total > 30 ? "30+" : `${total}+`),
for (let i = 0; i < 5; i++) { })
const src = gallery[i] },
const isLast = i === 4
slots.push({ src, loaded: false, isLast, moreLabel: (isLast && totalCount > 0) ? moreLabel : "" }) // ===== 收藏(图片收藏入口)=====
// 按当前页可见 id 拉服务端收藏态:本地先即时填充,登录态异步用服务端校正。
// 复杂度仅与「一屏多少张」相关,与用户收藏总量解耦,即便 10 万收藏也常数级。
async markFavs() {
const ids = this.items.map((i: any) => String(i.id))
if (!ids.length) return
// 本地即时填充(同设备秒显)
const m: Record<string, boolean> = { ...this.favMap }
for (const f of getFavorites()) {
const id = String(f.id)
if (!(id in m)) m[id] = true
} }
return slots this.favMap = m
} if (!getUser()) return
try {
const set = await checkFavorited(ids)
const m2: Record<string, boolean> = { ...this.favMap }
for (const id of ids) m2[id] = set.has(id)
this.favMap = m2
} catch {
/* 失败维持本地态 */
}
},
favState(id: string) { return !!this.favMap[String(id)] },
toggleFav(it: any) {
const id = String(it.id)
const now = !this.favState(id)
toggleFavorite({ id, type: 'runway', title: it.title || '', cover: it.cover || '', brand: it.brand_name || '' })
this.favMap = { ...this.favMap, [id]: now }
return now
},
} }
}) })
}) })

View File

@ -0,0 +1,644 @@
---
import type { RawStreetSnap } from "@/lib/api"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes'
import { ssrBase } from "@/lib/api"
interface Props {
snap: RawStreetSnap | null
error?: "missing" | "failed" | null
preview?: boolean // 后端未登录截断图片集时返回 true,详情页据此弹「登录查看全部图片」门禁
imageTotal?: number // 图片集真实总数(后端截断前),渲染「剩余 N 张」占位遮罩
}
const { snap, error = null, preview = false, imageTotal = 0 } = Astro.props
const { locale, t } = getI18n(Astro)
const full = (u: string): string => {
if (!u) return ""
if (/^https?:\/\//i.test(u)) return u
return ssrBase().replace(/\/$/, "") + u
}
const images = (snap?.images ?? [])
.map((im) => ({
id: im.id,
url: full(im.image || ""),
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
name: im.name || "",
}))
.filter((im) => im.url)
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5
const previewImages = images.slice(0, PREVIEW_LIMIT)
// 未登录预览占位遮罩数:补足「剩余 N 张」空图,提示登录后可见,避免误以为图集只有预览上限那几张。
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
const homeHref = href(locale, ROUTES.home)
const archiveHref = href(locale, ROUTES.streetSnaps)
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
---
<div
id="street-snap-view"
x-data="streetSnapView()"
data-fav-type="snap_image"
data-gallery-uid={snap?.id ?? ""}
data-gallery-title={displayTitle}
data-gallery-brand=""
data-preview={preview ? "true" : "false"}
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
>
{
snap ? (
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
<Breadcrumb>
<span slot="current" class="max-w-[60vw] truncate">{displayTitle}</span>
</Breadcrumb>
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{displayTitle}
</h1>
{previewImages.length > 0 && (
<section class="mt-16">
<div
id="snap-gallery"
class="grid grid-cols-2 md:grid-cols-3 gap-4"
>
{previewImages.map((im, i) => (
<figure
data-image-index={i}
data-image-url={im.url}
data-image-name={im.name}
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`}
class="relative bg-[#f2f2f2] cursor-zoom-in group"
>
<img
src={im.url}
alt={im.name}
loading={i < 6 ? "eager" : "lazy"}
decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
<button
login-required
type="button"
data-fav-id={im.id}
data-fav-url={im.raw}
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
{im.name && (
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
{im.name}
</figcaption>
)}
</figure>
) )}
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
{lockedCount > 0 &&
Array.from({ length: lockedCount }).map(() => (
<figure
x-show="!isAuthed && preview"
x-cloak
login-redirect
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[3/4] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
<div class="flex flex-col items-center gap-2">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
</svg>
<span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
</div>
</div>
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 img01 等小字):
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption>
</figure>
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url">
<figure
:data-image-index="5 + i"
:data-image-url="im.url"
:data-image-name="im.name"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click="openLb(5 + i)"
class="relative bg-[#f2f2f2] cursor-zoom-in group"
>
<img
:src="im.url"
:alt="im.name"
loading="lazy"
decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
<button
login-required
type="button"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
<template x-if="im.name">
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption>
</template>
</figure>
</template>
</div>
</section>
)}
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">
{error === "missing" ? t('No street snap ID specified') : t('Failed to load street snap. Please refresh or try later.')}
</p>
<a
href={homeHref}
class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
>
← {t('Back to Index')}
</a>
</div>
)
}
{snap && previewImages.length > 0 && (
<div
x-show="lbOpen"
x-cloak
@keydown.window.escape="closeLb()"
@keydown.window.arrow-right="nextLb()"
@keydown.window.arrow-left="prevLb()"
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
aria-hidden="true"
>
<div
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 font-mono text-[12px] font-semibold tracking-[0.14em] text-[#111] bg-white/75 border border-black/10 px-3 py-1.5 rounded-[6px] backdrop-blur-md"
x-text="`${lbIndex + 1} / ${imageCount}`"
></div>
<button
login-required
type="button"
@click.stop="toggleFav(currentFavId, currentFavUrl)"
:class="isFav(currentFavId) ? 'is-fav' : ''"
class="fav-btn absolute top-[18px] right-[70px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
<button
type="button"
@click="closeLb()"
class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('Close')}
>
✕
</button>
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
<div
id="lb-thumbs"
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar"
>
{previewImages.map((im, i) => (
<button
type="button"
data-lb-index={i}
data-thumb-url={im.url}
@click={`showLb(${i})`}
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
: ''"
aria-label={`${i + 1}`}
>
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm">
{String(i + 1).padStart(2, "0")}
</span>
</button>
))}
<template x-for="(im, i) in extraImages" :key="im.url">
<button
type="button"
:data-lb-index="5 + i"
:data-thumb-url="im.url"
@click="showLb(5 + i)"
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
: ''"
:aria-label="String(5 + i + 1)"
>
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
</button>
</template>
</div>
<button
type="button"
@click.stop="prevLb()"
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
aria-label={t('Previous image')}
>
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 14l6-6 6 6" /></svg>
</button>
<button
type="button"
@click.stop="nextLb()"
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
aria-label={t('Next image')}
>
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 10l6 6 6-6" /></svg>
</button>
<button
type="button"
@click.stop="prevLb()"
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('Previous image')}
>
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6" /></svg>
</button>
<button
type="button"
@click.stop="nextLb()"
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('Next image')}
>
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M9 6l6 6 6-6" /></svg>
</button>
<div
id="lb-main"
@click.self="closeLb()"
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
x-bind:class="lbZoomed
? 'overflow-y-auto overflow-x-hidden items-start'
: 'items-center'"
>
<img
id="lb-image"
src=""
alt=""
@click="toggleZoom()"
decoding="async"
class="block select-none transition-opacity duration-200"
x-bind:class="lbZoomed
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
/>
</div>
</div>
<div
class="absolute left-0 right-0 bottom-11 z-20 text-center text-black/60 font-mono text-[11px] uppercase tracking-[0.18em] pointer-events-none px-6"
x-text="currentImageName"
></div>
<div
x-show="!lbZoomed"
class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200"
>
{t('Click image to zoom · Scroll to view · Esc to close')}
</div>
</div>
)}
</div>
<script>
import { getUser, getStreetSnapDetailAuthed, toAbs } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
document.addEventListener("alpine:init", () => {
Alpine.data("streetSnapView", () => ({
lbOpen: false,
lbIndex: 0,
lbZoomed: false,
touchStartX: 0,
touchStartY: 0,
imageCount: 0,
currentImageName: "",
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
favIds: [],
favType: "snap_image",
galleryUid: "",
galleryTitle: "",
galleryBrand: "",
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
isAuthed: false,
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; raw: string; name: string }[],
showAll: false,
init() {
this.favType = this.$el.dataset.favType || "snap_image"
this.galleryUid = this.$el.dataset.galleryUid || ""
this.galleryTitle = this.$el.dataset.galleryTitle || ""
this.galleryBrand = this.$el.dataset.galleryBrand || ""
this.preview = this.$el.dataset.preview === "true"
this.isAuthed = !!getUser()
this.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]"
).length
this.initThumbLazyLoading()
this.initImageFavs()
this.maybeLocateImageFromUrl()
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染
if (getUser() && this.preview) this.loadFull()
// 登录后(如从门禁跳登录回跳)自动补全全量
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() })
},
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。
async loadFull() {
if (!this.preview) return
try {
const full = await getStreetSnapDetailAuthed(this.galleryUid)
if (!full || !Array.isArray(full.images)) return
const mapped = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "",
url: toAbs(im.image || ""),
raw: im.image || "",
name: im.name || "",
}))
this.extraImages = mapped
this.showAll = true
// VIP:首屏前 PREVIEW_LIMIT 张为 SSR 普通样式图,已登录 VIP 替换为高清原图(同 Item.astro)。
const me = getUser()
if (me && me.tier === "vip") {
const hd = (full.images || []).slice(0, 5).map((im: any) => toAbs(im.image || ""))
hd.forEach((url: string, i: number) => {
if (!url) return
const fig = document.querySelector<HTMLElement>(`#snap-gallery [data-image-index="${i}"]`)
if (!fig) return
fig.dataset.imageUrl = url
const img = fig.querySelector("img")
if (img) img.setAttribute("src", url)
})
}
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]"
).length
this.initImageFavs()
this.initThumbLazyLoading()
this.maybeLocateImageFromUrl()
} catch {
/* 失败维持截断态,门禁仍在 */
}
},
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
maybeLocateImageFromUrl() {
if (typeof window === "undefined") return
const q = new URLSearchParams(window.location.search)
const want = q.get("img")
if (!want) return
const figures = Array.from(
document.querySelectorAll<HTMLElement>("#snap-gallery [data-fav-id]"),
)
const idx = figures.findIndex((el) => el.dataset.favId === want)
if (idx < 0) return
const fig = figures[idx] as HTMLElement
requestAnimationFrame(() => {
if (q.get("lb") === "1") {
this.openLb(idx)
return
}
fig.scrollIntoView({ behavior: "smooth", block: "center" })
fig.classList.add("locate-flash")
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
})
},
getImageElement(index: number) {
return document.querySelector<HTMLElement>(
`#snap-gallery [data-image-index="${index}"]`
)
},
getImageUrl(index: number) {
return this.getImageElement(index)?.dataset.imageUrl || ""
},
getImageName(index: number) {
return this.getImageElement(index)?.dataset.imageName || ""
},
// ===== 单图收藏 =====
getImageFavId(index: number) {
return this.getImageElement(index)?.dataset.favId || ""
},
// 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主)
getImageFavUrl(index: number) {
return this.getImageElement(index)?.dataset.favUrl || ""
},
allImageFavIds(): string[] {
return Array.from(
document.querySelectorAll<HTMLElement>("#snap-gallery [data-fav-id]"),
)
.map((el) => el.dataset.favId || "")
.filter(Boolean)
},
initImageFavs() {
const ids = this.allImageFavIds()
if (!ids.length) return
this.favIds = ids.filter((id) => isFavorited(id))
checkFavorited(ids).then((hit) => {
this.favIds = ids.filter((id) => hit.has(id))
})
},
isFav(id: string) {
return !!id && this.favIds.includes(id)
},
toggleFav(id: string, url: string) {
if (!id) return
toggleImageFavorite({
imageId: id,
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
imageUrl: url,
parentUid: this.galleryUid,
title: this.galleryTitle,
brand: this.galleryBrand,
})
this.favIds = this.isFav(id)
? this.favIds.filter((x: string) => x !== id)
: [...this.favIds, id]
},
get currentFavId() {
return this.getImageFavId(this.lbIndex)
},
get currentFavUrl() {
return this.getImageFavUrl(this.lbIndex)
},
openLb(index = 0) {
if (!this.imageCount) return
this.lbIndex = this.normalizeIndex(index)
this.lbZoomed = false
this.lbOpen = true
document.body.style.overflow = "hidden"
this.updateLightboxImage()
requestAnimationFrame(() => {
this.scrollThumbIntoView()
this.preloadAround()
})
},
closeLb() {
this.lbOpen = false
this.lbZoomed = false
document.body.style.overflow = ""
},
showLb(index: number) {
if (!this.imageCount) return
this.lbIndex = this.normalizeIndex(index)
this.lbZoomed = false
this.updateLightboxImage()
const main = document.getElementById("lb-main")
if (main) main.scrollTop = 0
requestAnimationFrame(() => {
this.scrollThumbIntoView()
this.preloadAround()
})
},
nextLb() { this.showLb(this.lbIndex + 1) },
prevLb() { this.showLb(this.lbIndex - 1) },
normalizeIndex(index: number) {
if (!this.imageCount) return 0
return ((index % this.imageCount) + this.imageCount) % this.imageCount
},
updateLightboxImage() {
const image = document.getElementById("lb-image") as HTMLImageElement | null
if (!image) return
image.src = this.getImageUrl(this.lbIndex)
image.alt = this.getImageName(this.lbIndex)
this.currentImageName = this.getImageName(this.lbIndex)
},
toggleZoom() {
this.lbZoomed = !this.lbZoomed
const main = document.getElementById("lb-main")
if (main) main.scrollTop = 0
},
scrollThumbIntoView() {
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
if (!thumbs) return
const active = thumbs.querySelector<HTMLElement>(`[data-lb-index="${this.lbIndex}"]`)
if (!active) return
const target = active.offsetTop - (thumbs.clientHeight - active.clientHeight) / 2
thumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
},
preloadAround() {
const indexes = [this.lbIndex, this.normalizeIndex(this.lbIndex - 1), this.normalizeIndex(this.lbIndex + 1)]
indexes.forEach((index: number) => {
const url = this.getImageUrl(index)
if (!url) return
const image = new Image()
image.decoding = "async"
image.src = url
})
},
initThumbLazyLoading() {
const thumbs = document.querySelectorAll<HTMLElement>("#lb-thumbs [data-thumb-url]")
if (!thumbs.length) return
if (!("IntersectionObserver" in window)) {
thumbs.forEach((thumb) => { thumb.style.backgroundImage = `url("${thumb.dataset.thumbUrl}")` })
return
}
const observer = new IntersectionObserver(
(entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const element = entry.target as HTMLElement
const url = element.dataset.thumbUrl
if (url) element.style.backgroundImage = `url("${url}")`
obs.unobserve(element)
})
},
{ root: document.getElementById("lb-thumbs"), rootMargin: "300px" }
)
thumbs.forEach((thumb) => observer.observe(thumb))
},
}))
})
</script>
<style>
[x-cloak] { display: none !important; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* 单图收藏爱心:默认描边,已收藏实心 */
.fav-btn svg { fill: none; }
.fav-btn.is-fav svg { fill: currentColor; }
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
.locate-flash {
outline: 3px solid #111;
outline-offset: 4px;
}
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
@media (hover: none) {
.fav-btn { opacity: 1 !important; }
}
</style>

View File

@ -0,0 +1,405 @@
---
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from '@/i18n/utils'
import { clientHref, ROUTES } from '@/lib/routes'
import { toAbs } from '@/lib/api'
import "@/styles/look-grid.css"
const { locale, t } = getI18n(Astro)
// 年份筛选:近 10 年(含当年),末项兜底
const currentYear = new Date().getFullYear()
const yearFloor = currentYear - 9
const yearOptions: { value: number | string; label: string }[] = []
for (let y = currentYear; y >= yearFloor; y--) {
yearOptions.push({ value: y, label: String(y) })
}
yearOptions.push({ value: `before${yearFloor}`, label: `${yearFloor}年前` })
// 排序选项:image_count 默认(按图片数降序)/ year_desc / year_asc
const sortOptions: { value: string; label: string }[] = [
{ value: "image_count", label: t('Most photos') },
{ value: "year_desc", label: t('Newest year') },
{ value: "year_asc", label: t('Oldest year') },
]
// 地区筛选:值即后端 street_snap.city 列存储的规范英文名(与 backfill_city 词典保持一致)。
// 标签用 t(city) —— 英文环境回退显示英文(巴黎→Paris),中文环境显示中文(Paris→巴黎)。
const cityOptions: { value: string; label: string }[] = [
{ value: "Paris", label: t('Paris') },
{ value: "New York", label: t('New York') },
{ value: "Milan", label: t('Milan') },
{ value: "London", label: t('London') },
{ value: "Copenhagen", label: t('Copenhagen') },
{ value: "Florence", label: t('Florence') },
{ value: "Berlin", label: t('Berlin') },
{ value: "Tokyo", label: t('Tokyo') },
{ value: "Seoul", label: t('Seoul') },
{ value: "Shanghai", label: t('Shanghai') },
{ value: "Beijing", label: t('Beijing') },
{ value: "Rome", label: t('Rome') },
{ value: "Madrid", label: t('Madrid') },
{ value: "Amsterdam", label: t('Amsterdam') },
{ value: "Antwerp", label: t('Antwerp') },
{ value: "Los Angeles", label: t('Los Angeles') },
{ value: "Moscow", label: t('Moscow') },
{ value: "Mumbai", label: t('Mumbai') },
]
---
<div class="snapsroot w-full" x-data="streetSnapsPage()" data-locale={locale}>
<!-- 顶部:极简眉头 -->
<header class="snapshead">
<div>
<Breadcrumb>
<span slot="current">{t('Street Style')}</span>
</Breadcrumb>
<h1 class="snapsh1">{t('All Street Snaps')}</h1>
</div>
</header>
<!-- 已激活筛选 pills -->
<div class="snapspills" x-show="pills.length > 0" x-cloak>
<template x-for="(p, i) in pills" :key="i">
<span class="snapspill">
<span x-text="p.label"></span>
<button class="x" type="button" @click="p.clear()">×</button>
</span>
</template>
</div>
<!-- 主体:左 sidebar(REFINE 筛选) + 右卡片网格 -->
<div class="snapsbody">
<!-- LEFT SIDEBAR -->
<aside class="snapsaside">
<div class="snapsaside-head">
<h2 class="snapsaside-h">REFINE</h2>
</div>
<div class="snapsaside-body">
<!-- Year -->
<section class="snapsg">
<header class="snapsg-h" @click="toggleGroup('year')">
<span>{t('Year')}</span>
<span class="snapsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="snapsg-b" x-show="!collapsedGroups.year">
<ul class="snapschecks snapschecks--flat">
{yearOptions.map((o) => (
<li><label login-required><input type="radio" name="snapYear" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<!-- Sort -->
<section class="snapsg">
<header class="snapsg-h" @click="toggleGroup('sort')">
<span>{t('Sort')}</span>
<span class="snapsg-chev" :class="{ 'is-collapsed': collapsedGroups.sort }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="snapsg-b" x-show="!collapsedGroups.sort">
<ul class="snapschecks snapschecks--flat">
{sortOptions.map((o) => (
<li><label login-required><input type="radio" name="snapSort" value={o.value} :checked={'selectedSort === "' + o.value + '"'} :class={`{'is-checked': selectedSort === '${o.value}'}`} @click.prevent={'selectedSort = selectedSort === "' + o.value + '" ? "image_count" : "' + o.value + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<!-- City / 地区 -->
<section class="snapsg">
<header class="snapsg-h" @click="toggleGroup('city')">
<span>{t('City')}</span>
<span class="snapsg-chev" :class="{ 'is-collapsed': collapsedGroups.city }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="snapsg-b" x-show="!collapsedGroups.city">
<ul class="snapschecks snapschecks--flat">
{cityOptions.map((o) => (
<li><label login-required><input type="radio" name="snapCity" value={o.value} :checked={'selectedCity === "' + o.value + '"'} :class={`{'is-checked': selectedCity === '${o.value}'}`} @click.prevent={'selectedCity = selectedCity === "' + o.value + '" ? "" : "' + o.value + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<button login-required class="snapsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
</div>
</aside>
<!-- MAIN:卡片网格(左封面 + 右标题 + 缩略图条带) -->
<main class="snapsmain">
<div class="snapscontent" :class="{ 'is-loading': loading }">
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝尺寸抖动 -->
<template x-if="loading && items.length === 0">
<div class="snapsgrid" aria-hidden="true">
<template x-for="i in 4" :key="'skel-'+i">
<div class="snapscard snapscard--skel">
<div class="snapscard-left">
<div class="snapscard-img"></div>
</div>
<div class="snapscard-right">
<div class="snapscard-info">
<div class="snapsline h-[41px] mb-[10px] rounded-[2px]"></div>
<div class="snapsline h-[29px] mb-[14px] rounded-[2px]"></div>
</div>
<div class="snapscard-strip">
<div class="snapscard-thumb"></div>
<div class="snapscard-thumb"></div>
<div class="snapscard-thumb"></div>
<div class="snapscard-thumb"></div>
<div class="snapscard-thumb"></div>
</div>
</div>
</div>
</template>
</div>
</template>
<!-- Empty -->
<template x-if="!loading && items.length === 0">
<div class="snapsempty">No street snaps match the current filters</div>
</template>
<!-- Card List -->
<template x-if="!loading && items.length > 0">
<div class="snapsgrid">
<template x-for="it in items" :key="it.id">
<div class="showscard-wrap relative group">
<a class="snapscard" :href="itemHref(it)" :aria-label="cardTitle(it)">
<div class="snapscard-left">
<div class="snapscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
<img :src="toAbsUrl(it.cover)" :alt="cardTitle(it)" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
</div>
</div>
<div class="snapscard-right">
<div class="snapscard-info">
<h3 class="snapscard-title" x-text="titleParts(it)[0]"></h3>
<p class="snapscard-meta" x-text="titleParts(it)[1]"></p>
</div>
<div class="snapscard-strip">
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
<div class="snapscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
<span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG" x-show="slot.src"></span>
<img x-show="slot.src" :src="toAbsUrl(slot.src)" alt="" loading="lazy" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
</div>
</template>
</div>
</div>
</a>
<button login-required type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
</button>
</div>
</template>
</div>
</template>
</div>
<nav class="snappager" x-show="lastPage > 1" aria-label="Pagination">
<button login-required class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
<template x-for="(p, i) in pageList" :key="i">
<span class="pg-wrap">
<span class="pg-ellipsis" x-show="p === '...'" x-text="'…'"></span>
<button login-required class="pg" type="button" x-show="p !== '...'" :class="{ 'is-current': p === page }" :disabled="p === page" @click="goPage(p)" x-text="p"></button>
</span>
</template>
<button login-required class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
</nav>
</main>
</div>
</div>
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
<script>
import { getStreetSnaps, toAbs, getUser, type StreetSnapItem } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
const DEFAULT_SORT = "image_count"
document.addEventListener("alpine:init", () => {
Alpine.data("streetSnapsPage", () => {
const optNode = document.getElementById("snaps-year-opts")
const opts = optNode?.textContent ? JSON.parse(optNode.textContent) : { yearOptions: [], sortOptions: [] }
return {
locale: '',
SPIN_SVG,
yearOptions: opts.yearOptions || [],
sortOptions: opts.sortOptions || [],
cityOptions: opts.cityOptions || [],
selectedYear: '' as string,
selectedSort: DEFAULT_SORT as string,
selectedCity: '' as string,
// 侧栏分组折叠状态:默认全部收起(不做持久化,每次进页都是关着的)
collapsedGroups: { year: true, sort: true, city: true } as Record<string, boolean>,
items: [] as (StreetSnapItem & { _imgLoaded?: boolean })[],
loading: false,
page: 1,
lastPage: 1,
reqToken: 0,
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
favMap: {} as Record<string, boolean>,
init() {
this.locale = this.$el.dataset.locale || 'en'
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
const city = new URLSearchParams(window.location.search).get('city')
const allowed = (this.cityOptions || []).map((c: any) => c.value)
if (city && allowed.includes(city)) this.selectedCity = city
// 首页公开:未登录也直接加载首屏,不弹登录框
// 收藏态在 loadPage 内按当前页校正(markFavs),与收藏总量解耦,10万也常数级
this.loadPage(1)
// 浏览历史:改为「点开文章详情」时记录(见 item 详情页),列表页不再记。
},
// ===== 侧栏筛选 =====
toggleGroup(key: string) {
this.collapsedGroups[key] = !this.collapsedGroups[key]
},
clearAll() {
this.selectedYear = ''
this.selectedSort = DEFAULT_SORT
this.selectedCity = ''
this.loadPage(1)
},
// 已激活筛选 chips(顶部 pills + 侧栏 Clear all 的可见性都依赖它)
get pills() {
const out: { label: string; clear: () => void }[] = []
if (this.selectedYear) {
const hit = this.yearOptions.find((o: any) => String(o.value) === String(this.selectedYear))
out.push({
label: "YEAR · " + (hit ? hit.label : this.selectedYear),
clear: () => { this.selectedYear = ''; this.loadPage(1) },
})
}
if (this.selectedSort !== DEFAULT_SORT) {
const hit = this.sortOptions.find((o: any) => o.value === this.selectedSort)
out.push({
label: "SORT · " + (hit ? hit.label : this.selectedSort),
clear: () => { this.selectedSort = DEFAULT_SORT; this.loadPage(1) },
})
}
if (this.selectedCity) {
const hit = this.cityOptions.find((o: any) => o.value === this.selectedCity)
out.push({
label: "CITY · " + (hit ? hit.label : this.selectedCity),
clear: () => { this.selectedCity = ''; this.loadPage(1) },
})
}
return out
},
// 卡片右侧缩略图条带:固定 5 个槽位,缺图留 placeholder
// 街拍 images 是 { image, name }[](与 runway 的 string[] gallery 不同),先抽成纯 URL 数组
getCardSlots(it: StreetSnapItem) {
// 缩略图条带跳过主图(it.cover 即 images[0]),从第二张起,避免首张缩略图与主图重复。
const imgs = (it.images || []).map((im: any) => (im && (im.image || im)) || "")
return makeCardSlots(imgs)
},
async loadPage(page: number) {
const myToken = ++this.reqToken
this.loading = true
this.items = []
try {
const json = await getStreetSnaps({
page,
year: this.selectedYear || undefined,
city: this.selectedCity || undefined,
sort: this.selectedSort,
})
if (myToken !== this.reqToken) return
const rows = json.data.map((item: StreetSnapItem) => ({ ...item, _imgLoaded: false }))
this.lastPage = json.last_page
this.page = page
this.items = rows
this.markFavs() // 当前页可见 id 走服务端校验收藏态(与收藏总量解耦)
} catch {
if (myToken !== this.reqToken) return
this.items = []
} finally {
if (myToken === this.reqToken) this.loading = false
}
},
get pageList() {
return buildPageList(this.lastPage, this.page)
},
goPage(p: number) {
if (this.loading) return
if (p < 1 || p > this.lastPage || p === this.page) return
this.loadPage(p)
const main = this.$el.querySelector(".snapsmain")
if (main) main.scrollIntoView({ behavior: "smooth", block: "start" })
else window.scrollTo({ top: 0, behavior: "smooth" })
},
prevPage() { this.goPage(this.page - 1) },
nextPage() { this.goPage(this.page + 1) },
toAbsUrl(url: string) { return toAbs(url) },
// 街拍详情与走秀统一收口 /item/[id],前端按编码前缀 r/s 分流(见后端 hashid.EncodeTyped)
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
// theimpression 标题含字面 \t:地点 \t 场次,拆成 [location, event]
titleParts(it: StreetSnapItem): string[] {
const parts = (it.title || "").split("\t")
return [parts[0] || it.title || "", parts.slice(1).join(" ").trim()]
},
cardTitle(it: StreetSnapItem): string {
return (it.title || "").replace(/\t/g, " · ")
},
// ===== 收藏(图片收藏入口)=====
// 按当前页可见 id 拉服务端收藏态:本地先即时填充,登录态异步用服务端校正。
// 复杂度仅与「一屏多少张」相关,与用户收藏总量解耦,即便 10 万收藏也常数级。
async markFavs() {
const ids = this.items.map((i: any) => String(i.id))
if (!ids.length) return
// 本地即时填充(同设备秒显)
const m: Record<string, boolean> = { ...this.favMap }
for (const f of getFavorites()) {
const id = String(f.id)
if (!(id in m)) m[id] = true
}
this.favMap = m
if (!getUser()) return
try {
const set = await checkFavorited(ids)
const m2: Record<string, boolean> = { ...this.favMap }
for (const id of ids) m2[id] = set.has(id)
this.favMap = m2
} catch {
/* 失败维持本地态 */
}
},
favState(id: string) { return !!this.favMap[String(id)] },
toggleFav(it: any) {
const id = String(it.id)
const now = !this.favState(id)
toggleFavorite({ id, type: 'street', title: it.title || '', cover: it.cover || '', brand: (it as any).brand || '' })
this.favMap = { ...this.favMap, [id]: now }
return now
},
}
})
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,470 +0,0 @@
---
import { getReports, fmtDate } from "@/lib/data"
import { href, ROUTES } from '@/lib/routes'
import { getI18n } from "@/i18n/utils"
// 文章展示创意布局页:复用你现有的 Layout 外壳。
// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts);
// 后端不可达时回落到内置示例,非对称创意布局结构完全不变。
const reports = await getReports(30)
const fmt = fmtDate
const { locale } = getI18n(Astro)
const itemBase = href(locale, "/items")
---
<div class="archive" data-item-base={itemBase}>
<!-- 页头(极简黑白编辑风,与首页 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={href(locale, ROUTES.home)} 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={href(locale, ROUTES.item(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>
<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; }
/* ---------- 视图 1:Grid(瀑布流,非对称高低错落) ---------- */
.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; } }
/* ---------- 视图 2:Magazine(非对称网格,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; }
}
/* ---------- 视图 3:Timeline(时间轴,左侧日期 + 缩略图 + 正文) ---------- */
.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>
import { fetchArticlesList, toAbsPublic } from "@/lib/api"
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"))
// 系列类型 / 季节筛选状态(对应 #typefilter / #seasonfilter 按钮;仅后端接口分支使用)
let fType = ""
let fSeason = ""
const itemBase = archive?.dataset.itemBase || "/items"
// 来自品牌索引的聚焦筛选(/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="${itemBase}/${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 () => {
try {
const rows = await fetchArticlesList({
size: _brandId ? 100 : 30,
collection_type: fType || undefined,
season: fSeason || undefined,
brand_id: _brandId || undefined,
})
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: toAbsPublic(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>

View File

@ -19,12 +19,3 @@ export const LOCALE_NAMES: Record<string, { name: string; nativeName: string }>
nativeName: 'English', nativeName: 'English',
}, },
} as const; } as const;
/** 浏览器 Accept-Language / navigator.language → 本项目支持的语言。 */
export const BROWSER_LOCALE_MAP: Record<string, Locale> = {
'zh': 'cn',
'zh-cn': 'cn',
'zh-tw': 'cn',
'zh-hk': 'cn',
'en': 'en',
};

View File

@ -21,26 +21,99 @@ export const dict: Record<string, TranslationEntry> = {
// ── 导航 / Navigation ── // ── 导航 / Navigation ──
'about': { cn: '关于' },
'全部的 runway': { cn: '全部的 runway' }, '全部的 runway': { cn: '全部的 runway' },
'contact': { cn: '联系' },
'contact us': { cn: '联系我们' },
'home': { cn: '首页' }, 'home': { cn: '首页' },
'latest projects': { cn: '最新项目' },
'login': { cn: '登录' }, 'login': { cn: '登录' },
'portfolio': { cn: '作品集' }, 'portfolio': { cn: '作品集' },
'runway archive': { cn: '走秀档案' }, 'runway archive': { cn: '走秀档案' },
// ── Runway 子分类(header 下拉,与列表页 COLLECTION 筛选一致)──
'ready-to-wear': { cn: '女装成衣' },
'menswear': { cn: '男装' },
'couture': { cn: '高级定制' },
'resort': { cn: '度假系列' },
'pre-fall': { cn: '早秋系列' },
'signup': { cn: '注册' }, 'signup': { cn: '注册' },
// ── 账号 / Account ──
'hi': { cn: '你好' },
'password': { cn: '密码' },
'account': { cn: '账户' },
'sign out': { cn: '退出登录' },
'my account': { cn: '我的账户' },
'username or email': { cn: '用户名或邮箱' },
'login failed': { cn: '登录失败,请检查账号或密码' },
'please log in': { cn: '请先登录' },
'login to continue': { cn: '登录以继续使用筛选与翻页' },
'go to full login': { cn: '前往完整登录页' },
'login prompt': { cn: '登录后可筛选与翻页浏览全部档案' },
'login to view': { cn: '登录查看' },
'photos': { cn: '张照片' },
'members': { cn: '会员专区' },
'back to site': { cn: '返回网站' },
'email': { cn: '邮箱' },
'my collections': { cn: '我的收藏' },
'my history': { cn: '浏览历史' },
'coming soon': { cn: '敬请期待' },
// ── 账户中心 / Account Center(企业级个人中心)──
'overview': { cn: '概览' },
'sessions & security': { cn: '会话与安全' },
'settings': { cn: '设置' },
'saved looks': { cn: '收藏造型' },
'active sessions': { cn: '活跃会话' },
'member since': { cn: '加入于' },
'role': { cn: '角色' },
'internal member': { cn: '内部成员' },
'items': { cn: '条' },
'account overview': { cn: '账户概览' },
'account details': { cn: '账户详情' },
'username': { cn: '用户名' },
'edit profile': { cn: '编辑资料' },
'view archive': { cn: '浏览档案' },
'no collections yet': { cn: '暂无收藏内容' },
'no history yet': { cn: '暂无浏览记录' },
'browse the archive': { cn: '去浏览档案' },
'current session': { cn: '当前会话' },
'this device': { cn: '此设备' },
'last active': { cn: '最近活跃' },
'sign out all devices': { cn: '退出所有设备' },
'single session note': { cn: '为保障账号安全,同一账号仅允许在一处登录,新登录会顶替旧会话。' },
'preferences': { cn: '偏好设置' },
'appearance': { cn: '外观' },
'notifications': { cn: '通知' },
'soon': { cn: '即将推出' },
'quick stats': { cn: '数据概览' },
'now': { cn: '刚刚' },
'member profile': { cn: '会员资料' },
'vip': { cn: '高级会员' },
'save': { cn: '收藏' },
'saved': { cn: '已收藏' },
'single image': { cn: '单图' },
'galleries': { cn: '图集' },
'images': { cn: '单图' },
'remove': { cn: '移除' },
'cancel': { cn: '取消' },
'save changes': { cn: '保存修改' },
'display name': { cn: '昵称' },
'bio': { cn: '简介' },
'clear history': { cn: '清空历史' },
'no saved looks yet': { cn: '暂无收藏造型' },
'no history yet': { cn: '暂无浏览记录' },
'all': { cn: '全部' },
'image': { cn: '图片' },
'gallery': { cn: '图集' },
'viewed gallery': { cn: '浏览的图集' },
'light': { cn: '浅色' },
'dark': { cn: '深色' },
'on': { cn: '开' },
'off': { cn: '关' },
// ── 控件 / Controls ── // ── 控件 / Controls ──
'back to index': { cn: '返回首页' }, 'back to index': { cn: '返回首页' },
@ -96,8 +169,40 @@ export const dict: Record<string, TranslationEntry> = {
'language': { cn: '语言' }, 'language': { cn: '语言' },
'runway looks': { cn: '秀场造型' } 'runway looks': { cn: '秀场造型' },
// ── 街拍 / Street Snaps ──
'street style': { cn: '街拍' },
'street snaps': { cn: '街拍' },
'all street snaps': { cn: '全部街拍' },
'no street snap id specified': { cn: '未指定街拍 ID' },
'failed to load street snap. please refresh or try later.': { cn: '加载街拍失败,请刷新或稍后再试' },
// ── 地区 / Cities(街拍按城市分类,值对齐后端 street_snap.city 列;translate 会小写化 key)──
'city': { cn: '地区' },
'paris': { cn: '巴黎' },
'new york': { cn: '纽约' },
'milan': { cn: '米兰' },
'london': { cn: '伦敦' },
'copenhagen': { cn: '哥本哈根' },
'florence': { cn: '佛罗伦萨' },
'berlin': { cn: '柏林' },
'tokyo': { cn: '东京' },
'seoul': { cn: '首尔' },
'shanghai': { cn: '上海' },
'beijing': { cn: '北京' },
'rome': { cn: '罗马' },
'madrid': { cn: '马德里' },
'amsterdam': { cn: '阿姆斯特丹' },
'antwerp': { cn: '安特卫普' },
'los angeles': { cn: '洛杉矶' },
'moscow': { cn: '莫斯科' },
'mumbai': { cn: '孟买' },
}; };
/** 把 {name} 形式的占位符替换成参数值。 */ /** 把 {name} 形式的占位符替换成参数值。 */

View File

@ -1,14 +1,14 @@
--- ---
import '@/styles/global.css' import '@/styles/global.css'
import LoadingScreen from '@/components/LoadingScreen.astro'
import AuthModal from '@/components/AuthModal.astro'
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
import { getI18n } from '@/i18n/utils' import { getI18n } from '@/i18n/utils'
import { LOCALES, LOCALE_NAMES } from '@/i18n/config' import { LOCALE_NAMES } from '@/i18n/config'
import { setApiLocale } from '@/lib/api' import { setApiLocale } from '@/lib/api'
import { i18n } from 'astro:config/client'; import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n'; import { toCodes } from 'astro:i18n';
import LanguageDetector from '@/components/LanguageDetector.astro'
const { title } = Astro.props as { title?: string } const { title } = Astro.props as { title?: string }
const siteTitle = title ? `${title} — Portfolio Studio` : "Portfolio Studio" const siteTitle = title ? `${title} — Portfolio Studio` : "Portfolio Studio"
@ -19,8 +19,7 @@ const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
setApiLocale(locale) setApiLocale(locale)
// 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性 // 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性
const hiLabel = t('Hi') // (账号区现改为 Alpine 组件,译文直接在模板内用 t() 输出,无需 data 属性中转)
const logoutLabel = t('Logout')
const pathName = Astro.url.pathname; const pathName = Astro.url.pathname;
@ -32,16 +31,23 @@ const pathWithoutLocale = locales.includes(pathName.split('/')[1])
// 顶部导航数据模型(替代原 Alpine x-data 的 menu/state)。 // 顶部导航数据模型(替代原 Alpine x-data 的 menu/state)。
// 下拉菜单与语言列表均由此驱动渲染,逻辑用 Astro 原生 <script> 控制开合。 // 下拉菜单与语言列表均由此驱动渲染,逻辑用 Astro 原生 <script> 控制开合。
const headers = { const headers = {
// 走秀下拉菜单项(href 统一指向 /shows) // 走秀下拉菜单项:方案 A —— 全部 + 4 个真实 collection_type 分类(与列表页 COLLECTION 筛选一致),
// 深链带 ?collection= 参数,由 RunwayLooks 的 init() 读取并自动选中筛选。
runways: [ runways: [
{ label: t('全部的 runway'), href: href(locale, ROUTES.runwayLooks), current: true }, { label: t('全部的 runway'), href: href(locale, ROUTES.runwayLooks) },
{ label: 'Spring / Summer 2026', href: href(locale, ROUTES.shows) }, { label: t('Ready-to-Wear'), href: href(locale, ROUTES.runwayLooks) + '?collection=rtw' },
{ label: 'Fall / Winter 2025', href: href(locale, ROUTES.shows) }, { label: t('Menswear'), href: href(locale, ROUTES.runwayLooks) + '?collection=menswear' },
{ label: 'Resort 2026', href: href(locale, ROUTES.shows) }, { label: t('Couture'), href: href(locale, ROUTES.runwayLooks) + '?collection=couture' },
{ label: 'Pre-Fall 2025', href: href(locale, ROUTES.shows) }, { label: t('Resort'), href: href(locale, ROUTES.runwayLooks) + '?collection=resort' },
{ label: 'Couture FW25', href: href(locale, ROUTES.shows) }, ],
{ label: 'Menswear SS26', href: href(locale, ROUTES.shows) }, // 街拍下拉菜单项:按地区分类(值即后端 street_snap.city 列存储的规范英文名),
{ label: 'Menswear FW25', href: href(locale, ROUTES.shows) }, // 深链带 ?city= 参数,由 StreetSnaps 的 init() 读取并自动选中筛选。
// 仅保留 4 项(全部 + 三座核心时尚之都),完整城市列表在街拍页侧栏 City 筛选里。
streets: [
{ label: t('All Street Snaps'), href: href(locale, ROUTES.streetSnaps) },
{ label: t('Paris'), href: href(locale, ROUTES.streetSnaps) + '?city=Paris' },
{ label: t('New York'), href: href(locale, ROUTES.streetSnaps) + '?city=New York' },
{ label: t('Milan'), href: href(locale, ROUTES.streetSnaps) + '?city=Milan' },
], ],
// 语言列表(code + 本地名称) // 语言列表(code + 本地名称)
languages: locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName })), languages: locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName })),
@ -50,7 +56,6 @@ const headers = {
<!doctype html> <!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}> <html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head> <head>
<!-- TODO <LanguageDetector /> -->
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
@ -59,7 +64,10 @@ const headers = {
<title>{siteTitle}</title> <title>{siteTitle}</title>
</head> </head>
<body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans"> <body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans">
<!-- 加载动画屏:独立组件,标记/样式/隐藏逻辑封装在 LoadingScreen.astro -->
<LoadingScreen />
<!-- 顶部主导航栏 --> <!-- 顶部主导航栏 -->
<nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase"> <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"> <div class="flex flex-wrap items-center gap-6 md:gap-8">
@ -72,12 +80,12 @@ const headers = {
<!-- 全部的 runway:菜单项用 Astro 渲染(headers.runways.map),开合逻辑用 Alpine.js --> <!-- 全部的 runway:菜单项用 Astro 渲染(headers.runways.map),开合逻辑用 Alpine.js -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false"> <div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<a href={href(locale, ROUTES.shows)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('全部的 runway')}</a> <a href={href(locale, ROUTES.runwayLooks)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('全部的 runway')}</a>
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 runway 列表" aria-haspopup="true" :aria-expanded="open"> <button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 runway 列表" aria-haspopup="true" :aria-expanded="open">
<svg class="w-2.5 h-2.5 transition-transform duration-200" :class="open ? 'rotate-180' : ''" viewBox="0 0 10 6" fill="none"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> <svg class="w-2.5 h-2.5 transition-transform duration-200" :class="open ? 'rotate-180' : ''" viewBox="0 0 10 6" fill="none"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button> </button>
</div> </div>
<div x-show="open" x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1"> <div x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
{ {
headers.runways.map((item) => ( headers.runways.map((item) => (
<a href={item.href} class="block px-4 py-2 text-[12px] uppercase tracking-[0.4px] hover:bg-black/5 transition-colors"> <a href={item.href} class="block px-4 py-2 text-[12px] uppercase tracking-[0.4px] hover:bg-black/5 transition-colors">
@ -87,9 +95,24 @@ const headers = {
} }
</div> </div>
</div> </div>
<a href={href(locale, ROUTES.latestProjects)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Latest Projects')}</a> <!-- 街拍:菜单项用 Astro 渲染(headers.streets.map),开合逻辑用 Alpine.js -->
<a href={href(locale, ROUTES.about)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('About')}</a> <div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href={href(locale, ROUTES.contact)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Contact Us')}</a> <div class="flex items-center gap-1">
<a href={href(locale, ROUTES.streetSnaps)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Street Style')}</a>
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 street style 列表" aria-haspopup="true" :aria-expanded="open">
<svg viewBox="0 0 12 12" width="10" height="10"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<div x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
{
headers.streets.map((item) => (
<a href={item.href} class="block px-4 py-2 text-[12px] uppercase tracking-[0.4px] hover:bg-black/5 transition-colors">
{item.label}
</a>
))
}
</div>
</div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -104,7 +127,7 @@ const headers = {
<span>{LOCALE_NAMES[locale].nativeName}</span> <span>{LOCALE_NAMES[locale].nativeName}</span>
</button> </button>
<ul <ul
x-show="open" x-show="open" x-cloak
x-transition.opacity.duration.150ms x-transition.opacity.duration.150ms
class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2" class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2"
> >
@ -122,11 +145,29 @@ const headers = {
))} ))}
</ul> </ul>
</div> </div>
<div class="flex items-center gap-2" id="auth-slot" data-hi-label={hiLabel} data-logout-label={logoutLabel}> <div class="flex items-center gap-3" x-data="authMenu">
<a href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200">{t('Login')}</a> <!-- 未登录:Login 链接(Alpine 登录后置为隐藏) -->
<a href={href(locale, ROUTES.register)} class="hover:opacity-60 transition-opacity duration-200">{t('Signup')}</a> <a x-show="!user" x-cloak href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200 uppercase tracking-[0.5px] text-[13px]">{t('Login')}</a>
<!-- 已登录:账号下拉(hover 展开,与语言切换器同款交互) -->
<div x-show="user" x-cloak class="relative" @mouseenter="open = true" @mouseleave="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-haspopup="menu" :aria-expanded="open">
<span x-text="shortName"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="acct-chevron w-[14px] h-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<ul x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2" role="menu">
<li role="none">
<a href={href(locale, ROUTES.account)} @click="open = false" role="menuitem" class="block px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors opacity-60">{t('my account')}</a>
</li>
<li role="none">
<button type="button" @click="doLogout()" role="menuitem" class="block w-full text-left px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors opacity-60 bg-transparent border-0 cursor-pointer font-normal">{t('sign out')}</button>
</li>
</ul>
</div> </div>
</div> </div>
</div>
</nav> </nav>
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col">
@ -135,19 +176,36 @@ const headers = {
<footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans"> <footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans">
<script> <script>
import { getUser, fetchMe, clearSession } from "@/lib/api" import { getUser, fetchMe, logout } from "@/lib/api"
const slot = document.getElementById("auth-slot")
const hiLabel = slot?.dataset.hiLabel ?? "Hi" document.addEventListener("alpine:init", () => {
const logoutLabel = slot?.dataset.logoutLabel ?? "Logout" Alpine.data("authMenu", () => ({
function render(user) { user: null,
if (!user) return // 未登录:保留 SSR 渲染的 Login/Signup(已带语言前缀,无需重写) open: false,
slot.innerHTML = `<span class="opacity-70">${hiLabel}, ${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]">${logoutLabel}</button>`
const btn = slot.querySelector("#logout-btn") init() {
if (btn) btn.addEventListener("click", () => { clearSession(); location.reload() }) this.user = getUser() // 立即恢复缓存,避免登录态闪烁
}
const cached = getUser() fetchMe()
if (cached) render(cached) .then((u) => {
fetchMe().then((user) => render(user)).catch(() => {}) this.user = u ?? this.user ?? null
})
.catch(() => {
this.user = getUser() ?? null
})
},
// 用户名最多展示前 8 个字符,超出加省略号
get shortName() {
const n = (this.user && this.user.username) || ""
return n.length > 8 ? n.slice(0, 8) + "…" : n
},
doLogout() {
logout().finally(() => location.reload())
},
}))
})
</script> </script>
{/* Top Footer Grid */} {/* Top Footer Grid */}
<div class="grid grid-cols-1 md:grid-cols-12 gap-8 mb-10 pb-10 border-b border-zinc-200"> <div class="grid grid-cols-1 md:grid-cols-12 gap-8 mb-10 pb-10 border-b border-zinc-200">
@ -183,7 +241,10 @@ const headers = {
<div class="mt-6 text-[11px] font-mono text-zinc-400 uppercase tracking-widest"> <div class="mt-6 text-[11px] font-mono text-zinc-400 uppercase tracking-widest">
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')} {t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
</div> </div>
</footer> </footer>
{/* 全局登录门禁层(login-required 属性 + 统一登录浮层) */}
<AuthModal />
</body> </body>
</html> </html>

View File

@ -1,19 +1,18 @@
// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口) // src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口)
// //
// 集中封装全部 /api/v1 调用:公开查询(/api/v1/public/*)、账号体系(/api/v1/auth/*)、 // 集中封装全部 /api/v1 调用:公开查询(/api/v1/public/*)、账号体系(/api/v1/auth/*)、
// 以及构建期专用的 SSG 接口(/api/v1/ssg/*,仅在 Node 构建期使用)。 // 以及构建期专用的 SSG 接口(/api/internal/ssg/*,内部契约,仅 Node 构建期使用)。
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。 // 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。
// //
// base URL 解析规则(全项目统一两个基准地址,命名见下): // base URL 解析规则(全项目统一两个基准地址,命名见下):
// BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。 // BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。
// 空值 = 同源相对 /api;DEV 构建期固定走线上域名。 // 空值 = 同源相对 /api;DEV 构建期固定走线上域名。
// BASE_API_SSG —— 构建期 Node 调 /api/ssg/* 的「内部端口」(默认 8091), // BASE_API_SSG —— 构建期 Node 调 /api/internal/ssg/* 的「内部端口」(默认 8091),
// 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。 // 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。
// //
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。 // - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
// - publicBase():客户端运行期 base,仅 BASE_API(空 → 同源相对 /api)。
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。 // - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
// - ssgBase():构建期 Node 调 /api/ssg/*,只读 BASE_API_SSG。 // - ssgBase():构建期 Node 调 /api/internal/ssg/*,只读 BASE_API_SSG。
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。 // 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
import "@/lib/http" import "@/lib/http"
@ -34,7 +33,6 @@ export interface BrandEntry {
name: string // 英文名(name_en 回落 name) name: string // 英文名(name_en 回落 name)
nameCn?: string // 中文名(name_cn,可能为空) nameCn?: string // 中文名(name_cn,可能为空)
display?: string // 展示名:中文语境下为 "英文名 (中文名)",否则为英文名 display?: string // 展示名:中文语境下为 "英文名 (中文名)",否则为英文名
article_count: number
} }
export interface ArticleItem { export interface ArticleItem {
@ -43,56 +41,43 @@ export interface ArticleItem {
title: string title: string
cover: string cover: string
brand_name: string brand_name: string
year: number image_count: number // 该走秀图片总数(列表卡片 "N+" 角标用)
image_count: number gallery: string[] // 由 images 映射而来的附图 URL 列表
published_at: number
collection_type?: string
season?: string
season_code?: string
gallery: string[]
} }
export interface ArticleQuery { export interface ArticleQuery {
page?: number page?: number
size?: number
sort?: string sort?: string
brand_ids?: string[] // 编码串数组,透传给后端解码 brandId?: string // 编码串;传入则附加 ?brand_id 查询参数
collection_type?: string[] collection?: string // 单值:rtw/menswear/couture/resort/pre_fall
season?: string[] season?: string // 单值:spring/fall
year?: string[] year?: string | number
with_images?: number
} }
export interface ArticlePage { export interface ArticlePage {
data: ArticleItem[] data: ArticleItem[]
total: number
last_page: number last_page: number
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
} }
// 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。 // 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。
export type RawArticle = { export type RawArticle = {
id: string | number // 后端为 hashid 编码串(string);示例数据回落为数字 id: string | number // 后端为 hashid 编码串(string)
title: string title: string
summary?: string description?: string // 详情正文(仅详情接口返回)
description?: string
cover: string cover: string
brand_name: string brand_name: string
brand_id?: string | number // hashid 编码串 brand_id?: string | number // hashid 编码串
year: number image_count?: number // 列表卡片 "N+" 角标用(仅列表接口返回)
image_count: number images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用
published_at: number
created_at?: number
source_url?: string
collection_type?: string
season?: string
season_code?: string
images?: { id?: number; image: string; name?: string }[]
} }
export interface AuthUser { export interface AuthUser {
id: number id: number
username: string username: string
email: string email: string
/** 用户等级:free(普通)| vip(高级,图集详情页可见高清原图)。 */
tier: 'free' | 'vip'
} }
// SSG 构建专用类型 // SSG 构建专用类型
@ -108,7 +93,8 @@ export interface SsgPopularBrand {
// ===================== base 解析 ===================== // ===================== base 解析 =====================
export function getBase(): string { // 仅本模块内部使用(toAbs / request 等),不对外导出。
function getBase(): string {
// if (import.meta.env.DEV) return "https://seo.23cm.cn" // if (import.meta.env.DEV) return "https://seo.23cm.cn"
// 运行期/预渲染统一走 BASE_API(空则同源相对 /api) // 运行期/预渲染统一走 BASE_API(空则同源相对 /api)
return (BASE_API || "").replace(/\/$/, "") return (BASE_API || "").replace(/\/$/, "")
@ -120,24 +106,13 @@ export function toAbs(url: string): string {
return /^https?:\/\//i.test(url) ? url : getBase() + url return /^https?:\/\//i.test(url) ? url : getBase() + url
} }
// 客户端运行期 base:仅 BASE_API(空 → 同源相对 /api)。
export function publicBase(): string {
return (BASE_API || "").replace(/\/$/, "")
}
export function toAbsPublic(url: string): string {
if (!url) return ""
if (/^https?:\/\//i.test(url)) return url
return publicBase() + url
}
// 按需渲染(on-demand SSR)详情 base:BASE_API > 本地 8090 回落。 // 按需渲染(on-demand SSR)详情 base:BASE_API > 本地 8090 回落。
export function ssrBase(): string { export function ssrBase(): string {
return (BASE_API || "http://localhost:8090").replace(/\/$/, "") return (BASE_API || "http://localhost:8090").replace(/\/$/, "")
} }
// ===================== i18n:请求语言透传 ===================== // ===================== i18n:请求语言透传 =====================
// 后端 /api/v1/public/* 与 /api/v1/ssg/* 都通过 ?locale=cn|en(优先级最高,高于 Accept-Language) // 后端 /api/v1/public/* 与 /api/internal/ssg/* 都通过 ?locale=cn|en(优先级最高,高于 Accept-Language)
// 决定 name / title / brand_name 选用 *_en 还是 *_cn 列(service.pickLocale 选列并缺翻译回落), // 决定 name / title / brand_name 选用 *_en 还是 *_cn 列(service.pickLocale 选列并缺翻译回落),
// 出参字段名保持不变,故前端契约无需改动,仅需在请求上带上当前语言。 // 出参字段名保持不变,故前端契约无需改动,仅需在请求上带上当前语言。
// //
@ -166,160 +141,154 @@ function withLocale(params?: URLSearchParams, override?: string): URLSearchParam
return p return p
} }
// ===================== 前端 JS 请求签名(反爬一层) =====================
// 注意:这是「提高成本」而非「加密」。CLIENT_SIGN_SECRET 必须出现在前端 bundle 才能签名,
// 对浏览器可见;有决心的爬虫可反编译复刻。它用于拖慢 casual 爬虫,配合后端按 IP 限流才有效。
// 必须与后端 client_sign.secret 完全一致。生产请改用随机长串并同步两端。
const CLIENT_SIGN_SECRET = "dev-client-sign-secret-change-me"
const CLIENT_SIGN_TTL = 30
// 计算 HMAC-SHA256 签名。canonical = METHOD + "\n" + Path + "\n" + RawQuery + "\n" + ts + "\n" + nonce
// 与后端 middleware.ClientSign 的拼接方式严格一致(换行符分隔,顺序固定)。
async function clientSign(method: string, fullPath: string, rawQuery: string): Promise<{ sig: string; ts: string; nonce: string }> {
const ts = String(Math.floor(Date.now() / 1000))
const nonce = Math.random().toString(36).slice(2) + Date.now().toString(36)
const msg = [method, fullPath, rawQuery, ts, nonce].join("\n")
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
"raw",
enc.encode(CLIENT_SIGN_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
)
const buf = await crypto.subtle.sign("HMAC", key, enc.encode(msg))
const sig = Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return { sig, ts, nonce }
}
// ===================== 内部请求原语 ===================== // ===================== 内部请求原语 =====================
// 通用请求原语:走 getBase() + /api/v1 + path(公开接口)。GET-only。 // 通用请求原语:走 getBase() + /api/v1 + path(公开接口)。GET-only。
// 自动追加 ?locale=(当前语言),让后端按语言选列返回。 // 自动追加 ?locale=(当前语言),让后端按语言选列返回。
// 每个请求附带前端 JS 签名(X-Sign / X-Sign-Ts / X-Sign-Nonce),供后端 ClientSign 中间件校验。
async function request<T>(path: string, params?: URLSearchParams): Promise<T> { async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
const p = withLocale(params) const p = withLocale(params)
const qs = p.toString() ? "?" + p.toString() : "" const qs = p.toString() ? "?" + p.toString() : ""
const url = getBase() + "/api/v1" + path + qs const fullPath = "/api/v1" + path
const res = await fetch(url) const { sig, ts, nonce } = await clientSign("GET", fullPath, p.toString())
const url = getBase() + fullPath + qs
const headers: Record<string, string> = {
"X-Sign": sig,
"X-Sign-Ts": ts,
"X-Sign-Nonce": nonce,
}
// 已登录则自动附带 Bearer(翻页/筛选等需鉴权的列表请求才能通过后端校验)。
// 未登录时不带,首页(page<=1)仍可直接公开访问。
const token = getAccessToken()
if (token) headers["Authorization"] = `Bearer ${token}`
let res = await fetch(url, { headers })
// 登录态下 access 过期:用 refresh 续期一次后重试(与 authedFetch 行为一致)。
if (res.status === 401 && getRefreshToken()) {
try {
await refreshSession()
const newToken = getAccessToken()
if (newToken) headers["Authorization"] = `Bearer ${newToken}`
res = await fetch(url, { headers })
} catch {
/* 刷新失败:保留原 401,下面统一抛出,由调用方处理 */
}
}
if (!res.ok) throw new Error(`API ${res.status} ${url}`) if (!res.ok) throw new Error(`API ${res.status} ${url}`)
return (await res.json()) as T return (await res.json()) as T
} }
// ===================== 文章(/api/public/articles) ===================== // ===================== 走秀(/api/public/runway-looks) =====================
// 接口:GET /api/public/articles // 接口:GET /api/public/runway-looks/:id
// 原始文章列表(不分页映射),供数据层与各类 getter 复用。 // 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
export async function getArticlesRaw(opts?: {
page?: number
size?: number
sort?: string
brand_ids?: string[]
brand_id?: string
collection_type?: string[]
season?: string[]
year?: string[]
with_images?: number
keyword?: string
}): Promise<{ data: RawArticle[]; total: number; last_page: number }> {
const p = new URLSearchParams()
p.set("page", String(opts?.page ?? 1))
p.set("size", String(opts?.size ?? 24))
if (opts?.sort) p.set("sort", opts.sort)
if (opts?.keyword) p.set("keyword", opts.keyword)
if (opts?.with_images) p.set("with_images", String(opts.with_images))
if (opts?.brand_id) p.set("brand_id", opts.brand_id)
if (opts?.brand_ids?.length) p.set("brand_ids", opts.brand_ids.join(","))
for (const v of opts?.collection_type ?? []) p.append("collection_type", v)
for (const v of opts?.season ?? []) p.append("season", v)
for (const v of opts?.year ?? []) p.append("year", v)
const j = await request<{ data?: any[]; total?: number; last_page?: number }>(
"/public/articles",
p
)
return {
data: (j.data ?? []) as RawArticle[],
total: Number(j.total ?? (j.data?.length ?? 0)),
last_page: Number(j.last_page ?? 1),
}
}
// 接口:GET /api/public/articles/:id
// 文章详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。 // locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
export async function fetchArticleById( // 对外导出:详情取数 / 个人中心逐篇回查(浏览器端按需取 title/cover 渲染历史卡片)。
id: number | string, // 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。
base: string = ssrBase(),
locale?: string
): Promise<RawArticle | null> {
const b = base.replace(/\/$/, "")
if (!b) return null
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try {
const res = await fetch(`${b}/api/v1/public/articles/${id}${qs}`)
if (!res.ok) return null
const json: any = await res.json()
return (json?.data ?? null) as RawArticle | null
} catch {
return null
}
}
// 服务端按需渲染(on-demand)取文章详情:在 fetchArticleById 之上包揽
// 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。
// 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。 // 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。
// locale 显式传入(如 Astro.currentLocale)以绑定当前语言,避免并发串 locale。 // locale 显式传入(如 Astro.currentLocale)以绑定当前语言,避免并发串 locale。
export type SsrArticleResult = { export type SsrArticleResult = {
article: RawArticle | null article: RawArticle | null
error: "missing" | "failed" | null error: "missing" | "failed" | null
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
} }
export async function getSsrArticle( export async function getSsrArticle(
id: string | null | undefined, id: string | null | undefined,
locale?: string locale?: string
): Promise<SsrArticleResult> { ): Promise<SsrArticleResult> {
if (!id) return { article: null, error: "missing" } if (!id) return { article: null, error: "missing", preview: false, imageTotal: 0 }
const b = ssrBase().replace(/\/$/, "")
if (!b) return { article: null, error: "failed", preview: false, imageTotal: 0 }
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try { try {
const article = await fetchArticleById(id, ssrBase(), locale) const res = await fetch(`${b}/api/v1/public/runway-looks/${id}${qs}`)
return { article, error: article ? null : "failed" } if (!res.ok) return { article: null, error: "failed", preview: false, imageTotal: 0 }
const json: any = await res.json()
return {
article: (json?.data ?? null) as RawArticle | null,
preview: !!json?.preview,
imageTotal: Number(json?.image_total ?? 0),
error: null,
}
} catch { } catch {
return { article: null, error: "failed" } return { article: null, error: "failed", preview: false, imageTotal: 0 }
} }
} }
// 接口:GET /api/public/articles(经 getArticlesRaw 透传) // 已登录用户详情取数:走 request()(自动附带 Bearer + locale 签名),
// 文章列表(RunwayLooks / shows 用):多选用 repeated params,并把 images 映射成 gallery。 // 后端据此返回完整图片集(未登录截断到 PreviewLimit 张)。详情页未登录→登录后客户端补拉全量用。
export async function getArticleDetailAuthed(
id: string,
locale?: string
): Promise<RawArticle | null> {
try {
const json: any = await request(`/public/runway-looks/${encodeURIComponent(id)}`)
return (json?.data ?? null) as RawArticle | null
} catch {
return null
}
}
// 接口:GET /api/public/runway-looks
// 走秀列表(RunwayLooks / shows 用):单值筛选,brandId 走 ?brand_id 查询参数;
// 把后端 images[{image,name}] 映射成前端卡片用的 gallery(纯 URL 数组)。
export async function getArticles(q: ArticleQuery): Promise<ArticlePage> { export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
const { data, total, last_page } = await getArticlesRaw({ const p = new URLSearchParams()
page: q.page, p.set("page", String(q.page ?? 1))
size: q.size, if (q.sort) p.set("sort", q.sort)
sort: q.sort, if (q.brandId) p.set("brand_id", q.brandId)
with_images: q.with_images, if (q.collection) p.set("collection", q.collection)
brand_ids: q.brand_ids, if (q.season) p.set("season", q.season)
collection_type: q.collection_type, if (q.year) p.set("year", String(q.year))
season: q.season, const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>("/public/runway-looks", p)
year: q.year, const mapped: ArticleItem[] = (j.data ?? []).map((it: any) => ({
})
const mapped: ArticleItem[] = data.map((it: any) => ({
...it, ...it,
gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [], gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
})) }))
return { data: mapped, total: Number(total ?? mapped.length), last_page: Number(last_page ?? 1) } return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
}
// 接口:GET /api/public/articles(客户端运行期,base 走 BASE_API)
// articles.astro 客户端按需拉取:保留该页独有的单值参数形态。
export async function fetchArticlesList(opts?: {
page?: number
size?: number
collection_type?: string
season?: string
brand_id?: string
}): Promise<any[]> {
const base = publicBase()
if (!base) return []
const p = new URLSearchParams({ page: String(opts?.page ?? 1), size: String(opts?.size ?? 30) })
if (opts?.collection_type) p.set("collection_type", opts.collection_type)
if (opts?.season) p.set("season", opts.season)
if (opts?.brand_id) p.set("brand_id", opts.brand_id)
// 浏览器端按 URL path 自动推断当前语言并追加 ?locale=
p.set("locale", currentLocale())
const res = await fetch(`${base}/api/v1/public/articles?${p.toString()}`)
if (!res.ok) throw new Error(`list ${res.status}`)
const json = await res.json()
return json?.data ?? []
} }
// ===================== 品牌(/api/public/brands) ===================== // ===================== 品牌(/api/public/brands) =====================
// 接口:GET /api/public/brands // 接口:GET /api/public/brands
// size 已写死 30(后端 brandDefaultSize),only_with_articles 已删除(字母索引始终只返回有档案品牌),
// 前端均不传;关键词搜索仍不过滤"必须有走秀档案"(后端对 keyword 跳过该限制)。
export async function getBrands(opts?: { export async function getBrands(opts?: {
size?: number
only_with_articles?: number
featured?: number featured?: number
keyword?: string keyword?: string
letter?: string letter?: string
}): Promise<BrandEntry[]> { }): Promise<BrandEntry[]> {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (opts?.size) qs.set("size", String(opts.size))
// 关键词搜索:用户按名字找品牌,不应被"必须有走秀档案"过滤。
// 后端已对 keyword 跳过该限制,这里再显式置 0 双保险,使正在运行的后端也立即生效。
const onlyWA = opts?.keyword ? 0 : (opts?.only_with_articles ?? 1)
qs.set("only_with_articles", String(onlyWA))
if (opts?.featured != null) qs.set("featured", String(opts.featured)) if (opts?.featured != null) qs.set("featured", String(opts.featured))
if (opts?.keyword) qs.set("keyword", opts.keyword) if (opts?.keyword) qs.set("keyword", opts.keyword)
if (opts?.letter) qs.set("letter", opts.letter) if (opts?.letter) qs.set("letter", opts.letter)
@ -334,17 +303,17 @@ export async function getBrands(opts?: {
name: nameEn, name: nameEn,
nameCn, nameCn,
display, display,
article_count: Number(b.article_count ?? 0),
} }
}) })
} }
// ===================== 账号体系(/api/auth/*) ===================== // ===================== 账号体系(/api/auth/*) =====================
// 说明:当前前端只用「取当前用户 + 登出」两个能力(顶栏已登录态)。 // 双令牌:access_token(短命,请求鉴权)+ refresh_token(长命,落库可吊销)。
// 登录/注册的表单提交暂未接入,相关接口函数已移除,避免无人调用的死代码。 // 登录写两者;fetchMe 等受保护请求遇 401 自动用 refresh 续期一次;登出吊销 refresh。
const BASE_API_URL = (BASE_API || "").replace(/\/$/, "") const BASE_API_URL = (BASE_API || "").replace(/\/$/, "")
const TOKEN_KEY = "fa_token" const TOKEN_KEY = "fa_token" // access token
const REFRESH_KEY = "fa_refresh" // refresh token
const USER_KEY = "fa_user" const USER_KEY = "fa_user"
// 注意:getUser / clearSession 只读写 localStorage,不发网络请求。 // 注意:getUser / clearSession 只读写 localStorage,不发网络请求。
@ -358,21 +327,102 @@ export function getUser(): AuthUser | null {
} }
} }
export function getAccessToken(): string | null {
if (typeof localStorage === "undefined") return null
return localStorage.getItem(TOKEN_KEY)
}
export function getRefreshToken(): string | null {
if (typeof localStorage === "undefined") return null
return localStorage.getItem(REFRESH_KEY)
}
export function clearSession(): void { export function clearSession(): void {
if (typeof localStorage === "undefined") return if (typeof localStorage === "undefined") return
localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(USER_KEY) localStorage.removeItem(USER_KEY)
} }
// 写登录态:登录成功后把 access + refresh + user 写入 localStorage。
export function saveSession(access: string, refresh: string, user: AuthUser): void {
if (typeof localStorage === "undefined") return
localStorage.setItem(TOKEN_KEY, access)
localStorage.setItem(REFRESH_KEY, refresh)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
// 刷新 token:用 refresh_token 换新的 access_token(refresh 复用、不轮换)。
export async function refreshSession(): Promise<string> {
const refresh = getRefreshToken()
if (!refresh) throw new Error("no refresh token")
const res = await fetch(`${BASE_API_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
})
if (!res.ok) {
clearSession()
throw new Error("refresh failed")
}
const json: any = await res.json()
const access: string | undefined = json?.access_token
if (!access) throw new Error("refresh failed")
// refresh 复用:仅更新 access,保留原 refresh。
const user = getUser()
if (user) saveSession(access, refresh, user)
return access
}
// 带鉴权的请求:自动附带 Bearer,遇 401 静默刷新一次后重试。
async function authedFetch(url: string, opts: RequestInit = {}): Promise<Response> {
const headers = new Headers(opts.headers)
const token = getAccessToken()
if (token) headers.set("Authorization", `Bearer ${token}`)
let res = await fetch(url, { ...opts, headers })
if (res.status === 401 && getRefreshToken()) {
try {
await refreshSession()
const newToken = getAccessToken()
if (newToken) headers.set("Authorization", `Bearer ${newToken}`)
res = await fetch(url, { ...opts, headers })
} catch {
/* 刷新失败:返回原始 401,由调用方处理 */
}
}
return res
}
// 带鉴权的 JSON 请求:自动附 Bearer、遇 401 静默刷新一次后重试,
// 成功返回响应体中的 data 字段;非 2xx 抛出(含后端 message)。
// 收藏/个人中心等需要登录的写操作统一走这里。
export async function authJson<T = any>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${BASE_API_URL}/api/v1${path}`
const headers: Record<string, string> = { "Content-Type": "application/json" }
const res = await authedFetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let msg = `API ${res.status} ${method} ${path}`
try {
const j = await res.json()
if (j?.message) msg = String(j.message)
} catch {
/* 响应体非 JSON,沿用默认错误信息 */
}
throw new Error(msg)
}
const json: any = await res.json()
return (json?.data ?? null) as T
}
export async function fetchMe(): Promise<AuthUser | null> { export async function fetchMe(): Promise<AuthUser | null> {
if (typeof localStorage === "undefined") return null if (typeof localStorage === "undefined") return null
const token = localStorage.getItem(TOKEN_KEY) if (!getAccessToken()) return null
if (!token) return null
try { try {
// 接口:GET /api/auth/me(需 Bearer token,客户端运行期 base 走 BASE_API) const res = await authedFetch(`${BASE_API_URL}/api/v1/me?locale=${encodeURIComponent(currentLocale())}`)
const res = await fetch(`${BASE_API_URL}/api/v1/auth/me?locale=${encodeURIComponent(currentLocale())}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) { if (!res.ok) {
clearSession() clearSession()
return null return null
@ -385,14 +435,85 @@ export async function fetchMe(): Promise<AuthUser | null> {
} }
} }
// 登录/注册成功后写入本地会话(token + user),供 Layout 顶部已登录态与 fetchMe 读取。 // 登录:POST /api/v1/auth/login(公开端点)。成功后写 access+refresh+user 进 localStorage。
export function saveSession(token: string, user: AuthUser): void { export async function login(input: { account: string; password: string }): Promise<AuthUser> {
if (typeof localStorage === "undefined") return const res = await fetch(`${BASE_API_URL}/api/v1/auth/login`, {
localStorage.setItem(TOKEN_KEY, token) method: "POST",
localStorage.setItem(USER_KEY, JSON.stringify(user)) headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
if (!res.ok) {
clearSession()
throw new Error("login failed")
}
const json: any = await res.json()
const access: string | undefined = json?.access_token
const refresh: string | undefined = json?.refresh_token
const user: any = json?.user
if (!access || !refresh || !user) throw new Error("login failed")
saveSession(access, refresh, user)
// 登录成功广播:列表页在未登录时进页没记图集,登录后由监听补记(与「进页面就记」一致)。
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:login"))
}
return user as AuthUser
} }
// ===================== SSG 构建专用(/api/ssg/*,仅 Node 构建期) ===================== // 登出:吊销当前 refresh_token,再清本地态。
export async function logout(): Promise<void> {
const refresh = getRefreshToken()
if (refresh) {
try {
// 会话即 refresh token:单设备登出 = 吊销当前会话
await fetch(`${BASE_API_URL}/api/v1/auth/sessions/current`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
})
} catch {
/* 忽略网络错误,仍清本地态 */
}
}
clearSession()
}
// ===================== 浏览历史:单篇详情回查(账户页 My History 渲染用) =====================
// 历史只存文章对外编码串,展示时逐篇回查公开详情接口取 title/cover/brand。
// 编码串首字符即类型:s=… 实为 s 开头(如 s000zAwwQ)代表街拍,其余(r 开头等)为走秀。
// 走 request():自动带 locale 与前端签名,登录态附带 Bearer。
export interface HistoryMeta {
title: string
cover: string
brand: string
type: "street" | "runway"
}
// 单篇回查:按编码串首字符判断类型(s 开头 = 街拍,其余 = 走秀)。失败返回 null,由调用方容忍。
export async function getHistoryMeta(id: string): Promise<HistoryMeta | null> {
const sid = String(id)
// 编码串类型前缀是单个字符(如 s000zAwwQ),与 /item/[id] 路由判断一致(r= 走秀 / s= 街拍)
const type = sid.charAt(0) === "s" ? ("street" as const) : ("runway" as const)
const path =
type === "street"
? `/public/street-snaps/${encodeURIComponent(sid)}`
: `/public/runway-looks/${encodeURIComponent(sid)}`
try {
const j = await request<{ data?: any }>(path)
const d = j?.data
if (!d) return null
return {
title: (d.title || "").toString(),
cover: (d.cover || "").toString(),
brand: (d.brand_name || d.brand || "").toString(),
type,
}
} catch {
return null
}
}
// ===================== SSG 构建专用(/api/internal/ssg/*,仅 Node 构建期) =====================
function ssgEnv(name: string): string | undefined { function ssgEnv(name: string): string | undefined {
const e = import.meta.env as any const e = import.meta.env as any
@ -428,18 +549,18 @@ async function ssgRequest<T>(path: string): Promise<T | null> {
} }
} }
// 接口:GET /api/v1/ssg/index/runway(SSG 内部端口,构建期 Node 调用) // 接口:GET /api/internal/ssg/home/runway(SSG 内部端口,构建期 Node 调用)
// 首页(/index/)runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。 // 首页(/index/)runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。
export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> { export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> {
const j = await ssgRequest<{ data?: { hotBrand?: SsgPopularBrand[] } }>(`/api/v1/ssg/index/runway`) const j = await ssgRequest<{ data?: { hotBrand?: SsgPopularBrand[] } }>(`/api/internal/ssg/home/runway`)
return j?.data ?? { hotBrand: [] } return j?.data ?? { hotBrand: [] }
} }
// 接口:GET /api/ssg/brands(SSG 内部端口,构建期 Node 调用) // 接口:GET /api/internal/ssg/brands/popular(SSG 内部端口,构建期 Node 调用)
// SSG 构建期:走秀页热门品牌(按图片总数排名,结果按热度顺序)。 // SSG 构建期:走秀页热门品牌(按图片总数排名,结果按热度顺序)。
// 后端固定返回前 30 个;侧栏 filter 展示前 10,品牌弹窗 HOT 标签展示全部。 // 后端固定返回前 30 个;侧栏 filter 展示前 10,品牌弹窗 HOT 标签展示全部。
export async function getSsgHotBrands(): Promise<BrandEntry[]> { export async function getSsgHotBrands(): Promise<BrandEntry[]> {
const j = await ssgRequest<{ data?: any[] }>("/api/v1/ssg/brands/hot") const j = await ssgRequest<{ data?: any[] }>("/api/internal/ssg/brands/popular")
return (j?.data ?? []).map((b: any) => { return (j?.data ?? []).map((b: any) => {
const nameEn = (b.name_en || b.name || "").toString() const nameEn = (b.name_en || b.name || "").toString()
return { return {
@ -447,10 +568,123 @@ export async function getSsgHotBrands(): Promise<BrandEntry[]> {
name: nameEn, name: nameEn,
nameCn: (b.name_cn || "").toString(), nameCn: (b.name_cn || "").toString(),
display: nameEn, display: nameEn,
article_count: Number(b.article_count ?? 0),
} }
}) })
} }
// ===================== 街拍(/api/public/street-snaps) =====================
export interface StreetSnapItem {
id: string // hashid 编码串
title: string // 含字面 \t 分隔:地点 \t 场次
cover: string
image_count: number
images: { id?: string; image: string; name?: string }[] // 列表附带的每篇前 N 张缩略图(id 为图片自身的编码串)
}
// 列表响应(与走秀 ArticlePage 同构)
export interface StreetSnapPage {
data: StreetSnapItem[]
last_page: number
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
}
// 街拍详情原始结构(与 PublicStreetSnapDetail 对齐)。
export interface RawStreetSnap {
id: string
title: string
cover: string
images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用
}
// 接口:GET /api/public/street-snaps
// 街拍列表:年份过滤 + 排序(image_count 默认降序 / year_desc / year_asc)+ 地区(city 精确匹配)。
// 分页 size 服务端固定 24,images 服务端固定 6,前端均不传。
export async function getStreetSnaps(q: {
page?: number
year?: number | string
city?: string
sort?: string
} = {}): Promise<StreetSnapPage> {
const p = new URLSearchParams()
p.set("page", String(q.page ?? 1))
if (q.sort) p.set("sort", q.sort)
if (q.year) p.set("year", String(q.year))
if (q.city) p.set("city", q.city)
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>("/public/street-snaps", p)
const mapped: StreetSnapItem[] = (j.data ?? []).map((it: any) => ({
id: String(it.id),
title: it.title ?? "",
cover: it.cover ?? "",
image_count: Number(it.image_count ?? 0),
// 保留每张图的 id(图片编码串),单图收藏需要用它定位到具体某一张
images: Array.isArray(it.images)
? it.images.map((im: any) => ({ id: im.id || "", image: im.image || im, name: im.name || "" }))
: [],
}))
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
}
// 接口:GET /api/public/street-snaps/:id
// 街拍详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数)。
// 对外导出:详情取数 / 个人中心逐篇回查。
export type SsrStreetSnapResult = {
snap: RawStreetSnap | null
error: "missing" | "failed" | null
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
}
// 服务端按需渲染(on-demand)取街拍详情:直接 fetch 详情接口并透传 preview 标记。
export async function getSsrStreetSnap(
id: string | null | undefined,
locale?: string
): Promise<SsrStreetSnapResult> {
if (!id) return { snap: null, error: "missing", preview: false, imageTotal: 0 }
const b = ssrBase().replace(/\/$/, "")
if (!b) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try {
const res = await fetch(`${b}/api/v1/public/street-snaps/${id}${qs}`)
if (!res.ok) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
const json: any = await res.json()
return {
snap: (json?.data ?? null) as RawStreetSnap | null,
preview: !!json?.preview,
imageTotal: Number(json?.image_total ?? 0),
error: null,
}
} catch {
return { snap: null, error: "failed", preview: false, imageTotal: 0 }
}
}
// 已登录用户街拍详情取数:走 request()(自动附带 Bearer + locale 签名),返回完整图片集。
export async function getStreetSnapDetailAuthed(
id: string,
locale?: string
): Promise<RawStreetSnap | null> {
try {
const json: any = await request(`/public/street-snaps/${encodeURIComponent(id)}`)
return (json?.data ?? null) as RawStreetSnap | null
} catch {
return null
}
}
// 接口:GET /api/internal/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用)
// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。
export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapItem[]> {
const j = await ssgRequest<{ data?: any[] }>(`/api/internal/ssg/street-snaps/popular?limit=${limit}`)
return (j?.data ?? []).map((b: any) => ({
id: String(b.id),
title: b.title ?? "",
cover: b.cover ?? "",
image_count: Number(b.image_count ?? 0),
images: [],
}))
}

271
src/lib/favorites.ts Normal file
View File

@ -0,0 +1,271 @@
// src/lib/favorites.ts — 收藏(服务端持久化 + 本地缓存兜底)
//
// 设计要点(应对"收藏量极大"场景):
// - 列表卡片打勾:只把「当前可见页的 id」发给后端 /me/favorites/checks,
// 问哪些已收藏。复杂度与「一屏多少张」成正比,与「用户收藏了多少」彻底解耦,
// 即便收藏 10 万也只传输约 24~50 个 id(常数级)。
// - 个人中心 Saved looks:走 /me/favorites 的「服务端分页」,每次只取一页(默认 24 条),
// DOM 永远轻量,不存在一次渲染上万张卡片的问题。
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到 /api/v1/me/favorites(POST/DELETE),
// 即便网络失败也不回滚本地,保证前端不"卡死"。
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
//
// 两种收藏粒度(共用一张 favorites 表,后端以 kind 区分):
// - gallery:整篇图集。id = 图集 uid(r/s 前缀),type = runway / street。
// - image :单张图片。id = 图片自身的编码 id(i/j 前缀),type = runway_image / snap_image,
// 额外带 imageUrl(这一张图)与 parentUid(所属图集,供从收藏回跳原图集)。
// 两种粒度的 id 前缀互不冲突,因此可放在同一张表、同一套接口里。
//
// 对外函数:getFavorites / isFavorited / toggleFavorite / toggleImageFavorite / removeFavorite /
// fetchFavoritesPage / checkFavorited(列表与个人中心按需调用)。
import { getUser, getAccessToken, authJson } from "./api"
export type FavoriteType = "runway" | "street" | "runway_image" | "snap_image"
export type FavoriteKind = "gallery" | "image"
export interface Favorite {
id: string // 图集 uid(r/s 前缀)或图片编码 id(i/j 前缀),与后端 target_uid 对齐
type: FavoriteType
title: string
cover: string
brand: string
addedAt: number
kind?: FavoriteKind // 缺省视为 gallery(兼容升级前的本地数据)
imageUrl?: string // kind=image:这一张图
parentUid?: string // kind=image:所属图集 uid
}
// 后端 FavoriteItem 响应结构({data:{items:[...]}} 解包后)
interface ServerFavorite {
id: string
type: string
title?: string
cover?: string
brand?: string
added_at?: number
kind?: string
image_url?: string
parent_uid?: string
}
// ---------- 本地存储(兜底 / 离线) ----------
function storageKey(): string | null {
const u = getUser()
return u ? `fa_fav_${u.username}` : null
}
function getLocal(): Favorite[] {
const k = storageKey()
if (!k) return []
try {
const arr = JSON.parse(localStorage.getItem(k) || "[]")
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
function setLocal(list: Favorite[]): void {
const k = storageKey()
if (!k) return
try {
localStorage.setItem(k, JSON.stringify(list))
} catch {
/* 隐私模式等写入失败,忽略 */
}
}
// ---------- 服务端模式判定 ----------
function serverMode(): boolean {
return !!(getUser() && getAccessToken())
}
// ---------- 归一化(后端字符串 → 联合类型) ----------
function normalizeType(t?: string): FavoriteType {
if (t === "street" || t === "runway_image" || t === "snap_image") return t
return "runway"
}
function normalizeKind(k?: string): FavoriteKind {
return k === "image" ? "image" : "gallery"
}
// ---------- 对外 API ----------
// 本地收藏(兜底 / 离线态)。列表卡片打勾时用它做"即时填充"。
export function getFavorites(): Favorite[] {
return getLocal()
}
export function isFavorited(id: string): boolean {
return getLocal().some((f) => f.id === id)
}
/** 切换收藏状态:已收藏则移除返回 false,未收藏则新增返回 true(同步、即时)。 */
export function toggleFavorite(item: Omit<Favorite, "addedAt">): boolean {
const list = getLocal()
const i = list.findIndex((f) => f.id === item.id)
let newState: boolean
if (i >= 0) {
list.splice(i, 1)
newState = false
} else {
list.unshift({ ...item, addedAt: Date.now() })
newState = true
}
setLocal(list)
// 登录态:后台同步到服务端(失败不回滚本地,保证 UI 不卡)
if (serverMode()) {
syncServer(item, newState)
}
return newState
}
/**
* 收藏 / 取消单张图片(比 toggleFavorite 更省事的语义化封装)。
* cover 直接设为该图 URL,个人中心网格即可原样渲染这张图;
* parentUid 记下所属图集,收藏卡片点击时回跳到原图集。
*/
export function toggleImageFavorite(args: {
imageId: string
type: "runway_image" | "snap_image"
imageUrl: string
parentUid: string
title?: string
brand?: string
}): boolean {
return toggleFavorite({
id: args.imageId,
type: args.type,
title: args.title || "",
cover: args.imageUrl,
brand: args.brand || "",
kind: "image",
imageUrl: args.imageUrl,
parentUid: args.parentUid,
})
}
export function removeFavorite(id: string): void {
setLocal(getLocal().filter((f) => f.id !== id))
if (serverMode()) {
authJson("DELETE", `/me/favorites/${encodeURIComponent(id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}
// ---------- 个人中心:服务端分页拉取 ----------
export interface FavPageResult {
items: Favorite[]
total: number
lastPage: number
}
/**
* 拉取收藏的某一页(默认 24 条/页)。
* kind 传 gallery / image 可只取对应粒度(服务端过滤),留空为全部。
* 返回 { items, total, lastPage },前端据此渲染当前页 + 翻页器。
* 未登录回落本地收藏(无分页)。
*/
export async function fetchFavoritesPage(
page = 1,
perPage = 24,
kind?: FavoriteKind,
): Promise<FavPageResult> {
if (!serverMode()) {
const local = getLocal()
const items = kind ? local.filter((f) => normalizeKind(f.kind) === kind) : local
return { items, total: items.length, lastPage: 1 }
}
try {
const qs = kind ? `&kind=${kind}` : ""
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
"GET",
`/me/favorites?page=${page}&per_page=${perPage}${qs}`,
)
const items = (r?.items ?? []).map((it) => ({
id: String(it.id),
type: normalizeType(it.type),
title: it.title || "",
cover: it.cover || "",
brand: it.brand || "",
addedAt: Number(it.added_at) || 0,
kind: normalizeKind(it.kind),
imageUrl: it.image_url || "",
parentUid: it.parent_uid || "",
}))
return {
items,
total: Number(r?.total || 0),
lastPage: Number(r?.last_page || 1),
}
} catch {
const local = getLocal()
const items = kind ? local.filter((f) => normalizeKind(f.kind) === kind) : local
return { items, total: items.length, lastPage: 1 }
}
}
// ---------- 列表卡片 / 详情页:批量校验当前页收藏态 ----------
/**
* 传入当前可见的卡片 id 列表,返回「已收藏」的 id 集合。
* 后端只对这些 id 做 WHERE target_uid IN (...) 查询,复杂度与收藏总量无关。
* 图集 id 与图片 id 可混传(前缀不同,互不影响)。
*/
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
if (!serverMode() || ids.length === 0) return new Set()
try {
const arr = await authJson<string[]>("POST", "/me/favorites/checks", { ids })
return new Set(Array.isArray(arr) ? arr.map(String) : [])
} catch {
return new Set()
}
}
/** 服务端校验命中时把该篇补进本地收藏(详情页跨设备校正用;不触发 POST——服务端已有该收藏)。 */
export function hydrateFavorited(
id: string,
type: FavoriteType,
title = "",
cover = "",
brand = "",
extra?: { kind?: FavoriteKind; imageUrl?: string; parentUid?: string },
): void {
const list = getLocal()
if (list.some((f) => f.id === id)) return
list.unshift({
id,
type,
title,
cover,
brand,
addedAt: Date.now(),
kind: normalizeKind(extra?.kind),
imageUrl: extra?.imageUrl || "",
parentUid: extra?.parentUid || "",
})
setLocal(list)
}
// ---------- 内部:后台同步到服务端 ----------
function syncServer(item: Omit<Favorite, "addedAt">, newState: boolean): void {
const payload = {
target_type: item.type,
target_uid: item.id,
title: item.title,
cover: item.cover,
brand: item.brand,
kind: normalizeKind(item.kind),
image_url: item.imageUrl || "",
parent_uid: item.parentUid || "",
}
if (newState) {
authJson("POST", "/me/favorites", payload).catch(() => {
/* 失败不回滚本地 */
})
} else {
authJson("DELETE", `/me/favorites/${encodeURIComponent(item.id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}

148
src/lib/history.ts Normal file
View File

@ -0,0 +1,148 @@
// src/lib/history.ts — 浏览历史(服务端持久化 + 本地缓存兜底)
//
// 2026-09-02 语义收敛(用户拍板):
// - 浏览历史 = 「点开过哪篇文章」,触发时机在 item 详情页(打开某篇走秀/街拍即记一条)。
// 列表页(Runway Looks / Street Snaps)进入不再记。
// - 后端 histories 表只存 (user_id, target_uid, viewed_at):target_uid 为文章对外编码串
// (首字符区分类型:s 开头=街拍 / r 开头=走秀,编码串自带类型,故不再有 kind/target_type 概念),
// 表已去掉 title/cover/brand 快照列。
// - 账户页 My History 展示:先取服务端 id 列表,再逐篇回查公开详情接口拿 title/cover/brand
// (用户选「只存 id、展示时回查」),历史卡片点击跳回文章详情。
// - 未登录:纯本地 localStorage(按用户名隔离);登录态写本地 + 后台 POST(失败不回滚本地)。
import { getUser, getAccessToken, authJson, getHistoryMeta } from "./api"
export interface HistoryData {
title: string
cover: string
brand: string
type: "street" | "runway"
}
export interface HistoryEntry {
id: string // 文章对外编码串:r=xxx(走秀) / s=xxx(街拍)
viewedAt: number
data?: HistoryData // 账户页展示时回查详情获得;本地/回查失败时缺失(卡片占位渲染)
}
// 后端 HistoryItem 响应结构({data:{items:[...]}} 解包后,仅 id + viewed_at)
interface ServerHistory {
id: string
viewed_at?: number
}
// ---------- 本地存储(兜底 / 离线) ----------
function storageKey(): string | null {
const u = getUser()
return u ? `fa_hist_${u.username}` : null
}
function getLocal(): HistoryEntry[] {
const k = storageKey()
if (!k) return []
try {
const arr = JSON.parse(localStorage.getItem(k) || "[]")
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
function setLocal(list: HistoryEntry[]): void {
const k = storageKey()
if (!k) return
try {
localStorage.setItem(k, JSON.stringify(list))
} catch {
/* 隐私模式等写入失败,忽略 */
}
}
// ---------- 服务端模式判定 ----------
function serverMode(): boolean {
return !!(getUser() && getAccessToken())
}
// ---------- 对外 API ----------
/**
* 记录一次浏览:打开某篇文章详情时调用(见 item 详情页)。
* 登录态:写入本地缓存 + 后台 POST 服务端(upsert 刷新时间,失败不回滚本地)。
* 未登录:仅写入本地缓存。
*/
export function recordHistory(id: string): void {
if (!id) return
const entry: HistoryEntry = { id, viewedAt: Date.now() }
// 本地去重(同 id 只保留一条并提到最前)
const list = getLocal().filter((h) => h.id !== id)
list.unshift(entry)
if (list.length > 200) list.length = 200
setLocal(list)
if (serverMode()) {
authJson("POST", "/me/history", { target_uid: id }).catch(() => {
/* 失败不回滚本地 */
})
}
}
export interface HistPageResult {
items: HistoryEntry[]
total: number
lastPage: number
}
/**
* 拉取浏览历史的一页(登录态走服务端分页 + 逐篇回查公开详情;未登录回落本地缓存)。
*/
export async function fetchHistoryPage(page = 1, perPage = 24): Promise<HistPageResult> {
if (!serverMode()) {
const local = getLocal()
return { items: local, total: local.length, lastPage: 1 }
}
try {
const qs = `page=${page}&per_page=${perPage}`
const r = await authJson<{ items: ServerHistory[]; total: number; last_page: number }>(
"GET",
`/me/history?${qs}`,
)
const base: HistoryEntry[] = (r?.items ?? []).map((it) => ({
id: String(it.id),
viewedAt: Number(it.viewed_at) || 0,
}))
// 逐篇回查公开详情接口拿展示数据(每篇 1 次;单条失败容忍,卡片仍可点击)
const items = await Promise.all(
base.map(async (h) => {
try {
const meta = await getHistoryMeta(h.id)
if (meta) h.data = meta
} catch {
/* 单条回查失败:保留无 data,卡片走占位渲染 */
}
return h
}),
)
return { items, total: Number(r?.total || 0), lastPage: Number(r?.last_page || 1) }
} catch {
const local = getLocal()
return { items: local, total: local.length, lastPage: 1 }
}
}
/** 删除一条历史(按文章 id)。 */
export function removeHistory(id: string): void {
setLocal(getLocal().filter((h) => h.id !== id))
if (serverMode()) {
authJson("DELETE", `/me/history/${encodeURIComponent(id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}
/** 清空全部历史。 */
export function clearHistory(): void {
setLocal([])
if (serverMode()) {
authJson("DELETE", "/me/history").catch(() => {
/* 失败不回滚本地 */
})
}
}

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

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

View File

@ -11,17 +11,20 @@ export const ROUTES = {
shows: '/shows', shows: '/shows',
brands: '/brands', brands: '/brands',
portfolio: '/portfolio', portfolio: '/portfolio',
latestProjects: '/latest-projects',
about: '/about',
contact: '/contact',
login: '/login', login: '/login',
register: '/register', register: '/register',
account: '/account',
runwayLooks: '/runway-looks', runwayLooks: '/runway-looks',
// 动态路由(工厂函数) // 街拍
streetSnaps: '/street-snaps',
// 动态路由(工厂函数)。
// 走秀 lookbook 详情统一收口到 /item/[id](src/pages/{locale}/item/[id].astro)。
// 街拍详情独立收口到 /street-snaps/[id](src/pages/{locale}/street-snaps/[id].astro),
// 与 runway 分路由是为了避免 HashID 类型无关导致两类 id 在同一 /item 路由里撞车
//(runway 与 street_snap 共用一套自增数字 id + 同一盐值,编码串不可区分类型)。
item: (id: string | number) => `/item/${id}`, item: (id: string | number) => `/item/${id}`,
article: (id: string | number) => `/article?id=${id}`, // 街拍详情与走秀详情统一收口到 /item/[id],由前端按编码前缀(r=runway/s=snap)分流。
articlesByBrand: (id: string | number) => `/articles?brand_id=${id}`, streetSnap: (id: string | number) => `/item/${id}`,
// runwayLooksDetail: (id: string | number) => `/runway-looks/${id}`,
} as const; } as const;
/** 服务端:生成带 locale 前缀的完整 URL(依赖 astro:i18n,遵循 astro.config)。 */ /** 服务端:生成带 locale 前缀的完整 URL(依赖 astro:i18n,遵循 astro.config)。 */

335
src/pages/cn/account.astro Normal file
View File

@ -0,0 +1,335 @@
---
import Layout from "@/layouts/Layout.astro"
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from "@/lib/routes"
const { locale, t } = getI18n(Astro)
const titleLabel = t("my account")
const pleaseLabel = t("please log in")
const loginLabel = t("login")
const historyLabel = t("my history")
const savedLooksLabel = t("saved looks")
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
const archiveUrl = href(locale, ROUTES.runwayLooks)
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
const removeLabel = t("remove")
const itemsLabel = t("items")
const clearHistoryLabel = t("clear history")
const noSavedLabel = t("no saved looks yet")
const noHistoryLabel = t("no history yet")
const browseLabel = t("browse the archive")
---
<Layout title={titleLabel}>
<!-- 页面版心与 Layout 一致:黑白编辑风,衬线大标题 + 等宽小标 + 粗黑细线 -->
<main class="account-root mx-auto w-full max-w-[1400px] px-5 py-14 md:px-10 md:py-20 font-sans" x-data="accountPage" data-locale={locale}>
<!-- ===== 未登录:整版引导 ===== -->
<div x-show="!user" x-cloak class="flex flex-col items-center justify-center border border-black/15 px-6 py-32 text-center">
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{pleaseLabel}</p>
<p class="mb-12 font-serif text-5xl font-black uppercase leading-none tracking-tight md:text-7xl">{titleLabel}</p>
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-10 py-4 font-mono text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
<!-- ===== 已登录 ===== -->
<div x-show="user" x-cloak>
<!-- 报头:衬线大标题会员名 + 档案标签 -->
<header class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8 border-b-2 border-black pb-10 pt-2">
<div class="min-w-0">
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
<div class="flex flex-wrap items-end gap-x-4">
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
<span x-show="user?.tier === 'vip'" x-cloak class="mb-2 inline-block border border-black bg-black px-2.5 py-1 font-mono text-[10px] font-bold uppercase tracking-[0.3em] text-white">{t("vip")}</span>
</div>
</div>
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
<p>Portfolio Studio</p>
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</header>
<!-- 主体:左侧分区索引 + 右侧内容 -->
<div class="grid gap-12 pt-12 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16 lg:pt-16">
<!-- 左:编号目录式垂直导航(移动端折成横向条) -->
<nav class="lg:sticky lg:top-12 lg:self-start" aria-label="Account sections">
<p class="mb-3 hidden font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400 lg:block">Index</p>
<div class="flex gap-10 overflow-x-auto border-b border-black/15 font-mono text-[12px] uppercase tracking-[0.2em] lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0 lg:border-t lg:border-black/10">
<button type="button" @click="tab='saved'" :class="tab==='saved' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
<span class="mr-3 text-[10px] opacity-60">01</span>{savedLooksLabel}
</button>
<button type="button" @click="tab='history'" :class="tab==='history' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
<span class="mr-3 text-[10px] opacity-60">02</span>{historyLabel}
</button>
</div>
</nav>
<div class="min-w-0">
<!-- ===== Saved looks ===== -->
<div x-show="tab==='saved'" x-cloak>
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
<p class="pb-1 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
</div>
<!-- 粒度切换:图集 / 单图(服务端 kind 过滤分页) -->
<div class="mb-10 flex items-center gap-8 border-b border-black/15 font-mono text-[11px] uppercase tracking-[0.2em]">
<button type="button" @click="setFavKind('gallery')" :class="favKind==='gallery' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('galleries')}</button>
<button type="button" @click="setFavKind('image')" :class="favKind==='image' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('images')}</button>
</div>
<!-- 空态 -->
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noSavedLabel}</p>
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
</div>
<!-- 收藏网格 -->
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
<template x-for="f in favorites" :key="f.id">
<div class="group">
<a :href="itemHref(f)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
</a>
<button @click="removeFav(f.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
<div class="mt-3 min-w-0">
<a :href="itemHref(f)" class="block">
<p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p>
</a>
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p>
</div>
</div>
</template>
</div>
<!-- 收藏分页(服务端分页,与收藏总量解耦) -->
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="favLastPage > 1" aria-label="Saved pagination">
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
<template x-for="(p, i) in favPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
</nav>
</div>
<!-- ===== History ===== -->
<div x-show="tab==='history'" x-cloak>
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{historyLabel}</h2>
<div class="flex items-baseline gap-8 pb-1">
<p class="font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="histTotal"></span> {itemsLabel}</p>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
</div>
</div>
<!-- 空态 -->
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noHistoryLabel}</p>
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
</div>
<!-- 历史网格 -->
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
<template x-for="h in history" :key="h.id">
<div class="group">
<a :href="itemHref(h)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
</a>
<button @click="removeHist(h.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
<div class="mt-3 min-w-0">
<a :href="itemHref(h)" class="block">
<p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p>
</a>
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p>
</div>
</div>
</template>
</div>
<!-- 历史分页 -->
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="histLastPage > 1" aria-label="History pagination">
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
<template x-for="(p, i) in histPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
</nav>
</div>
</div>
</div>
</div>
</main>
</Layout>
<style>
/* 触屏没有 hover:卡片右上角操作常驻显示,否则点不到 */
@media (hover: none) {
.card-act { opacity: 1 !important; }
}
</style>
<script>
import { getUser, toAbs } from "@/lib/api"
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
import { clientHref, ROUTES } from "@/lib/routes"
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
'<rect width="300" height="400" fill="#f1f1f1"/>' +
'<text x="150" y="200" font-family="monospace" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
'</svg>'
)
document.addEventListener("alpine:init", () => {
Alpine.data("accountPage", () => ({
user: null as any,
locale: "en",
tab: "saved",
favKind: "gallery", // 收藏子分类:gallery=整篇图集 / image=单张图片
favorites: [] as any[],
favoritesCount: 0,
favPage: 1,
favPerPage: 24,
favLastPage: 1,
favTotal: 0,
history: [] as any[],
historyCount: 0,
histPage: 1,
histPerPage: 24,
histLastPage: 1,
histTotal: 0,
init() {
this.locale = this.$el.dataset.locale || "en"
const u = getUser()
if (!u) {
window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
return
}
this.user = u
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
this.loadFavPage(1)
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
this.loadHistPage(1)
},
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级);kind 决定取图集还是单图
async loadFavPage(page: number, kind?: string) {
const p = Math.max(1, page | 0)
const k = kind || this.favKind
try {
const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery")
this.favorites = r.items
this.favTotal = r.total
this.favLastPage = r.lastPage
this.favPage = p
this.favoritesCount = r.total
} catch {
const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery"))
this.favorites = local
this.favTotal = local.length
this.favLastPage = 1
this.favPage = 1
this.favoritesCount = local.length
}
},
setFavKind(k: string) {
if (this.favKind === k) return
this.favKind = k
this.favorites = []
this.favoritesCount = 0
this.favLastPage = 1
this.loadFavPage(1, k)
},
goFavPage(p: number) {
if (p < 1 || p > this.favLastPage || p === this.favPage) return
this.loadFavPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
get favPageList() {
const cur = this.favPage, last = this.favLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
coverUrl(x: any): string {
const c = x.data?.cover || x.cover
return c ? toAbs(c) : PLACEHOLDER
},
itemHref(f: any): string {
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
// 图集收藏与历史仍用自身 uid。
const isImg = f.kind === "image"
const uid = isImg && f.parentUid ? f.parentUid : f.id
const base = clientHref(this.locale, ROUTES.item(uid))
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
},
removeFav(id: string) {
removeFavorite(id)
this.loadFavPage(this.favPage) // 重新拉当前页,保证分页计数一致
},
// ===== 浏览历史(服务端分页)=====
async loadHistPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchHistoryPage(p, this.histPerPage)
this.history = r.items
this.histTotal = r.total
this.histLastPage = r.lastPage
this.histPage = p
} catch {
this.history = []
this.histTotal = 0
this.histLastPage = 1
this.histPage = 1
}
},
goHistPage(p: number) {
if (p < 1 || p > this.histLastPage || p === this.histPage) return
this.loadHistPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
get histPageList() {
const cur = this.histPage, last = this.histLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
removeHist(id: string) {
removeHistory(id)
this.loadHistPage(this.histPage) // 重新拉当前页,保证分页计数一致
},
clearAllHistory() {
clearHistory()
this.loadHistPage(1)
},
}))
})
</script>

View File

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

View File

@ -1,15 +1,123 @@
--- ---
import Layout from "@/layouts/Layout.astro" import Layout from "@/layouts/Layout.astro"
import Item from "@/components/Item.astro" import Item from "@/components/Item.astro"
import { getSsrArticle } from "@/lib/api" import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/items/:id 链接,无需改动其他页面) // 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
export const prerender = false export const prerender = false
const { id } = Astro.params const { id } = Astro.params
const { article, error } = await getSsrArticle(id, Astro.currentLocale) const isSnap = !!id && id[0] === "s"
const { t } = getI18n(Astro)
let article = null
let articleErr = null
let snap = null
let snapErr = null
let preview = false
let imageTotal = 0
// await 必须位于 frontmatter 顶层(Astro 不支持块内顶层 await)
const data = isSnap
? await getSsrStreetSnap(id, Astro.currentLocale)
: await getSsrArticle(id, Astro.currentLocale)
if (isSnap) {
snap = data.snap
snapErr = data.error
} else {
article = data.article
articleErr = data.error
}
preview = data.preview ?? false
imageTotal = data.imageTotal ?? 0
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
const meta = isSnap
? { id: id ?? "", type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
: { id: id ?? "", type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
const saveLabel = t("save")
const savedLabel = t("saved")
--- ---
<Layout> <Layout>
<Item article={article} error={error} /> {article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
</Layout> </Layout>
<style>
#fav-fab.is-active .fav-heart { fill: currentColor; }
</style>
{(article || snap) && (
<button login-required id="fav-fab" type="button"
data-id={meta.id}
data-type={meta.type}
data-title={meta.title}
data-cover={meta.cover}
data-brand={meta.brand}
data-save-label={saveLabel}
data-saved-label={savedLabel}
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
<span id="fav-label">{saveLabel}</span>
</button>
)}
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
<script>
import { getUser } from "@/lib/api"
import { isFavorited, toggleFavorite, checkFavorited, hydrateFavorited } from "@/lib/favorites"
import { recordHistory } from "@/lib/history"
const btn = document.getElementById("fav-fab")
const label = document.getElementById("fav-label")
if (btn) {
const meta = {
id: btn.dataset.id || "",
type: btn.dataset.type || "",
title: btn.dataset.title || "",
cover: btn.dataset.cover || "",
brand: btn.dataset.brand || "",
}
const saveLabel = btn.dataset.saveLabel || "save"
const savedLabel = btn.dataset.savedLabel || "saved"
if (meta.id) {
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
if (getUser()) recordHistory(meta.id)
const sync = () => {
const fav = isFavorited(meta.id)
if (label) label.textContent = fav ? savedLabel : saveLabel
btn.classList.toggle("is-active", fav)
}
sync()
// 登录态:异步问服务端当前篇是否已收藏并写回本地(换浏览器/设备后,爱心状态与服务端一致)
if (getUser()) {
checkFavorited([meta.id])
.then((set) => {
if (set.has(meta.id)) hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
sync()
})
.catch(() => {})
}
btn.addEventListener("click", () => {
toggleFavorite({
id: meta.id,
type: meta.type,
title: meta.title,
cover: meta.cover,
brand: meta.brand,
} as any)
sync()
})
}
}
</script>

166
src/pages/cn/login.astro Normal file
View File

@ -0,0 +1,166 @@
---
import '@/styles/global.css'
import { getI18n } from '@/i18n/utils'
import { href, ROUTES } from '@/lib/routes'
import { LOCALE_NAMES } from '@/i18n/config'
import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n';
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
const pathName = Astro.url.pathname
const locales = toCodes(i18n!.locales)
const pathWithoutLocale = locales.includes(pathName.split('/')[1])
? pathName.replace(`/${locale}`, '')
: pathName
const languages = locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName }))
const loginLabel = t('login')
const promptLabel = t('login prompt')
const accountLabel = t('username or email')
const passwordLabel = t('password')
const failLabel = t('login failed')
const membersLabel = t('members')
const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks)
const pageTitle = `${loginLabel} — Portfolio Studio`
---
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
</head>
<!-- 全站一致:白底 + 衬线大标题 + 等宽小标 + 粗黑细线,直角无阴影 -->
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
<!-- 顶部工具行:返回档案 + 语言切换 -->
<header class="mb-12 flex items-center justify-between gap-6">
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black/50 transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false" @click.outside="open = false">
<button type="button" @click="open = !open" class="inline-flex items-center gap-2 border border-black/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
<span>{LOCALE_NAMES[locale].nativeName}</span>
<svg viewBox="0 0 12 12" width="11" height="11" class="transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<ul x-show="open" x-cloak class="absolute right-0 top-full z-20 mt-2 min-w-[170px] border border-black/15 bg-white py-1">
{languages.map((l) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 font-mono text-[11px] uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/55']}>{l.label}</a>
</li>
))}
</ul>
</div>
</header>
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 2px 粗黑线) -->
<section class="border-b-2 border-black pb-10">
<div class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
<div>
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
</div>
<div class="pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
<p>Portfolio Studio</p>
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</div>
</section>
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
<div class="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
<div class="w-full max-w-md">
<p class="mb-10 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400">{promptLabel}</p>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required autofocus
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<p x-show="error" x-text="error" x-cloak class="font-mono text-[11px] uppercase tracking-[0.2em] text-red-600"></p>
<button type="submit" :disabled="loading"
class="w-full border border-black bg-black py-4 font-mono text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-50">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak>…</span>
</button>
</form>
<div class="mt-10 border-t border-black/15 pt-5">
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
</div>
</div>
<aside class="border-t border-black/10 pt-8 md:border-l md:border-t-0 md:pl-8 md:pt-0">
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">Archive note</p>
<p class="font-serif text-lg leading-relaxed text-black/80">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
<div class="mt-8 border-t border-black/10 pt-5 font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400">
<p class="mb-3">Runway · Street · Archive</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</aside>
</div>
</main>
<script>
import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
document.addEventListener("alpine:init", () => {
Alpine.data("loginForm", () => ({
account: "",
password: "",
error: "",
loading: false,
init() {
if (getUser()) {
const loc = this.$el.dataset.locale || "en"
window.location.href = clientHref(loc, ROUTES.account)
}
},
submit() {
this.error = ""
this.loading = true
login({ account: this.account, password: this.password })
.then(() => {
const params = new URLSearchParams(window.location.search)
const redirect = params.get("redirect")
const loc = this.$el.dataset.locale || "en"
window.location.href = redirect || clientHref(loc, ROUTES.account)
})
.catch(() => {
this.error = this.$el.dataset.failLabel || "login failed"
})
.finally(() => {
this.loading = false
})
},
}))
})
</script>
</body>
</html>

View File

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

336
src/pages/en/account.astro Normal file
View File

@ -0,0 +1,336 @@
---
import Layout from "@/layouts/Layout.astro"
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from "@/lib/routes"
const { locale, t } = getI18n(Astro)
const titleLabel = t("my account")
const pleaseLabel = t("please log in")
const loginLabel = t("login")
const historyLabel = t("my history")
const savedLooksLabel = t("saved looks")
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
const archiveUrl = href(locale, ROUTES.runwayLooks)
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
const removeLabel = t("remove")
const itemsLabel = t("items")
const clearHistoryLabel = t("clear history")
const noSavedLabel = t("no saved looks yet")
const noHistoryLabel = t("no history yet")
const browseLabel = t("browse the archive")
---
<Layout title={titleLabel}>
<!-- 页面版心与 Layout 一致:黑白编辑风,衬线大标题 + 等宽小标 + 粗黑细线 -->
<main class="account-root mx-auto w-full max-w-[1400px] px-5 py-14 md:px-10 md:py-20 font-sans" x-data="accountPage" data-locale={locale}>
<!-- ===== 未登录:整版引导 ===== -->
<div x-show="!user" x-cloak class="flex flex-col items-center justify-center border border-black/15 px-6 py-32 text-center">
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{pleaseLabel}</p>
<p class="mb-12 font-serif text-5xl font-black uppercase leading-none tracking-tight md:text-7xl">{titleLabel}</p>
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-10 py-4 font-mono text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
<!-- ===== 已登录 ===== -->
<div x-show="user" x-cloak>
<!-- 报头:衬线大标题会员名 + 档案标签 -->
<header class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8 border-b-2 border-black pb-10 pt-2">
<div class="min-w-0">
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
<div class="flex flex-wrap items-end gap-x-4">
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
<!-- VIP 徽标:仅高级用户(图集详情页可见高清原图)显示 -->
<span x-show="user?.tier === 'vip'" x-cloak class="mb-2 inline-block border border-black bg-black px-2.5 py-1 font-mono text-[10px] font-bold uppercase tracking-[0.3em] text-white">{t("vip")}</span>
</div>
</div>
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
<p>Portfolio Studio</p>
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</header>
<!-- 主体:左侧分区索引 + 右侧内容 -->
<div class="grid gap-12 pt-12 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16 lg:pt-16">
<!-- 左:编号目录式垂直导航(移动端折成横向条) -->
<nav class="lg:sticky lg:top-12 lg:self-start" aria-label="Account sections">
<p class="mb-3 hidden font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400 lg:block">Index</p>
<div class="flex gap-10 overflow-x-auto border-b border-black/15 font-mono text-[12px] uppercase tracking-[0.2em] lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0 lg:border-t lg:border-black/10">
<button type="button" @click="tab='saved'" :class="tab==='saved' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
<span class="mr-3 text-[10px] opacity-60">01</span>{savedLooksLabel}
</button>
<button type="button" @click="tab='history'" :class="tab==='history' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
<span class="mr-3 text-[10px] opacity-60">02</span>{historyLabel}
</button>
</div>
</nav>
<div class="min-w-0">
<!-- ===== Saved looks ===== -->
<div x-show="tab==='saved'" x-cloak>
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
<p class="pb-1 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
</div>
<!-- 粒度切换:图集 / 单图(服务端 kind 过滤分页) -->
<div class="mb-10 flex items-center gap-8 border-b border-black/15 font-mono text-[11px] uppercase tracking-[0.2em]">
<button type="button" @click="setFavKind('gallery')" :class="favKind==='gallery' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('galleries')}</button>
<button type="button" @click="setFavKind('image')" :class="favKind==='image' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('images')}</button>
</div>
<!-- 空态 -->
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noSavedLabel}</p>
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
</div>
<!-- 收藏网格 -->
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
<template x-for="f in favorites" :key="f.id">
<div class="group">
<a :href="itemHref(f)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
</a>
<button @click="removeFav(f.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
<div class="mt-3 min-w-0">
<a :href="itemHref(f)" class="block">
<p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p>
</a>
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p>
</div>
</div>
</template>
</div>
<!-- 收藏分页(服务端分页,与收藏总量解耦) -->
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="favLastPage > 1" aria-label="Saved pagination">
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
<template x-for="(p, i) in favPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
</nav>
</div>
<!-- ===== History ===== -->
<div x-show="tab==='history'" x-cloak>
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{historyLabel}</h2>
<div class="flex items-baseline gap-8 pb-1">
<p class="font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="histTotal"></span> {itemsLabel}</p>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
</div>
</div>
<!-- 空态 -->
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noHistoryLabel}</p>
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
</div>
<!-- 历史网格 -->
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
<template x-for="h in history" :key="h.id">
<div class="group">
<a :href="itemHref(h)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
</a>
<button @click="removeHist(h.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
<div class="mt-3 min-w-0">
<a :href="itemHref(h)" class="block">
<p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p>
</a>
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p>
</div>
</div>
</template>
</div>
<!-- 历史分页 -->
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="histLastPage > 1" aria-label="History pagination">
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
<template x-for="(p, i) in histPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
</nav>
</div>
</div>
</div>
</div>
</main>
</Layout>
<style>
/* 触屏没有 hover:卡片右上角操作常驻显示,否则点不到 */
@media (hover: none) {
.card-act { opacity: 1 !important; }
}
</style>
<script>
import { getUser, toAbs } from "@/lib/api"
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
import { clientHref, ROUTES } from "@/lib/routes"
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
'<rect width="300" height="400" fill="#f1f1f1"/>' +
'<text x="150" y="200" font-family="monospace" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
'</svg>'
)
document.addEventListener("alpine:init", () => {
Alpine.data("accountPage", () => ({
user: null as any,
locale: "en",
tab: "saved",
favKind: "gallery", // 收藏子分类:gallery=整篇图集 / image=单张图片
favorites: [] as any[],
favoritesCount: 0,
favPage: 1,
favPerPage: 24,
favLastPage: 1,
favTotal: 0,
history: [] as any[],
historyCount: 0,
histPage: 1,
histPerPage: 24,
histLastPage: 1,
histTotal: 0,
init() {
this.locale = this.$el.dataset.locale || "en"
const u = getUser()
if (!u) {
window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
return
}
this.user = u
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
this.loadFavPage(1)
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
this.loadHistPage(1)
},
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级);kind 决定取图集还是单图
async loadFavPage(page: number, kind?: string) {
const p = Math.max(1, page | 0)
const k = kind || this.favKind
try {
const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery")
this.favorites = r.items
this.favTotal = r.total
this.favLastPage = r.lastPage
this.favPage = p
this.favoritesCount = r.total
} catch {
const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery"))
this.favorites = local
this.favTotal = local.length
this.favLastPage = 1
this.favPage = 1
this.favoritesCount = local.length
}
},
setFavKind(k: string) {
if (this.favKind === k) return
this.favKind = k
this.favorites = []
this.favoritesCount = 0
this.favLastPage = 1
this.loadFavPage(1, k)
},
goFavPage(p: number) {
if (p < 1 || p > this.favLastPage || p === this.favPage) return
this.loadFavPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
get favPageList() {
const cur = this.favPage, last = this.favLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
coverUrl(x: any): string {
const c = x.data?.cover || x.cover
return c ? toAbs(c) : PLACEHOLDER
},
itemHref(f: any): string {
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
// 图集收藏与历史仍用自身 uid。
const isImg = f.kind === "image"
const uid = isImg && f.parentUid ? f.parentUid : f.id
const base = clientHref(this.locale, ROUTES.item(uid))
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
},
removeFav(id: string) {
removeFavorite(id)
this.loadFavPage(this.favPage) // 重新拉当前页,保证分页计数一致
},
// ===== 浏览历史(服务端分页)=====
async loadHistPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchHistoryPage(p, this.histPerPage)
this.history = r.items
this.histTotal = r.total
this.histLastPage = r.lastPage
this.histPage = p
} catch {
this.history = []
this.histTotal = 0
this.histLastPage = 1
this.histPage = 1
}
},
goHistPage(p: number) {
if (p < 1 || p > this.histLastPage || p === this.histPage) return
this.loadHistPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
get histPageList() {
const cur = this.histPage, last = this.histLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
removeHist(id: string) {
removeHistory(id)
this.loadHistPage(this.histPage) // 重新拉当前页,保证分页计数一致
},
clearAllHistory() {
clearHistory()
this.loadHistPage(1)
},
}))
})
</script>

View File

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

View File

@ -1,15 +1,123 @@
--- ---
import Layout from "@/layouts/Layout.astro" import Layout from "@/layouts/Layout.astro"
import Item from "@/components/Item.astro" import Item from "@/components/Item.astro"
import { getSsrArticle } from "@/lib/api" import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/items/:id 链接,无需改动其他页面) // 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
export const prerender = false export const prerender = false
const { id } = Astro.params const { id } = Astro.params
const { article, error } = await getSsrArticle(id, Astro.currentLocale) const isSnap = !!id && id[0] === "s"
const { t } = getI18n(Astro)
let article = null
let articleErr = null
let snap = null
let snapErr = null
let preview = false
let imageTotal = 0
// await 必须位于 frontmatter 顶层(Astro 不支持块内顶层 await)
const data = isSnap
? await getSsrStreetSnap(id, Astro.currentLocale)
: await getSsrArticle(id, Astro.currentLocale)
if (isSnap) {
snap = data.snap
snapErr = data.error
} else {
article = data.article
articleErr = data.error
}
preview = data.preview ?? false
imageTotal = data.imageTotal ?? 0
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
const meta = isSnap
? { id: id ?? "", type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
: { id: id ?? "", type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
const saveLabel = t("save")
const savedLabel = t("saved")
--- ---
<Layout> <Layout>
<Item article={article} error={error} /> {article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
</Layout> </Layout>
<style>
#fav-fab.is-active .fav-heart { fill: currentColor; }
</style>
{(article || snap) && (
<button login-required id="fav-fab" type="button"
data-id={meta.id}
data-type={meta.type}
data-title={meta.title}
data-cover={meta.cover}
data-brand={meta.brand}
data-save-label={saveLabel}
data-saved-label={savedLabel}
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
<span id="fav-label">{saveLabel}</span>
</button>
)}
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
<script>
import { getUser } from "@/lib/api"
import { isFavorited, toggleFavorite, checkFavorited, hydrateFavorited } from "@/lib/favorites"
import { recordHistory } from "@/lib/history"
const btn = document.getElementById("fav-fab")
const label = document.getElementById("fav-label")
if (btn) {
const meta = {
id: btn.dataset.id || "",
type: btn.dataset.type || "",
title: btn.dataset.title || "",
cover: btn.dataset.cover || "",
brand: btn.dataset.brand || "",
}
const saveLabel = btn.dataset.saveLabel || "save"
const savedLabel = btn.dataset.savedLabel || "saved"
if (meta.id) {
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
if (getUser()) recordHistory(meta.id)
const sync = () => {
const fav = isFavorited(meta.id)
if (label) label.textContent = fav ? savedLabel : saveLabel
btn.classList.toggle("is-active", fav)
}
sync()
// 登录态:异步问服务端当前篇是否已收藏并写回本地(换浏览器/设备后,爱心状态与服务端一致)
if (getUser()) {
checkFavorited([meta.id])
.then((set) => {
if (set.has(meta.id)) hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
sync()
})
.catch(() => {})
}
btn.addEventListener("click", () => {
toggleFavorite({
id: meta.id,
type: meta.type,
title: meta.title,
cover: meta.cover,
brand: meta.brand,
} as any)
sync()
})
}
}
</script>

166
src/pages/en/login.astro Normal file
View File

@ -0,0 +1,166 @@
---
import '@/styles/global.css'
import { getI18n } from '@/i18n/utils'
import { href, ROUTES } from '@/lib/routes'
import { LOCALE_NAMES } from '@/i18n/config'
import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n';
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
const pathName = Astro.url.pathname
const locales = toCodes(i18n!.locales)
const pathWithoutLocale = locales.includes(pathName.split('/')[1])
? pathName.replace(`/${locale}`, '')
: pathName
const languages = locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName }))
const loginLabel = t('login')
const promptLabel = t('login prompt')
const accountLabel = t('username or email')
const passwordLabel = t('password')
const failLabel = t('login failed')
const membersLabel = t('members')
const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks)
const pageTitle = `${loginLabel} — Portfolio Studio`
---
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
</head>
<!-- 全站一致:白底 + 衬线大标题 + 等宽小标 + 粗黑细线,直角无阴影 -->
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
<!-- 顶部工具行:返回档案 + 语言切换 -->
<header class="mb-12 flex items-center justify-between gap-6">
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black/50 transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false" @click.outside="open = false">
<button type="button" @click="open = !open" class="inline-flex items-center gap-2 border border-black/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
<span>{LOCALE_NAMES[locale].nativeName}</span>
<svg viewBox="0 0 12 12" width="11" height="11" class="transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<ul x-show="open" x-cloak class="absolute right-0 top-full z-20 mt-2 min-w-[170px] border border-black/15 bg-white py-1">
{languages.map((l) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 font-mono text-[11px] uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/55']}>{l.label}</a>
</li>
))}
</ul>
</div>
</header>
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 2px 粗黑线) -->
<section class="border-b-2 border-black pb-10">
<div class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
<div>
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
</div>
<div class="pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
<p>Portfolio Studio</p>
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</div>
</section>
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
<div class="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
<div class="w-full max-w-md">
<p class="mb-10 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400">{promptLabel}</p>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required autofocus
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<div>
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<p x-show="error" x-text="error" x-cloak class="font-mono text-[11px] uppercase tracking-[0.2em] text-red-600"></p>
<button type="submit" :disabled="loading"
class="w-full border border-black bg-black py-4 font-mono text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-50">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak>…</span>
</button>
</form>
<div class="mt-10 border-t border-black/15 pt-5">
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
</div>
</div>
<aside class="border-t border-black/10 pt-8 md:border-l md:border-t-0 md:pl-8 md:pt-0">
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">Archive note</p>
<p class="font-serif text-lg leading-relaxed text-black/80">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
<div class="mt-8 border-t border-black/10 pt-5 font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400">
<p class="mb-3">Runway · Street · Archive</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>
</aside>
</div>
</main>
<script>
import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
document.addEventListener("alpine:init", () => {
Alpine.data("loginForm", () => ({
account: "",
password: "",
error: "",
loading: false,
init() {
if (getUser()) {
const loc = this.$el.dataset.locale || "en"
window.location.href = clientHref(loc, ROUTES.account)
}
},
submit() {
this.error = ""
this.loading = true
login({ account: this.account, password: this.password })
.then(() => {
const params = new URLSearchParams(window.location.search)
const redirect = params.get("redirect")
const loc = this.$el.dataset.locale || "en"
window.location.href = redirect || clientHref(loc, ROUTES.account)
})
.catch(() => {
this.error = this.$el.dataset.failLabel || "login failed"
})
.finally(() => {
this.loading = false
})
},
}))
})
</script>
</body>
</html>

View File

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

View File

@ -1,3 +1,4 @@
@import "tailwindcss"; @import "tailwindcss";
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }

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

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