update
This commit is contained in:
132
.workbuddy/memory/2026-09-14.md
Normal file
132
.workbuddy/memory/2026-09-14.md
Normal file
@ -0,0 +1,132 @@
|
||||
# 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) 仍未逐行审。
|
||||
@ -1,29 +1,60 @@
|
||||
# 项目长期记忆(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`(SSG 取数走 ssgRequest,失败回落不影响构建)。
|
||||
- 前端 `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/ssgRequest),**绝不裸写 fetch**;语言以 URL 前缀为准。
|
||||
- **后端编译产物坑(Windows)**:`go build -o server-bin ./cmd/server` 实际产出无扩展名 `server-bin`,但运行的是 `server-bin.exe`。必须 `go build -o server-bin.exe` 显式覆盖 `.exe`,再 `Start-Process server-bin.exe`;杀进程 `(Get-NetTCPConnection -LocalPort 8090).OwningProcess`。本地后端长空闲报 `invalid connection` 先重启后端(连接池 `conn_max_idle_time` 须 < MySQL `wait_timeout`)。
|
||||
- 前端 dev server **不热加载 `.env`**:改 BASE_API 后 `astro dev stop`+`astro dev --background`;浏览器旧 bundle 硬刷新、旧 token 失效重登录。
|
||||
- 接口 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 小标签 + Georgia 衬线大标题 + 细线),**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系。
|
||||
- 视觉:黑白极简编辑风(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` 下拉(Runway/Street/语言/账号/未登录 Login)一律带 `x-cloak`(global.css 已 `[x-cloak]{display:none!important}`)。
|
||||
- **加载屏组件化**:`src/components/LoadingScreen.astro` 封装标记+样式+打包 `<script>`(无 `is:inline`),Raleway 字体链随组件 hoist 到 head。改加载屏只动这一个文件。
|
||||
- **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-*` 属性透传(item/[id].astro 的 fav-fab 已用此套路)。
|
||||
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 符号**:`x-show="!getUser() && preview"` 中 `getUser` 是 import 来的自由变量,Alpine 只认组件 data 属性 / `$magic` / global → 求值抛 ReferenceError、元素卡 `x-cloak` 隐藏。**铁律:表达式里只写组件 data 属性或 `$magic`,外部状态先存进 data 属性**(`isAuthed` 即为此而生)。
|
||||
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` 同款)。
|
||||
7. **Swiper 生命周期(首页 Index.astro)**:`initSwipers()` 开头先 `destroySwipers()` 防重复/泄漏;统一入口 `setupSwipers()` 用 rAF 延后一帧,挂 `DOMContentLoaded` + `astro:page-load`(View Transitions 重入);`swiper.update()` 用 `updateScheduled` + rAF 合并防多图同帧狂 update;Tab 点击 `document` 事件委托(`e.target.closest('.tab')`)兼容 VT 新 DOM。
|
||||
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`)。
|
||||
@ -31,18 +62,44 @@
|
||||
|
||||
## 未登录预览门禁(详情页,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 兜底(始终解析 Bearer、仅 page>1 强制)。
|
||||
- 前端:详情组件 `Item.astro`(runway)/`StreetSnap.astro`(street) 由 SSR `getSsr*` 透传 `preview`/`imageTotal`;画廊 SSR 渲染前 5 张 + 剩余 `(imageTotal-5)` 张空图遮罩(锁图标 + "login to view" + "+N photos",`x-show="!isAuthed && preview"` + `x-cloak`,点 `goLogin()`)。已登录/`auth:login` 触发 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed`(带 Bearer 取全量)把第 6 张起追加进 `extraImages`,`isAuthed` 为真时遮罩自动隐藏。`getUser()` 读 localStorage `fa_user`。
|
||||
- **底部 Preview 大框门禁已于 2026-09-03 后续移除**(用户认为意义不大),仅保留画廊内遮罩提示;对应 dict key `login to view all photos` 已删。
|
||||
- 后端硬截断(真防护):`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)。前端 `.env` `BASE_API=http://localhost:8090`。
|
||||
- 双令牌 JWT:access 7d 无状态 + refresh 30d 仅存 SHA256 落库;登录即吊销该用户全部 refresh(互斥登录)。`/api/v1/auth/`:`/login` `/refresh`(复用不轮换) `/logout` `/logout-all` `/me`(Bearer)。前端 `src/lib/auth.ts`(2026-09-13 从 api.ts 拆出):`saveSession/clearSession/refreshSession/fetchMe/authedFetch(401 静默 refresh 一次)/login/logout`;`getUser/getAccessToken/getRefreshToken` 也在此。业务取数在 `src/lib/api.ts`,签名在 `src/lib/crypto.ts`(`clientSign`),请求语言透传在 `src/lib/locale.ts`(`setApiLocale/currentLocale/withLocale`)。依赖无环:api→{crypto,auth,locale}、auth→locale。
|
||||
- 仅预置内部账号不开放注册。`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/auth/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 落地)**:原 `checkFavorited` 触发 3 次(①② `Item.astro`/`StreetSnap.astro` 图片级 init+loadFull、③ fav-fab 图集级)已消除——前端 `Item.astro`/`StreetSnap.astro` 的 `loadFull()` 改为 `loadAuthedState()`,登录态用一次 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed` 拿回完整图片集+收藏态:图集级 `fav` 经 `fav:set` 事件广播给 fav-fab、图片级以 `im.favorited` 重建 `favIds`;fav-fab 改监听 `fav:set` 不再调 `checkFavorited([meta.id])`。后端 `article_handler.go`/`street_snap_handler.go` Detail 在 `UserIDFrom` 命中时一次性 `favSvc.Check` 回写 `detail.Favorited` + `Images[].Favorited`(匿名不动)。**item 页已登录收藏校验 3→0 次**。**列表页 `RunwayLooks.astro`/`StreetSnaps.astro` 仍用 `/me/favorites/checks` 批量校正(保留);dead 单数 legacy `/auth/favorites/check` 路由已删**。铁律:收藏态只能合并进「带 Bearer 的 authed 详情接口」,匿名公开/列表接口绝不合并。
|
||||
- **浏览历史**(2026-09-02):记录时机在 item 详情页 `recordHistory(meta.id)`(编码串带类型,无需 kind);列表页不记。后端 `histories` 表唯一键 `uniq(user_id,target_uid)`,`HISTORY_CAP=2000` FIFO。前端 `src/lib/history.ts`:账户页服务端分页 + 逐篇回查 `getHistoryMeta(id)`。`recordHistory` 仅 `getUser()` 时调用。
|
||||
- 个人中心 `account.astro`:报头 + 两 tab(Saved looks / My History)。
|
||||
- **收藏服务端化**:表 `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`。
|
||||
@ -51,12 +108,9 @@
|
||||
- 走秀 `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 行样本属预期。
|
||||
|
||||
## 前端复用
|
||||
- 详情页统一 `/item`:`src/pages/{locale}/item/[id].astro`(`prerender=false`,运行时 SSR `getSsrArticle`→`getSsrStreetSnap`),id 类型前缀 r=/s= 直接查对应表无回落。`ROUTES.article`/`streetSnap` 指向 `/item/${id}`。
|
||||
- 列表组件同构,抽 `src/styles/look-grid.css`+`src/lib/looks-grid.ts`;品牌筛选弹窗 `BrandModal.astro`(仅 RunwayLooks)。卡片标记在 Alpine `x-for` 内客户端渲染,不能提成 Astro 组件。
|
||||
|
||||
## 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 告警。
|
||||
- 调用面:组件用 `t('English phrase')`;部分标题经变量 `t(city)` 传入(城市 18 个,勿因「无字面量 t()」误删)。
|
||||
- **中文绝不可当 key**:`t('中文')` 在 en 态显示中文。硬编中文先改英文 key 并在 dict 补 `{cn:'中文'}`。
|
||||
- 2026-09-13 审计:补 6 缺失 key(brands/Most photos/Newest year/Oldest year/Year/Sort)、中文 key `全部的 runway` 改 `all runway looks`、删 20 个零调用死条目;字典 92→78 条。
|
||||
## 已知待办(尚未做,勿误以为已完成)
|
||||
- 详情页重复 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 个,加载完会跳)。
|
||||
|
||||
Reference in New Issue
Block a user