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

20 KiB
Raw Permalink Blame History

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) 仍未逐行审。