22 KiB
22 KiB
项目长期记忆(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: nodestandalone,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须 < MySQLwait_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> 铁律(踩坑汇总)
- 作用域隔离:打包型
<script>拿不到 frontmatter 的 import/变量,需脚本顶部重新 import。 define:vars+import= 致命:define:vars把脚本包成 IIFE 注入const meta=...,原顶部import被塞进函数体 → SyntaxError 整段不执行。凡脚本要 import,改用data-*属性透传(ItemPage.astro的 fav-fab 已用此套路)。- 片段页(无
<html>/<head>)的import '@/styles/global.css'build 时不注入 head → 整页无样式(login.astro 曾踩)。 - Alpine 属性表达式访问不到模块 import 符号:Alpine 只认组件 data 属性 /
$magic/ global。铁律:表达式里只写组件 data 属性或$magic,外部状态/函数先存进 data 属性。- 正例:
toAbsUrl(url) { return toAbs(url) }—— 看着像「空包装」,但模板里有:src="toAbsUrl(it.cover)",不能删(删了图片加载不出来)。isAuthed亦为此而生。
- 正例:
- 验证 Alpine 运行时务必真跑浏览器(或至少确认表达式可解析),只看 SSR 静态 HTML 会漏掉运行时绑定失效。
- 共享 Alpine 组件:
document.addEventListener('alpine:init', () => Alpine.data('name', () => ({...})))注册,x-data="name()"引用(articleView/streetSnapView/imgLoader/showsPage/streetSnapsPage/accountPage/authMenu同款)。若 data 由工厂展开而来,必须显式标注回调返回类型,见下节。 - Swiper 生命周期(首页 Index.astro):
initSwipers()开头先destroySwipers()防重复/泄漏;统一入口setupSwipers()用 rAF 延后一帧,挂DOMContentLoaded;swiper.update()用updateScheduled+ rAF 合并;Tab 点击document事件委托((e.target as Element|null)?.closest('.tab'))。 - 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-btn11 = 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()读 localStoragefa_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编码串(图集 runwayr=/streets=,图片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 详情接口」,匿名公开/列表接口绝不合并。
- item 页收藏态已合并进 authed 详情接口(2026-09-13):登录态用一次
- 浏览历史(2026-09-02):记录时机在 item 详情页
recordHistory(meta.id)(编码串带类型);列表页不记。后端histories表唯一键uniq(user_id,target_uid),HISTORY_CAP=2000FIFO。前端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.astro1161→约 850 行、StreetSnap.astro645→约 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)。
- 灯箱视觉基准 = Item 的编辑风(StreetSnap 旧版已对齐):计数零填充
- 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)。卡片标记在 Alpinex-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)对 keytoLowerCase()后查,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 个,加载完会跳)。