Compare commits
12 Commits
e1c0e53b81
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 19fb597bb4 | |||
| 36f3ad039f | |||
| 6902376bc2 | |||
| 64af75c0a9 | |||
| 50e5775dc8 | |||
| 97b023bbbd | |||
| 0d13748d7e | |||
| 662b12c612 | |||
| dd66e5b693 | |||
| e24bfe164d | |||
| a133413d74 | |||
| 3c7c5d8855 |
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
@ -1,4 +1,5 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
"recommendations": [
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
|
||||
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
32
.workbuddy/memory/2026-08-29.md
Normal file
32
.workbuddy/memory/2026-08-29.md
Normal 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。
|
||||
43
.workbuddy/memory/2026-08-30.md
Normal file
43
.workbuddy/memory/2026-08-30.md
Normal 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。
|
||||
29
.workbuddy/memory/2026-08-31.md
Normal file
29
.workbuddy/memory/2026-08-31.md
Normal 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 手动端点保留(可用于"登出其他设备"按钮/管理员强踢)。
|
||||
87
.workbuddy/memory/2026-09-01.md
Normal file
87
.workbuddy/memory/2026-09-01.md
Normal 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 错误。
|
||||
125
.workbuddy/memory/2026-09-02.md
Normal file
125
.workbuddy/memory/2026-09-02.md
Normal 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 浏览历史条目已整体改写。
|
||||
|
||||
71
.workbuddy/memory/2026-09-03.md
Normal file
71
.workbuddy/memory/2026-09-03.md
Normal 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 转义为 && >)。登录后门禁消失、分页器出现、显示全部。
|
||||
|
||||
## 未登录预览门禁后端硬截断 + 重启踩坑(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 && 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 弄反导致重复插入门禁块,须先读文件确认再一次性删除所有重复块;改文件先想清楚替换方向再提交。
|
||||
@ -1,37 +1,55 @@
|
||||
# 项目长期记忆(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/backend_v2** — Go + Gin + GORM(MySQL),公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`),SSG 内部引擎 :8091(仅 127.0.0.1,`/api/v1/ssg/*`,SSGToken 中间件,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` 选列返回(pickLocale 缺翻译回落另一语言);前端取数统一复用带 locale 的原语(request/ssgRequest 等),**绝不裸写 fetch;语言以 URL 前缀为准**。
|
||||
- 走秀标题 `title_cn` 已规则回填(backend_v2 `scripts/backfill_title_cn/main.go`,幂等);`description_cn` 为空白属预期,中文页回落英文。
|
||||
- 前端 `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,nginx 不反代)。
|
||||
- 接口 i18n:后端按 `?locale=cn|en` 选列;前端取数复用带 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)。
|
||||
- 前端 dev server **不热加载 `.env`**:改 BASE_API 后需 `astro dev stop`+`astro dev --background` 重启;浏览器旧 bundle 需硬刷新、旧 token 失效需重登录。
|
||||
|
||||
## 关键约定
|
||||
- 视觉语言:黑白极简编辑风(monospace + Georgia 衬线大标题 + 细线),HBX / 时尚档案调性。字体全走 Tailwind 体系,禁止自写字体栈。
|
||||
- **品牌名前端一律只显示英文**(用户偏好 2026-08-26:「前端品牌不要展示中文名称,标题可以留着」):api.ts 映射 `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`)。
|
||||
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`。Tailwind v4 无零值类。
|
||||
- 导航(Layout.astro)保持原样,门禁仅由占位页承担。
|
||||
- 视觉:黑白极简编辑风(monospace 小标签 + Georgia 衬线大标题 + 细线),HBX/时尚档案调性。**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系。
|
||||
- 品牌名前端只显英文(`display=nameEn`);走秀标题/描述按 locale 选列。
|
||||
- API 命名:一个功能一个接口,跨资源/独立功能独立路径(如 `brands/hot` 而非 `?featured=1`)。
|
||||
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`;Tailwind v4 无零值类。
|
||||
- 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)
|
||||
- Layout 把 `<slot />` 包在 `flex-1 flex flex-col` 容器里;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 内容多少决定宽度、页面抖动)。用 `w-full`(可配 `max-w-[xxx]` + `mx-auto`)。
|
||||
## Astro `<script>` 坑
|
||||
- 作用域隔离:打包型 `<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。
|
||||
- **不做模板插值**:打包型 `<script>` 里 `{t('...')}` 不会被执行 → `t is not defined`。预翻译用 `define:vars={{ hiLabel }}` 注入;模板区 `{t()}` 正常。
|
||||
- 打包后脚本在 `_astro/*.js` 而非内联;查 dist 产物用 search_content(ripgrep),PowerShell 无 `Select-String -Recurse`。
|
||||
## 模块策略
|
||||
- 主线:首页 index + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。品牌索引页已下线(2026-08-26):品牌仅经走秀页侧栏 filter + 品牌弹窗(热门 30 SSG,搜索/字母运行时 `/api/v1/public/brands`)。
|
||||
- 导航死链清理(2026-08-30):删 `/latest-projects`/`/about`/`/contact`(Layout/routes/dictionary 同步)。保留未动死链:`/portfolio`/`/login`/`/register`。「header 别动」= 不擅改导航结构/视觉;用户已批的调整(Runway 下拉分类、Street Style 减城、删死链)属授权。
|
||||
|
||||
## 走秀数据分类(brand_runway)
|
||||
- 三维分类字段:`collection_type`(rtw/menswear/couture/resort/pre_fall)、`season`(spring/fall/null)、`season_code`(SS26/FW25…派生)。靠 title 规则回填(幂等,25,206 行)。
|
||||
- 公开列表 `?collection_type=&season=&season_code=&year=` 筛选;前端走秀页有系列/季节/年份 chips。
|
||||
- 地理/城市维度:用户明确暂不做。
|
||||
- 分布:rtw 15203 / menswear 4061 / couture 672 / resort 2894 / pre_fall 2376。
|
||||
## 未登录预览门禁(详情页,2026-09-03 定稿)
|
||||
- 用户真实意图:**列表页照常展示全部图集**,限制只发生在**点进图集详情后**——未登录仅看前 5 张图,其余提示登录(此前误做成列表页限制已回退)。
|
||||
- 后端硬截断(真防护,落详情接口):`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 → 匿名放行、已登录写态。
|
||||
- 前端:详情组件 `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 组件。
|
||||
|
||||
BIN
_tmp_build.log
BIN
_tmp_build.log
Binary file not shown.
26
_tmp_verify2.mjs
Normal file
26
_tmp_verify2.mjs
Normal 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
60
_tmp_verify_hist.mjs
Normal 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);
|
||||
@ -11,8 +11,9 @@ server {
|
||||
}
|
||||
|
||||
# 网关隔离:SSG 内部接口只存在于 backend 的 8091 端口(回环绑定,nginx 不反代)。
|
||||
# 若有人从公网域名探测 /api/ssg/*,此处直接 404,确保构建期全量数据绝不从公网出口。
|
||||
location /api/v1/ssg/ {
|
||||
# 若有人从公网域名探测 /api/internal/ssg/*(或旧 /api/v1/ssg/*),此处直接 404,
|
||||
# 确保构建期全量数据绝不从公网出口。
|
||||
location ~ ^/api/(internal/ssg/|v1/ssg/) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
|
||||
594
package-lock.json
generated
594
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,7 @@
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/alpinejs": "^3.13.11",
|
||||
"alpinejs": "^3.16.1",
|
||||
"astro": "^7.2.4",
|
||||
"astro": "^7.2.9",
|
||||
"swiper": "^11.2.10",
|
||||
"tailwindcss": "^4.3.3"
|
||||
}
|
||||
|
||||
9
server.err
Normal file
9
server.err
Normal 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.
|
||||
@ -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 |
@ -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 |
150
src/components/AuthModal.astro
Normal file
150
src/components/AuthModal.astro
Normal 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>
|
||||
160
src/components/BrandModal.astro
Normal file
160
src/components/BrandModal.astro
Normal 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>
|
||||
@ -22,7 +22,7 @@ const { locale } = getI18n(Astro)
|
||||
{note || "该模块正在筹备中,尚未对外开放。站点正按模块逐个完善,你可以先浏览已上线的走秀档案。"}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -5,11 +5,12 @@ import { toAbs } from "@/lib/api"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { getSsgIndexRunway } from "@/lib/api"
|
||||
import { getSsgStreetSnapPopular } from "@/lib/api"
|
||||
|
||||
// 国际化:一行取当前语言 + 翻译函数
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
// 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/v1/ssg/index/runway(server-only),
|
||||
// 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/internal/ssg/home/runway(server-only),
|
||||
// 首页 runway 数据含多模块,hotBrand 为其中之一;封面/标题取自每个品牌代表走秀,
|
||||
// 直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
|
||||
const popular = (await getSsgIndexRunway()).hotBrand
|
||||
@ -29,6 +30,19 @@ const galleryItems: GalleryItem[] = popular
|
||||
articleId: b.article_id,
|
||||
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">
|
||||
<!-- 二级分类标签页 -->
|
||||
@ -44,8 +58,8 @@ const galleryItems: GalleryItem[] = popular
|
||||
</li>
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'shows' }"
|
||||
@click="activeTab = 'shows'"
|
||||
:class="{ 'is-active': activeTab === 'street' }"
|
||||
@click="activeTab = 'street'"
|
||||
>
|
||||
Street Style
|
||||
</li>
|
||||
@ -136,9 +150,36 @@ const galleryItems: GalleryItem[] = popular
|
||||
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
||||
</div>
|
||||
|
||||
<!-- 街拍 面板 -->
|
||||
<!-- 街拍 面板(构建期 SSG 拉取热门街拍封面;去掉 swiper,改为 HBX 大图网格) -->
|
||||
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
|
||||
{
|
||||
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>
|
||||
</main>
|
||||
</div>
|
||||
@ -310,6 +351,66 @@ const galleryItems: GalleryItem[] = popular
|
||||
@media (max-width: 768px) {
|
||||
.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>
|
||||
|
||||
<script>
|
||||
@ -319,9 +420,9 @@ const galleryItems: GalleryItem[] = popular
|
||||
import "swiper/css/navigation";
|
||||
import "swiper/css/free-mode";
|
||||
|
||||
// 初始化 Swiper 轮播
|
||||
const el = document.getElementById("hsSwiper");
|
||||
if (el) {
|
||||
// 初始化 Swiper 轮播(首页可能有多个:Hot Brand / Street Style)
|
||||
const swipers: Swiper[] = []
|
||||
document.querySelectorAll<HTMLElement>(".hs-swiper").forEach((el) => {
|
||||
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
|
||||
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
|
||||
|
||||
@ -335,12 +436,21 @@ const galleryItems: GalleryItem[] = popular
|
||||
keyboard: { enabled: true },
|
||||
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
|
||||
observer: true,
|
||||
observeParents: true,
|
||||
observeSlideChildren: true,
|
||||
resizeObserver: true,
|
||||
});
|
||||
swipers.push(swiper);
|
||||
});
|
||||
|
||||
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
|
||||
window.addEventListener("img-ready", () => {
|
||||
requestAnimationFrame(() => swiper.update());
|
||||
// Street Style 等面板默认 x-show 隐藏,Swiper 在 display:none 下初始化会把 slide 算成 0 宽,
|
||||
// 切到对应 tab 后必须 update() 一次重算尺寸,否则图片虽在但宽度 0 不可见。
|
||||
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>
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import type { RawArticle } from "@/lib/data"
|
||||
import type { RawArticle } from "@/lib/api"
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
@ -8,9 +8,11 @@ import { ssrBase } from "@/lib/api"
|
||||
interface Props {
|
||||
article: RawArticle | 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 full = (u: string): string => {
|
||||
@ -23,10 +25,20 @@ const images = (article?.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 descParas = (article?.description || "")
|
||||
.split(/\n\n+/)
|
||||
.map((s) => s.trim())
|
||||
@ -53,6 +65,11 @@ if (article?.brand_id) {
|
||||
<div
|
||||
id="article-view"
|
||||
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"
|
||||
>
|
||||
{
|
||||
@ -68,19 +85,21 @@ if (article?.brand_id) {
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
{images.length > 0 && (
|
||||
{previewImages.length > 0 && (
|
||||
<section class="mt-16">
|
||||
<div
|
||||
id="article-gallery"
|
||||
class="grid grid-cols-2 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{images.map((im, i) => (
|
||||
{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="bg-[#f2f2f2] cursor-zoom-in group"
|
||||
class="relative bg-[#f2f2f2] cursor-zoom-in group"
|
||||
>
|
||||
<img
|
||||
src={im.url}
|
||||
@ -90,6 +109,22 @@ if (article?.brand_id) {
|
||||
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}
|
||||
@ -97,6 +132,71 @@ if (article?.brand_id) {
|
||||
)}
|
||||
</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>
|
||||
</section>
|
||||
)}
|
||||
@ -125,7 +225,7 @@ if (article?.brand_id) {
|
||||
)
|
||||
}
|
||||
|
||||
{article && images.length > 0 && (
|
||||
{article && previewImages.length > 0 && (
|
||||
<div
|
||||
x-show="lbOpen"
|
||||
x-cloak
|
||||
@ -140,6 +240,19 @@ if (article?.brand_id) {
|
||||
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()"
|
||||
@ -154,7 +267,7 @@ if (article?.brand_id) {
|
||||
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"
|
||||
>
|
||||
{images.map((im, i) => (
|
||||
{previewImages.map((im, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-lb-index={i}
|
||||
@ -171,6 +284,22 @@ if (article?.brand_id) {
|
||||
</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
|
||||
@ -282,6 +411,9 @@ if (article?.brand_id) {
|
||||
// handleTouchEnd(event: TouchEvent): void
|
||||
// }
|
||||
|
||||
import { getUser, getArticleDetailAuthed, toAbs } from "@/lib/api"
|
||||
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("articleView", () => ({
|
||||
lbOpen: false,
|
||||
@ -292,12 +424,108 @@ if (article?.brand_id) {
|
||||
imageCount: 0,
|
||||
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() {
|
||||
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(
|
||||
"#article-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 渲染。
|
||||
// 后端详情接口对已登录(带 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) {
|
||||
@ -314,6 +542,65 @@ if (article?.brand_id) {
|
||||
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) {
|
||||
if (!this.imageCount) return
|
||||
|
||||
@ -488,4 +775,25 @@ if (article?.brand_id) {
|
||||
-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>
|
||||
@ -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>
|
||||
112
src/components/LoadingScreen.astro
Normal file
112
src/components/LoadingScreen.astro
Normal 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>
|
||||
@ -1,33 +1,35 @@
|
||||
---
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import BrandModal from "@/components/BrandModal.astro"
|
||||
import { getSsgHotBrands } from "@/lib/api"
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import "@/styles/look-grid.css"
|
||||
|
||||
|
||||
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 个。
|
||||
// 接口不可用时回落假数据先看效果。
|
||||
const FAKE_HOT_BRANDS = [
|
||||
{ id: "hot-chanel", name: "Chanel", article_count: 342 },
|
||||
{ id: "hot-dior", name: "Dior", article_count: 318 },
|
||||
{ id: "hot-gucci", name: "Gucci", article_count: 287 },
|
||||
{ id: "hot-prada", name: "Prada", article_count: 254 },
|
||||
{ id: "hot-louisvuitton", name: "Louis Vuitton", article_count: 241 },
|
||||
{ id: "hot-saintlaurent", name: "Saint Laurent", article_count: 219 },
|
||||
{ id: "hot-balenciaga", name: "Balenciaga", article_count: 198 },
|
||||
{ id: "hot-valentino", name: "Valentino", article_count: 176 },
|
||||
{ id: "hot-givenchy", name: "Givenchy", article_count: 162 },
|
||||
{ id: "hot-burberry", name: "Burberry", article_count: 153 },
|
||||
{ id: "hot-fendi", name: "Fendi", article_count: 141 },
|
||||
{ id: "hot-bottega", name: "Bottega Veneta", article_count: 128 },
|
||||
{ id: "hot-chanel", name: "Chanel" },
|
||||
{ id: "hot-dior", name: "Dior" },
|
||||
{ id: "hot-gucci", name: "Gucci" },
|
||||
{ id: "hot-prada", name: "Prada" },
|
||||
{ id: "hot-louisvuitton", name: "Louis Vuitton" },
|
||||
{ id: "hot-saintlaurent", name: "Saint Laurent" },
|
||||
{ id: "hot-balenciaga", name: "Balenciaga" },
|
||||
{ id: "hot-valentino", name: "Valentino" },
|
||||
{ id: "hot-givenchy", name: "Givenchy" },
|
||||
{ id: "hot-burberry", name: "Burberry" },
|
||||
{ id: "hot-fendi", name: "Fendi" },
|
||||
{ id: "hot-bottega", name: "Bottega Veneta" },
|
||||
]
|
||||
let hotBrands: { id: string; name: string; article_count: number }[] = []
|
||||
let hotBrands: { id: string; name: string }[] = []
|
||||
try {
|
||||
hotBrands = (await getSsgHotBrands())
|
||||
.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 {}
|
||||
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
|
||||
|
||||
@ -107,15 +109,14 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
<ul class="showschecks">
|
||||
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
|
||||
<li>
|
||||
<label 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)" />
|
||||
<label login-required class="flex items-center gap-2 w-full cursor-pointer">
|
||||
<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 class="ct" x-text="b.article_count ? b.article_count : ''"></span>
|
||||
</label>
|
||||
</li>
|
||||
</template>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@ -130,7 +131,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
<div class="showsg-b" x-show="!collapsedGroups.collection">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{collectionOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
<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>
|
||||
</div>
|
||||
@ -147,7 +148,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
<div class="showsg-b" x-show="!collapsedGroups.season">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{seasonOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
<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>
|
||||
</div>
|
||||
@ -164,13 +165,13 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
<div class="showsg-b" x-show="!collapsedGroups.year">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{yearOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
@ -210,7 +211,8 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
|
||||
<!-- Card List -->
|
||||
<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-img" :class="{ 'is-loaded': it._imgLoaded }">
|
||||
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
|
||||
@ -219,7 +221,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
</div>
|
||||
<div class="showscard-right">
|
||||
<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 class="showscard-strip">
|
||||
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
|
||||
@ -232,374 +234,41 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
</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>
|
||||
|
||||
<div class="showsloadmore-wrap" x-show="items.length > 0">
|
||||
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
|
||||
</div>
|
||||
<nav class="showspager" 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>
|
||||
|
||||
<!-- 品牌筛选弹窗:搜索 + 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"
|
||||
/>
|
||||
</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>
|
||||
<!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) -->
|
||||
<BrandModal />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<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>
|
||||
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"
|
||||
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", () => {
|
||||
Alpine.data("showsPage", () => {
|
||||
@ -610,16 +279,17 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
locale: '',
|
||||
hotBrands,
|
||||
SPIN_SVG,
|
||||
// 侧栏分组折叠状态:默认全部收起(不做 localStorage 持久化,每次进页都是关着的)。
|
||||
collapsedGroups: {
|
||||
designers: localStorage.getItem("showsg-designers") === "1",
|
||||
collection: localStorage.getItem("showsg-collection") === "1",
|
||||
season: localStorage.getItem("showsg-season") === "1",
|
||||
year: localStorage.getItem("showsg-year") === "1",
|
||||
designers: true,
|
||||
collection: true,
|
||||
season: true,
|
||||
year: true,
|
||||
},
|
||||
selectedBrands: [] as number[],
|
||||
selectedCt: [] as string[],
|
||||
selectedSeason: [] as string[],
|
||||
selectedYear: [] as string[],
|
||||
selectedBrand: null as (string | null),
|
||||
selectedCt: "" as string,
|
||||
selectedSeason: "" as string,
|
||||
selectedYear: "" as string,
|
||||
|
||||
// 品牌筛选弹窗状态
|
||||
modalOpen: false,
|
||||
@ -627,93 +297,84 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
modalLetter: "",
|
||||
modalResults: [] as BrandEntry[],
|
||||
modalLoading: false,
|
||||
modalSelected: [] as number[],
|
||||
get modalMaxReached() {
|
||||
return this.modalSelected.length >= 1
|
||||
},
|
||||
modalSelected: null as (string | null),
|
||||
|
||||
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
page: 1,
|
||||
size: 24,
|
||||
total: 0,
|
||||
isEnd: false,
|
||||
lastPage: 1,
|
||||
reqToken: 0,
|
||||
|
||||
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
|
||||
favMap: {} as Record<string, boolean>,
|
||||
|
||||
init() {
|
||||
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() {
|
||||
return this.size
|
||||
return 24
|
||||
},
|
||||
|
||||
// 已筛选选项 Pills 视图数据
|
||||
get pills() {
|
||||
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 labels = this.selectedBrands
|
||||
.map((id) => findName(id))
|
||||
.slice(0, 4)
|
||||
const more = this.selectedBrands.length > 4 ? ` +${this.selectedBrands.length - 4}` : ""
|
||||
const name = findName(this.selectedBrand)
|
||||
list.push({
|
||||
label: "DESIGNER · " + labels.join(", ") + more,
|
||||
clear: () => { this.selectedBrands = []; this.refreshFilter(); }
|
||||
label: "DESIGNER · " + name,
|
||||
clear: () => { this.selectedBrand = null; this.refreshFilter(); }
|
||||
})
|
||||
}
|
||||
if (this.selectedCt.length) {
|
||||
if (this.selectedCt) {
|
||||
list.push({
|
||||
label: "COLLECTION · " + this.selectedCt.join("+").toUpperCase(),
|
||||
clear: () => { this.selectedCt = []; this.refreshFilter(); }
|
||||
label: "COLLECTION · " + this.selectedCt.toUpperCase(),
|
||||
clear: () => { this.selectedCt = ""; this.refreshFilter(); }
|
||||
})
|
||||
}
|
||||
if (this.selectedSeason.length) {
|
||||
if (this.selectedSeason) {
|
||||
list.push({
|
||||
label: "SEASON · " + this.selectedSeason.join("+").toUpperCase(),
|
||||
clear: () => { this.selectedSeason = []; this.refreshFilter(); }
|
||||
label: "SEASON · " + this.selectedSeason.toUpperCase(),
|
||||
clear: () => { this.selectedSeason = ""; this.refreshFilter(); }
|
||||
})
|
||||
}
|
||||
if (this.selectedYear.length) {
|
||||
if (this.selectedYear) {
|
||||
list.push({
|
||||
label: "YEAR · " + [...this.selectedYear].sort().join("+"),
|
||||
clear: () => { this.selectedYear = []; this.refreshFilter(); }
|
||||
label: "YEAR · " + this.selectedYear,
|
||||
clear: () => { this.selectedYear = ""; this.refreshFilter(); }
|
||||
})
|
||||
}
|
||||
return list
|
||||
},
|
||||
|
||||
get loadBtnText() {
|
||||
if (this.loadingMore) return "Loading…"
|
||||
if (this.isEnd) return "— End of Archive —"
|
||||
return "Load More ↓"
|
||||
},
|
||||
|
||||
// 点击抽屉开关
|
||||
// 点击抽屉开关:仅切换内存状态,刷新后回到默认(全部收起)
|
||||
toggleGroup(group: "designers" | "collection" | "season" | "year") {
|
||||
this.collapsedGroups[group] = !this.collapsedGroups[group]
|
||||
localStorage.setItem(`showsg-${group}`, this.collapsedGroups[group] ? "1" : "")
|
||||
},
|
||||
|
||||
// 选中热门品牌
|
||||
toggleBrand(id: number) {
|
||||
const idx = this.selectedBrands.indexOf(id)
|
||||
if (idx >= 0) this.selectedBrands.splice(idx, 1)
|
||||
else this.selectedBrands.push(id)
|
||||
this.refreshFilter()
|
||||
},
|
||||
|
||||
// 清除筛选条件
|
||||
refreshFilter() {
|
||||
this.loadPage(1, false)
|
||||
// 首页公开:筛选切换后重新加载第 1 页,未登录也可查看
|
||||
this.loadPage(1)
|
||||
},
|
||||
|
||||
toggleBrand(id: string) {
|
||||
this.selectedBrand = this.selectedBrand === id ? null : id
|
||||
this.refreshFilter()
|
||||
},
|
||||
|
||||
// ===== 品牌筛选弹窗方法 =====
|
||||
async openBrandModal() {
|
||||
this.modalSelected = [...this.selectedBrands]
|
||||
this.modalSelected = this.selectedBrand
|
||||
this.modalKeyword = ""
|
||||
this.modalLetter = "HOT"
|
||||
this.modalOpen = true
|
||||
@ -732,7 +393,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
this.modalLetter = "HOT"
|
||||
this.modalLoading = true
|
||||
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 {
|
||||
this.modalLoading = false
|
||||
}
|
||||
@ -744,7 +405,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
this.modalLoading = true
|
||||
this.modalResults = []
|
||||
try {
|
||||
this.modalResults = await getBrands({ keyword: kw, size: 60 })
|
||||
this.modalResults = await getBrands({ keyword: kw })
|
||||
} catch {
|
||||
this.modalResults = []
|
||||
} finally {
|
||||
@ -768,23 +429,15 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
this.modalResults = []
|
||||
try {
|
||||
const letter = l === "#" ? "OTHER" : l
|
||||
this.modalResults = await getBrands({ letter, size: 200 })
|
||||
this.modalResults = await getBrands({ letter })
|
||||
} catch {
|
||||
this.modalResults = []
|
||||
} finally {
|
||||
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() {
|
||||
this.selectedBrands = [...this.modalSelected]
|
||||
this.selectedBrand = this.modalSelected
|
||||
this.modalOpen = false
|
||||
document.documentElement.style.overflow = ""
|
||||
document.documentElement.style.paddingRight = ""
|
||||
@ -792,73 +445,121 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
},
|
||||
|
||||
clearAll() {
|
||||
this.selectedBrands = []
|
||||
this.selectedCt = []
|
||||
this.selectedSeason = []
|
||||
this.selectedYear = []
|
||||
this.selectedBrand = null
|
||||
this.selectedCt = ""
|
||||
this.selectedSeason = ""
|
||||
this.selectedYear = ""
|
||||
this.refreshFilter()
|
||||
},
|
||||
|
||||
// API 加载
|
||||
async loadPage(page: number, append = false) {
|
||||
// API 加载(分页:每次整页替换,不追加)
|
||||
async loadPage(page: number) {
|
||||
const myToken = ++this.reqToken
|
||||
if (append) {
|
||||
this.loadingMore = true
|
||||
} else {
|
||||
this.loading = true
|
||||
this.items = []
|
||||
}
|
||||
|
||||
try {
|
||||
const json = await getArticles({
|
||||
page,
|
||||
size: this.size,
|
||||
sort: "newest",
|
||||
brand_ids: this.selectedBrands,
|
||||
collection_type: this.selectedCt,
|
||||
season: this.selectedSeason,
|
||||
year: this.selectedYear,
|
||||
with_images: 6,
|
||||
brandId: this.selectedBrand || undefined,
|
||||
collection: this.selectedCt || undefined,
|
||||
season: this.selectedSeason || undefined,
|
||||
year: this.selectedYear || undefined,
|
||||
})
|
||||
|
||||
if (myToken !== this.reqToken) return
|
||||
|
||||
const rows = json.data.map((item: ArticleItem) => ({ ...item, _imgLoaded: false }))
|
||||
this.total = json.total
|
||||
this.lastPage = json.last_page
|
||||
this.page = page
|
||||
this.items = append ? [...this.items, ...rows] : rows
|
||||
|
||||
this.isEnd = page >= json.last_page || rows.length < this.size
|
||||
this.items = rows
|
||||
this.markFavs() // 当前页可见 id 走服务端校验收藏态(与收藏总量解耦)
|
||||
} catch {
|
||||
if (myToken !== this.reqToken) return
|
||||
if (!append) this.items = []
|
||||
this.items = []
|
||||
} finally {
|
||||
if (myToken === this.reqToken) {
|
||||
this.loading = false
|
||||
this.loadingMore = false
|
||||
}
|
||||
if (myToken === this.reqToken) this.loading = 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) },
|
||||
|
||||
// 渲染文章url
|
||||
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) {
|
||||
const gallery = it.gallery || []
|
||||
const totalCount = it.image_count || 0
|
||||
const moreLabel = totalCount > 30 ? "30+" : `${totalCount || 0}+`
|
||||
const slots = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const src = gallery[i]
|
||||
const isLast = i === 4
|
||||
slots.push({ src, loaded: false, isLast, moreLabel: (isLast && totalCount > 0) ? moreLabel : "" })
|
||||
const total = it.image_count || 0
|
||||
// 缩略图条带跳过主图(it.cover 即 gallery[0]),从第二张起,避免首张缩略图与主图重复。
|
||||
return makeCardSlots(it.gallery || [], 5, {
|
||||
moreLabel: () => (total > 30 ? "30+" : `${total}+`),
|
||||
})
|
||||
},
|
||||
|
||||
// ===== 收藏(图片收藏入口)=====
|
||||
// 按当前页可见 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
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
644
src/components/StreetSnap.astro
Normal file
644
src/components/StreetSnap.astro
Normal 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>
|
||||
405
src/components/StreetSnaps.astro
Normal file
405
src/components/StreetSnaps.astro
Normal 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>
|
||||
@ -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>
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
|
||||
import ComingSoon from "@/components/ComingSoon.astro"
|
||||
---
|
||||
|
||||
<ComingSoon module="About" moduleZh="关于" />
|
||||
@ -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’t have an account? Sign up →</a>
|
||||
<a class="auth-link muted" href="#">Forgot your password?</a>
|
||||
</>
|
||||
) : (
|
||||
<a class="auth-link" href={loginHref}>Already have an account? Sign in →</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>
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
|
||||
import ComingSoon from "@/components/ComingSoon.astro"
|
||||
---
|
||||
|
||||
<ComingSoon module="Contact" moduleZh="联系" />
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
|
||||
import ComingSoon from "@/components/ComingSoon.astro"
|
||||
---
|
||||
|
||||
<ComingSoon module="Latest Projects" moduleZh="最新项目" />
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
|
||||
import ComingSoon from "@/components/ComingSoon.astro"
|
||||
---
|
||||
|
||||
<ComingSoon module="Sign In" moduleZh="登录" note="账户系统正在筹备中,暂未开放注册与登录。" />
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
|
||||
import ComingSoon from "@/components/ComingSoon.astro"
|
||||
---
|
||||
|
||||
<ComingSoon module="Portfolio" moduleZh="作品集" />
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
|
||||
import ComingSoon from "@/components/ComingSoon.astro"
|
||||
---
|
||||
|
||||
<ComingSoon module="Create Account" moduleZh="注册" note="账户系统正在筹备中,暂未开放注册与登录。" />
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -19,12 +19,3 @@ export const LOCALE_NAMES: Record<string, { name: string; nativeName: string }>
|
||||
nativeName: 'English',
|
||||
},
|
||||
} 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',
|
||||
};
|
||||
|
||||
@ -21,26 +21,99 @@ export const dict: Record<string, TranslationEntry> = {
|
||||
|
||||
// ── 导航 / Navigation ──
|
||||
|
||||
'about': { cn: '关于' },
|
||||
|
||||
'全部的 runway': { cn: '全部的 runway' },
|
||||
|
||||
'contact': { cn: '联系' },
|
||||
|
||||
'contact us': { cn: '联系我们' },
|
||||
|
||||
'home': { cn: '首页' },
|
||||
|
||||
'latest projects': { cn: '最新项目' },
|
||||
|
||||
'login': { cn: '登录' },
|
||||
|
||||
'portfolio': { cn: '作品集' },
|
||||
|
||||
'runway archive': { cn: '走秀档案' },
|
||||
|
||||
// ── Runway 子分类(header 下拉,与列表页 COLLECTION 筛选一致)──
|
||||
'ready-to-wear': { cn: '女装成衣' },
|
||||
'menswear': { cn: '男装' },
|
||||
'couture': { cn: '高级定制' },
|
||||
'resort': { cn: '度假系列' },
|
||||
'pre-fall': { 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 ──
|
||||
|
||||
'back to index': { cn: '返回首页' },
|
||||
@ -96,8 +169,40 @@ export const dict: Record<string, TranslationEntry> = {
|
||||
|
||||
'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} 形式的占位符替换成参数值。 */
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
---
|
||||
import '@/styles/global.css'
|
||||
import LoadingScreen from '@/components/LoadingScreen.astro'
|
||||
import AuthModal from '@/components/AuthModal.astro'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { LOCALES, LOCALE_NAMES } from '@/i18n/config'
|
||||
import { LOCALE_NAMES } from '@/i18n/config'
|
||||
import { setApiLocale } from '@/lib/api'
|
||||
import { i18n } from 'astro:config/client';
|
||||
import { toCodes } from 'astro:i18n';
|
||||
|
||||
import LanguageDetector from '@/components/LanguageDetector.astro'
|
||||
|
||||
const { title } = Astro.props as { title?: string }
|
||||
const siteTitle = title ? `${title} — Portfolio Studio` : "Portfolio Studio"
|
||||
|
||||
@ -19,8 +19,7 @@ const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||
setApiLocale(locale)
|
||||
|
||||
// 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性
|
||||
const hiLabel = t('Hi')
|
||||
const logoutLabel = t('Logout')
|
||||
// (账号区现改为 Alpine 组件,译文直接在模板内用 t() 输出,无需 data 属性中转)
|
||||
|
||||
const pathName = Astro.url.pathname;
|
||||
|
||||
@ -32,16 +31,23 @@ const pathWithoutLocale = locales.includes(pathName.split('/')[1])
|
||||
// 顶部导航数据模型(替代原 Alpine x-data 的 menu/state)。
|
||||
// 下拉菜单与语言列表均由此驱动渲染,逻辑用 Astro 原生 <script> 控制开合。
|
||||
const headers = {
|
||||
// 走秀下拉菜单项(href 统一指向 /shows)
|
||||
// 走秀下拉菜单项:方案 A —— 全部 + 4 个真实 collection_type 分类(与列表页 COLLECTION 筛选一致),
|
||||
// 深链带 ?collection= 参数,由 RunwayLooks 的 init() 读取并自动选中筛选。
|
||||
runways: [
|
||||
{ label: t('全部的 runway'), href: href(locale, ROUTES.runwayLooks), current: true },
|
||||
{ label: 'Spring / Summer 2026', href: href(locale, ROUTES.shows) },
|
||||
{ label: 'Fall / Winter 2025', href: href(locale, ROUTES.shows) },
|
||||
{ label: 'Resort 2026', href: href(locale, ROUTES.shows) },
|
||||
{ label: 'Pre-Fall 2025', href: href(locale, ROUTES.shows) },
|
||||
{ label: 'Couture FW25', href: href(locale, ROUTES.shows) },
|
||||
{ label: 'Menswear SS26', href: href(locale, ROUTES.shows) },
|
||||
{ label: 'Menswear FW25', href: href(locale, ROUTES.shows) },
|
||||
{ label: t('全部的 runway'), href: href(locale, ROUTES.runwayLooks) },
|
||||
{ label: t('Ready-to-Wear'), href: href(locale, ROUTES.runwayLooks) + '?collection=rtw' },
|
||||
{ label: t('Menswear'), href: href(locale, ROUTES.runwayLooks) + '?collection=menswear' },
|
||||
{ label: t('Couture'), href: href(locale, ROUTES.runwayLooks) + '?collection=couture' },
|
||||
{ label: t('Resort'), href: href(locale, ROUTES.runwayLooks) + '?collection=resort' },
|
||||
],
|
||||
// 街拍下拉菜单项:按地区分类(值即后端 street_snap.city 列存储的规范英文名),
|
||||
// 深链带 ?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 + 本地名称)
|
||||
languages: locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName })),
|
||||
@ -50,7 +56,6 @@ const headers = {
|
||||
<!doctype html>
|
||||
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
|
||||
<head>
|
||||
<!-- TODO <LanguageDetector /> -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
@ -60,6 +65,9 @@ const headers = {
|
||||
</head>
|
||||
<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">
|
||||
<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 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<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">
|
||||
<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>
|
||||
</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) => (
|
||||
<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>
|
||||
<a href={href(locale, ROUTES.latestProjects)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Latest Projects')}</a>
|
||||
<a href={href(locale, ROUTES.about)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('About')}</a>
|
||||
<a href={href(locale, ROUTES.contact)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Contact Us')}</a>
|
||||
<!-- 街拍:菜单项用 Astro 渲染(headers.streets.map),开合逻辑用 Alpine.js -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<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 class="flex items-center gap-3">
|
||||
@ -104,7 +127,7 @@ const headers = {
|
||||
<span>{LOCALE_NAMES[locale].nativeName}</span>
|
||||
</button>
|
||||
<ul
|
||||
x-show="open"
|
||||
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"
|
||||
>
|
||||
@ -122,9 +145,27 @@ const headers = {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" id="auth-slot" data-hi-label={hiLabel} data-logout-label={logoutLabel}>
|
||||
<a href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200">{t('Login')}</a>
|
||||
<a href={href(locale, ROUTES.register)} class="hover:opacity-60 transition-opacity duration-200">{t('Signup')}</a>
|
||||
<div class="flex items-center gap-3" x-data="authMenu">
|
||||
<!-- 未登录:Login 链接(Alpine 登录后置为隐藏) -->
|
||||
<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>
|
||||
</nav>
|
||||
@ -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">
|
||||
<script>
|
||||
import { getUser, fetchMe, clearSession } from "@/lib/api"
|
||||
const slot = document.getElementById("auth-slot")
|
||||
const hiLabel = slot?.dataset.hiLabel ?? "Hi"
|
||||
const logoutLabel = slot?.dataset.logoutLabel ?? "Logout"
|
||||
function render(user) {
|
||||
if (!user) return // 未登录:保留 SSR 渲染的 Login/Signup(已带语言前缀,无需重写)
|
||||
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")
|
||||
if (btn) btn.addEventListener("click", () => { clearSession(); location.reload() })
|
||||
}
|
||||
const cached = getUser()
|
||||
if (cached) render(cached)
|
||||
fetchMe().then((user) => render(user)).catch(() => {})
|
||||
import { getUser, fetchMe, logout } from "@/lib/api"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("authMenu", () => ({
|
||||
user: null,
|
||||
open: false,
|
||||
|
||||
init() {
|
||||
this.user = getUser() // 立即恢复缓存,避免登录态闪烁
|
||||
|
||||
fetchMe()
|
||||
.then((u) => {
|
||||
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>
|
||||
{/* Top Footer Grid */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-8 mb-10 pb-10 border-b border-zinc-200">
|
||||
@ -184,6 +242,9 @@ const headers = {
|
||||
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* 全局登录门禁层(login-required 属性 + 统一登录浮层) */}
|
||||
<AuthModal />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
584
src/lib/api.ts
584
src/lib/api.ts
@ -1,19 +1,18 @@
|
||||
// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口)
|
||||
//
|
||||
// 集中封装全部 /api/v1 调用:公开查询(/api/v1/public/*)、账号体系(/api/v1/auth/*)、
|
||||
// 以及构建期专用的 SSG 接口(/api/v1/ssg/*,仅在 Node 构建期使用)。
|
||||
// 以及构建期专用的 SSG 接口(/api/internal/ssg/*,内部契约,仅 Node 构建期使用)。
|
||||
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。
|
||||
//
|
||||
// base URL 解析规则(全项目统一两个基准地址,命名见下):
|
||||
// BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。
|
||||
// 空值 = 同源相对 /api;DEV 构建期固定走线上域名。
|
||||
// BASE_API_SSG —— 构建期 Node 调 /api/ssg/* 的「内部端口」(默认 8091),
|
||||
// BASE_API_SSG —— 构建期 Node 调 /api/internal/ssg/* 的「内部端口」(默认 8091),
|
||||
// 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。
|
||||
//
|
||||
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
|
||||
// - publicBase():客户端运行期 base,仅 BASE_API(空 → 同源相对 /api)。
|
||||
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
|
||||
// - ssgBase():构建期 Node 调 /api/ssg/*,只读 BASE_API_SSG。
|
||||
// - ssgBase():构建期 Node 调 /api/internal/ssg/*,只读 BASE_API_SSG。
|
||||
|
||||
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
|
||||
import "@/lib/http"
|
||||
@ -34,7 +33,6 @@ export interface BrandEntry {
|
||||
name: string // 英文名(name_en 回落 name)
|
||||
nameCn?: string // 中文名(name_cn,可能为空)
|
||||
display?: string // 展示名:中文语境下为 "英文名 (中文名)",否则为英文名
|
||||
article_count: number
|
||||
}
|
||||
|
||||
export interface ArticleItem {
|
||||
@ -43,56 +41,43 @@ export interface ArticleItem {
|
||||
title: string
|
||||
cover: string
|
||||
brand_name: string
|
||||
year: number
|
||||
image_count: number
|
||||
published_at: number
|
||||
collection_type?: string
|
||||
season?: string
|
||||
season_code?: string
|
||||
gallery: string[]
|
||||
image_count: number // 该走秀图片总数(列表卡片 "N+" 角标用)
|
||||
gallery: string[] // 由 images 映射而来的附图 URL 列表
|
||||
}
|
||||
|
||||
export interface ArticleQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
sort?: string
|
||||
brand_ids?: string[] // 编码串数组,透传给后端解码
|
||||
collection_type?: string[]
|
||||
season?: string[]
|
||||
year?: string[]
|
||||
with_images?: number
|
||||
brandId?: string // 编码串;传入则附加 ?brand_id 查询参数
|
||||
collection?: string // 单值:rtw/menswear/couture/resort/pre_fall
|
||||
season?: string // 单值:spring/fall
|
||||
year?: string | number
|
||||
}
|
||||
|
||||
export interface ArticlePage {
|
||||
data: ArticleItem[]
|
||||
total: number
|
||||
last_page: number
|
||||
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
|
||||
}
|
||||
|
||||
// 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。
|
||||
export type RawArticle = {
|
||||
id: string | number // 后端为 hashid 编码串(string);示例数据回落为数字
|
||||
id: string | number // 后端为 hashid 编码串(string)
|
||||
title: string
|
||||
summary?: string
|
||||
description?: string
|
||||
description?: string // 详情正文(仅详情接口返回)
|
||||
cover: string
|
||||
brand_name: string
|
||||
brand_id?: string | number // hashid 编码串
|
||||
year: number
|
||||
image_count: number
|
||||
published_at: number
|
||||
created_at?: number
|
||||
source_url?: string
|
||||
collection_type?: string
|
||||
season?: string
|
||||
season_code?: string
|
||||
images?: { id?: number; image: string; name?: string }[]
|
||||
image_count?: number // 列表卡片 "N+" 角标用(仅列表接口返回)
|
||||
images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
/** 用户等级:free(普通)| vip(高级,图集详情页可见高清原图)。 */
|
||||
tier: 'free' | 'vip'
|
||||
}
|
||||
|
||||
// SSG 构建专用类型
|
||||
@ -108,7 +93,8 @@ export interface SsgPopularBrand {
|
||||
|
||||
// ===================== base 解析 =====================
|
||||
|
||||
export function getBase(): string {
|
||||
// 仅本模块内部使用(toAbs / request 等),不对外导出。
|
||||
function getBase(): string {
|
||||
// if (import.meta.env.DEV) return "https://seo.23cm.cn"
|
||||
// 运行期/预渲染统一走 BASE_API(空则同源相对 /api)
|
||||
return (BASE_API || "").replace(/\/$/, "")
|
||||
@ -120,24 +106,13 @@ export function toAbs(url: string): string {
|
||||
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 回落。
|
||||
export function ssrBase(): string {
|
||||
return (BASE_API || "http://localhost:8090").replace(/\/$/, "")
|
||||
}
|
||||
|
||||
// ===================== 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 选列并缺翻译回落),
|
||||
// 出参字段名保持不变,故前端契约无需改动,仅需在请求上带上当前语言。
|
||||
//
|
||||
@ -166,160 +141,154 @@ function withLocale(params?: URLSearchParams, override?: string): URLSearchParam
|
||||
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。
|
||||
// 自动追加 ?locale=(当前语言),让后端按语言选列返回。
|
||||
// 每个请求附带前端 JS 签名(X-Sign / X-Sign-Ts / X-Sign-Nonce),供后端 ClientSign 中间件校验。
|
||||
async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
|
||||
const p = withLocale(params)
|
||||
const qs = p.toString() ? "?" + p.toString() : ""
|
||||
const url = getBase() + "/api/v1" + path + qs
|
||||
const res = await fetch(url)
|
||||
const fullPath = "/api/v1" + path
|
||||
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}`)
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
// ===================== 文章(/api/public/articles) =====================
|
||||
// ===================== 走秀(/api/public/runway-looks) =====================
|
||||
|
||||
// 接口:GET /api/public/articles
|
||||
// 原始文章列表(不分页映射),供数据层与各类 getter 复用。
|
||||
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()(运行期按需渲染取数),也可显式传入。
|
||||
// 接口:GET /api/public/runway-looks/:id
|
||||
// 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
|
||||
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
|
||||
export async function fetchArticleById(
|
||||
id: number | string,
|
||||
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 页面只调这一行即可。
|
||||
// 对外导出:详情取数 / 个人中心逐篇回查(浏览器端按需取 title/cover 渲染历史卡片)。
|
||||
// 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。
|
||||
// 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。
|
||||
// locale 显式传入(如 Astro.currentLocale)以绑定当前语言,避免并发串 locale。
|
||||
export type SsrArticleResult = {
|
||||
article: RawArticle | null
|
||||
error: "missing" | "failed" | null
|
||||
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
|
||||
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
|
||||
}
|
||||
|
||||
export async function getSsrArticle(
|
||||
id: string | null | undefined,
|
||||
locale?: string
|
||||
): 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 {
|
||||
const article = await fetchArticleById(id, ssrBase(), locale)
|
||||
return { article, error: article ? null : "failed" }
|
||||
const res = await fetch(`${b}/api/v1/public/runway-looks/${id}${qs}`)
|
||||
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 {
|
||||
return { article: null, error: "failed" }
|
||||
return { article: null, error: "failed", preview: false, imageTotal: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 接口:GET /api/public/articles(经 getArticlesRaw 透传)
|
||||
// 文章列表(RunwayLooks / shows 用):多选用 repeated params,并把 images 映射成 gallery。
|
||||
// 已登录用户详情取数:走 request()(自动附带 Bearer + locale 签名),
|
||||
// 后端据此返回完整图片集(未登录截断到 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> {
|
||||
const { data, total, last_page } = await getArticlesRaw({
|
||||
page: q.page,
|
||||
size: q.size,
|
||||
sort: q.sort,
|
||||
with_images: q.with_images,
|
||||
brand_ids: q.brand_ids,
|
||||
collection_type: q.collection_type,
|
||||
season: q.season,
|
||||
year: q.year,
|
||||
})
|
||||
const mapped: ArticleItem[] = data.map((it: any) => ({
|
||||
const p = new URLSearchParams()
|
||||
p.set("page", String(q.page ?? 1))
|
||||
if (q.sort) p.set("sort", q.sort)
|
||||
if (q.brandId) p.set("brand_id", q.brandId)
|
||||
if (q.collection) p.set("collection", q.collection)
|
||||
if (q.season) p.set("season", q.season)
|
||||
if (q.year) p.set("year", String(q.year))
|
||||
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>("/public/runway-looks", p)
|
||||
const mapped: ArticleItem[] = (j.data ?? []).map((it: any) => ({
|
||||
...it,
|
||||
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) }
|
||||
}
|
||||
|
||||
// 接口: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 ?? []
|
||||
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
|
||||
}
|
||||
|
||||
// ===================== 品牌(/api/public/brands) =====================
|
||||
|
||||
// 接口:GET /api/public/brands
|
||||
// size 已写死 30(后端 brandDefaultSize),only_with_articles 已删除(字母索引始终只返回有档案品牌),
|
||||
// 前端均不传;关键词搜索仍不过滤"必须有走秀档案"(后端对 keyword 跳过该限制)。
|
||||
export async function getBrands(opts?: {
|
||||
size?: number
|
||||
only_with_articles?: number
|
||||
featured?: number
|
||||
keyword?: string
|
||||
letter?: string
|
||||
}): Promise<BrandEntry[]> {
|
||||
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?.keyword) qs.set("keyword", opts.keyword)
|
||||
if (opts?.letter) qs.set("letter", opts.letter)
|
||||
@ -334,17 +303,17 @@ export async function getBrands(opts?: {
|
||||
name: nameEn,
|
||||
nameCn,
|
||||
display,
|
||||
article_count: Number(b.article_count ?? 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== 账号体系(/api/auth/*) =====================
|
||||
// 说明:当前前端只用「取当前用户 + 登出」两个能力(顶栏已登录态)。
|
||||
// 登录/注册的表单提交暂未接入,相关接口函数已移除,避免无人调用的死代码。
|
||||
// 双令牌:access_token(短命,请求鉴权)+ refresh_token(长命,落库可吊销)。
|
||||
// 登录写两者;fetchMe 等受保护请求遇 401 自动用 refresh 续期一次;登出吊销 refresh。
|
||||
|
||||
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"
|
||||
|
||||
// 注意: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 {
|
||||
if (typeof localStorage === "undefined") return
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_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> {
|
||||
if (typeof localStorage === "undefined") return null
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
if (!token) return null
|
||||
if (!getAccessToken()) return null
|
||||
try {
|
||||
// 接口:GET /api/auth/me(需 Bearer token,客户端运行期 base 走 BASE_API)
|
||||
const res = await fetch(`${BASE_API_URL}/api/v1/auth/me?locale=${encodeURIComponent(currentLocale())}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const res = await authedFetch(`${BASE_API_URL}/api/v1/me?locale=${encodeURIComponent(currentLocale())}`)
|
||||
if (!res.ok) {
|
||||
clearSession()
|
||||
return null
|
||||
@ -385,14 +435,85 @@ export async function fetchMe(): Promise<AuthUser | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// 登录/注册成功后写入本地会话(token + user),供 Layout 顶部已登录态与 fetchMe 读取。
|
||||
export function saveSession(token: string, user: AuthUser): void {
|
||||
if (typeof localStorage === "undefined") return
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
// 登录:POST /api/v1/auth/login(公开端点)。成功后写 access+refresh+user 进 localStorage。
|
||||
export async function login(input: { account: string; password: string }): Promise<AuthUser> {
|
||||
const res = await fetch(`${BASE_API_URL}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
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 {
|
||||
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 为其中之一(当前仅此模块)。
|
||||
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: [] }
|
||||
}
|
||||
|
||||
// 接口:GET /api/ssg/brands(SSG 内部端口,构建期 Node 调用)
|
||||
// 接口:GET /api/internal/ssg/brands/popular(SSG 内部端口,构建期 Node 调用)
|
||||
// SSG 构建期:走秀页热门品牌(按图片总数排名,结果按热度顺序)。
|
||||
// 后端固定返回前 30 个;侧栏 filter 展示前 10,品牌弹窗 HOT 标签展示全部。
|
||||
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) => {
|
||||
const nameEn = (b.name_en || b.name || "").toString()
|
||||
return {
|
||||
@ -447,10 +568,123 @@ export async function getSsgHotBrands(): Promise<BrandEntry[]> {
|
||||
name: nameEn,
|
||||
nameCn: (b.name_cn || "").toString(),
|
||||
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
271
src/lib/favorites.ts
Normal 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
148
src/lib/history.ts
Normal 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
59
src/lib/looks-grid.ts
Normal 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
|
||||
}
|
||||
@ -11,17 +11,20 @@ export const ROUTES = {
|
||||
shows: '/shows',
|
||||
brands: '/brands',
|
||||
portfolio: '/portfolio',
|
||||
latestProjects: '/latest-projects',
|
||||
about: '/about',
|
||||
contact: '/contact',
|
||||
login: '/login',
|
||||
register: '/register',
|
||||
account: '/account',
|
||||
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}`,
|
||||
article: (id: string | number) => `/article?id=${id}`,
|
||||
articlesByBrand: (id: string | number) => `/articles?brand_id=${id}`,
|
||||
// runwayLooksDetail: (id: string | number) => `/runway-looks/${id}`,
|
||||
// 街拍详情与走秀详情统一收口到 /item/[id],由前端按编码前缀(r=runway/s=snap)分流。
|
||||
streetSnap: (id: string | number) => `/item/${id}`,
|
||||
} as const;
|
||||
|
||||
/** 服务端:生成带 locale 前缀的完整 URL(依赖 astro:i18n,遵循 astro.config)。 */
|
||||
|
||||
335
src/pages/cn/account.astro
Normal file
335
src/pages/cn/account.astro
Normal 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>
|
||||
@ -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>
|
||||
@ -1,15 +1,123 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.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
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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
166
src/pages/cn/login.astro
Normal 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>
|
||||
7
src/pages/cn/street-snaps/index.astro
Normal file
7
src/pages/cn/street-snaps/index.astro
Normal 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
336
src/pages/en/account.astro
Normal 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>
|
||||
@ -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>
|
||||
@ -1,15 +1,123 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.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
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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
166
src/pages/en/login.astro
Normal 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>
|
||||
7
src/pages/en/street-snaps/index.astro
Normal file
7
src/pages/en/street-snaps/index.astro
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro'
|
||||
import StreetSnaps from '@/components/StreetSnaps.astro'
|
||||
---
|
||||
<Layout>
|
||||
<StreetSnaps />
|
||||
</Layout>
|
||||
@ -1,3 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
|
||||
242
src/styles/look-grid.css
Normal file
242
src/styles/look-grid.css
Normal 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; }
|
||||
Reference in New Issue
Block a user