Files
frontend_v2/.workbuddy/memory/MEMORY.md
toom1996 afff2d9a49 update
2026-09-14 21:30:15 +08:00

117 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 项目长期记忆(Project MEMORY.md)
> 维护约定:本文件只放**跨会话长期有效**的事实与铁律;当日细节写 `.workbuddy/memory/YYYY-MM-DD.md`。
> 历次合并去重:2026-09-14(修正 BASE_API 位置、ROUTES.article、ssgRequest、Raleway、View Transitions 等过时记述;补 gallery/灯箱与 Alpine 展开推断坑)。
## 架构总览
- 前端 `d:/project/frontend_v2`:Astro(`adapter: node` standalone,SSR 运行时取数)+ Tailwind v4 + Alpine.js。构建 `npm run build`;类型检查 `npm run check`(= `astro check`)。SSG 取数走 `requestSsg`(src/lib/request.ts),失败 `null` 回落不影响构建。
- 后端 `d:/project/backend_v2`:Go + Gin + GORM(MySQL)。公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`);SSG 引擎 :8091(仅 127.0.0.1,nginx 不反代)。
- 接口 i18n:后端按 `?locale=cn|en` 选列;前端取数复用带 locale 原语(`request` / `requestSsg`),**绝不裸写 fetch**;语言以 URL 前缀为准。
- **base 地址全在 `src/lib/config/`(目录)**:`index.ts` 导出 `config`(`validate(isProd?prod:dev)`)、`types.ts`、`env.dev.ts`/`env.prod.ts`。三把址:`config.baseApi`(浏览器运行期)/ `config.baseApiSsr`(SSR 绝对 URL)/ `config.baseApiSsg`(构建期内部端口)。**全站无直接 `import.meta.env.BASE_API` 读取**(值硬编码在 env.*.ts,**不在 `.env`**)。密钥类(`SSG_TOKEN`)故意直读 env,不进客户端 bundle。
- **后端编译产物坑(Windows)**:`go build -o server-bin ./cmd/server` 实际产出无扩展名 `server-bin`,但运行的是 `server-bin.exe`。必须 `go build -o server-bin.exe` 显式覆盖,再 `Start-Process server-bin.exe`;杀进程 `(Get-NetTCPConnection -LocalPort 8090).OwningProcess`。本地后端长空闲报 `invalid connection` 先重启后端(连接池 `conn_max_idle_time` 须 < MySQL `wait_timeout`)。
- 前端 dev server **不热加载 `.env`**:改 base 后 `astro dev stop`+`astro dev --background`;浏览器旧 bundle 硬刷新、旧 token 失效重登录。
- **`504 (Outdated Optimize Dep)` 修法**:动过 `package.json` 依赖后 Vite 会重新预打包,哈希变了而浏览器仍攥着旧哈希 → 控制台报 `GET /node_modules/.vite/deps/xxx.js 504 (Outdated Optimize Dep)`。修:`astro dev stop` → 删 `node_modules/.vite` → `astro dev --background` → 浏览器**硬刷新**(Ctrl+Shift+R)。
- 报错里的 **`audit-*.js` 是 Astro 开发工具栏的 chunk**,只在 dev 存在,与站点代码无关,生产构建没有。
- 控制台里 `Images loaded lazily and replaced with placeholders. Load events are deferred.` 是 **Chrome DevTools 的提示**(开着 Network/Performance 面板时会把懒加载图临时换成占位图并推迟 load 事件),**不是错误**。
## 关键约定
- 视觉:黑白极简编辑风(monospace 小标签 + 宽字距 + 细线 + 直角),**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系。
- 品牌名前端只显英文(`display=nameEn`);走秀标题/描述按 locale 选列。
- API 命名:一个功能一个接口,跨资源/独立功能独立路径(如 `brands/hot` 而非 `?featured=1`)。
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`(Tailwind v4 编译期指令,最终 HTML 不出现,样式已生效);Tailwind v4 无零值类。
- **跨组件复用的原子样式必须放 `global.css`,不能放组件 `<style>`**:Astro 会给组件样式加作用域哈希,`.fav-btn` 同时出现在详情网格、灯箱(`GalleryLightbox`)、列表卡片,组件作用域**跨组件命中不了**。现 global.css 含 `.fav-btn`、`.locate-flash`、`@media(hover:none)`、`.no-scrollbar`、`[x-cloak]`。
- Layout `<slot/>` 包 `flex-1 flex flex-col`;页内根容器**禁用 `m-auto`**(shrink-to-fit 抖动),用 `w-full`+`max-w-[x] mx-auto`。
- **Header/下拉 FOUC**:所有 `x-show` 下拉一律带 `x-cloak`。
- **加载屏组件化**:`src/components/LoadingScreen.astro` 封装标记+样式+打包 `<script>`(无 `is:inline`)。字体用**系统字体栈**(ui-serif / ui-monospace),**不引外部字体**(Raleway 已移除)。改加载屏只动这一个文件。
- 项目**未启用 View Transitions**(Layout 无 `ClientRouter`),故 `astro:page-load` 监听实际不会触发(`Index.astro` 里那条属防御性死代码,保留无害)。
## Astro `<script>` 铁律(踩坑汇总)
1. 作用域隔离:打包型 `<script>` 拿不到 frontmatter 的 import/变量,需脚本顶部重新 import。
2. **`define:vars` + `import` = 致命**:`define:vars` 把脚本包成 IIFE 注入 `const meta=...`,原顶部 `import` 被塞进函数体 → SyntaxError 整段不执行。凡脚本要 import,改用 `data-*` 属性透传(`ItemPage.astro` 的 fav-fab 已用此套路)。
3. 片段页(无 `<html>/<head>`)的 `import '@/styles/global.css'` build 时不注入 head → 整页无样式(login.astro 曾踩)。
4. **Alpine 属性表达式访问不到模块 import 符号**:Alpine 只认组件 data 属性 / `$magic` / global。**铁律:表达式里只写组件 data 属性或 `$magic`,外部状态/函数先存进 data 属性**。
- 正例:`toAbsUrl(url) { return toAbs(url) }` —— 看着像「空包装」,但模板里有 `:src="toAbsUrl(it.cover)"`,**不能删**(删了图片加载不出来)。`isAuthed` 亦为此而生。
5. **验证 Alpine 运行时务必真跑浏览器**(或至少确认表达式可解析),只看 SSR 静态 HTML 会漏掉运行时绑定失效。
6. 共享 Alpine 组件:`document.addEventListener('alpine:init', () => Alpine.data('name', () => ({...})))` 注册,`x-data="name()"` 引用(`articleView`/`streetSnapView`/`imgLoader`/`showsPage`/`streetSnapsPage`/`accountPage`/`authMenu` 同款)。**若 data 由工厂展开而来,必须显式标注回调返回类型**,见下节。
7. **Swiper 生命周期(首页 Index.astro)**:`initSwipers()` 开头先 `destroySwipers()` 防重复/泄漏;统一入口 `setupSwipers()` 用 rAF 延后一帧,挂 `DOMContentLoaded`;`swiper.update()` 用 `updateScheduled` + rAF 合并;Tab 点击 `document` 事件委托(`(e.target as Element|null)?.closest('.tab')`)。
8. **IntersectionObserver 要复用**:`initThumbLazyLoading()` 在 `loadAuthedState()` 后会被再调一次;灯箱缩略图的 observer 现在是 `src/lib/gallery.ts` 里的**模块级单例** `thumbObserver`,重建前 `?.disconnect()`(别放 Alpine data 属性,会被响应式代理包一层)。
## 类型检查与 Alpine 类型推断(2026-09-14 引入 `npm run check`)
- devDeps:`@astrojs/check` + `typescript` + `@types/node`。引入时暴露 **60 个既存错误,已全部修到 0**。
- **`@types/alpinejs` 的坑 A(当时 90% 报错的根因)**:`data<T extends { [key in keyof T]: T[key] }, A extends unknown[]>(name, cb: (...a:A)=>AlpineComponent<T>)` 的自引用约束使 `T` 推断失败 → **Alpine data 里的属性退化成 `unknown`、方法内 `this` 退化成 `{}`**;`Alpine.store` 则因 `Stores = [key: string|symbol]: unknown` 让内联字面量失去上下文类型。
- 修法:属性显式标注(`x as T[]` / `favIds: [] as string[]` / `user: null as AuthUser|null`);`Alpine.store` 先 `const s: Store = {...}` 再注册(`AuthModal.astro` 一处修掉 16 个错)。
- **坑 B(2026-09-14 灯箱重构新增):对象展开会让 T 退化成 `{}`。**
`Alpine.data("x", () => ({ ...createGalleryView(cfg), density: 5 }))` → T 推断失败 → `this` 变成 `InferInterceptors<{}> & XDataContext & Magics<{}>` → **所有 `this.xxx` 报 ts(2339)**(一次报 69 个)。
原因:T 能从「对象字面量」推断,但从「展开表达式」推断不出来。
**修法:给回调显式标注返回类型** —— `(): ReturnType<typeof createGalleryView> & ArticleExtras => ({...})`;`this` 取上下文类型后全部解析。Item 为此写了 `ArticleExtras` 类型(约 28 个成员)。
- **排查铁律**:见到「Property 'x' does not exist on type '{}'」或「Object of type 'unknown'」,先怀疑这两个推断问题,别怀疑业务代码。
- 其他常见修法:`(e.target as Element|null)?.closest()`;`var el` 收窄后另存 `const screen = el` 供闭包用(闭包内不保留收窄);`querySelector<HTMLElement>()` + `?? null`。
- **swiper 样式声明必须放「非模块」d.ts**(`src/types/swiper-css.d.ts`):`env.d.ts` 有 `export {}` 是模块,简写式 ambient 声明在模块文件里**不生效**。
## 验证手段(本项目可复用套路)
- 改完必跑 `npm run build`(全链路 import 校验 + 路由预渲染)+ `npm run check`。
- **预渲染页面:产物 HTML 归一化对比**。`dist/client/{en,cn}/<page>/index.html`,改前 `Copy-Item` 存一份,改后归一化掉 `astro-cid-*` 与 `_astro/<asset>` 再逐字节比。
- **`prerender=false` 页面(`item/[id]`):临时验证页法**(2026-09-14 新增)。dist 里没有 HTML,产物对比失效。做法:临时建一个 **prerender 的页面**(如 `src/pages/zzverify.astro`)直接渲染目标组件、喂假数据 → `npm run build` → 用 PowerShell 正则**统计产物 HTML 里的关键类名/绑定出现次数** → 验完**删页并重建**清 dist。
例:核对灯箱时统计 `id="lb-thumbs"`×2、`id="lb-details"`×1、`currentFavId()`×4、`aspect-[2/3]`×6 / `aspect-[3/4]`×6(6 = 5 张 SSR + 1 个 x-for 模板);核对旧样式清零。
- **`prerender=false` 页面更真实的验法(dev server 起在 :4321 时)**:直接 curl 真实 SSR 页面,比 zzverify 假数据更真。取 id:`curl http://localhost:8090/api/v1/public/runway-looks`(响应是 `{"data":[{...}]}`,**`data` 直接是数组**,不是 `data.list`)→ `data[0].id`;再 `curl http://localhost:4321/en/item/<id>` → 正则统计关键标记。注意匿名请求会带 preview 门禁(只渲染前 5 张)。2026-09-14 灯箱重构即此法验证:`fav-btn` 11 = 3 条内联 CSS + 6 网格(5 SSR + 1 个 x-for 模板)+ 1 画廊灯箱 + 1 细节灯箱。
- **PowerShell 取输出**:`astro check` 的 stdout 会被吞 → `npx astro check 2>&1 | Out-File -FilePath x.txt -Encoding utf8` 再用文件工具读;`Get-Content` **必须带 `-Encoding utf8`**(否则被安全策略拦)。HTML 是单行,别直接 grep 打印,用 `[regex]::Matches($h,'...').Count` 统计。
## 模块策略
- 主线:首页 + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。品牌索引页已下线(2026-08-26),品牌经走秀页侧栏 filter + 品牌弹窗(热门 30 SSG,搜索/字母运行时 `/api/v1/public/brands`)。
- 导航死链清理(2026-08-30):删 `/latest-projects`/`/about`/`/contact`。保留未动死链 `/portfolio`/`/login`/`/register`。「header 别动」= 不擅改导航结构/视觉。
## 未登录预览门禁(详情页,2026-09-03 定稿 + 后续)
- 用户意图:列表页全量展示,限制只发生在**点进图集详情后**——未登录仅看前 5 张图,其余锁图遮罩提示登录。
- 后端硬截断(真防护):`GET /api/v1/public/runway-looks/:id` 与 `/street-snaps/:id` 按 `middleware.UserIDFrom(c)` 判匿名且 `len(Images)>5` 时 `Images=Images[:5]` 并回 `preview:true` + `image_total`(截断前总数)。已登录(带 Bearer)返回完整图片集、无 preview。列表接口全量,翻页 page>1 仍 401 由 `PublicFirstPageAuth` 兜底。
- 前端:详情组件由 SSR `getSsr*` 透传 `preview`/`imageTotal`;画廊 SSR 渲染前 5 张 + 剩余 `(imageTotal-5)` 张空图遮罩(锁图标 + "login to view" + "+N photos",`x-show="!isAuthed && preview"` + `x-cloak`)。已登录/`auth:login` 触发 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed`(带 Bearer 取全量)把第 6 张起追加进 `extraImages`。`getUser()` 读 localStorage `fa_user`。
- **底部 Preview 大框门禁已于 2026-09-03 后续移除**,仅保留画廊内遮罩提示。
## 账号体系
- 仅预置内部账号不开放注册。`seed_users` 建 `admin`(`SEED_ADMIN_PASSWORD` 缺省 `Studio#2026!Admin`) + `root`/`root`(uid 通常 8,本地 `db_dev` 确有,登录 200)。
- 双令牌 JWT:access 7d 无状态 + refresh 30d 仅存 SHA256 落库;登录即吊销该用户全部 refresh(互斥登录)。`/api/v1/auth/`:`/login` `/refresh`(复用不轮换) `/logout` `/logout-all` `/me`(Bearer)。
- **前端 lib 分层**(2026-09-14 核对磁盘,纠正此前多次误记):
- `src/lib/auth.ts` **仅会话存储原语**:`getUser`/`getAccessToken`/`getRefreshToken`/`getAccessExpiresAt`/`isAccessExpiring`/`getLastActiveAt`/`touchActivity`/`clearSession`/`saveSession`。
- `src/lib/api.ts` 业务取数 + 鉴权函数(`login`/`logout`/`fetchMe`/`enforceIdleLogout`)+ 收藏/历史写接口 + 带 Bearer 的 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed`。
- **`login`/`logout`/`fetchMe`/`enforceIdleLogout` 有意留在 api.ts,不要搬进 auth.ts**:它们要经 `request()`,而 request.ts 依赖 auth.ts 的令牌读取,搬过去会形成 `auth ↔ request` 循环依赖(09-13 那次拆分没落地就是这个原因)。api.ts 头注释已写明。
- `src/lib/ssg.ts` **构建期 SSG 专用**:`getSsgIndexRunway`/`getSsgHotBrands`/`getSsgStreetSnapPopular` + `SsgPopularBrand`;消费 `Index.astro`/`RunwayLooks.astro`。
- `src/lib/ssr.ts` **运行期按需 SSR 取数**:`getSsrArticle`/`getSsrStreetSnap` + 两个 Result 类型;消费 `components/pages/ItemPage.astro`。与 ssg.ts 对称(ssg=构建期 / ssr=运行期),**两者都是服务端可信内部取数,不经签名、不带 Bearer**。
- **`src/lib/gallery.ts`(2026-09-14 新增)**:`createGalleryView(cfg)` 工厂,装走秀/街拍详情页**逐行同构**的图集+大图灯箱 Alpine 逻辑(约 250 行)。cfg 仅 4 项差异:`rootSelector`/`gallerySelector`/`favType`/`fetchDetail`。导出 `GalleryView`(=`ReturnType<typeof createGalleryView>`)、`GalleryImageRaw`、`GalleryDetailFetcher`、`GalleryExtraImage`。**三条工厂约束(别"顺手改回去")**:① 内部**不用 Alpine 魔法**(`$el`/`$nextTick`)→ 用 `cfg.rootSelector` 查根节点 + `requestAnimationFrame` 等渲染(否则展开后 this 丢魔法);② **getter 会被展开求值成静态值** → `currentFavId`/`currentFavUrl` 是**方法**,模板写 `currentFavId()`;③ 共享初始化叫 **`galleryInit()`** 而非 `init()`(组件的 `init()` 会覆盖它)。
- `src/lib/crypto.ts` 签名 `clientSign`;`src/lib/locale.ts` 语言透传 `setApiLocale`/`currentLocale`/`withLocale`。
- `src/lib/endpoints.ts` **后端 API 端点路径唯一来源**:分 `public`/`auth`/`me`/`ssg` 四组(动态 :id 用函数如 `public.runwayLook(id)`;`V1="/api/v1"`、`SSG="/api/internal/ssg"` 前缀只此定义一次)。**与 `src/lib/routes.ts` 的 `ROUTES` 严格区分**:`endpoints`=fetch 的后端接口路径(locale 走 ?locale=);`ROUTES`=前端页面跳转 URL(带 /en /cn 前缀)。改接口路径只动 `endpoints.ts` 一处。
- `src/lib/http.ts` 全局 fetch 调试日志拦截器(`request.ts` 加载即安装)。**开关只认 `DEV || DEBUG_API==="true"`**——曾含 `import.meta.env.SSR`,而该值在构建出的 Node 端恒 true,导致生产每请求都 `clone()+读 body` 且往容器 `logs/api-debug.log` 无限追加。
- 依赖无环:api→{request,crypto,auth,locale,endpoints};ssr→{request,endpoints,locale,api(type-only)};ssg→{request,api(type),endpoints};gallery→{request,auth,favorites};request→{crypto,auth,locale,config,endpoints};auth→locale;endpoints 为叶子。
- 硬门禁:RunwayLooks/StreetSnaps 筛选与翻页 `requireLogin()`,未登录弹内嵌登录表单,登录成功 `loadPage(1)`;首屏 `init()` 的 `loadPage(1)` 不拦。
- **收藏服务端化**:表 `favorites` + `/api/v1/me/favorites`(GET 分页/POST 幂等/DELETE/`/me/favorites/checks` 批量校验)。按 `user_id` 隔离,`target_uid` 编码串(图集 runway `r=`/street `s=`,图片 `i=`/`j=`)。前端 `src/lib/favorites.ts`:`fetchFavoritesPage()` 服务端分页;`checkFavorited(ids)` 仅发当前可见 id(单发请求,无循环);收藏/取消本地乐观 + 后台同步。
- **item 页收藏态已合并进 authed 详情接口(2026-09-13)**:登录态用一次 `get*DetailAuthed` 拿回完整图片集+收藏态:图集级 `fav` 经 `fav:set` 事件广播给 fav-fab、图片级以 `im.favorited` 重建 `favIds`;fav-fab 只听 `fav:set` 不再调 `checkFavorited`。**item 页已登录收藏校验 3→0 次**。列表页仍用 `/me/favorites/checks` 批量校正。**铁律:收藏态只能合并进「带 Bearer 的 authed 详情接口」,匿名公开/列表接口绝不合并**。
- **浏览历史**(2026-09-02):记录时机在 item 详情页 `recordHistory(meta.id)`(编码串带类型);列表页不记。后端 `histories` 表唯一键 `uniq(user_id,target_uid)`,`HISTORY_CAP=2000` FIFO。前端 `src/lib/history.ts`:账户页服务端分页 + 逐篇回查 `getHistoryMeta(id)`。
## 前端复用与页面结构
- 详情页统一 `/item`:页面 `src/pages/{en,cn}/item/[id].astro` 是 4 行薄包装(**`export const prerender = false` 必须留在页面文件**,Astro 只读页面模块的该导出),实现见 `src/components/pages/ItemPage.astro`(id 由页面 `Astro.params.id` 取出后作 prop 透传)。id 类型前缀 r=/s= 直接查对应表无回落。`ROUTES.item(id)`/`ROUTES.streetSnap(id)` 都指向 `/item/${id}`。
- **详情页灯箱组件化(2026-09-14)**:`src/components/GalleryLightbox.astro` = 走秀/街拍共享的灯箱**标记**(约 150 行),props 仅 `images`(SSR 首屏图,须带 `thumb`)+ `aspect`(`2/3` 走秀 / `3/4` 街拍,**两个字面量都写在组件源码里**保证 JIT 扫得到);走秀独有的「细节图簇」经具名插槽 `slot="details"` 注入。交互全在 `lib/gallery.ts`。`Item.astro` 1161→约 850 行、`StreetSnap.astro` 645→约 330 行,两者 `<style>` 整块删除(原子样式已上提 global.css)。
- **灯箱视觉基准 = Item 的编辑风**(StreetSnap 旧版已对齐):计数零填充 `01 / 05` + `tracking-[0.22em]` 直角细框;缩略图激活态 `ring-1 ring-black`;nav/关闭按钮直角 + `hover:bg-black hover:text-white`;关闭图标用 SVG(不用 `✕` 字符);缩略图条无渐变。
- 灯箱的单图收藏按钮是**圆形**(对齐全站收藏语义:列表卡片 `.fav-toggle`、详情页 `#fav-fab` 都 `rounded-full`)。
- **cn/en 页面去重(2026-09-14)**:`src/components/pages/{LoginPage,AccountPage,ItemPage}.astro` = 共享实现(语言由 `Astro.currentLocale` 运行时决定,**页面文件里没有任何语言分支**);`pages/{en,cn}/{login,account,item/[id]}.astro` 变薄包装。`pages/{en,cn}/{index,runway-looks,street-snaps}` 本就是 5 行包装,**未去重**(去了反而更长)。`LoginPage` 不走 Layout、自带完整 `<html>` 文档。
- 列表组件同构,抽 `src/styles/look-grid.css` + `src/lib/looks-grid.ts`(`SPIN_SVG`/`makeCardSlots`/`buildPageList`/`buildWindowPages`)。注意两个分页函数形态不同:`buildPageList` 输出 `number|"..."`(列表页用),`buildWindowPages` 输出 `{label,page}`(账户页用),勿混用。品牌筛选弹窗 `BrandModal.astro`(仅 RunwayLooks)。卡片标记在 Alpine `x-for` 内客户端渲染,不能提成 Astro 组件。
- 账户页 `AccountPage.astro`:报头 + 两 tab(Saved looks / My History)。
## i18n 字典约定(`src/i18n/dictionary.ts`)
- 结构 `export const dict: Record<string, {cn:string}>`。**key 即英文原文**(en 直接返 key),value 只存非默认语言(仅 cn)。`translate(key,locale)` 对 key `toLowerCase()` 后查,**key 大小写不敏感**;缺翻译 en 回退 + dev 告警。
- **勿把 key 大小写不一致当「死 key」**:`t('Home')`/`t('Previous')`/`t('No article ID specified')` 与 dict 全小写不一致但**都能命中**(`toLowerCase()` 查表)。
- 调用面:组件用 `t('English phrase')`;部分标题经变量 `t(city)` 传入(城市 18 个,勿因「无字面量 t()」误删)。
- **中文绝不可当 key**:`t('中文')` 在 en 态显示中文。硬编中文先改英文 key 并在 dict 补 `{cn:'中文'}`。
- 2026-09-13 审计:补 6 缺失 key、中文 key `全部的 runway` 改 `all runway looks`、删 20 个零调用死条目;字典 92→78 条。
## 部署
- Docker + Gitea Actions:Dockerfile/nginx.conf/docker-compose.yml/deploy.sh;nginx 拦截 `/api/v1/ssg/` 404,健康检查 `/api/v1/public/brands`。
## 数据分类
- 走秀 `brand_runway`:`collection_type`(rtw/menswear/couture/resort/pre_fall)+`season`+`season_code`(SS26…),title 规则回填(幂等)。公开列表 `?collection_type=&season=&season_code=&year=` 筛选。
- 街拍 `street_snap.city`:VARCHAR(128) 精确匹配 `WHERE city=?`;前端 header 下拉 `?city=` + 侧栏 City pills。规范值「首字母大写英文」(Paris/New York/Copenhagen…),大小写敏感须一致;dev 库仅 12 行样本属预期。
## 已知待办(尚未做,勿误以为已完成)
- 详情页重复 HTML 片段(SSR 首屏图与 `extraImages` 两份 figure 结构)抽组件 —— 灯箱主体已抽,但**网格图那两份 figure 仍在 Item/StreetSnap 各写一遍**。
- 列表页分页/收藏 mixin 下沉(净减行数仅约 35 行,收益/风险比低,暂缓)。
- `favorites.ts`(8.3KB)、`history.ts`(6.5KB)、`look-grid.css`(8.9KB) 未逐行审。
- `account.astro` 残留 `any`(`user: null as any`、`favorites: [] as any[]` 等)未补类型。
- P2 未做(按"不改样式"主动跳过):图片 `eager` 数量(现前 6 张)、街拍骨架屏数量(现 4 个 vs 走秀 24 个,加载完会跳)。