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

133 lines
20 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.

# 2026-09-14
## 前端 API 客户端二次拆分:SSG 构建期取数抽到 ssg.ts ✅
用户嫌 api.ts 杂乱,要求把构建期 SSG 接口单独成文件。
- 新建 `src/lib/ssg.ts`:`SsgPopularBrand` 类型 + `getSsgIndexRunway`/`getSsgHotBrands`/`getSsgStreetSnapPopular`(原 api.ts 末尾「SSG 构建专用」段)。import `requestSsg` from `./request`、`import type { BrandEntry, StreetSnapItem } from "./api"`(类型仅用,不引入运行期依赖,无环)。
- `api.ts`:删除上述类型与 3 个函数、`SsgPopularBrand` 类型;import 去掉 `requestSsg`;文件头注释移除 SSG 分组。
- 消费方改 import:`Index.astro`、`RunwayLooks.astro` 的 `getSsg*` 改从 `@/lib/ssg` 导入。
- 验证:`astro build` 退出码 0,全部路由预渲染成功(Vite 导入解析 = 全链路校验)。lint 0。
## ⚠️ 重要对账:auth.ts 鉴权函数并未真正拆出(纠正 09-13 总结)
09-13 总结称 login/logout/fetchMe 已拆到 `auth.ts`,但 09-14 读磁盘:`auth.ts` 只含会话存储原语(getUser/getAccessToken/saveSession/clearSession 等),`login`/`logout`/`fetchMe`/`enforceIdleLogout` 仍在 `api.ts`(约 387–465 行)。即上次的鉴权拆分未落盘(或已被还原)。已同步修正 MEMORY.md,避免以后再绕回去。
## 待用户决定是否继续
- 是否要把 `login`/`logout`/`fetchMe`/`enforceIdleLogout` 真挪到 `auth.ts`(完成上次未落地的拆分)?
- `getSsrArticle`/`getSsrStreetSnap`(运行期 SSR 按需,非 SSG)是否也要单独成 `ssr.ts`?目前与 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed` 成对留在 api.ts。
## 端点路径集中到 endpoints.ts ✅(11:30)
用户要求把 `/api/v1/xxx` 端点字面量用 map/const 集中,且要与页面路由 `ROUTES` 区分。
- 新建 `src/lib/endpoints.ts`:导出 `endpoints`(分 `public`/`auth`/`me`/`ssg` 四组;动态 id 用函数如 `runwayLook(id)`;`V1="/api/v1"`、`SSG="/api/internal/ssg"` 前缀仅此定义一次)。`as const` 保证字面路径类型。
- 与 `src/lib/routes.ts` 的 `ROUTES`(前端跳转 URL)严格区分:endpoints=fetch 接口路径;ROUTES=页面路由(带 /en /cn)。命名上选 `endpoints` 而非 `apiRoutes`/`routes`,避免与 Astro 页面路由(及既有 `routes.ts`)混淆。
- 替换范围:api.ts 21 处、ssg.ts 3 处(含 `?limit=` 模板)、request.ts 1 处(refresh)。共 25 处端点字面量清零;grep 确认仅剩注释与 endpoints.ts 定义常量。
- 坑:`enforceIdleLogout` 那行 `auth: false }).catch` 有空格,首轮替换因 old_str 缺空格未命中(工具误报成功),已补替换;`getSsr*` 的 `${b}/api/v1/...` 模板改为 `${b}${endpoints.xxx(id)}`。
- 验证:`astro build` 退出码 0,全部 11 路由预渲染成功(SSG 三端点经新常量正常解析)。
## 用户问「api 地址要不要抽象出来」→ 已落地核查(10:5x)
- 结论:地址**已经**集中在 `src/lib/config/`(目录,非单文件):`index.ts` 导出 `config`(`validate(isProd?prod:dev)`)、`types.ts` 定义 `AppConfig`、`env.dev.ts`/`env.prod.ts` 两份值。无 `config.ts` 单文件,import `"./config"` 解析到 `config/index.ts`。
- 三把址:`config.baseApi`(浏览器运行期,request.ts:30)/ `config.baseApiSsr`(SSR 绝对 URL,request.ts:41,api.ts 经 ssrBase())/ `config.baseApiSsg`(构建期,request.ts:183)。**全站无直接 `import.meta.env.BASE_API` 读取**(memory 早先说的 `.env BASE_API` 已不实,dev/prod 值在 env.*.ts 硬编码)。
- 仅两处「还散」:① `/api/v1` 版本前缀每个端点内联(api.ts 头注释有意为之,便于全局替换升 v2);② `SSG_TOKEN` 故意读 `import.meta.env.SSG_TOKEN`(密钥,不进客户端 bundle,安全设计勿动)。
- 用户尚未决定:是否抽 `const API_PREFIX="/api/v1"` 常数。
## clientSign 安全性讨论(12:0x)→ 未改代码
- 实证:`client-sign-secret-change-me` **明文出现在产物** `dist/client/_astro/api.BMQlzPjg.js`(esbuild 只压缩不加密字符串)。`crypto.ts` 是 HMAC 软门槛,非加密。
- 结论:拒绝 VM 混淆/指纹(对内容站负收益,且救不了静态密钥);真正提难度靠服务端。建议方向=**服务端下发滚动密钥 + TTL/nonce 防重放 + IP 失败限流**(需动 backend_v2 的 `middleware.ClientSign`)。
- 附带发现(**待修**):`crypto.ts:9` `CLIENT_SIGN_SECRET` 是**硬编码 dev 值、未走 env** → 生产要么功能失效要么形同虚设。
## 全站代码审计(12:19 起,只读,未改任何文件)→ 报告已出
范围:js/css/libs/api/复用/目录/命名/性能。产出分级清单(artifact: code-review-plan.md)。核心发现:
- **P0-1 ⭐ 生产 SSR 仍在跑调试日志**:`lib/http.ts:16` `ENABLED = DEV || import.meta.env.SSR || DEBUG_API`——`import.meta.env.SSR` 在构建出的 Node 端恒 true,故生产每请求都 `res.clone()+await text()` 且 `writeLog()` 往容器 `logs/api-debug.log` **追加写入**(磁盘无限增长)。改法:ENABLED 去掉 SSR 分支。
- **重复地图**:① `Item.astro`(1161行) vs `StreetSnap.astro`(645行) 灯箱逻辑逐行重复(仅 `#article-gallery`↔`#snap-gallery`),Item 多 density/detailLb → 抽 `createGalleryView()`;② `RunwayLooks` vs `StreetSnaps` 的 pills/goPage/prevPage/nextPage/pageList/markFavs/favState/toggleFav 同构 → 继续下沉 `looks-grid.ts`;③ `full()`(ssrBase 绝对化) 在 Item:17 / StreetSnap:18 逐字相同 → 抽 `ssrAbs()` 到 request.ts;④ `toAbsUrl(){return toAbs(url)}` 是空包装可直接删;⑤ account.astro 的 `favPageList`/`histPageList` 同一段逻辑写两遍。
- **cn/en 页面**:6 对文件里 8 个**逐字节相同**,`account.astro` 仅差 1 行注释;语言差异 100% 在运行时(`Astro.currentLocale`),页面无任何语言分支 → 可抽共享页面组件。
- **项目未启用 View Transitions**(Layout 无 ClientRouter,全局搜无):故 `Index.astro:579` 的 `astro:page-load` 监听是死代码(保留无害)。
- **死配置/过时注释**:`astro.config.mjs` 空 `build{}` 块 + 第 29 行「英文在根路径」注释错误(prefixDefaultLocale:true 实际在 /en);`routes.ts:18-20` 过时注释(称街拍详情走 /street-snaps/[id],实际统一 /item/[id]);`Item.astro:1084` `// TODO 这是干啥的`;`pages/index.astro:3` 「应该跳转到 /en」未实现。
- **遗留文件**:`_tmp_verify_hist.mjs`/`_tmp_verify2.mjs`/`dev.log`/`server.err`/`footertail.txt`/`README.md`(Astro 模板残留)。`.gitignore` 未覆盖 `_tmp*`/`*.err`/`footertail.txt` → 误入库风险。删除属破坏性操作,**待用户确认**。
- **配置**:`package.json` `name:"test"`、**无 lint/format/check 脚本**(tsconfig 用 strict 却无处触发校验)。
- **纠正子代理误判**:dictionary 的 key 大小写差异(`t('Home')` vs dict `home`)**不是死 key**——`translate` 走 `key.toLowerCase()` 查表,都能命中。勿再当死 key 处理。
- 未深入:`favorites.ts`(8.3KB)、`history.ts`(6.5KB)、`look-grid.css`(8.9KB) 仅做了引用面检查,未逐行审。
- 用户尚未选择要做哪几项。
## i18n 目录迁入 lib 并合并 ✅(17:26,方案 A + 合并 lib/locale)
用户问「i18n 移到 lib 合理吗 + 合成 i18n.ts」。判定:移入 lib 合理(本就是基础设施层,且 `lib/locale.ts` 原本反向 import i18n);合成单文件要当心 dictionary 会无限增长。
- **新结构**:`src/lib/i18n/index.ts`(配置+工具+API语言透传,原 config.ts+utils.ts+lib/locale.ts 三合一)+ `src/lib/i18n/dictionary.ts`(纯数据,独立保留,随 UI 增长)。
- **删除**:`src/i18n/{config,utils,dictionary}.ts` + `src/lib/locale.ts` + 空目录 `src/i18n`。
- 消费方 16 处 import 改写:`@/i18n/utils`→`@/lib/i18n`、`@/i18n/config`→`@/lib/i18n`、`@/lib/locale`→`@/lib/i18n`、`lib/{ssr,request}.ts` 内 `./locale`→`./i18n`。`Layout.astro` 三段合并成一行 `import { getI18n, LOCALE_NAMES, setApiLocale } from '@/lib/i18n'`。
- **循环依赖注意(良性)**:`index.ts` import `translate` from `./dictionary`,`dictionary.ts` import `DEFAULT_LOCALE/Locale` from `./index`。均「函数体内才用」,模块求值不触发 TDZ,安全。已在 index.ts 头注释标注。
- 验证:`npm run build` Complete!;`npx astro check` **0 errors / 0 warnings / 5 hints**(hints 全是预存 `is:inline` 提示,非本次引入)。
## 清理 5 个 astro check hint ✅(19:18)
用户要求清掉上一轮残留的 5 hint。实际构成(非全 is:inline,前轮口误更正):
- `env.d.ts:8` ts(80003):原 `import type * as Alpine from "alpinejs"` → 改 `import type Alpine from "alpinejs"`(默认导入)。安全依据:tsconfig 继承 astro/strict 且 TS 主动提示 ts(80003) 即证明 esModuleInterop 已开,默认导入被允许;原注释"不能用默认导入"是过时误解,已重写。
- `LoadingScreen.astro:10` ts(6133)×2:`getI18n(Astro)` 解构出的 `locale`/`getRelativeLocaleUrl` 未用 → 改为 `const { t } = getI18n(Astro)`。
- `RunwayLooks.astro:263` / `StreetSnaps.astro:219` astro(4000):两个 JSON 数据 `<script>` 带属性被当 is:inline → 显式加 `is:inline` 消除歧义(语义本就该内联)。
- 复验 `npx astro check`:**0 errors / 0 warnings / 0 hints**。
## 清理 lib 未使用导出 ✅(19:46)
用户要求删 lib 内没用到的函数。子代理扫全 19 个 lib 文件导出符号后,交叉核对结论:
- **唯一真死代码**:`crypto.ts` 的 `CLIENT_SIGN_TTL`(导出 const,全项目零引用,注释称"调试时读取"但无人读)。已删 + 清过时注释。`clientSign` 仍用硬编码 30s 逻辑(TTL 仅服务端校验,前端不回传)。
- **导出但只被本模块内部调用(不算没用到,保留)**:`auth.getAccessExpiresAt`(被 isAccessExpiring 调)、`http.loggedFetch`/`installApiLogger`(installApiLogger 模块底自动安装时调)、`i18n.useTranslations`(被 getI18n 调)、`i18n.getLocaleFromUrl`/`LOCALES`(被本模块调)。这些是模块自身 API 面,勿当死代码删。
- 验证:`npm run build` Complete,`astro check` **0/0/0**。
## 详情页灯箱重构:抽共享工厂 + 共享灯箱组件 + 样式统一 ✅(15:5x)
用户要求「灯箱也帮我改改,顺便看样式怎么和全站更契合」。
- **新建 `src/lib/gallery.ts`**:`createGalleryView(cfg)` 工厂,装走秀/街拍详情页**逐行同构**的图集+大图灯箱逻辑(约 250 行)。cfg 只有 4 项差异:`rootSelector` / `gallerySelector` / `favType` / `fetchDetail`。导出 `GalleryView`(= `ReturnType<typeof createGalleryView>`)、`GalleryImageRaw`、`GalleryDetailFetcher`、`GalleryExtraImage`。
- **新建 `src/components/GalleryLightbox.astro`**:共享灯箱标记(约 150 行),props 仅 `images`(SSR 首屏图,需带 thumb)+ `aspect`(`2/3` 走秀 / `3/4` 街拍,两个字面量都写在组件里保证 JIT 扫得到);走秀独有的「细节图簇」经具名插槽 `slot="details"` 注入。
- **`Item.astro` 1161→约 850 行、`StreetSnap.astro` 645→约 330 行**;两者 `<style>` 整块删除。
- **共用原子样式上提 `src/styles/global.css`**:`.fav-btn`(爱心描边/实心)、`.locate-flash`、`@media (hover:none)`、`.no-scrollbar`。原因:Astro 组件 `<style>` 会加作用域哈希,而 `.fav-btn` 同时出现在网格图、灯箱、列表卡片,组件作用域**跨组件命中不了**。
- **`RawStreetSnap.images` 补 `thumb?: string`**(api.ts),使街拍灯箱缩略图条也能用 240px 缩略图。
### ⭐ 新踩的坑:对象展开会让 `Alpine.data` 的泛型 T 退化成 `{}`
`Alpine.data("x", () => ({ ...createGalleryView(cfg), density: 5 }))` → T 推断失败 → `this` 变成 `InferInterceptors<{}> & XDataContext & Magics<{}>` → **所有 `this.xxx` 报 ts(2339)「属性不存在」**(本次一次报 69 个)。
**根因**:`@types/alpinejs` 的 `data<T extends {[key in keyof T]: T[key]}>(...)` 自引用约束,从「对象字面量」能推断,从「展开表达式」推断不出来。
**修法**:给回调**显式标注返回类型**——`(): ReturnType<typeof createGalleryView> & ArticleExtras => ({...})`。`this` 于是取上下文类型,全部解析。Item 需为此写出 `ArticleExtras` 类型(约 28 个成员)。
**顺带三条工厂约束**(已写进 gallery.ts 头注释):
1. 工厂内**不用 Alpine 魔法**(`$el`/`$nextTick`)→ 改 `cfg.rootSelector` 查根节点 + `requestAnimationFrame` 等渲染。
2. **getter 会被展开求值成静态值** → `currentFavId`/`currentFavUrl` 改成**方法**,模板里写 `currentFavId()`。
3. 共享初始化叫 **`galleryInit()`** 而非 `init()`(组件自己的 `init()` 会覆盖它)。
### 灯箱样式统一(用户明确要求"更契合")
以 `Item.astro` 的灯箱为基准(它本来就是编辑风),把 `StreetSnap.astro` 的旧版对齐过来。产物 HTML 核对:旧样式 `hover:scale-105` / `bg-gradient-to-b` / `rounded-[6px]` / `bg-white/75` / `✕`字符 **全部为 0**。
统一项:计数改零填充 `01 / 05` + `tracking-[0.22em]` 直角细框;缩略图激活态 `ring-1 ring-black`(去掉 `scale-[1.03]`+`ring-2`);nav/关闭按钮改直角 + `hover:bg-black hover:text-white`(去掉圆角药丸 + scale 悬停);关闭图标改 SVG(去掉 `✕` 字符);缩略图条去掉顶部渐变;文案字号/字距对齐。
**唯一一处超出"纯统一"的改动**:灯箱的单图收藏按钮由**直角改圆形**,对齐全站收藏语义(列表卡片 `.fav-toggle`、详情页 `#fav-fab` 都是 `rounded-full`)。用户若不认同一行可回退。
### 验证手段(新增,值得复用)
`item/[id]` 是 `prerender=false`,**dist 里没有 HTML**,没法用产物对比。改法:**临时建一个 prerender 的验证页**(`src/pages/zzverify.astro`)直接渲染目标组件、喂假数据 → `npm run build` → 用 PowerShell 正则统计产物 HTML 里的关键类名/绑定次数 → 验完删页重建。
本次核对结果:`id="lb-thumbs"`×2、`id="lb-details"`×1(仅走秀)、`currentFavId()`×4、走秀缩略图 `aspect-[2/3]`×6 / 街拍 `aspect-[3/4]`×6(6 = 5 张 SSR + 1 个 x-for 模板)。
- 最终验证:`npm run build` exit 0;`npx astro check` **0 errors / 0 warnings / 3 hints**(与基线一致,hints 是既有 `is:inline` 提示)。
- 坑:`astro check` 输出会被 PowerShell 吞;用 `Out-File -Encoding utf8` 落盘再用文件工具读;`Get-Content` 必须带 `-Encoding utf8`(否则被安全策略拦)。
## 用户「全都帮我改下吧」→ 已落地的优化(13:xx–14:xx)
全部通过 `npm run build`(exit 0)+ `npx astro check`(exit 0)+ 产物 HTML 对比验证。
### P0
- **http.ts 生产关闭调试日志**:`ENABLED` 去掉 `import.meta.env.SSR` 分支(该值在构建出的 Node 端恒 true → 生产每请求都 clone+读 body,且 `writeLog` 往容器 `logs/api-debug.log` 无限追加)。现仅 `DEV || DEBUG_API==="true"`。
- 清理死配置/过时注释 5 处:`astro.config.mjs` 空 `build{}` 块 + 第29行「英文在根路径」错误注释(实际 /en);`routes.ts:18-20` 过时注释;`Item.astro` 的 `// TODO 这是干啥的`;`pages/index.astro:3`「应该跳转到 /en」未实现。
- **抽 `ssrAbs()` 到 request.ts**,删掉 Item.astro/StreetSnap.astro 各自本地 `full()`(逐字相同)。
- `package.json`:`name: "test"` → `frontend-v2`,加 `private: true` + `"check": "astro check"`;devDeps 加 `@astrojs/check`/`typescript`/`@types/node`。
- `.gitignore` 补 `_tmp*`、`*.err`。
- 删遗留文件:`_tmp_verify_hist.mjs`、`_tmp_verify2.mjs`、`dev.log`、`server.err`、`footertail.txt`。**`README.md`(Astro 模板残留)未动**;`_tmp_backfill.log` 非本次创建,保留(已被 ignore)。
### ⭐ 类型检查从 0 到有:60 errors → 0
- **根因**:`@types/alpinejs` 的 `data<T extends { [key in keyof T]: T[key] }, A extends unknown[]>(name, cb: (...a:A)=>AlpineComponent<T>)` 自引用约束使 `T` 推断失败 → 属性退化成 `unknown`、方法内 `this` 退化成 `{}`。`Alpine.store` 则是 `Stores` 为 `[key: string|symbol]: unknown`,内联字面量失去上下文类型。
- **修法**:① `Alpine.data` 的属性用 `as` 显式标注(如 `hotBrands as {...}[]`、`favIds: [] as string[]`、`user: null as AuthUser|null`);② `Alpine.store("auth", …)` 改为先 `const authStore: AuthStore = {...}` 再注册(AuthModal 一处修掉 16 个错);③ swiper 样式声明要放**非模块** d.ts(`src/types/swiper-css.d.ts`,env.d.ts 有 `export {}` 是模块,简写 ambient 声明不生效);④ `e.target` 用 `(e.target as Element|null)?.closest()`;⑤ `var el` → 收窄后另存 `const screen = el` 供闭包用(闭包内不保留收窄)。
- 剩余 3 hints(`is:inline` 提示、`env.d.ts` 默认导入建议)属噪音,未处理。
### P1
- **新建 `src/lib/ssr.ts`**:把 `getSsrArticle`/`getSsrStreetSnap` + `SsrArticleResult`/`SsrStreetSnapResult` 从 api.ts 迁出,与 ssg.ts 对称(ssg=构建期 / ssr=运行期按需,均服务端可信内部取数、不经签名)。api.ts 去掉 `ssrBase`/`withLocale` 导入。
- **`login/logout/fetchMe/enforceIdleLogout` 有意留在 api.ts**:它们要经 `request()`,而 request.ts 依赖 auth.ts 的令牌读取;搬进 auth.ts 会形成 `auth ↔ request` 循环依赖(**这就是 09-13 那次拆分没落地的真正原因**)。已在 api.ts 头注释写明。
- **cn/en 页面去重**:新建 `src/components/pages/{LoginPage,AccountPage,ItemPage}.astro`(用 `Copy-Item` 复制而非手抄,避免 600+ 行转写误差),`src/pages/{en,cn}/{login,account,item/[id]}.astro` 变 4 行薄包装。`ItemPage` 收 `id` prop —— **`export const prerender = false` 必须留在页面文件**(Astro 只读页面模块的该导出)。`pages/{en,cn}/{index,runway-looks,street-snaps}` 本来就是 5 行包装,未动(去重后反而更长)。
- **account.astro 分页去重**:`favPageList`/`histPageList` 同一段逻辑写两遍 → 抽 `buildWindowPages(cur,last)` 到 `looks-grid.ts`(与 `buildPageList` 形态不同:前者输出 `{label,page}`,后者输出 `number|"..."`,已在注释里写明勿混用)。
### P2
- **IntersectionObserver 泄漏**:`initThumbLazyLoading()` 在 `loadAuthedState()` 后会被再调一次,每次 `new` 一个 observer 且旧的没断 → Item/StreetSnap 各加模块级 `let thumbObserver: IntersectionObserver|null`,重建前 `thumbObserver?.disconnect()`。用模块级单例(非 Alpine data 属性)以免把 observer 塞进响应式代理。
### ⚠️ 纠正审计报告的两处错误结论(子代理给的,勿照做)
1. **dictionary 的「死 key」判断是错的**:`t('Home')`/`t('Previous')`/`t('No article ID specified')` 大小写与 dict 全小写不一致,但 `translate()` 走 `key.toLowerCase()` 查表,**全部能命中,不是死 key**。
2. **`toAbsUrl()` 不是「空包装、可直接删」**:模板里有 `:src="toAbsUrl(it.cover)"`(RunwayLooks:219/230),而 **Alpine 表达式访问不到模块 import 符号**(见 MEMORY 铁律 #4),所以 `toAbsUrl` 是必须存在的 data 方法,删了图片就加载不出来。
### 未做(下次可继续)
- **P1-1 详情页灯箱工厂**(Item.astro 1161 行 vs StreetSnap.astro 645 行,约 300 行逐行重复 → 抽 `createGalleryView()`):**最大重复源,但风险最高**。灯箱是交互核心,且 `item/[id]` 是 `prerender=false`(dist 无 HTML)→ **无法用产物 HTML 对比验证**,只能靠真跑浏览器。建议单独一轮做。
- **P1-3 详情页重复 HTML 片段**(SSR 首屏图与 extraImages 两份 figure 结构):同上,会动模板结构。
- **P0-4 列表页逻辑下沉**(pagination/fav mixin):净减行数只有约 35 行(mixin 机制本身要 ~75 行),且会让 UI 工具层 import 数据层;另有 Alpine `this` 推断的不确定性。收益/风险比不划算,暂缓。
- **P2-5 补 any 类型**(account.astro 的 `user: null as any`、`favorites: [] as any[]` 等)未动。
- `favorites.ts`(8.3KB)、`history.ts`(6.5KB)、`look-grid.css`(8.9KB) 仍未逐行审。