update
This commit is contained in:
@ -1,116 +1,63 @@
|
||||
# 项目长期记忆(Project MEMORY.md)
|
||||
|
||||
> 维护约定:本文件只放**跨会话长期有效**的事实与铁律;当日细节写 `.workbuddy/memory/YYYY-MM-DD.md`。
|
||||
> 历次合并去重:2026-09-14(修正 BASE_API 位置、ROUTES.article、ssgRequest、Raleway、View Transitions 等过时记述;补 gallery/灯箱与 Alpine 展开推断坑)。
|
||||
> 历次合并去重:2026-09-15(大幅压缩;保留架构/约定/脚本铁律/类型坑/lib分层/验证手段/门禁/账号/i18n;删冗长示例与已过时待办)。
|
||||
|
||||
## 架构总览
|
||||
- 前端 `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/frontend_v2`:Astro(`adapter: node` standalone,SSR 取数)+ Tailwind v4 + Alpine.js。构建 `npm run build`;类型检查 `npm run check`(= `astro check`)。
|
||||
- 后端 `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 事件),**不是错误**。
|
||||
- 接口 i18n:后端按 `?locale=cn|en` 选列;**语言以 URL 前缀为准**;`prefixDefaultLocale: true`(/en、/cn 都带前缀;根 `/` 由服务端 302 到 /en)。
|
||||
- **base 地址全在 `src/lib/config/`**:`config.baseApi`(浏览器) / `config.baseApiSsr`(SSR 绝对) / `config.baseApiSsg`(构建期内部端口)。密钥类(SSG_TOKEN)直读 env。
|
||||
- 后端 Windows 编译:`go build -o server-bin.exe`(非 `server-bin`)。前端 dev 不热加载 `.env`,改 base 后 `astro dev stop`+`--background`+浏览器硬刷新。
|
||||
- `504 (Outdated Optimize Dep)`:删 `node_modules/.vite` 后重启 dev。
|
||||
|
||||
## 关键约定
|
||||
- 视觉:黑白极简编辑风(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` 里那条属防御性死代码,保留无害)。
|
||||
- 视觉:黑白极简编辑风,纯黑白禁用暖米色/柔和阴影;字体走 Tailwind 体系(系统字体栈,不引外部字体)。
|
||||
- 跨组件复用原子样式放 `global.css`(Astro 组件 `<style>` 有作用域哈希,跨组件命中不了)。现含 `.fav-btn`/`.locate-flash`/`@media(hover:none)`/`.no-scrollbar`/`[x-cloak]`。
|
||||
- Tailwind v4:`<style>` 顶部 `@reference "tailwindcss";`(编译期指令,不进 HTML);无零值类;能 @apply 就 @apply。
|
||||
- Header 下拉 `x-show` 一律带 `x-cloak` 防 FOUC。Layout `<slot/>` 包 `flex-1 flex flex-col`;页内根容器禁用 `m-auto`(用 `w-full`+`max-w-[x] mx-auto`)。
|
||||
- 未启用 View Transitions。
|
||||
|
||||
## 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 属性,会被响应式代理包一层)。
|
||||
1. 打包型 `<script>` 拿不到 frontmatter 变量,需脚本顶部重新 import。
|
||||
2. **`define:vars` + `import` = 致命**(包成 IIFE 后 import 进函数体 → SyntaxError)。要 import 改用 `data-*` 透传。
|
||||
3. 片段页(无 `<html>`)`import '@/styles/global.css'` build 不注入 head → 整页无样式。
|
||||
4. **Alpine 表达式只认组件 data / `$magic` / global**,外部函数先存进 data 属性(如 `isAuthed`、`toAbsUrl` 看似空包装但不能删)。
|
||||
5. 验证 Alpine 运行时务必真跑浏览器,只看 SSR HTML 会漏掉绑定失效。
|
||||
6. 共享组件 `Alpine.data('name', () => ({...}))` + `x-data="name()"`;由工厂展开须显式标注返回类型。
|
||||
7. Swiper:`initSwipers()` 先 `destroySwipers()`;`setupSwipers()` rAF 延后 + `DOMContentLoaded`;Tab 用 document 事件委托。
|
||||
8. IntersectionObserver 用模块级单例(如 gallery 的 `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 声明在模块文件里**不生效**。
|
||||
## 类型检查与 Alpine 类型推断(2026-09-14 引入 `npm run check`,已修到 0)
|
||||
- 坑 A:`@types/alpinejs` 自引用约束使 data 属性退化 `unknown`/`{}` → 属性显式标注(`user: null as AuthUser|null`、`favIds: [] as string[]`);`Alpine.store` 先 `const s: Store = {...}` 再注册。
|
||||
- 坑 B:对象展开让 T 退化 `{}` → 给回调显式标注返回类型 `(): ReturnType<typeof createGalleryView> & X => ({...})`。
|
||||
- 排查铁律:「Property does not exist on type '{}'」/「Object of type 'unknown'」先怀疑这两类,别怀疑业务代码。
|
||||
- `astro check` 的 stdout 会被吞 → `npx astro check 2>&1 | Out-File -FilePath x.txt -Encoding utf8` 再读;`Get-Content` 须带 `-Encoding utf8`。
|
||||
|
||||
## 验证手段(本项目可复用套路)
|
||||
- 改完必跑 `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` 统计。
|
||||
## lib 模块分层(2026-09-14 核对磁盘)
|
||||
- `i18n/index.ts`(配置+工具+API语言透传:原 config/utils/lib/locale 三合一)+ `i18n/dictionary.ts`(纯数据,独立保留)。
|
||||
- `auth.ts` 仅会话存储原语;`api.ts` 业务取数+鉴权(`login/logout/fetchMe/enforceIdleLogout` 有意留 api.ts,避免 auth↔request 循环);`ssg.ts` 构建期、`ssr.ts` 运行期、`gallery.ts` 灯箱工厂、`crypto.ts` 签名、`config/` base 址、`endpoints.ts` 后端路径唯一来源、`routes.ts` 前端跳转 URL(带前缀,`href` 用 astro:i18n、`clientHref` 浏览器内自拼)、`http.ts` 调试日志(开关 `DEV||DEBUG_API`)、`favorites.ts`/`history.ts`。
|
||||
- 依赖无环:api→{request,crypto,auth,i18n,endpoints};ssr→{request,endpoints,i18n,api(type)};ssg→{request,api(type),endpoints};gallery→{request,auth,favorites};request→{crypto,auth,i18n,config,endpoints}。
|
||||
|
||||
## 模块策略
|
||||
- 主线:首页 + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。品牌索引页已下线(2026-08-26),品牌经走秀页侧栏 filter + 品牌弹窗(热门 30 SSG,搜索/字母运行时 `/api/v1/public/brands`)。
|
||||
- 导航死链清理(2026-08-30):删 `/latest-projects`/`/about`/`/contact`。保留未动死链 `/portfolio`/`/login`/`/register`。「header 别动」= 不擅改导航结构/视觉。
|
||||
## 验证手段
|
||||
- 改完必跑 `npm run build`(全链路 import + 预渲染)+ `npm run check`。
|
||||
- 预渲染页:`dist/client/{en,cn}/<page>/index.html` 归一化对比(去 `astro-cid-*` 与 `_astro/<asset>` 逐字节比)。
|
||||
- `prerender=false` 页:临时建 prerender 验证页喂假数据,或 dev :4321 时 curl 真实 SSR(`curl :8090/api/v1/public/runway-looks` 的 `data` 直接是数组)。HTML 单行用 `[regex]::Matches($h,'...').Count` 统计关键标记。
|
||||
|
||||
## 未登录预览门禁(详情页,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 后续移除**,仅保留画廊内遮罩提示。
|
||||
## 未登录预览门禁(详情页)
|
||||
- 后端硬截断:`/runway-looks/:id`、`/street-snaps/:id` 匿名且图>5 时截断前 5 张 + 回 `preview:true`+`image_total`;已登录(Bearer)返回完整。列表接口全量。
|
||||
- 前端:SSR 前 5 张 + 剩余空图遮罩(`x-show="!isAuthed && preview"`+`x-cloak`,"login to view" + "+N photos");登录后取全量追加 `extraImages`。`getUser()` 读 localStorage `fa_user`。
|
||||
|
||||
## 账号体系
|
||||
- 仅预置内部账号不开放注册。`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)`。
|
||||
- 仅预置内部账号(admin/root)。双令牌 JWT(access 7d 无状态 + refresh 30d 仅存 SHA256 落库);登录即吊销该用户全部 refresh。
|
||||
- 收藏服务端化 `favorites` 表(按 user_id 隔离,编码串 r=/s=、i=/j=);浏览历史 `histories` 表(唯一键 user_id+target_uid,FIFO 上限 2000)。
|
||||
|
||||
## 前端复用与页面结构
|
||||
- 详情页统一 `/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 字典约定
|
||||
- `dict: Record<string,{cn:string}>`,key 即英文原文(en 返 key),value 只存非默认语言(仅 cn)。`translate(key,locale)` 对 key `toLowerCase()` 查(大小写不敏感,`t('Home')` 命中 `home`);缺翻译 en 回退 + dev 告警。中文绝不可当 key。
|
||||
|
||||
## 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 个,加载完会跳)。
|
||||
## 记住语言偏好(2026-09-15 新增)
|
||||
- 需求:选过语言后下次进首页直接用上次语言。
|
||||
- `i18n/index.ts` 新增 `STORED_LOCALE_KEY='preferred_locale'`、`storeLocale(locale)`、`getStoredLocale()`。
|
||||
- Layout `<head>` 顶部 `is:inline`+`define:vars` 脚本:进首页(path==='/' 或 '/'+defaultLocale)且记住非默认语言时 `location.replace('/'+stored)`。
|
||||
- 语言切换器 `x-data="langSwitch()"`;`<a>` `@click="open=false; pick('<code>')"`;footer 注册 `langSwitch`(`pick(code){ storeLocale(code) }`)。
|
||||
- **铁律:只在显式点击切换器时写 localStorage,绝不按页面加载 URL 写**——否则与首页自动跳转冲突(点了英文又被 URL 的 en 覆盖,首页永远跳不回去)。
|
||||
|
||||
Reference in New Issue
Block a user