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

This commit is contained in:
toom1996
2026-09-02 21:50:30 +08:00
parent 50e5775dc8
commit 64af75c0a9
23 changed files with 1730 additions and 285 deletions

View File

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

View File

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

View File

@ -1,69 +1,53 @@
# 项目长期记忆(Project MEMORY.md) # 项目长期记忆(Project MEMORY.md)
## 模块策略(重要) ## 模块策略
- 站点按模块逐个完善。当前上线「发布会模块」:首页 index + 走秀列表/详情(RunwayLooks = 走秀档案页,articles/shows 页面已在 i18n 重构中重组)。 - 站点按模块逐个完善,当前主线为「发布会模块」:首页 index + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。
- **品牌索引页已下线**(2026-08-26 确认):`src/pages/brands.astro` 及孤儿组件 `src/components/pages/brands.astro` 均已删除;品牌数据现在只通过走秀页侧栏 filter + 品牌弹窗呈现(热门 30 条走 SSG 接口,搜索/字母走运行时 `/api/v1/public/brands`)。 - 品牌索引页已下线(2026-08-26):`brands.astro` 与孤儿组件已删;品牌仅经走秀页侧栏 filter + 品牌弹窗呈现(热门 30 走 SSG,搜索/字母走运行时 `/api/v1/public/brands`)。
- **占位模块组件已清理(2026-08-29)**:`components/pages/` 下 `About`/`Contact`/`LatestProjects`/`Portfolio`/`Login`/`Register`/`AuthForm`/`article` 等占位/旧详情组件查无引用、也不是真实路由,已删除并移除空目录。 - 导航死链清理(2026-08-30):删 `/latest-projects`、`/about`、`/contact` 三处(Layout/routes/dictionary 三处同步清)。**保留未动的死链**:`/portfolio`、`/login`、`/register`(用户未要求动)。
- **导航死链清理(2026-08-30)**:用户决定移除 `/latest-projects`、`/about`、`/contact` 三个死链——已从 `Layout.astro` 主导航(早已不在)、`routes.ts`(`latestProjects`/`about`/`contact` 键)、`dictionary.ts`(about/contact/latest projects 翻译键)三处清除,全 src 扫描零残留引用。**剩余死链 `/portfolio`、`/login`、`/register` 保留未动**(Portfolio 导航项、Login/Signup 链接仍展示,用户未要求移除)。 - 「header 别动」= 不擅自改导航结构/视觉;但用户主动批准的调整(Runway 下拉分类、Street Style 减城、删死链)均属已授权,不算违规。
- **「header 别动」解读(2026-08-30 明确)**:用户「header 别动」指不要擅自改导航结构与视觉;但用户**主动批准**的导航调整(Runway 下拉改 5 个 collection 分类、Street Style 下拉减为 3 城、删除死链项)均属于已授权变更,不算违规。`/en/login`、`/en/register` 实际并非真实页面(旧 MEMORY 记录有误),AuthForm 组件已随清理删除。
## 架构总览 ## 架构总览
- 前端:**d:/project/frontend_v2** — Astro SSG + Tailwind v4 + Alpine.js。`npm run build`(SSG 取数走 ssgRequest,失败回落空/假数据不影响构建)。 - 前端:`d:/project/frontend_v2` — Astro SSG + Tailwind v4 + Alpine.js。构建 `npm run build`(SSG 取数走 ssgRequest,失败回落空/假数据不影响构建)。
- 后端:**d:/project/backend_v2** — Go + Gin + GORM(MySQL),公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`),SSG 内部引擎 :8091(仅 127.0.0.1,`/api/v1/ssg/*`,SSGToken 中间件,nginx 不反代)。 - 后端:`d:/project/backend_v2` — Go + Gin + GORM(MySQL)。公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`);SSG 引擎 :8091(仅 127.0.0.1,`/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` 均已删除。 - SSG 端点仅剩:`/api/v1/ssg/index/runway`(首页 runway 区块)与 `/api/v1/ssg/brands/hot`(走秀页热门品牌,固定 30 条)。
- **接口 i18n 约定**:后端按 `?locale=cn|en` 选列返回(pickLocale 缺翻译回落另一语言);前端取数统一复用带 locale 的原语(request/ssgRequest 等),**绝不裸写 fetch;语言以 URL 前缀为准**。 - 接口 i18n:后端按 `?locale=cn|en` 选列(pickLocale 缺翻译回落另一语言);前端取数统一复用带 locale 原语(request/ssgRequest),**绝不裸写 fetch**;语言以 URL 前缀为准。
- 走秀标题 `title_cn` 已规则回填(backend_v2 `scripts/backfill_title_cn/main.go`,幂等);`description_cn` 为空白属预期,中文页回落英文。 - 走秀标题 `title_cn` 已规则回填(幂等);`description_cn` 空白属预期(中文页回落英文)。
- 后端连接池:已设 `conn_max_idle_time`(config.yml 默认 60s,须 < MySQL wait_timeout)主动回收空闲连接,避免空闲连接被 MySQL 回收后池子持死连接报 `invalid connection`。**本地后端若长时间空闲后接口报 invalid connection,先重启后端**(`cd d:/project/backend_v2 && go build -o server-bin ./cmd/server` 后杀旧进程重启)。
- 前端 dev server **不热加载 `.env`**:改 `BASE_API` 后必须 `astro dev stop` + `astro dev --background` 重启才生效。浏览器旧 bundle 仍用旧 `BASE_API`,需**硬刷新**(Ctrl+Shift+R);旧 token 对新后端无效,需**重新登录**。改 BASE_API 指向本地/线上后端后,若历史/收藏/登录"没反应",先查 dev server 是否重启、浏览器是否硬刷新+重登录。
## 关键约定 ## 关键约定
- 视觉语言:黑白极简编辑风(monospace + Georgia 衬线大标题 + 细线),HBX / 时尚档案调性。字体全走 Tailwind 体系,禁止自写字体栈。 - 视觉语言:黑白极简编辑风(monospace 小标签 + Georgia 衬线大标题 + 细线),HBX / 时尚档案调性。**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系,禁止自写字体栈。
- **品牌名前端一律只显示英文**(用户偏好 2026-08-26:「前端品牌不要展示中文名称,标题可以留着」):api.ts 映射 `display = nameEn`;走秀标题/描述仍按 locale 选列。 - 品牌名前端一律只显示英文(`display = nameEn`);走秀标题/描述仍按 locale 选列。
- **API 命名哲学(用户原则)**:「每一个地方尽可能不要通过参数来复用接口,尽量一个功能一个接口」。查询参数仅限「同一资源的列表筛选/分页」;跨资源/独立功能一律独立路径(如 `brands/hot` 而非 `?featured=1`)。待办:公开侧 `/api/v1/public/brands` 的 featured/keyword/letter 参数拆分、`/articles` 的 `brand_id` 拆 `brands/:id/shows`、资源命名 `shows` vs `articles`、`popular` vs `featured`(目标清单见 artifact `api_endpoints_catalog.md`)。 - API 命名哲学(用户原则):「一个功能一个接口」,查询参数仅限同资源列表筛选/分页;跨资源/独立功能一律独立路径(如 `brands/hot` 而非 `?featured=1`)。
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`。Tailwind v4 无零值类。 - CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`。Tailwind v4 无零值类。
- 导航(Layout.astro):「header 别动」指不擅自改结构/视觉,但用户**主动批准**的导航调整有效(Runway 下拉改 4 个 collection 分类 rtw/menswear/couture/resort、Street Style 下拉减为「全部/Paris/New York/Milan」4 项、移除 latest-projects/about/contact 死链)。剩余死链 /portfolio、/login、/register 用户未要求动,保留。 - Layout 把 `<slot/>` 包在 `flex-1 flex flex-col`;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 抖动),用 `w-full` + `max-w-[x] mx-auto`。
## Layout 陷阱(2026-08-12) ## Astro `<script>` 坑
- Layout 把 `<slot />` 包在 `flex-1 flex flex-col` 容器里;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 内容多少决定宽度、页面抖动)。用 `w-full`(可配 `max-w-[xxx]` + `mx-auto`)。 - 作用域隔离:frontmatter 的 import/变量在打包型 `<script>` 拿不到,需脚本顶部重新 import。
- 不做模板插值:打包型 `<script>` 内 `{t('...')}` 不执行 → `t is not defined`;预翻译用 `define:vars` 或 data 属性注入,模板区 `{t()}` 正常。
- **`define:vars` + `import` = 致命组合(已踩坑)**:`define:vars` 会把脚本包成内联 IIFE 并注入 `const meta=...`,而原本脚本顶部的 `import` 语句被塞进函数体内 → `import` 在 IIFE/非模块上下文非法 → 整段脚本 SyntaxError 完全不执行(连带 `recordHistory`/事件绑定全失效)。**凡脚本里要 `import`,绝不能用 `define:vars`**。改用 `data-*` 属性在模板里透传变量,脚本内 `btn.dataset.xxx` 读取(Layout.astro 已用此模式)。en/cn `item/[id].astro` 曾因此导致详情页浏览历史/收藏全不工作,已修复。
- 片段页(无 `<html>/<head>` 的 .astro)的 `import '@/styles/global.css'` 在 build 时不会被注入 head → 产物缺 `<link rel=stylesheet>` 整页无样式。login.astro 曾因此丢样式,已补完整文档结构修复。
## Astro `<script>` 两大坑 ## 数据分类
- **作用域隔离**:frontmatter 的 import/变量在打包型 `<script>` 里拿不到(运行时 ReferenceError);脚本里要用什么就在脚本顶部重新 import。 - 走秀 `brand_runway`:`collection_type`(rtw/menswear/couture/resort/pre_fall) + `season`(spring/fall/null) + `season_code`(SS26…),title 规则回填(25,206 行,幂等)。公开列表 `?collection_type=&season=&season_code=&year=` 筛选。地理维度暂不做。
- **不做模板插值**:打包型 `<script>` 里 `{t('...')}` 不会被执行 → `t is not defined`。预翻译用 `define:vars={{ hiLabel }}` 注入;模板区 `{t()}` 正常。 - 街拍 `street_snap.city`(2026-08-30 落地):DB 列 VARCHAR(128) + 精确匹配 `WHERE city = ?`;前端 header 下拉 `?city=` + 侧栏 City pills + 深链选中。规范值存「首字母大写英文」(Paris/New York/Copenhagen…),前后端大小写敏感须一致;dev 库仅 12 行样本(Paris 8 / Copenhagen 4),其余城市本地为空属预期。
- 打包后脚本在 `_astro/*.js` 而非内联;查 dist 产物用 search_content(ripgrep),PowerShell 无 `Select-String -Recurse`。
## 走秀数据分类(brand_runway) ## 前端复用
- 三维分类字段:`collection_type`(rtw/menswear/couture/resort/pre_fall)、`season`(spring/fall/null)、`season_code`(SS26/FW25…派生)。靠 title 规则回填(幂等,25,206 行)。 - 详情页统一 `/item`:`src/pages/{locale}/item/[id].astro`,服务端 getSsrArticle→getSsrStreetSnap 回落;`ROUTES.article`/`streetSnap` 均指向 `/item/${id}`。列表回链指向 `/runway-looks`、`/street-snaps`。
- 公开列表 `?collection_type=&season=&season_code=&year=` 筛选;前端走秀页有系列/季节/年份 chips。 - 列表组件同构,抽 `src/styles/look-grid.css` + `src/lib/looks-grid.ts`(SPIN_SVG / makeCardSlots / buildPageList);品牌筛选弹窗 `BrandModal.astro`(仅 RunwayLooks)。卡片标记在 Alpine `x-for` 内客户端渲染,不能提成 Astro 组件。
- 地理/城市维度(runway):用户明确暂不做。
- 分布:rtw 15203 / menswear 4061 / couture 672 / resort 2894 / pre_fall 2376。
## 街拍地区分类(street_snap.city,2026-08-30)
- 全链路已落地:DB 加 `city` 列(VARCHAR(128),脚本 `scripts/sql/004_add_streetsnap_city.sql` + 幂等回填 `scripts/backfill_city/main.go`,无 AutoMigrate)+ 后端 `StreetSnapQuery.City` 精确匹配 `WHERE city = ?` + 前端 header 下拉 `?city=` + 列表侧栏 City pills + 深链自动选中。
- **规范值约定(重要)**:city 列存「首字母大写英文规范名」(Paris / New York / Copenhagen …),前端 header 下拉与列表 `cityOptions` 的 value 用**同一大写值**,后端做精确匹配、大小写敏感,必须前后一致。backfill 首选从 title 第一段(location,`\t` 分隔)按城市关键词匹配,兜底从 source_url/cover 的 URL slug 解析。
- 前端 `t('Paris')` 经 `translate()` 自动 `key.toLowerCase()` 查字典,中文回落「巴黎」,i18n 正常。
- dev 库 db_dev.street_snap 仅 12 行样本(Paris 8 / Copenhagen 4),其余下拉城市(London/Milan…)本地预览为空属预期;生产真实爬取数据才有完整覆盖。
- 验证:重启后端 :8090 后,`?city=Paris`→8、`?city=Copenhagen`→4、`?city=London`→0、无参→12,全链路通过。client_sign 本地 `enabled:false`,可直接 curl/Node 验证。
## 前端组件复用(2026-08-29)
- **详情页统一 `/item`**:走秀 lookbook 与街拍详情全部收口到 `src/pages/{locale}/item/[id].astro`,服务端按 `getSsrArticle(id)` → `getSsrStreetSnap(id)` 回落渲染(先 runway 后 street)。`ROUTES.article` 与 `ROUTES.streetSnap` 均指向 `/item/${id}`(`runway-looks/[id]`、`street-snaps/[id]`、`article.astro(?id=)` 等旧详情页均已删除)。列表页回链仍指向列表 `/runway-looks`、`/street-snaps`。
- **列表页共享资源**:两个列表组件(RunwayLooks / StreetSnaps)同构,抽成 `src/styles/look-grid.css`(卡片/网格/分页/侧栏/spinner/骨架/动画)+ `src/lib/looks-grid.ts`(`SPIN_SVG`、`makeCardSlots(images,count,opts)`、`buildPageList(total,cur)`);品牌筛选弹窗 `src/components/BrandModal.astro`(仅 RunwayLooks 用,`openBrandModal/confirmBrandModal` 等由页面脚本驱动)。卡片标记因在 Alpine `x-for` 内客户端渲染,**不能**提成 Astro 组件。
## 账号体系(2026-08-31) ## 账号体系(2026-08-31)
- 决策:仅预置内部账号,**不开放注册**(前端无注册入口,避免垃圾账号/撞库)。账号由 seed 脚本预置。 - 仅预置内部账号,不开放注册(前端无注册入口)。`seed_users/main.go` 创建两个账号:`admin`(密码取 `SEED_ADMIN_PASSWORD`,缺省 `Studio#2026!Admin`)+ `root`/`root`(测试弱密码,uid 通常为 8)。**本地库 `db_dev` 确有 root/root**;实测 `POST /api/v1/auth/login {account:"root",password:"root"}` 打到 `http://localhost:8090` 返回 200,`GET /auth/history` 显示 root 账号下已有 `street-snaps`/`runway-looks` 两条真实图集历史。**不要再说"本地无 root / root 登录必失败"——那是误判**(2026-09-02 因一次 401 错判,已纠正)。前端 `.env` 当前 `BASE_API=http://localhost:8090`。
- 默认内部账号:`admin` / `admin@studio.local` / 密码 `Studio#2026!Admin`(env `SEED_ADMIN_PASSWORD` 可覆盖;**务必改默认密码**)。 - 双令牌 JWT:access 默认 **7 天(168h)**(无状态,前端请求用)+ refresh 默认 **30d(720h)**(随机串仅存 SHA256 哈希落库 `refresh_tokens`)。登录即吊销该用户全部既有 refresh(互斥登录)。
- **双令牌 JWT(2026-08-31 落地)**:access token 短命(默认 **2h**,无状态 JWT,仅前端请求用)+ refresh token 长命(默认 **30d**,随机串只存 SHA256 哈希落库 `refresh_tokens`)。纯无状态 JWT 无法吊销,故 refresh 必须落库才能做踢下线。 - 接口(`/api/v1/auth/`,除 /me 外公开):`POST /login`→`{access_token,refresh_token,expires_in,user}`;`POST /refresh`→`{access_token,expires_in}`(refresh 复用不轮换);`POST /logout`(单设备);`POST /logout-all`→`{revoked:N}`(踢下线);`GET /me`(Bearer)。
- 接口(均在 `/api/v1/auth/`,除 /me 外均公开、无需 Bearer): - 前端 `api.ts`:`saveSession/clearSession/refreshSession/fetchMe(authedFetch 遇 401 静默 refresh 一次)/logout(吊销当前 refresh 再清本地)/logoutAll`。
- `POST /login` → `{access_token, refresh_token, expires_in(秒), user}`(login 返回结构已从 `{token,user}` 改为双 token,前端已同步)。**登录即顶旧会话**:签发前先吊销该用户全部既有 refresh,保证同账号同一时刻只有一条活性会话(用户明确诉求:不希望同一账号被不同人同时登录)。 - 未做 `token_version` 即时吊销(access 在 7 天窗口内仍有效),如需即时失效再加。
- `POST /refresh` → `{access_token, expires_in}`(用 refresh_token 换 access;**refresh 复用、不轮换**)。 - 硬门禁:RunwayLooks/StreetSnaps 筛选与翻页入口调 `requireLogin()`,未登录弹内嵌登录表单,登录成功 `loadPage(1)` 刷新;首屏 `init()` 的 `loadPage(1)` 不拦。
- `POST /logout` → `{ok:true}`(吊销当前 refresh,单设备登出)。 - 个人中心 `account.astro`:报头(方块字母头像+用户名大标题) + 两 tab(**Saved looks / My History**),已移除 Overview/Sessions & Security/Settings/退出入口/偏好设置/编辑资料(2026-09-01 精简)。
- `POST /logout-all` → `{revoked:N}`(按 refresh 反查用户,吊销其全部 refresh = **踢下线/全设备登出**)。 - **收藏已服务端化(2026-09-02)**:后端表 `favorites` + `/api/v1/auth/favorites`(GET 分页 `?page&per_page` / POST 新增幂等 / DELETE /:target_uid / POST `/check` 批量校验当前页收藏态),按 `user_id` 隔离,`target_uid` 存前端编码串(runway `r=` / street `s=`)。前端 `src/lib/favorites.ts`:账户页用 `fetchFavoritesPage()` 服务端分页;列表卡片用 `checkFavorited(ids)` 仅把当前页可见 id 发给后端打勾(与收藏总量解耦,10 万级也常数级);收藏/取消本地乐观 + 后台同步(失败不回滚本地)。favorites 表含复合索引 `idx_user_created(user_id,created_at)` 支撑深翻页。
- `GET /me`(Bearer 校验)保留。 - **浏览历史(2026-09-02 晚收敛为「文章级」,用户澄清纠正)**:语义=「点开过哪篇文章」,记录时机在 **item 详情页**(`recordHistory(meta.id)`,meta.id 是文章对外编码串 `r=`走秀 / `s=`街拍,编码串自带类型、无需 kind);**RunwayLooks/StreetSnaps 列表页进页不再记**(此前按"图集=列表页"实现是理解错,已纠正)。后端 `histories` 表收敛为 `(user_id, target_uid, viewed_at)`:`kind/target_type/title/cover/brand` 五列已 DROP(008 建表脚本同步改 + `migrate_refresh` 对存量库 ALTER,实测 GET items 仅 `{id,viewed_at}`);唯一键 `uniq(user_id,target_uid)`;保留 `HISTORY_CAP=2000` FIFO(文章会积累,有存在意义)。接口 `/api/v1/auth/history`:GET 分页 / POST `{target_uid}` upsert / DELETE /:target_uid / DELETE 清空,按 `user_id` 隔离;kind 参数与校验已随收敛移除(kind=all 字面过滤坑随之消失)。账户页 My History:服务端分页拿 id 列表后**逐篇回查公开详情**(`api.ts` 的 `getHistoryMeta(id)` → `/public/runway-looks|street-snaps/:id` 取 title/cover/brand;用户选"只存 id、展示回查"),卡片跳回文章详情,无 kind 筛选 tab。前端 `src/lib/history.ts`:`recordHistory(id)` / `fetchHistoryPage(page,perPage)`(服务端分页+回查,单条回查失败容忍回落本地)。
- 后端改动:`config.JWT` 新增 `RefreshExpireHours`(env `JWT_REFRESH_EXPIRE_HOURS`);`AuthService` 加 `Refresh/Logout/RevokeAllByRefresh`;`RefreshTokenRepository`(Create/FindByHash/Revoke/RevokeAllByUser/DeleteExpired);`model.RefreshToken`(gorm 表 `refresh_tokens`)。 - **待办服务端化**:users 加 display_name/bio、refresh_tokens 列表接口。
- 建表:`scripts/sql/006_create_refresh_tokens.sql`;一键建表脚本 `scripts/migrate_refresh/main.go`(`go run ./scripts/migrate_refresh`,DSN 默认 root:root@127.0.0.1:3306/db_dev,可由 `DB_DSN` 覆盖)。 - 视觉:登录/个人中心沿用站点黑白极简编辑风(monospace 小标签 + 细线下划线输入框 + Georgia 衬线大标题 + hover:opacity-60)。
- 前端 `api.ts`(2026-08-31 改):`saveSession(access,refresh,user)` 存 `fa_token`/`fa_refresh`/`fa_user`;新增 `refreshSession()`(POST /auth/refresh,刷新 access、复用 refresh);`fetchMe()` 走 `authedFetch`(遇 401 静默 refresh 一次再重试);`logout()`(吊销当前 refresh 再清本地)、`logoutAll()`(踢下线再清本地)。`clearSession()` 一并清三键。
- 踢下线效果:**普通踢**=吊销 refresh,已签发 access 在 2h 窗口内仍有效,到期后才被弹回登录(非即时,内部工具足够);未做 `token_version` 即时吊销方案(如需访问令牌即时失效再加 `users.token_version` 并在 `middleware.Auth` 比对)。
- 验证(2026-08-31):`go build -o bin/server.exe ./cmd/server` EXIT=0;端到端 Node 脚本确认 login→refresh→logout-all(revoked:1)→再 refresh=401(ALL_OK);同账号二次 login 后首次 refresh 返回 401、二次 refresh 返回 200(MUTEX_OK,互斥登录生效)。前端 `npm run build` EXIT=0。
- **硬门禁(用户拍板)**:RunwayLooks / StreetSnaps 的筛选与翻页入口调 `requireLogin()`,未登录则弹出内嵌登录表单(`showLoginModal`);登录成功后 `loadPage(1)` 按当前筛选刷新。首屏 `init()` 的 `loadPage(1)` 不拦(保证未登录能看到第一页)。
- 页面:`src/pages/{en,cn}/login.astro`(黑白极简风,Alpine 表单,登录后跳 `?redirect=` 或 /account)、`account.astro`(资料+登出+收藏/历史占位,未登录跳 /login?redirect=/account)。
- 视觉:登录/个人中心沿用站点黑白极简编辑风(monospace + 细线下划线输入框 + Georgia 衬线大标题 + hover:opacity-60)。
## 部署(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`。 - 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`。

Binary file not shown.

21
_tmp_dbg.mjs Normal file
View File

@ -0,0 +1,21 @@
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;
async function raw(method, path, body) {
const res = await fetch(BASE + path, {
method, headers: { "Content-Type": "application/json", Authorization: "Bearer " + tok },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
console.log(method, path, "=>", res.status, text.slice(0, 200));
}
await raw("POST", "/api/v1/auth/favorites", { target_type: "runway", target_uid: "r0042h1mx", title: "t", cover: "c", brand: "b" });
await raw("POST", "/api/v1/auth/history", { target_uid: "r0042h1mx" });
await raw("POST", "/api/v1/auth/history", { target_uid: "r0042h1mx", extra: 1 });
await raw("POST", "/api/v1/auth/history", JSON.stringify({ target_uid: "r0042h1mx" }));
process.exit(0);

26
_tmp_verify2.mjs Normal file
View File

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

60
_tmp_verify_hist.mjs Normal file
View File

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

BIN
dev.log

Binary file not shown.

9
server.err Normal file
View File

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

View File

@ -220,6 +220,7 @@ const seasonOptions: { value: string; label: string }[] = [
<!-- Card List --> <!-- Card List -->
<template x-for="it in items" :key="it.id"> <template x-for="it in items" :key="it.id">
<div class="showscard-wrap relative group">
<a class="showscard" :href="itemHref(it)" :aria-label="`${it.brand_name} ${it.title}`"> <a class="showscard" :href="itemHref(it)" :aria-label="`${it.brand_name} ${it.title}`">
<div class="showscard-left"> <div class="showscard-left">
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }"> <div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
@ -242,6 +243,10 @@ const seasonOptions: { value: string; label: string }[] = [
</div> </div>
</div> </div>
</a> </a>
<button type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
</button>
</div>
</template> </template>
</div> </div>
@ -298,6 +303,8 @@ const seasonOptions: { value: string; label: string }[] = [
import { getArticles, getBrands, toAbs, getUser, login, type ArticleItem, type BrandEntry } from "@/lib/api" import { getArticles, getBrands, toAbs, getUser, login, type ArticleItem, type BrandEntry } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid" import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => { Alpine.data("showsPage", () => {
@ -334,6 +341,9 @@ const seasonOptions: { value: string; label: string }[] = [
lastPage: 1, lastPage: 1,
reqToken: 0, reqToken: 0,
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
favMap: {} as Record<string, boolean>,
// 登录门禁弹窗状态(硬门禁:未登录点筛选/翻页时弹出内联登录表单) // 登录门禁弹窗状态(硬门禁:未登录点筛选/翻页时弹出内联登录表单)
showLoginModal: false, showLoginModal: false,
loginModalAccount: "", loginModalAccount: "",
@ -348,7 +358,9 @@ const seasonOptions: { value: string; label: string }[] = [
const ALLOWED = ['rtw', 'menswear', 'womenswear', 'couture', 'resort', 'pre_fall', 'bridal', 'swimwear'] const ALLOWED = ['rtw', 'menswear', 'womenswear', 'couture', 'resort', 'pre_fall', 'bridal', 'swimwear']
if (ct && ALLOWED.includes(ct)) this.selectedCt = ct if (ct && ALLOWED.includes(ct)) this.selectedCt = ct
// 首页(含筛选后的首页)公开:未登录也直接加载首屏,不弹登录框 // 首页(含筛选后的首页)公开:未登录也直接加载首屏,不弹登录框
// 收藏态在 loadPage 内按当前页校正(markFavs),与收藏总量解耦,10万也常数级
this.loadPage(1) this.loadPage(1)
// 浏览历史:改为「点开文章详情」时记录(见 item 详情页),列表页不再记。
}, },
// 骨架屏数量与首屏每页条数一致(后端固定 ArticleFixedSize=24),消除加载抖动 // 骨架屏数量与首屏每页条数一致(后端固定 ArticleFixedSize=24),消除加载抖动
@ -533,6 +545,7 @@ const seasonOptions: { value: string; label: string }[] = [
this.lastPage = json.last_page this.lastPage = json.last_page
this.page = page this.page = page
this.items = rows this.items = rows
this.markFavs() // 当前页可见 id 走服务端校验收藏态(与收藏总量解耦)
} catch { } catch {
if (myToken !== this.reqToken) return if (myToken !== this.reqToken) return
this.items = [] this.items = []
@ -577,7 +590,40 @@ const seasonOptions: { value: string; label: string }[] = [
return makeCardSlots(it.gallery || [], 5, { return makeCardSlots(it.gallery || [], 5, {
moreLabel: () => (total > 30 ? "30+" : `${total}+`), 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
} }
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) {
if (!getUser()) { this.showLoginModal = true; return false }
const id = String(it.id)
const now = !this.favState(id)
toggleFavorite({ id, type: 'runway', title: it.title || '', cover: it.cover || '', brand: it.brand_name || '' })
this.favMap = { ...this.favMap, [id]: now }
return now
},
} }
}) })
}) })

View File

@ -180,6 +180,7 @@ const cityOptions: { value: string; label: string }[] = [
<template x-if="!loading && items.length > 0"> <template x-if="!loading && items.length > 0">
<div class="snapsgrid"> <div class="snapsgrid">
<template x-for="it in items" :key="it.id"> <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)"> <a class="snapscard" :href="itemHref(it)" :aria-label="cardTitle(it)">
<div class="snapscard-left"> <div class="snapscard-left">
<div class="snapscard-img" :class="{ 'is-loaded': it._imgLoaded }"> <div class="snapscard-img" :class="{ 'is-loaded': it._imgLoaded }">
@ -202,6 +203,10 @@ const cityOptions: { value: string; label: string }[] = [
</div> </div>
</div> </div>
</a> </a>
<button type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
</button>
</div>
</template> </template>
</div> </div>
</template> </template>
@ -256,6 +261,8 @@ const cityOptions: { value: string; label: string }[] = [
import { getStreetSnaps, toAbs, getUser, login, type StreetSnapItem } from "@/lib/api" import { getStreetSnaps, toAbs, getUser, login, type StreetSnapItem } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid" import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
const DEFAULT_SORT = "image_count" const DEFAULT_SORT = "image_count"
@ -290,6 +297,9 @@ const cityOptions: { value: string; label: string }[] = [
loginModalError: "", loginModalError: "",
loginModalLoading: false, loginModalLoading: false,
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
favMap: {} as Record<string, boolean>,
init() { init() {
this.locale = this.$el.dataset.locale || 'en' this.locale = this.$el.dataset.locale || 'en'
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致) // 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
@ -297,7 +307,9 @@ const cityOptions: { value: string; label: string }[] = [
const allowed = (this.cityOptions || []).map((c: any) => c.value) const allowed = (this.cityOptions || []).map((c: any) => c.value)
if (city && allowed.includes(city)) this.selectedCity = city if (city && allowed.includes(city)) this.selectedCity = city
// 首页公开:未登录也直接加载首屏,不弹登录框 // 首页公开:未登录也直接加载首屏,不弹登录框
// 收藏态在 loadPage 内按当前页校正(markFavs),与收藏总量解耦,10万也常数级
this.loadPage(1) this.loadPage(1)
// 浏览历史:改为「点开文章详情」时记录(见 item 详情页),列表页不再记。
}, },
// ===== 侧栏筛选 ===== // ===== 侧栏筛选 =====
@ -390,6 +402,7 @@ const cityOptions: { value: string; label: string }[] = [
this.lastPage = json.last_page this.lastPage = json.last_page
this.page = page this.page = page
this.items = rows this.items = rows
this.markFavs() // 当前页可见 id 走服务端校验收藏态(与收藏总量解耦)
} catch { } catch {
if (myToken !== this.reqToken) return if (myToken !== this.reqToken) return
this.items = [] this.items = []
@ -426,6 +439,39 @@ const cityOptions: { value: string; label: string }[] = [
cardTitle(it: StreetSnapItem): string { cardTitle(it: StreetSnapItem): string {
return (it.title || "").replace(/\t/g, " · ") 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) {
if (!getUser()) { this.showLoginModal = true; return false }
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
},
} }
}) })
}) })

View File

@ -58,6 +58,55 @@ export const dict: Record<string, TranslationEntry> = {
'my history': { cn: '浏览历史' }, 'my history': { cn: '浏览历史' },
'coming soon': { 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: '会员资料' },
'save': { cn: '收藏' },
'saved': { cn: '已收藏' },
'remove': { cn: '移除' },
'cancel': { cn: '取消' },
'save changes': { cn: '保存修改' },
'display name': { cn: '昵称' },
'bio': { cn: '简介' },
'clear history': { cn: '清空历史' },
'no saved looks yet': { cn: '暂无收藏造型' },
'no history yet': { cn: '暂无浏览记录' },
'all': { cn: '全部' },
'image': { cn: '图片' },
'gallery': { cn: '图集' },
'viewed gallery': { cn: '浏览的图集' },
'light': { cn: '浅色' },
'dark': { cn: '深色' },
'on': { cn: '开' },
'off': { cn: '关' },
// ── 控件 / Controls ── // ── 控件 / Controls ──
'back to index': { cn: '返回首页' }, 'back to index': { cn: '返回首页' },

View File

@ -207,8 +207,8 @@ async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
// 接口:GET /api/public/runway-looks/:id // 接口:GET /api/public/runway-looks/:id
// 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。 // 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。 // locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
// 仅本模块内部使用(getSsrArticle 在其之上包一层),不对外导出。 // 对外导出:详情取数 / 个人中心逐篇回查(浏览器端按需取 title/cover 渲染历史卡片)。
async function fetchArticleById( export async function fetchArticleById(
id: number | string, id: number | string,
base: string = ssrBase(), base: string = ssrBase(),
locale?: string locale?: string
@ -383,6 +383,31 @@ async function authedFetch(url: string, opts: RequestInit = {}): Promise<Respons
return res return res
} }
// 带鉴权的 JSON 请求:自动附 Bearer、遇 401 静默刷新一次后重试,
// 成功返回响应体中的 data 字段;非 2xx 抛出(含后端 message)。
// 收藏/个人中心等需要登录的写操作统一走这里。
export async function authJson<T = any>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${BASE_API_URL}/api/v1${path}`
const headers: Record<string, string> = { "Content-Type": "application/json" }
const res = await authedFetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let msg = `API ${res.status} ${method} ${path}`
try {
const j = await res.json()
if (j?.message) msg = String(j.message)
} catch {
/* 响应体非 JSON,沿用默认错误信息 */
}
throw new Error(msg)
}
const json: any = await res.json()
return (json?.data ?? null) as T
}
export async function fetchMe(): Promise<AuthUser | null> { export async function fetchMe(): Promise<AuthUser | null> {
if (typeof localStorage === "undefined") return null if (typeof localStorage === "undefined") return null
if (!getAccessToken()) return null if (!getAccessToken()) return null
@ -417,6 +442,10 @@ export async function login(input: { account: string; password: string }): Promi
const user: any = json?.user const user: any = json?.user
if (!access || !refresh || !user) throw new Error("login failed") if (!access || !refresh || !user) throw new Error("login failed")
saveSession(access, refresh, user) saveSession(access, refresh, user)
// 登录成功广播:列表页在未登录时进页没记图集,登录后由监听补记(与「进页面就记」一致)。
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:login"))
}
return user as AuthUser return user as AuthUser
} }
@ -454,6 +483,40 @@ export async function logoutAll(): Promise<void> {
clearSession() clearSession()
} }
// ===================== 浏览历史:单篇详情回查(账户页 My History 渲染用) =====================
// 历史只存文章对外编码串(r= 走秀 / s= 街拍),展示时逐篇回查公开详情接口取 title/cover/brand。
// 走 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)
const type = sid.startsWith("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/ssg/*,仅 Node 构建期) ===================== // ===================== SSG 构建专用(/api/ssg/*,仅 Node 构建期) =====================
function ssgEnv(name: string): string | undefined { function ssgEnv(name: string): string | undefined {
@ -564,8 +627,8 @@ export async function getStreetSnaps(q: {
// 接口:GET /api/public/street-snaps/:id // 接口:GET /api/public/street-snaps/:id
// 街拍详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数)。 // 街拍详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数)。
// 仅本模块内部使用(getSsrStreetSnap 在其之上包一层),不对外导出。 // 对外导出:详情取数 / 个人中心逐篇回查。
async function fetchStreetSnapById( export async function fetchStreetSnapById(
id: number | string, id: number | string,
base: string = ssrBase(), base: string = ssrBase(),
locale?: string locale?: string

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

@ -0,0 +1,192 @@
// src/lib/favorites.ts — 收藏(服务端持久化 + 本地缓存兜底)
//
// 设计要点(应对"收藏量极大"场景):
// - 列表卡片打勾:只把「当前可见页的 id」发给后端 /auth/favorites/check,
// 问哪些已收藏。复杂度与「一屏多少张」成正比,与「用户收藏了多少」彻底解耦,
// 即便收藏 10 万也只传输约 24~50 个 id(常数级)。
// - 个人中心 Saved looks:走 /auth/favorites 的「服务端分页」,每次只取一页(默认 24 条),
// DOM 永远轻量,不存在一次渲染上万张卡片的问题。
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到 /api/v1/auth/favorites(POST/DELETE),
// 即便网络失败也不回滚本地,保证前端不"卡死"。
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
//
// 对外函数:getFavorites / isFavorited / toggleFavorite / removeFavorite /
// fetchFavoritesPage / checkFavorited(列表与个人中心按需调用)。
import { getUser, getAccessToken, authJson } from "./api"
export type FavoriteType = "runway" | "street"
export interface Favorite {
id: string // 走秀/街拍的对外编码 id(带 r=/s= 前缀),与后端 target_uid 对齐
type: FavoriteType
title: string
cover: string
brand: string
addedAt: number
}
// 后端 FavoriteItem 响应结构({data:{items:[...]}} 解包后)
interface ServerFavorite {
id: string
type: string
title?: string
cover?: string
brand?: string
added_at?: number
}
// ---------- 本地存储(兜底 / 离线) ----------
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())
}
// ---------- 对外 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
}
export function removeFavorite(id: string): void {
setLocal(getLocal().filter((f) => f.id !== id))
if (serverMode()) {
authJson("DELETE", `/auth/favorites/${encodeURIComponent(id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}
// ---------- 个人中心:服务端分页拉取 ----------
export interface FavPageResult {
items: Favorite[]
total: number
lastPage: number
}
/**
* 拉取收藏的某一页(默认 24 条/页)。
* 返回 { items, total, lastPage },前端据此渲染当前页 + 翻页器。
* 未登录回落本地收藏(无分页)。
*/
export async function fetchFavoritesPage(page = 1, perPage = 24): Promise<FavPageResult> {
if (!serverMode()) {
const local = getLocal()
return { items: local, total: local.length, lastPage: 1 }
}
try {
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
"GET",
`/auth/favorites?page=${page}&per_page=${perPage}`,
)
const items = (r?.items ?? []).map((it) => ({
id: String(it.id),
type: it.type === "street" ? "street" : "runway",
title: it.title || "",
cover: it.cover || "",
brand: it.brand || "",
addedAt: Number(it.added_at) || 0,
}))
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 列表,返回「已收藏」的 id 集合。
* 后端只对这些 id 做 WHERE target_uid IN (...) 查询,复杂度与收藏总量无关。
*/
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
if (!serverMode() || ids.length === 0) return new Set()
try {
const arr = await authJson<string[]>("POST", "/auth/favorites/check", { 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 = ""): void {
const list = getLocal()
if (list.some((f) => f.id === id)) return
list.unshift({ id, type, title, cover, brand, addedAt: Date.now() })
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,
}
if (newState) {
authJson("POST", "/auth/favorites", payload).catch(() => {
/* 失败不回滚本地 */
})
} else {
authJson("DELETE", `/auth/favorites/${encodeURIComponent(item.id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}

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

@ -0,0 +1,148 @@
// src/lib/history.ts — 浏览历史(服务端持久化 + 本地缓存兜底)
//
// 2026-09-02 语义收敛(用户拍板):
// - 浏览历史 = 「点开过哪篇文章」,触发时机在 item 详情页(打开某篇走秀/街拍即记一条)。
// 列表页(Runway Looks / Street Snaps)进入不再记。
// - 后端 histories 表只存 (user_id, target_uid, viewed_at):target_uid 为文章对外编码串
// (r= 前缀走秀 / s= 前缀街拍,编码串自带类型,故不再有 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", "/auth/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",
`/auth/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", `/auth/history/${encodeURIComponent(id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}
/** 清空全部历史。 */
export function clearHistory(): void {
setLocal([])
if (serverMode()) {
authJson("DELETE", "/auth/history").catch(() => {
/* 失败不回滚本地 */
})
}
}

34
src/lib/profile.ts Normal file
View File

@ -0,0 +1,34 @@
// src/lib/profile.ts — 个人资料(昵称/简介,客户端本地存储版)
//
// 后端 users 表当前只有 username/email,没有展示用的 display_name / bio。
// 在后端补齐字段前,这里先把「编辑资料」做成真实可用的本地版(按账号隔离),
// 后续接 /api/v1/me/profile 时把 saveProfile 换成请求即可。
import { getUser } from "./api"
export interface Profile {
displayName?: string
bio?: string
}
function storageKey(): string | null {
const u = getUser()
return u ? `fa_profile_${u.username}` : null
}
export function getProfile(): Profile {
const k = storageKey()
if (!k) return {}
try {
const obj = JSON.parse(localStorage.getItem(k) || "{}")
return obj && typeof obj === "object" ? obj : {}
} catch {
return {}
}
}
export function saveProfile(p: Profile): void {
const k = storageKey()
if (!k) return
localStorage.setItem(k, JSON.stringify(p))
}

View File

@ -1,102 +1,277 @@
--- ---
import Layout from '@/layouts/Layout.astro' import Layout from "@/layouts/Layout.astro"
import { getI18n } from '@/i18n/utils' import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from "@/lib/routes"
const { locale, t } = getI18n(Astro) const { locale, t } = getI18n(Astro)
const titleLabel = t('my account') const titleLabel = t("my account")
const pleaseLabel = t('please log in') const pleaseLabel = t("please log in")
const loginLabel = t('login') const loginLabel = t("login")
const accountLabel = t('account') const historyLabel = t("my history")
const emailLabel = t('email') const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
const signOutLabel = t('sign out') const archiveUrl = href(locale, ROUTES.runwayLooks)
const collectionsLabel = t('my collections') // 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
const historyLabel = t('my history') const removeLabel = t("remove")
const soonLabel = t('coming soon') const itemsLabel = t("items")
const clearHistoryLabel = t("clear history")
--- ---
<Layout title={titleLabel}> <Layout title={titleLabel}>
<main class="mx-auto w-full max-w-6xl px-6 py-12 md:px-8 lg:px-12 lg:py-16" x-data="accountPage" data-locale={locale}> <main class="account-root mx-auto w-full max-w-7xl px-6 py-10 md:px-8 md:py-14" x-data="accountPage" data-locale={locale}>
<div class="grid gap-8 lg:grid-cols-[0.9fr_1.5fr]">
<aside class="rounded-[30px] border border-black/10 bg-black p-6 text-white shadow-[0_20px_60px_rgba(0,0,0,0.08)] md:p-8"> <!-- 未登录:引导登录 -->
<div class="mb-10 flex items-center justify-between gap-3"> <div x-show="!user" x-cloak class="border border-black/15 bg-white px-8 py-20 text-center">
<span class="text-[10px] font-mono uppercase tracking-[0.38em] text-white/60">Archive</span> <p class="mb-6 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<span class="border border-white/20 bg-white/5 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.32em] text-white">2026</span> <a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-6 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div> </div>
<div class="mb-10"> <!-- 已登录:个人中心 -->
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.38em] text-white/50">Member profile</p> <div x-show="user" x-cloak class="space-y-10">
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.06em] md:text-5xl">{titleLabel}</h1>
</div>
<div class="space-y-3 text-sm text-white/70"> <!-- 报头:头像 + 用户名 -->
<p class="text-[11px] font-mono uppercase tracking-[0.32em] text-white/45">Portfolio Studio</p> <section class="flex items-center gap-5 border-b border-black/15 pb-8">
<p class="leading-relaxed">Curated access to runway archives, editorial notes, and private collections.</p> <div class="flex h-16 w-16 shrink-0 items-center justify-center border border-black font-serif text-2xl tracking-[-0.04em]" x-text="(user?.username || '?').slice(0,2).toUpperCase()"></div>
<div>
<p class="mb-2 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{t("member profile")}</p>
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.04em] md:text-5xl" x-text="user?.username"></h1>
</div> </div>
</section>
<!-- 主体:左侧导航 + 右侧内容 -->
<div class="grid gap-8 lg:grid-cols-[200px_1fr]">
<!-- 侧栏导航 -->
<aside class="lg:sticky lg:top-8 lg:self-start">
<nav class="flex gap-1 overflow-x-auto border-b border-black/15 lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0">
<button @click="tab='saved'" :class="tab==='saved' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] font-mono uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{t("saved looks")}</button>
<button @click="tab='history'" :class="tab==='history' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] font-mono uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{historyLabel}</button>
</nav>
</aside> </aside>
<section class="rounded-[30px] border border-black/10 bg-[#f7f5f1] p-6 shadow-[0_18px_50px_rgba(0,0,0,0.04)] md:p-8 lg:p-10"> <!-- 内容面板 -->
<div x-show="!user" x-cloak class="space-y-6"> <section class="js-surface js-panel border border-black/15 bg-white p-6 md:p-10">
<p class="text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<a href={href(locale, ROUTES.login) + '?redirect=' + encodeURIComponent(href(locale, ROUTES.account))} <!-- Saved looks -->
class="inline-flex items-center justify-center border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a> <div x-show="tab==='saved'" x-cloak>
<div class="mb-6 flex items-center justify-between">
<h2 class="font-serif text-2xl uppercase tracking-[-0.03em] md:text-3xl">{t("saved looks")}</h2>
<span class="text-[10px] font-mono uppercase tracking-[0.24em] text-black/40"><span x-text="favoritesCount"></span> {itemsLabel}</span>
</div>
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1Z"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no saved looks yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="f in favorites" :key="f.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(f)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeFav(f.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] font-mono uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(f)"><p class="truncate text-[12px] font-medium" x-text="f.title"></p></a>
<p class="mt-0.5 truncate text-[10px] font-mono uppercase tracking-[0.16em] text-black/45" x-text="f.brand || '—'"></p>
</div>
</div>
</template>
</div> </div>
<div x-show="user" x-cloak class="space-y-8"> <!-- 收藏分页(服务端分页,与收藏总量解耦,10万也常数级) -->
<div> <nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="favLastPage > 1" aria-label="Saved pagination">
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">Account</p> <button type="button" class="border border-black/20 px-3 py-1.5 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>
<dl class="space-y-4"> <template x-for="(p, i) in favPageList" :key="i">
<div class="flex gap-5 border-b border-black/10 pb-4"> <span>
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{accountLabel}</dt> <span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<dd class="flex-1 font-medium" x-text="user?.username"></dd> <button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase 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>
</div> </span>
<div class="flex gap-5 border-b border-black/10 pb-4"> </template>
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{emailLabel}</dt> <button type="button" class="border border-black/20 px-3 py-1.5 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>
<dd class="flex-1 font-medium break-all" x-text="user?.email"></dd> </nav>
</div>
</dl>
</div> </div>
<div class="flex flex-wrap gap-3"> <!-- History -->
<button @click="logout()" class="border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{signOutLabel}</button> <div x-show="tab==='history'" x-cloak>
<a href={href(locale, ROUTES.runwayLooks)} class="border border-black/15 bg-transparent px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t('View archive')}</a> <div class="mb-6">
<h2 class="font-serif text-2xl uppercase tracking-[-0.03em] md:text-3xl">{historyLabel}</h2>
</div>
<div class="mb-4 flex items-center justify-between">
<span class="text-[10px] font-mono uppercase tracking-[0.24em] text-black/40"><span x-text="histTotal"></span> {itemsLabel}</span>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[10px] font-mono uppercase tracking-[0.22em] text-black/45 underline-offset-4 hover:text-black hover:underline">{clearHistoryLabel}</button>
</div>
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no history yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="h in history" :key="h.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(h)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeHist(h.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] font-mono uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(h)"><p class="truncate text-[12px] font-medium" x-text="h.data?.title || '…'"></p></a>
<p class="mt-0.5 truncate text-[10px] font-mono uppercase tracking-[0.16em] text-black/45" x-text="h.data?.brand || '—'"></p>
</div>
</div>
</template>
</div>
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="histLastPage > 1" aria-label="History pagination">
<button type="button" class="border border-black/20 px-3 py-1.5 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-3 py-1.5 text-[11px] uppercase 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-3 py-1.5 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 class="space-y-8 pt-4">
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{collectionsLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{historyLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section> </section>
</div> </div>
</div> </div>
</section>
</div>
</main> </main>
</Layout> </Layout>
<script> <script>
import { getUser, logout as logoutApi } from "@/lib/api" 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" 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", () => { document.addEventListener("alpine:init", () => {
Alpine.data("accountPage", () => ({ Alpine.data("accountPage", () => ({
user: null, user: null as any,
locale: "en",
tab: "saved",
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() { init() {
const loc = this.$el.dataset.locale || "en" this.locale = this.$el.dataset.locale || "en"
const u = getUser() const u = getUser()
if (!u) { if (!u) {
window.location.href = clientHref(loc, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(loc, ROUTES.account)) window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
return return
} }
this.user = u this.user = u
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
this.loadFavPage(1)
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
this.loadHistPage(1)
}, },
logout() { // 服务端分页拉取收藏(与收藏总量解耦,10万也常数级)
logoutApi().finally(() => window.location.reload()) async loadFavPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchFavoritesPage(p, this.favPerPage)
this.favorites = r.items
this.favTotal = r.total
this.favLastPage = r.lastPage
this.favPage = p
this.favoritesCount = r.total
} catch {
const local = getFavorites()
this.favorites = local
this.favTotal = local.length
this.favLastPage = 1
this.favPage = 1
this.favoritesCount = local.length
} }
},
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 {
return clientHref(this.locale, ROUTES.item(f.id))
},
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> </script>

View File

@ -3,6 +3,7 @@ import Layout from "@/layouts/Layout.astro"
import Item from "@/components/Item.astro" import Item from "@/components/Item.astro"
import StreetSnap from "@/components/StreetSnap.astro" import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api" import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。 // 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。 // 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
@ -11,6 +12,8 @@ export const prerender = false
const { id } = Astro.params const { id } = Astro.params
const isSnap = !!id && id[0] === "s" const isSnap = !!id && id[0] === "s"
const { t } = getI18n(Astro)
let article = null let article = null
let articleErr = null let articleErr = null
let snap = null let snap = null
@ -28,8 +31,93 @@ if (isSnap) {
article = data.article article = data.article
articleErr = data.error articleErr = data.error
} }
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
const meta = isSnap
? { id: id ?? "", type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
: { id: id ?? "", type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
const saveLabel = t("save")
const savedLabel = t("saved")
--- ---
<Layout> <Layout>
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />} {article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
</Layout> </Layout>
<style>
#fav-fab.is-active .fav-heart { fill: currentColor; }
</style>
{(article || snap) && (
<button 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", () => {
if (!getUser()) {
window.location.href = "/login"
return
}
toggleFavorite({
id: meta.id,
type: meta.type,
title: meta.title,
cover: meta.cover,
brand: meta.brand,
} as any)
sync()
})
}
}
</script>

View File

@ -23,8 +23,17 @@ const failLabel = t('login failed')
const membersLabel = t('members') const membersLabel = t('members')
const backLabel = t('back to site') const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks) const homeUrl = href(locale, ROUTES.runwayLooks)
const pageTitle = `${loginLabel} — Portfolio Studio`
--- ---
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white"> <!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-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<div class="absolute right-0 top-0 z-30 p-6 md:p-10" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false"> <div class="absolute right-0 top-0 z-30 p-6 md:p-10" x-data="{ open: false }" @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 rounded-full border border-black/10 bg-white/85 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.26em] text-black backdrop-blur-sm transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open"> <button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 rounded-full border border-black/10 bg-white/85 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.26em] text-black backdrop-blur-sm transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="h-[14px] w-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="h-[14px] w-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
@ -118,9 +127,8 @@ const homeUrl = href(locale, ROUTES.runwayLooks)
</div> </div>
</section> </section>
</main> </main>
</body>
<script> <script>
import { login, getUser } from "@/lib/api" import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
@ -158,4 +166,6 @@ const homeUrl = href(locale, ROUTES.runwayLooks)
}, },
})) }))
}) })
</script> </script>
</body>
</html>

View File

@ -1,102 +1,277 @@
--- ---
import Layout from '@/layouts/Layout.astro' import Layout from "@/layouts/Layout.astro"
import { getI18n } from '@/i18n/utils' import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from "@/lib/routes"
const { locale, t } = getI18n(Astro) const { locale, t } = getI18n(Astro)
const titleLabel = t('my account') const titleLabel = t("my account")
const pleaseLabel = t('please log in') const pleaseLabel = t("please log in")
const loginLabel = t('login') const loginLabel = t("login")
const accountLabel = t('account') const historyLabel = t("my history")
const emailLabel = t('email') const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
const signOutLabel = t('sign out') const archiveUrl = href(locale, ROUTES.runwayLooks)
const collectionsLabel = t('my collections') // 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
const historyLabel = t('my history') const removeLabel = t("remove")
const soonLabel = t('coming soon') const itemsLabel = t("items")
const clearHistoryLabel = t("clear history")
--- ---
<Layout title={titleLabel}> <Layout title={titleLabel}>
<main class="mx-auto w-full max-w-6xl px-6 py-12 md:px-8 lg:px-12 lg:py-16" x-data="accountPage" data-locale={locale}> <main class="account-root mx-auto w-full max-w-7xl px-6 py-10 md:px-8 md:py-14" x-data="accountPage" data-locale={locale}>
<div class="grid gap-8 lg:grid-cols-[0.9fr_1.5fr]">
<aside class="rounded-[30px] border border-black/10 bg-black p-6 text-white shadow-[0_20px_60px_rgba(0,0,0,0.08)] md:p-8"> <!-- 未登录:引导登录 -->
<div class="mb-10 flex items-center justify-between gap-3"> <div x-show="!user" x-cloak class="border border-black/15 bg-white px-8 py-20 text-center">
<span class="text-[10px] font-mono uppercase tracking-[0.38em] text-white/60">Archive</span> <p class="mb-6 text-[10px] uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<span class="border border-white/20 bg-white/5 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.32em] text-white">2026</span> <a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-6 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div> </div>
<div class="mb-10"> <!-- 已登录:个人中心 -->
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.38em] text-white/50">Member profile</p> <div x-show="user" x-cloak class="space-y-10">
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.06em] md:text-5xl">{titleLabel}</h1>
</div>
<div class="space-y-3 text-sm text-white/70"> <!-- 报头:头像 + 用户名 -->
<p class="text-[11px] font-mono uppercase tracking-[0.32em] text-white/45">Portfolio Studio</p> <section class="flex items-center gap-5 border-b border-black/15 pb-8">
<p class="leading-relaxed">Curated access to runway archives, editorial notes, and private collections.</p> <div class="flex h-16 w-16 shrink-0 items-center justify-center border border-black text-2xl tracking-[-0.04em]" x-text="(user?.username || '?').slice(0,2).toUpperCase()"></div>
<div>
<p class="mb-2 text-[10px] uppercase tracking-[0.32em] text-black/45">{t("member profile")}</p>
<h1 class="text-4xl uppercase leading-none tracking-[-0.04em] md:text-5xl" x-text="user?.username"></h1>
</div> </div>
</section>
<!-- 主体:左侧导航 + 右侧内容 -->
<div class="grid gap-8 lg:grid-cols-[200px_1fr]">
<!-- 侧栏导航 -->
<aside class="lg:sticky lg:top-8 lg:self-start">
<nav class="flex gap-1 overflow-x-auto border-b border-black/15 lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0">
<button @click="tab='saved'" :class="tab==='saved' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{t("saved looks")}</button>
<button @click="tab='history'" :class="tab==='history' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{historyLabel}</button>
</nav>
</aside> </aside>
<section class="rounded-[30px] border border-black/10 bg-[#f7f5f1] p-6 shadow-[0_18px_50px_rgba(0,0,0,0.04)] md:p-8 lg:p-10"> <!-- 内容面板 -->
<div x-show="!user" x-cloak class="space-y-6"> <section class="js-surface js-panel border border-black/15 bg-white p-6 md:p-10">
<p class="text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<a href={href(locale, ROUTES.login) + '?redirect=' + encodeURIComponent(href(locale, ROUTES.account))} <!-- Saved looks -->
class="inline-flex items-center justify-center border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a> <div x-show="tab==='saved'" x-cloak>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl uppercase tracking-[-0.03em] md:text-3xl">{t("saved looks")}</h2>
<span class="text-[10px] uppercase tracking-[0.24em] text-black/40"><span x-text="favoritesCount"></span> {itemsLabel}</span>
</div>
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1Z"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no saved looks yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="f in favorites" :key="f.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(f)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeFav(f.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(f)"><p class="truncate text-[12px] font-medium" x-text="f.title"></p></a>
<p class="mt-0.5 truncate text-[10px] uppercase tracking-[0.16em] text-black/45" x-text="f.brand || '—'"></p>
</div>
</div>
</template>
</div> </div>
<div x-show="user" x-cloak class="space-y-8"> <!-- 收藏分页(服务端分页,与收藏总量解耦,10万也常数级) -->
<div> <nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="favLastPage > 1" aria-label="Saved pagination">
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">Account</p> <button type="button" class="border border-black/20 px-3 py-1.5 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>
<dl class="space-y-4"> <template x-for="(p, i) in favPageList" :key="i">
<div class="flex gap-5 border-b border-black/10 pb-4"> <span>
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{accountLabel}</dt> <span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<dd class="flex-1 font-medium" x-text="user?.username"></dd> <button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase 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>
</div> </span>
<div class="flex gap-5 border-b border-black/10 pb-4"> </template>
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{emailLabel}</dt> <button type="button" class="border border-black/20 px-3 py-1.5 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>
<dd class="flex-1 font-medium break-all" x-text="user?.email"></dd> </nav>
</div>
</dl>
</div> </div>
<div class="flex flex-wrap gap-3"> <!-- History -->
<button @click="logout()" class="border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{signOutLabel}</button> <div x-show="tab==='history'" x-cloak>
<a href={href(locale, ROUTES.runwayLooks)} class="border border-black/15 bg-transparent px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t('View archive')}</a> <div class="mb-6">
<h2 class="text-2xl uppercase tracking-[-0.03em] md:text-3xl">{historyLabel}</h2>
</div>
<div class="mb-4 flex items-center justify-between">
<span class="text-[10px] uppercase tracking-[0.24em] text-black/40"><span x-text="histTotal"></span> {itemsLabel}</span>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[10px] uppercase tracking-[0.22em] text-black/45 underline-offset-4 hover:text-black hover:underline">{clearHistoryLabel}</button>
</div>
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no history yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="h in history" :key="h.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(h)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeHist(h.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(h)"><p class="truncate text-[12px] font-medium" x-text="h.data?.title || '…'"></p></a>
<p class="mt-0.5 truncate text-[10px] uppercase tracking-[0.16em] text-black/45" x-text="h.data?.brand || '—'"></p>
</div>
</div>
</template>
</div>
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="histLastPage > 1" aria-label="History pagination">
<button type="button" class="border border-black/20 px-3 py-1.5 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-3 py-1.5 text-[11px] uppercase 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-3 py-1.5 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 class="space-y-8 pt-4">
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{collectionsLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{historyLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section> </section>
</div> </div>
</div> </div>
</section>
</div>
</main> </main>
</Layout> </Layout>
<script> <script>
import { getUser, logout as logoutApi } from "@/lib/api" 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" 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", () => { document.addEventListener("alpine:init", () => {
Alpine.data("accountPage", () => ({ Alpine.data("accountPage", () => ({
user: null, user: null as any,
locale: "en",
tab: "saved",
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() { init() {
const loc = this.$el.dataset.locale || "en" this.locale = this.$el.dataset.locale || "en"
const u = getUser() const u = getUser()
if (!u) { if (!u) {
window.location.href = clientHref(loc, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(loc, ROUTES.account)) window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
return return
} }
this.user = u this.user = u
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
this.loadFavPage(1)
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
this.loadHistPage(1)
}, },
logout() { // 服务端分页拉取收藏(与收藏总量解耦,10万也常数级)
logoutApi().finally(() => window.location.reload()) async loadFavPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchFavoritesPage(p, this.favPerPage)
this.favorites = r.items
this.favTotal = r.total
this.favLastPage = r.lastPage
this.favPage = p
this.favoritesCount = r.total
} catch {
const local = getFavorites()
this.favorites = local
this.favTotal = local.length
this.favLastPage = 1
this.favPage = 1
this.favoritesCount = local.length
} }
},
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 {
return clientHref(this.locale, ROUTES.item(f.id))
},
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> </script>

View File

@ -3,6 +3,7 @@ import Layout from "@/layouts/Layout.astro"
import Item from "@/components/Item.astro" import Item from "@/components/Item.astro"
import StreetSnap from "@/components/StreetSnap.astro" import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api" import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。 // 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。 // 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
@ -11,6 +12,8 @@ export const prerender = false
const { id } = Astro.params const { id } = Astro.params
const isSnap = !!id && id[0] === "s" const isSnap = !!id && id[0] === "s"
const { t } = getI18n(Astro)
let article = null let article = null
let articleErr = null let articleErr = null
let snap = null let snap = null
@ -28,8 +31,93 @@ if (isSnap) {
article = data.article article = data.article
articleErr = data.error articleErr = data.error
} }
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
const meta = isSnap
? { id: id ?? "", type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
: { id: id ?? "", type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
const saveLabel = t("save")
const savedLabel = t("saved")
--- ---
<Layout> <Layout>
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />} {article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
</Layout> </Layout>
<style>
#fav-fab.is-active .fav-heart { fill: currentColor; }
</style>
{(article || snap) && (
<button 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", () => {
if (!getUser()) {
window.location.href = "/login"
return
}
toggleFavorite({
id: meta.id,
type: meta.type,
title: meta.title,
cover: meta.cover,
brand: meta.brand,
} as any)
sync()
})
}
}
</script>

View File

@ -23,8 +23,17 @@ const failLabel = t('login failed')
const membersLabel = t('members') const membersLabel = t('members')
const backLabel = t('back to site') const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks) const homeUrl = href(locale, ROUTES.runwayLooks)
const pageTitle = `${loginLabel} — Portfolio Studio`
--- ---
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white"> <!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-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<div class="absolute right-0 top-0 z-30 p-6 md:p-10" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false"> <div class="absolute right-0 top-0 z-30 p-6 md:p-10" x-data="{ open: false }" @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 rounded-full border border-black/10 bg-white/85 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.26em] text-black backdrop-blur-sm transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open"> <button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 rounded-full border border-black/10 bg-white/85 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.26em] text-black backdrop-blur-sm transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="h-[14px] w-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="h-[14px] w-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
@ -118,9 +127,8 @@ const homeUrl = href(locale, ROUTES.runwayLooks)
</div> </div>
</section> </section>
</main> </main>
</body>
<script> <script>
import { login, getUser } from "@/lib/api" import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
@ -158,4 +166,6 @@ const homeUrl = href(locale, ROUTES.runwayLooks)
}, },
})) }))
}) })
</script> </script>
</body>
</html>

View File

@ -255,3 +255,12 @@
@keyframes looksProg { 0% { left: -38%; } 100% { left: 100%; } } @keyframes looksProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes looksShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } } @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; }