20 KiB
2026-09-14
前端 API 客户端二次拆分:SSG 构建期取数抽到 ssg.ts ✅
用户嫌 api.ts 杂乱,要求把构建期 SSG 接口单独成文件。
- 新建
src/lib/ssg.ts:SsgPopularBrand类型 +getSsgIndexRunway/getSsgHotBrands/getSsgStreetSnapPopular(原 api.ts 末尾「SSG 构建专用」段)。importrequestSsgfrom./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:9CLIENT_SIGN_SECRET是硬编码 dev 值、未走 env → 生产要么功能失效要么形同虚设。
全站代码审计(12:19 起,只读,未改任何文件)→ 报告已出
范围:js/css/libs/api/复用/目录/命名/性能。产出分级清单(artifact: code-review-plan.md)。核心发现:
- P0-1 ⭐ 生产 SSR 仍在跑调试日志:
lib/http.ts:16ENABLED = 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行) vsStreetSnap.astro(645行) 灯箱逻辑逐行重复(仅#article-gallery↔#snap-gallery),Item 多 density/detailLb → 抽createGalleryView();②RunwayLooksvsStreetSnaps的 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.jsonname:"test"、无 lint/format/check 脚本(tsconfig 用 strict 却无处触发校验)。 - 纠正子代理误判:dictionary 的 key 大小写差异(
t('Home')vs dicthome)不是死 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.tsimporttranslatefrom./dictionary,dictionary.tsimportDEFAULT_LOCALE/Localefrom./index。均「函数体内才用」,模块求值不触发 TDZ,安全。已在 index.ts 头注释标注。 - 验证:
npm run buildComplete!;npx astro check0 errors / 0 warnings / 5 hints(hints 全是预存is:inline提示,非本次引入)。
清理 5 个 astro check hint ✅(19:18)
用户要求清掉上一轮残留的 5 hint。实际构成(非全 is:inline,前轮口误更正):
env.d.ts:8ts(80003):原import type * as Alpine from "alpinejs"→ 改import type Alpine from "alpinejs"(默认导入)。安全依据:tsconfig 继承 astro/strict 且 TS 主动提示 ts(80003) 即证明 esModuleInterop 已开,默认导入被允许;原注释"不能用默认导入"是过时误解,已重写。LoadingScreen.astro:10ts(6133)×2:getI18n(Astro)解构出的locale/getRelativeLocaleUrl未用 → 改为const { t } = getI18n(Astro)。RunwayLooks.astro:263/StreetSnaps.astro:219astro(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 buildComplete,astro check0/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.astro1161→约 850 行、StreetSnap.astro645→约 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 头注释):
- 工厂内不用 Alpine 魔法(
$el/$nextTick)→ 改cfg.rootSelector查根节点 +requestAnimationFrame等渲染。 - getter 会被展开求值成静态值 →
currentFavId/currentFavUrl改成方法,模板里写currentFavId()。 - 共享初始化叫
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 buildexit 0;npx astro check0 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收idprop ——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 塞进响应式代理。
⚠️ 纠正审计报告的两处错误结论(子代理给的,勿照做)
- dictionary 的「死 key」判断是错的:
t('Home')/t('Previous')/t('No article ID specified')大小写与 dict 全小写不一致,但translate()走key.toLowerCase()查表,全部能命中,不是死 key。 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) 仍未逐行审。