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

22 KiB
Raw Blame History

项目长期记忆(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 个,加载完会跳)。