This commit is contained in:
toom1996
2026-09-13 21:39:50 +08:00
parent c85c561a99
commit d6cb616337
24 changed files with 1076 additions and 813 deletions

View File

@ -0,0 +1,57 @@
# 2026-09-13 工作日志
## 首页图片加载状态 + Swiper 初始化优化(14:5x)✅
用户建议对首页 Index.astro 的 Hot Brand / Street Style 图片加载与 Swiper 初始化做一层优化(7 点)。改动:
- **统一图片状态初始化逻辑**:Hot Brand `hs-img-box` 与 Street Style `ss-img-box` 两份完全重复的内联 `x-data="{loaded,error,init()检查complete+naturalWidth,handleLoad,handleError}"` 抽成共享 Alpine 组件 `imgLoader`,在 `<script>` 里 `document.addEventListener('alpine:init', () => Alpine.data('imgLoader', () => ({...})))` 注册,两处 markup 改 `x-data="imgLoader()"`。保留 init 的 complete+naturalWidth 立即置位 与 @load/@error 兜底,dispatch `img-ready` 不变。
- **Swiper 初始化重写**:`initSwipers()` 开头先 `destroySwipers()`(防重复/VT 旧实例泄漏);统一入口 `setupSwipers()` 用 `requestAnimationFrame` 延后一帧,挂到 `DOMContentLoaded`(首次)与 `astro:page-load`(VT 重新进入);`swiper.update()` 用 `updateScheduled` 标志 + rAF 合并(原 updateAllSwipers 的 RAF 合并保留并改名 scheduleSwiperUpdate);Tab 点击监听由 `document.querySelectorAll('.tab').forEach(addEventListener)` 改为 `document` 事件委托 `e.target.closest('.tab')`,修复 View Transitions 后新 DOM 上监听失效(旧 .tab 元素已 detach)的问题。
- 验证:lint 0;dev server(:4321) 首页渲染 HTML 含 `x-data="imgLoader()"`;打包模块 `Index.astro?astro&type=script` 含 `imgLoader`/`astro:page-load`/`scheduleSwiperUpdate`/`destroySwipers`。当前 SSG 数据下 Hot Brand 有图(1 个 imgLoader 渲染)、Street Style 列表为空走 ComingSoon(符合数据,非代码问题)。
## i18n 字典审计:补缺失 + 删死条目 + 修中文 key(15:4x)✅
用户要求:找出没翻译的 i18n 补上、没用到的删掉。审计方法:解析 dictionary.ts 全部 key + 全代码字面量 `t('...')`/`translate('...')` 调用 + 对每个 key 在代码做存在性 grep(覆盖城市等变量用法)。
- **缺失(代码 t() 用到但字典没有,cn 页回退英文)**:`brands`(RunwayLooks 设计师筛选)/`Most photos`/`Newest year`/`Oldest year`(StreetSnaps 排序)/`Year`(StreetSnaps 分组标题)/`Sort`(StreetSnaps 分组标题)。已补 `{ cn: ... }`(品牌/照片最多/年份最新/年份最早/年份/排序)。
- **中文当 key(英文态显示中文)**:`'全部的 runway'` 被 Layout 导航×2 与 RunwayLooks `<h1>` 硬编中文使用。改为英文 key `'all runway looks': { cn: '全部的 runway' }`,三处调用改为 `t('all runway looks')`(其中 RunwayLooks h1 由硬编改为 `{t('all runway looks')}`)。utils.ts 文档注释示例同步改英文。
- **死条目(0 次 t() 调用,连引号串都没出现)**:删 20 条 —— runway archive / pre-fall / account / email / coming soon / role / username / appearance / soon / now / all / image / gallery / light / on / off / default / hi / logout / street snaps(多为账户中心/外观设置/历史相对时间等半成品或未接 UI 的预留翻译,且有用同义 key 如 `my account`/`sign out`/`street style` 替代)。
- 结果:字典 92 → 78 条;重跑审计确认 无中文真 key、无缺失、无死条目(仅剩注释里的 `英文原文` 示例误报,非真实 key)。
- 验证:lint 0;`/en/runway-looks` h1 渲染 `all runway looks`、`/cn/runway-looks` 渲染 `全部的 runway`,i18n 正常。
## 前端 API 客户端拆分:api.ts → api/crypto/locale/auth 四文件(19:5x)✅
用户要求把 `src/lib/api.ts` 里的登录/权限与加密逻辑抽出去,让 api.ts 只负责业务取数。已拆分:
- **`src/lib/crypto.ts`**:前端请求签名 `clientSign()`(HMAC-SHA256 → X-Sign/X-Sign-Ts/X-Sign-Nonce),原 `CLIENT_SIGN_SECRET`/`CLIENT_SIGN_TTL` 一并移入。
- **`src/lib/locale.ts`**:请求语言透传 `setApiLocale`/`currentLocale`/`withLocale`(原在 api.ts)。抽出来是为了**避免 api↔auth 循环依赖**(auth.ts 的 fetchMe 也要用 currentLocale)。
- **`src/lib/auth.ts`**:账号体系与鉴权 —— `getUser`/`getAccessToken`/`getRefreshToken`/`clearSession`/`saveSession`/`refreshSession`/`authedFetch`(内部)/`authJson`/`fetchMe`/`login`/`logout` + `AuthUser` 类型 + `BASE_API_URL`。
- **`src/lib/api.ts`**:重写后只保留 类型定义(BrandEntry/ArticleItem/RawArticle…)/ base 解析 / `request()` 公开原语 / 全部业务取数(走秀/品牌/街拍/历史回查/SSG)。`request()` 改为 `import { clientSign } from "./crypto"`、`import { getAccessToken, getRefreshToken, refreshSession } from "./auth"`、`import { withLocale, currentLocale } from "./locale"`。
- **消费方 import 已全量更新**:`getUser`/`login`/`logout`/`fetchMe`/`authJson`/`getAccessToken` 改从 `@/lib/auth` 或 `./auth` 导入;`setApiLocale` 改从 `@/lib/locale`;其余(`getArticles`/`getBrands`/`getStreetSnaps`/`getSsrArticle`/`getHistoryMeta`/`toAbs`/`ssrBase`/`RawXxx` 类型等)仍在 `@/lib/api`。涉及:StreetSnaps/StreetSnap/RunwayLooks/Item/Index.astro、en/cn 的 login/account/item/[id].astro、AuthModal.astro、Layout.astro、lib/favorites.ts、lib/history.ts。
- 依赖方向无环:api→{crypto,auth,locale};auth→locale;locale→i18n。
- **验证**:`astro build` 退出码 0,全部路由预渲染成功,无 Vite/import 解析错误(Vite 会在命名导出缺失或路径错误时报错,故等于全链路导入校验通过)。本地无 TypeScript 二进制,未跑 tsc(npx tsc 会拉到无关包)。
- **脚本教训**:PowerShell 内联 `node -e` 对正则/双引号转义极不可靠,凡要正则或双引号一律写 `.mjs` 脚本文件再 `node` 执行。
## Index.astro 代码排版整理(15:1x)✅
用户嫌 Index.astro 乱。纯排版,逻辑/class 未改:
- 原来第 48 行把 tabbar 第一个 `Runway` `<li>` 与注释全挤在一行(其余两个 li 正常多行)→ 重排为三个 li 统一多行缩进。
- 原来整段 `<style>`(含 Hot Brand / Street Style 全部样式)压成**一行** → 重排为多行、按 section 注释分组、每条规则缩进 2 空格、keyframes 体换行。注意:`@reference "tailwindcss";` 是 Tailwind v4 编译期指令,最终 HTML 里不会出现(被处理掉),样式已生效(.hs-swiper 在 SSR 输出出现 5 次)。
- 三块面板(Runway/Social Media/Street Style)统一 2 空格缩进与 `<!-- -->` 注释;Hot Brand 的 skel/spin/img/caption 与 Street Style 同结构重排。
- `<script>` 保持原样(已带分节注释,较整洁)。
- 验证:lint 0;dev 首页 SSR 仍渲染 `imgLoader`(1)/`tabbar`(1)/`hs-swiper`(5 含 style)/`hs-ring`(2)/`hs-cap-brand`(3)/`ss-cap-loc`(1),结构完整。
## item 页 /me/favorites/checks 调用三遍诊断(17:2x)🔍
用户问已登录访问 item 页为何 `/me/favorites/checks` 发三遍。`checkFavorited()`(favorites.ts:215)每次只单发一次请求(无循环),故三遍 = 三个独立调用点:
1. `Item.astro` `articleView.init()`→`initImageFavs()` 查 SSR 前 5 张图片(i/j 级,line 647→777)。
2. 同组件 `loadFull()` 补全量图后再次 `initImageFavs()` 查全量图片(line 678→复用 777)。
3. `en/item/[id].astro` fav-fab 脚本 `checkFavorited([meta.id])` 查整篇图集(r/s 级,line 105-106,粒度不同不可省)。
未登录 0 次(`serverMode()` 返 false 空返回;fav-fab 也跳过)。①② 为图片级、因图分批上 DOM 时序拆两次,可优化:init 仅本地 `isFavorited` 即时填充、服务端校正推到 loadFull 后统一一次 → 总 3→2。已向用户说明根因并给轻量优化方案,**用户尚未确认是否改**(待定)。同时清理 MEMORY.md 超长截断:精简合并、修正两处过时(底部门禁块已删 / `login to view all photos` key 已删),补全该诊断章节。
## item 页收藏态合并进主接口的设计讨论(18:1x)💡
用户进一步问:把「文章(图集)收藏态」与「图片收藏态」都合并进「获取图片的接口」返回是否合理。分析结论:
- **详情页(authed 全量接口)合并:合理、推荐**。已登录走 `getArticleDetailAuthed`(带 Bearer,后端 detail handler 已有 user 态)拉全量图片,正好顺带返回 `favorited`(图集级 r/s)+ `images[].favorited`(图片级 i/j)。收益:item 页已登录从 3 次 `/me/favorites/checks` → 0 次、消除图片分批上 DOM 的时序两次查询、首屏爱心不闪。条件:**仅附加在带 Bearer 的 authed 接口**,匿名公开接口(`getSsrArticle`)不动(保留缓存/公开性);响应用结构化字段返回。
- **列表页不合并:保持独立 `/me/favorites/checks` 批量校验**。列表分页、公开匿名为主、量大,合并会让接口无法缓存且每 item join favorites 成本高;现有「进页查当前页可见 id」更划算。
- 实时性:合并后收藏态随进页主接口一次性带出,页内点爱心走本地 optimistic + 后台同步即可,无需回查(跨设备校正需求弱)。
- 落地成本:需改 Go 后端 detail handler(加 favorites JOIN/IN 查询)+ 前端删 `Item.astro`/`StreetSnap.astro` 三处 checkFavorited 调用、改读主接口响应。**用户尚未确认是否落地**(待定)。
## item 页收藏态合并进主接口:已落地(19:0x)✅
用户确认「合并,并把没用到的接口去掉」。已完成前后端合并 + 清理:
- **后端(Go,已 `go build ./...` 通过)**:走秀 `article_handler.go:117-137` 与街拍 `street_snap_handler.go:99-119` 的 Detail handler,在 `uid:=middleware.UserIDFrom(c); ok` 时把 `detail.UID` + 全部 `detail.Images[].ID` 一次性 `h.favSvc.Check`(`Check` 内部 `WHERE user_id=? AND target_uid IN (?)`),回写 `detail.Favorited`(图集级 r/s)+ `detail.Images[i].Favorited`(图片级 i/j)。匿名(`ok=false`)不动 → 公开接口保持可缓存。DTO:`PublicArticleImage.Favorited` / `PublicStreetSnapDetail.Favorited`(`json:"favorited,omitempty"`)。
- **前端(读取主接口响应,不再单独查)**:`Item.astro`/`StreetSnap.astro` 把 `loadFull()` 重写为 `loadAuthedState()`——登录态(`getUser()`)即用一次 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed` 拿回完整图片集 + 收藏态:图集级 `fav` 经 `window.dispatchEvent(new CustomEvent('fav:set',{detail:{id, fav}}))` 广播给右下角 fav-fab;图片级以 `im.favorited` 为准重建 `favIds`(覆盖 `initImageFavs` 的本地初值)。`init()` 与 `auth:login` 监听都改调 `loadAuthedState()`(不再限于 `preview`)。
- **fav-fab(`en/cn/item/[id].astro`)**:删 `checkFavorited([meta.id])`,改 `window.addEventListener('fav:set', ...)` 监听,`fav=true` 时 `hydrateFavorited` 写回本地 + `sync()` 刷新标签;初始仍本地 `isFavorited` 即时填充。
- **接口清理**:删后端唯一无调用的 dead route `legacy.POST("/auth/favorites/check", ...)`(单数 legacy 别名,前端从只用复数 `/me/favorites/checks`)。**列表页 `RunwayLooks.astro`/`StreetSnaps.astro` 仍用 `/me/favorites/checks` 批量校正,该接口保留**(按设计讨论:列表不分页合并)。
- 结果:item 页已登录收藏校验请求由 **3 次 → 0 次**(合并进 1 次详情请求);`read_lints` 相关文件 0 错误;后端编译 0 错误。验证:dev(:4321) 登录后打开 item 页 Network 不再出现 `/me/favorites/checks`,爱心态随详情响应直接正确(图集+图片级)。
- **关键约束(铁律补充)**:详情页收藏态只能合并进「带 Bearer 的 authed 详情接口」,匿名公开接口与列表接口绝不合并(保缓存/控成本)。

View File

@ -1,55 +1,62 @@
# 项目长期记忆(Project MEMORY.md) # 项目长期记忆(Project MEMORY.md)
## 架构总览 ## 架构总览
- 前端 `d:/project/frontend_v2`:Astro SSG + 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`(SSG 取数走 ssgRequest,失败回落不影响构建)。
- 后端 `d:/project/backend_v2`:Go + Gin + GORM(MySQL)。公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`);SSG 引擎 :8091(仅 127.0.0.1,nginx 不反代)。 - 后端 `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 前缀为准。 - 接口 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` 拿 PID。本地后端长时间空闲后报 `invalid connection` 先重启后端(连接池 `conn_max_idle_time` 已设,须 < MySQL wait_timeout)。 - **后端编译产物坑(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 失效需重登录。 - 前端 dev server **不热加载 `.env`**:改 BASE_API 后 `astro dev stop`+`astro dev --background`;浏览器旧 bundle 硬刷新、旧 token 失效重登录。
## 关键约定 ## 关键约定
- 视觉:黑白极简编辑风(monospace 小标签 + Georgia 衬线大标题 + 细线),HBX/时尚档案调性。**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系。 - 视觉:黑白极简编辑风(monospace 小标签 + Georgia 衬线大标题 + 细线),**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系。
- 品牌名前端只显英文(`display=nameEn`);走秀标题/描述按 locale 选列。 - 品牌名前端只显英文(`display=nameEn`);走秀标题/描述按 locale 选列。
- API 命名:一个功能一个接口,跨资源/独立功能独立路径(如 `brands/hot` 而非 `?featured=1`)。 - API 命名:一个功能一个接口,跨资源/独立功能独立路径(如 `brands/hot` 而非 `?featured=1`)。
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`;Tailwind v4 无零值类。 - CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`(Tailwind v4 编译期指令,最终 HTML 不出现,样式已生效);Tailwind v4 无零值类。
- Layout `<slot/>` 包 `flex-1 flex flex-col`;页内根容器**禁用 `m-auto`**(触发 shrink-to-fit 抖动),用 `w-full`+`max-w-[x] mx-auto`。 - 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}`)。 - **Header/下拉 FOUC**:所有 `x-show` 下拉(Runway/Street/语言/账号/未登录 Login)一律带 `x-cloak`(global.css 已 `[x-cloak]{display:none!important}`)。
- **加载屏组件化**:`src/components/LoadingScreen.astro` 封装标记+样式+打包 `<script>`(**无 `is:inline`**,module/defer 在 window.load 前执行无竞态),Raleway 字体链随组件 hoist 到 head。文案 Portfolio Studio + 副标 Loading,黑白配色。改加载屏只动这一个文件。 - **加载屏组件化**:`src/components/LoadingScreen.astro` 封装标记+样式+打包 `<script>`(无 `is:inline`),Raleway 字体链随组件 hoist 到 head。改加载屏只动这一个文件。
## Astro `<script>` 坑 ## Astro `<script>` 铁律(踩坑汇总)
- 作用域隔离:打包型 `<script>` 拿不到 frontmatter 的 import/变量,需脚本顶部重新 import。 1. 作用域隔离:打包型 `<script>` 拿不到 frontmatter 的 import/变量,需脚本顶部重新 import。
- **`define:vars` + `import` = 致命**(已踩坑):`define:vars` 把脚本包成 IIFE 注入 `const meta=...`,原顶部 `import` 被塞进函数体 → 非模块上下文非法 → 整段 SyntaxError 不执行。凡脚本要 import,改用 `data-*` 属性透传(Layout.astro 已用)。en/cn `item/[id].astro` 曾因此详情页历史/收藏全失效,已修。 2. **`define:vars` + `import` = 致命**:`define:vars` 把脚本包成 IIFE 注入 `const meta=...`,原顶部 `import` 被塞进函数体 → SyntaxError 整段不执行。凡脚本要 import,改用 `data-*` 属性透传(item/[id].astro 的 fav-fab 已用此套路)。
- 片段页(无 `<html>/<head>` 的 .astro)的 `import '@/styles/global.css'` build 时不注入 head → 整页无样式。login.astro 曾因此丢样式,已修。 3. 片段页(无 `<html>/<head>`)的 `import '@/styles/global.css'` build 时不注入 head → 整页无样式(login.astro 曾踩)。
- **Alpine `x-show`/`x-on` 等属性表达式访问不到模块 `import` 的符号**:`@astrojs/alpinejs` 下,`.astro` `<script>` 里 `import { getUser } from "@/lib/api"` 的函数,在 `x-show="!getUser() && preview"` 这类属性表达式里是**未定义自由变量**(Alpine 只认组件 data 属性 / `$magic` / global),求值抛 ReferenceError → 绑定失效、元素卡在 `x-cloak` 的 `display:none`。**已踩坑**:详情页「剩余 N 张」占位遮罩与底部门禁用 `x-show="!getUser()&&preview"`,SSR 静态 HTML 有遮罩但运行时全隐藏(误判成功只因验证看了静态 HTML)。修复:组件 data 增 `isAuthed:false`,`init()` 用 `this.isAuthed = !!getUser()` 初始化、`auth:login` 事件置 `true`,`x-show` 改用 `!isAuthed && preview`(列表分页器 `x-show="getUser() && lastPage>1"` 同病,已一并改 `isAuthed`)。**铁律:Alpine 属性表达式里只写组件 data 属性或 `$magic`,绝不直接调模块 import 函数;要读外部状态先存进 data 属性。** 4. **Alpine 属性表达式访问不到模块 import 符号**:`x-show="!getUser() && preview"` 中 `getUser` 是 import 来的自由变量,Alpine 只认组件 data 属性 / `$magic` / global → 求值抛 ReferenceError、元素卡 `x-cloak` 隐藏。**铁律:表达式里只写组件 data 属性或 `$magic`,外部状态先存进 data 属性**(`isAuthed` 即为此而生)。
- 验证 Alpine 运行时务必真跑浏览器(或至少确认表达式可解析),**只看 SSR 静态 HTML 会漏掉运行时绑定失效**。 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。
## 模块策略 ## 模块策略
- 主线:首页 index + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。品牌索引页已下线(2026-08-26):品牌仅经走秀页侧栏 filter + 品牌弹窗(热门 30 SSG,搜索/字母运行时 `/api/v1/public/brands`)。 - 主线:首页 + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。品牌索引页已下线(2026-08-26),品牌经走秀页侧栏 filter + 品牌弹窗(热门 30 SSG,搜索/字母运行时 `/api/v1/public/brands`)。
- 导航死链清理(2026-08-30):删 `/latest-projects`/`/about`/`/contact`(Layout/routes/dictionary 同步)。保留未动死链:`/portfolio`/`/login`/`/register`。「header 别动」= 不擅改导航结构/视觉;用户已批的调整(Runway 下拉分类、Street Style 减城、删死链)属授权。 - 导航死链清理(2026-08-30):删 `/latest-projects`/`/about`/`/contact`。保留未动死链 `/portfolio`/`/login`/`/register`。「header 别动」= 不擅改导航结构/视觉。
## 未登录预览门禁(详情页,2026-09-03 定稿) ## 未登录预览门禁(详情页,2026-09-03 定稿 + 后续)
- 用户真实意图:**列表页照常展示全部图集**,限制只发生在**点进图集详情后**——未登录仅看前 5 张图,其余提示登录(此前误做成列表页限制已回退)。 - 用户意图:列表页全量展示,限制只发生在**点进图集详情后**——未登录仅看前 5 张图,其余锁图遮罩提示登录。
- 后端硬截断(真防护,落详情接口):`GET /api/v1/public/runway-looks/:id` 与 `/street-snaps/:id` 判断 `middleware.UserIDFrom(c)`——未登录且 `len(Images)>dto.PreviewLimit(=5)` 时 `Images=Images[:5]` 并回 `preview:true`。`response.DataPreview` 包新增带 `image_total`(截断前图片总数),供前端渲染剩余占位遮罩。已登录(带 Bearer)返回完整图片集、无 preview。列表接口 `response.Page` 全量,翻页 page>1 仍 401 由 PublicFirstPageAuth 兜底。 - 后端硬截断(真防护):`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 强制)。
- `PublicFirstPageAuth` 始终解析 Bearer(有效写 Context),仅 page>1 强制;详情路由无 page → 匿名放行、已登录写态。 - 前端:详情组件 `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`。
- 前端:详情组件 `Item.astro`(runway)/`StreetSnap.astro`(street) 由 SSR `getSsr*` 透传 `preview` 与 `imageTotal` prop;画廊渲染 `previewImages=SSR.images.slice(0,5)`(SSR 渲染前 5 张)+ **剩余 `(imageTotal-5)` 张空图遮罩**(锁图标 + "login to view" + "+N photos",`x-show="!isAuthed && preview"`+`x-cloak`,点击 `goLogin()`)+ 底部 Preview 门禁块。`isAuthed` 是组件 data 属性(`init()` 用 `getUser()` 初始化、`auth:login` 置真),因 `getUser` 是模块 import 符号、不能直接用于 Alpine 表达式(见 Astro `<script>` 坑)。已登录或 `auth:login` 触发 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed`(带 Bearer 取全量)把第 6 张起追加进 `extraImages`,此时 `isAuthed` 为真、遮罩 `x-show` 为假自动隐藏。`getUser()` 读 localStorage `fa_user`。 - **底部 Preview 大框门禁已于 2026-09-03 后续移除**(用户认为意义不大),仅保留画廊内遮罩提示;对应 dict key `login to view all photos` 已删。
- 列表页已回退全量展示,仅保留筛选/翻页 `requireLogin()` 硬门禁。
- i18n:`login to view`(cn 登录查看)/`photos`(cn 张照片)/`login to view all photos`(cn 登录查看全部图片,底部门禁用)。
## 账号体系 ## 账号体系
- 仅预置内部账号不开放注册。`seed_users/main.go` 建 `admin`(`SEED_ADMIN_PASSWORD`,缺省 `Studio#2026!Admin`) + `root`/`root`(测试弱密码,uid 通常 8)。**本地 `db_dev` 确有 root/root**,打 `http://localhost:8090` 登录 200——勿再误判"本地无 root"。前端 `.env` 当前 `BASE_API=http://localhost:8090`。 - 仅预置内部账号不开放注册。`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(168h) 无状态 + refresh 默认 30d(720h) 仅存 SHA256 落库;登录即吊销该用户全部 refresh(互斥登录)。接口 `/api/v1/auth/`:`/login` `/refresh`(复用不轮换) `/logout` `/logout-all` `/me`(Bearer)。前端 `api.ts`:`saveSession/clearSession/refreshSession/fetchMe/authedFetch(401 静默 refresh 一次)/logout/logoutAll`。 - 双令牌 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。
- 硬门禁:RunwayLooks/StreetSnaps 筛选与翻页入口 `requireLogin()`,未登录弹内嵌登录表单,登录成功 `loadPage(1)` 刷新;首屏 `init()` 的 `loadPage(1)` 不拦。 - 硬门禁:RunwayLooks/StreetSnaps 筛选与翻页 `requireLogin()`,未登录弹内嵌登录表单,登录成功 `loadPage(1)`;首屏 `init()` 的 `loadPage(1)` 不拦。
- 收藏服务端化(2026-09-02):表 `favorites` + `/api/v1/auth/favorites`(GET 分页/POST 幂等/DELETE/`/check` 批量校验当前页收藏态),按 `user_id` 隔离,`target_uid` 存编码串(runway `r=`/street `s=`)。前端 `src/lib/favorites.ts`:账户页 `fetchFavoritesPage()` 服务端分页;列表卡片 `checkFavorited(ids)` 仅发当前页可见 id;收藏/取消本地乐观+后台同步。 - **收藏服务端化**:表 `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(单发请求,无循环);收藏/取消本地乐观 + 后台同步。
- 浏览历史文章级(2026-09-02 收敛):记录时机在 item 详情页 `recordHistory(meta.id)`(编码串自带类型,无需 kind);列表页进页不再记。后端 `histories` 表 `(user_id,target_uid,viewed_at)`,唯一键 `uniq(user_id,target_uid)`,`HISTORY_CAP=2000` FIFO。前端 `src/lib/history.ts`:账户页 My History 服务端分页+逐篇回查 `getHistoryMeta(id)` 取 title/cover/brand。`recordHistory` 仅在 `getUser()` 时调用。 - **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 详情接口」,匿名公开/列表接口绝不合并。
- 个人中心 `account.astro`:报头 + 两 tab(Saved looks / My History),已精简移除 Overview/Sessions 等。 - **浏览历史**(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)。
## 部署 ## 部署
- Docker + Gitea Actions:前端 Dockerfile/nginx.conf/docker-compose.yml/deploy.sh;nginx 拦截 `/api/v1/ssg/` 404,健康检查 `/api/v1/public/brands`。 - 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=` 筛选。 - 走秀 `brand_runway`:`collection_type`(rtw/menswear/couture/resort/pre_fall)+`season`+`season_code`(SS26…),title 规则回填(幂等)。公开列表 `?collection_type=&season=&season_code=&year=` 筛选。
- 街拍 `street_snap.city`(2026-08-30):VARCHAR(128) 精确匹配 `WHERE city=?`;前端 header 下拉 `?city=` + 侧栏 City pills。规范值「首字母大写英文」(Paris/New York/Copenhagen…),大小写敏感须一致;dev 库仅 12 行样本属预期。 - 街拍 `street_snap.city`:VARCHAR(128) 精确匹配 `WHERE city=?`;前端 header 下拉 `?city=` + 侧栏 City pills。规范值「首字母大写英文」(Paris/New York/Copenhagen…),大小写敏感须一致;dev 库仅 12 行样本属预期。
## 前端复用 ## 前端复用
- 详情页统一 `/item`:`src/pages/{locale}/item/[id].astro`,SSR `getSsrArticle`→`getSsrStreetSnap` 回落;`ROUTES.article`/`streetSnap` 指向 `/item/${id}`。 - 详情页统一 `/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 组件。 - 列表组件同构,抽 `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 条。

Binary file not shown.

View File

@ -66,7 +66,7 @@ const fullLoginLabel = t('go to full login')
</div> </div>
<script> <script>
import { getUser, login } from "@/lib/api" import { getUser, login } from "@/lib/auth"
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
// 全局登录 store:request(el) 返回是否放行;未登录记录 pending 并开层 // 全局登录 store:request(el) 返回是否放行;未登录记录 pending 并开层

View File

@ -1,24 +1,17 @@
--- ---
import ComingSoon from "@/components/ComingSoon.astro" import ComingSoon from "@/components/ComingSoon.astro"
import { toAbs } from "@/lib/api" import { toAbs, getSsgIndexRunway, getSsgStreetSnapPopular } from "@/lib/api"
import { getI18n } from "@/i18n/utils" import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from "@/lib/routes"
import { getSsgIndexRunway } from "@/lib/api"
import { getSsgStreetSnapPopular } from "@/lib/api"
// 国际化:一行取当前语言 + 翻译函数
const { locale, t } = getI18n(Astro) const { locale, t } = getI18n(Astro)
// 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/internal/ssg/home/runway(server-only),
// 首页 runway 数据含多模块,hotBrand 为其中之一;封面/标题取自每个品牌代表走秀,
// 直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
const popular = (await getSsgIndexRunway()).hotBrand const popular = (await getSsgIndexRunway()).hotBrand
type GalleryItem = { type GalleryItem = {
src: string src: string
brand: string brand: string
articleId: string // 代表走秀 hashid 编码串(后端返回 string) articleId: string
title: string title: string
} }
@ -31,10 +24,14 @@ const galleryItems: GalleryItem[] = popular
title: b.title, title: b.title,
})) }))
// 首页「Street Style」热门街拍:构建期走 SSG 内部接口 /api/internal/ssg/street-snaps/popular(server-only),
// 返回按图片数前 N 的街拍(含封面),直接烧入静态 HTML;后端不可达则静默留空(区块回退 ComingSoon)。
const streetPopular = (await getSsgStreetSnapPopular(12)).filter((b) => b.cover && b.id) const streetPopular = (await getSsgStreetSnapPopular(12)).filter((b) => b.cover && b.id)
const streetItems: { src: string; loc: string; event: string; id: string }[] = streetPopular.map((b) => {
const streetItems: {
src: string
loc: string
event: string
id: string
}[] = streetPopular.map((b) => {
const parts = (b.title || "").split("\t") const parts = (b.title || "").split("\t")
return { return {
src: toAbs(b.cover || ""), src: toAbs(b.cover || ""),
@ -44,7 +41,9 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
} }
}) })
--- ---
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen"> <div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
<!-- 二级分类标签页 --> <!-- 二级分类标签页 -->
<div id="tabbar" class="mt-16 md:mt-24"> <div id="tabbar" class="mt-16 md:mt-24">
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto"> <ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
@ -54,8 +53,8 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
@click="activeTab = 'backstage'" @click="activeTab = 'backstage'"
> >
Runway Runway
<!-- <span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span> -->
</li> </li>
<li <li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'street' }" :class="{ 'is-active': activeTab === 'street' }"
@ -63,6 +62,7 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
> >
Street Style Street Style
</li> </li>
<li <li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'events' }" :class="{ 'is-active': activeTab === 'events' }"
@ -75,42 +75,41 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
<!-- 中间内容展示区 --> <!-- 中间内容展示区 -->
<main class="grow min-h-[400px] mt-10"> <main class="grow min-h-[400px] mt-10">
<!-- 发布会 面板(默认激活) -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
<!-- 横向滚动画廊 --> <!-- Runway -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">
Hot Brand
</div>
{ {
galleryItems.length > 0 && ( galleryItems.length > 0 && (
<section class="hs-section"> <section class="hs-section">
<div class="hs-stage"> <div class="hs-stage">
<div class="swiper hs-swiper" id="hsSwiper"> <div class="swiper hs-swiper" id="hsSwiper">
<div class="swiper-wrapper"> <div class="swiper-wrapper">
{ {galleryItems.map((g, i) => (
galleryItems.map((g, i) => (
<div class="swiper-slide hs-slide"> <div class="swiper-slide hs-slide">
<a class="hs-item" href={href(locale, ROUTES.item(g.articleId))} data-idx={i}> <a class="hs-item" href={href(locale, ROUTES.item(g.articleId))} data-idx={i}>
<!-- Alpine 管理单张图片的加载/失败状态 --> <div class="hs-img-box" x-data="imgLoader()" :class="{ 'is-loaded': loaded || error }">
<div
class="hs-img-box"
x-data="{ loaded: false, error: false }"
:class="{ 'is-loaded': loaded || error }"
>
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div> <div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="hs-spin" aria-hidden="true" x-show="!loaded && !error"> <span class="hs-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22"> <svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" /> <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" />
</svg> </svg>
</span> </span>
<img <img
src={g.src} src={g.src}
alt={g.brand} alt={g.brand}
loading={i < 4 ? "eager" : "lazy"} loading={i < 4 ? "eager" : "lazy"}
decoding="async" decoding="async"
:class="{ 'is-error': error }" :class="{ 'is-error': error }"
@load="loaded = true; $dispatch('img-ready')" @load="handleLoad()"
@error="error = true; $dispatch('img-ready')" @error="handleError()"
/> />
<span class="hs-caption font-sans"> <span class="hs-caption font-sans">
<span class="hs-cap-brand">{g.brand}</span> <span class="hs-cap-brand">{g.brand}</span>
<span class="hs-cap-meta">{g.title}</span> <span class="hs-cap-meta">{g.title}</span>
@ -118,13 +117,26 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
</div> </div>
</a> </a>
</div> </div>
)) ))}
}
</div> </div>
</div> </div>
<div class="hs-controls flex justify-between items-center text-[13px] font-bold"> <div class="hs-controls flex justify-between items-center text-[13px] font-bold">
<div class="text-[16px] hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Previous')}</div> <div
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Next')}</div> class="text-[16px] hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200"
role="button"
tabindex="0"
>
{t('Previous')}
</div>
<div
class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200"
role="button"
tabindex="0"
>
{t('Next')}
</div>
</div> </div>
</div> </div>
</section> </section>
@ -132,67 +144,100 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
} }
</div> </div>
<!-- 社媒 面板 --> <!-- Social Media -->
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak> <div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" /> <ComingSoon
module="社媒"
moduleZh="Social"
note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。"
/>
</div> </div>
<!-- 街拍 面板(构建期 SSG 拉取热门街拍封面;去掉 swiper,改为 HBX 大图网格) --> <!-- Street Style -->
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak> <div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
{ {
streetItems.length > 0 ? ( streetItems.length > 0 ? (
<section class="ss-section"> <section class="ss-section">
<!-- 街拍大图卡片网格 -->
<div class="ss-grid"> <div class="ss-grid">
{ {streetItems.map((g) => (
streetItems.map((g) => (
<a class="ss-card" href={href(locale, ROUTES.streetSnap(g.id))}> <a class="ss-card" href={href(locale, ROUTES.streetSnap(g.id))}>
<div class="ss-img-box" x-data="{ loaded: false, error: false, init() { const i = this.$el.querySelector('img'); if (i && i.complete) { this.loaded = i.naturalWidth > 0; this.error = i.naturalWidth === 0; } } }" :class="{ 'is-loaded': loaded || error }"> <div class="ss-img-box" x-data="imgLoader()" :class="{ 'is-loaded': loaded || error }">
<div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div> <div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="ss-spin" aria-hidden="true" x-show="!loaded && !error"> <span class="ss-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="ss-ring" /></svg> <svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="ss-ring" />
</svg>
</span> </span>
<img src={g.src} alt={g.loc} loading="lazy" decoding="async" :class="{ 'is-error': error }" @load="loaded = true; $dispatch('img-ready')" @error="error = true; $dispatch('img-ready')" />
<img
src={g.src}
alt={g.loc}
loading="lazy"
decoding="async"
:class="{ 'is-error': error }"
@load="handleLoad()"
@error="handleError()"
/>
<span class="ss-cap"> <span class="ss-cap">
<span class="ss-cap-loc">{g.loc}</span> <span class="ss-cap-loc">{g.loc}</span>
<span class="ss-cap-event">{g.event}</span> <span class="ss-cap-event">{g.event}</span>
</span> </span>
</div> </div>
</a> </a>
)) ))}
}
</div> </div>
</section> </section>
) : ( ) : (
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" /> <ComingSoon
module="街拍"
moduleZh="Street"
note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。"
/>
) )
} }
</div> </div>
</main> </main>
</div> </div>
<style> <style>
@reference "tailwindcss"; @reference "tailwindcss";
:root { --brand: #cf5a44; } :root {
--brand: #cf5a44;
}
/* 标签基础与激活样式 */ /* 标签 */
.tab { .tab {
@apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline; @apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline;
} }
.tab:hover { @apply text-[#666]; } .tab:hover {
@apply text-[#666];
}
.tab::after { .tab::after {
content: ""; content: "";
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out; @apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
} }
.tab.is-active { @apply text-black; } .tab.is-active {
.tab.is-active::after { @apply scale-x-100; } @apply text-black;
}
.tab.is-active::after {
@apply scale-x-100;
}
[x-cloak] {
display: none !important;
}
/* 防闪烁标签 */ /* ========================= Hot Brand ========================= */
[x-cloak] { display: none !important; } .hs-section {
@apply mb-9;
/* 横向画廊样式 */ }
.hs-section { @apply mb-9; } .hs-stage {
.hs-stage { @apply relative; } @apply relative;
}
.hs-controls { .hs-controls {
position: absolute; position: absolute;
top: 50%; top: 50%;
@ -205,22 +250,27 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
mix-blend-mode: difference; mix-blend-mode: difference;
color: #fff; color: #fff;
} }
.hs-controls .hs-arrow { pointer-events: auto; } .hs-controls .hs-arrow {
.hs-arrow { @apply select-none; } pointer-events: auto;
.hs-arrow.swiper-button-disabled { pointer-events: none; } }
.hs-arrow {
/* Swiper 尺寸规整 */ @apply select-none;
}
.hs-arrow.swiper-button-disabled {
pointer-events: none;
}
.hs-swiper { .hs-swiper {
width: 100%; width: 100%;
height: clamp(500px, 80vh, 900px); height: clamp(500px, 80vh, 900px);
padding: 0 4px; padding: 0 4px;
} }
.hs-swiper .swiper-wrapper { align-items: center; } .hs-swiper .swiper-wrapper {
align-items: center;
}
.hs-swiper .hs-slide { .hs-swiper .hs-slide {
width: auto; width: auto;
height: auto; height: auto;
} }
.hs-item { .hs-item {
display: block; display: block;
height: auto; height: auto;
@ -232,7 +282,6 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
position: relative; position: relative;
display: block; display: block;
height: clamp(500px, 80vh, 900px); height: clamp(500px, 80vh, 900px);
/* aspect-ratio: 3 / 4; */
background: #f0f0f0; background: #f0f0f0;
overflow: hidden; overflow: hidden;
} }
@ -245,10 +294,14 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
opacity: 0; opacity: 0;
transition: opacity 0.4s ease; transition: opacity 0.4s ease;
} }
.hs-img-box.is-loaded > img { opacity: 1; } .hs-img-box.is-loaded > img {
.hs-img-box > img.is-error { opacity: 0 !important; } opacity: 1;
}
.hs-img-box > img.is-error {
opacity: 0 !important;
}
/* 加载骨架屏与旋转 Spinner */ /* 骨架屏 */
.hs-skel { .hs-skel {
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -258,8 +311,9 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
animation: hs-shimmer 1.3s ease-in-out infinite; animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease; transition: opacity 0.35s ease;
} }
.hs-img-box.is-loaded .hs-skel { opacity: 0; } .hs-img-box.is-loaded .hs-skel {
opacity: 0;
}
.hs-spin { .hs-spin {
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -271,17 +325,31 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
z-index: 2; z-index: 2;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
.hs-img-box.is-loaded .hs-spin { opacity: 0; } .hs-img-box.is-loaded .hs-spin {
.hs-spin svg { display: block; } opacity: 0;
.hs-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; } }
.hs-spin svg {
@keyframes hs-rotate { to { transform: rotate(360deg); } } display: block;
}
.hs-ring {
transform-origin: 12px 12px;
animation: hs-rotate 0.8s linear infinite;
}
@keyframes hs-rotate {
to {
transform: rotate(360deg);
}
}
@keyframes hs-shimmer { @keyframes hs-shimmer {
from { background-position: 200% 0; } from {
to { background-position: -200% 0; } background-position: 200% 0;
}
to {
background-position: -200% 0;
}
} }
/* 底部品牌/标题信息——左下角大标题+小副标题,始终可见 */ /* Hot Brand 文案 */
.hs-caption { .hs-caption {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@ -321,19 +389,28 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.hs-caption { padding: 60px 28px 26px; } .hs-caption {
.hs-cap-brand { font-size: 26px; } padding: 60px 28px 26px;
.hs-cap-meta { font-size: 15px; } }
.hs-cap-brand {
font-size: 26px;
}
.hs-cap-meta {
font-size: 15px;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); } .hs-swiper,
.hs-img-box {
height: clamp(340px, 56vh, 540px);
}
} }
/* ===== 首页 Street Style 网格(去掉 swiper,改为 HBX 大图卡片,与街拍列表页一致) ===== */ /* ========================= Street Style ========================= */
.ss-section { @apply mb-9; } .ss-section {
@apply mb-9;
}
.ss-grid { .ss-grid {
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[14px] md:gap-[18px]; @apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[14px] md:gap-[18px];
} }
@ -350,62 +427,122 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
opacity: 0; opacity: 0;
transition: opacity 0.45s ease, transform 0.7s ease; transition: opacity 0.45s ease, transform 0.7s ease;
} }
.ss-img-box.is-loaded > img { opacity: 1; } .ss-img-box.is-loaded > img {
.ss-img-box > img.is-error { opacity: 0 !important; } opacity: 1;
.ss-card:hover .ss-img-box > img { transform: scale(1.04); } }
.ss-img-box > img.is-error {
opacity: 0 !important;
}
.ss-card:hover .ss-img-box > img {
transform: scale(1.04);
}
/* 骨架屏 + spinner(与首页 hs-* 平行) */ /* Street Style 骨架 */
.ss-skel { .ss-skel {
position: absolute; inset: 0; z-index: 0; position: absolute;
inset: 0;
z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%); background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%; background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite; animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease; transition: opacity 0.35s ease;
} }
.ss-img-box.is-loaded .ss-skel { opacity: 0; } .ss-img-box.is-loaded .ss-skel {
opacity: 0;
}
.ss-spin { .ss-spin {
position: absolute; inset: 0; z-index: 2; position: absolute;
display: flex; align-items: center; justify-content: center; inset: 0;
color: #bdbdbd; pointer-events: none; z-index: 2;
display: flex;
align-items: center;
justify-content: center;
color: #bdbdbd;
pointer-events: none;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
.ss-img-box.is-loaded .ss-spin { opacity: 0; } .ss-img-box.is-loaded .ss-spin {
.ss-spin svg { display: block; } opacity: 0;
.ss-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; } }
.ss-spin svg {
display: block;
}
.ss-ring {
transform-origin: 12px 12px;
animation: hs-rotate 0.8s linear infinite;
}
/* 底部叠层白字(loc / event) */ /* Street Style 文案 */
.ss-cap { .ss-cap {
position: absolute; bottom: 0; left: 0; right: 0; position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 28px 20px 18px; padding: 28px 20px 18px;
background: linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.34) 55%, transparent 100%); background: linear-gradient(to top, rgba(0, 0, 0, 0.72) 0%, rgba(0, 0, 0, 0.34) 55%, transparent 100%);
color: white; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-end; color: white;
gap: 5px; z-index: 3; pointer-events: none; display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 5px;
z-index: 3;
pointer-events: none;
} }
.ss-cap-loc { .ss-cap-loc {
font-size: clamp(18px, 2vw, 26px); font-size: clamp(18px, 2vw, 26px);
font-weight: 700; line-height: 1.1; letter-spacing: -0.01em; font-weight: 700;
text-shadow: 0 1px 4px rgba(0,0,0,0.55); line-height: 1.1;
letter-spacing: -0.01em;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
} }
.ss-cap-event { .ss-cap-event {
font-size: clamp(10px, 0.8vw, 12px); font-size: clamp(10px, 0.8vw, 12px);
font-weight: 500; text-transform: uppercase; letter-spacing: 0.16em; line-height: 1.4; font-weight: 500;
opacity: 0.85; text-shadow: 0 1px 3px rgba(0,0,0,0.5); text-transform: uppercase;
letter-spacing: 0.16em;
line-height: 1.4;
opacity: 0.85;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
} }
</style> </style>
<script> <script>
import Swiper from "swiper"; import Swiper from "swiper"
import { Navigation, FreeMode, Keyboard } from "swiper/modules"; import { Navigation, FreeMode, Keyboard } from "swiper/modules"
import "swiper/css"; import "swiper/css"
import "swiper/css/navigation"; import "swiper/css/navigation"
import "swiper/css/free-mode"; import "swiper/css/free-mode"
// 初始化 Swiper 轮播(首页可能有多个:Hot Brand / Street Style) /* ---------------- Swiper 实例管理 ---------------- */
const swipers: Swiper[] = [] let swipers = []
document.querySelectorAll<HTMLElement>(".hs-swiper").forEach((el) => { let updateScheduled = false
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
// RAF 合并:同一帧内多张图片加载完成 / 多个 resize,只执行一次 swiper.update()
function scheduleSwiperUpdate() {
if (updateScheduled) return
updateScheduled = true
requestAnimationFrame(() => {
updateScheduled = false
for (const swiper of swipers) {
if (!swiper.destroyed) swiper.update()
}
})
}
// 销毁已有实例:防止重复初始化 / View Transitions 重新进入时旧实例泄漏
function destroySwipers() {
for (const swiper of swipers) {
if (!swiper.destroyed) swiper.destroy(true, true)
}
swipers = []
}
function initSwipers() {
destroySwipers()
document.querySelectorAll(".hs-swiper").forEach((el) => {
const prevBtn = el.parentElement?.querySelector(".hs-prev")
const nextBtn = el.parentElement?.querySelector(".hs-next")
const swiper = new Swiper(el, { const swiper = new Swiper(el, {
modules: [Navigation, FreeMode, Keyboard], modules: [Navigation, FreeMode, Keyboard],
slidesPerView: "auto", slidesPerView: "auto",
@ -419,18 +556,67 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
observeParents: true, observeParents: true,
observeSlideChildren: true, observeSlideChildren: true,
resizeObserver: true, resizeObserver: true,
}); })
swipers.push(swiper); swipers.push(swiper)
}); })
scheduleSwiperUpdate()
}
// Street Style 等面板默认 x-show 隐藏,Swiper 在 display:none 下初始化会把 slide 算成 0 宽, // 统一入口:延后一帧,避免首次进入时 DOM / Alpine / Swiper 初始化时序互相抢
// 切到对应 tab 后必须 update() 一次重算尺寸,否则图片虽在但宽度 0 不可见。 function setupSwipers() {
const updateAllSwipers = () => requestAnimationFrame(() => initSwipers())
requestAnimationFrame(() => swipers.forEach((s) => s.update())); }
document.querySelectorAll<HTMLElement>(".tab").forEach((tab) => {
tab.addEventListener("click", () => setTimeout(updateAllSwipers, 60)); // 首次普通加载:等 DOMContentLoaded
}); if (document.readyState === "loading") {
window.addEventListener("resize", updateAllSwipers); document.addEventListener("DOMContentLoaded", setupSwipers, { once: true })
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁 } else {
window.addEventListener("img-ready", updateAllSwipers); setupSwipers()
}
// Astro View Transitions 客户端导航进入:重新初始化
document.addEventListener("astro:page-load", setupSwipers)
// Tab 切换后重算尺寸(事件委托,兼容 View Transitions 后的新 DOM)
document.addEventListener("click", (e) => {
if (e.target.closest(".tab")) {
setTimeout(scheduleSwiperUpdate, 60)
}
})
// 浏览器尺寸变化
window.addEventListener("resize", scheduleSwiperUpdate)
// 图片加载完成(每张图的 Alpine 组件 dispatch "img-ready")后更新 Swiper
window.addEventListener("img-ready", scheduleSwiperUpdate)
/* ---------------- 共享图片加载状态组件 ---------------- */
// Hot Brand 与 Street Style 复用同一份逻辑:
// 1) init() 检查 complete + naturalWidth,缓存已完成的图片立即置位,不一直显示骨架;
// 2) 保留 @load / @error,处理 Alpine 初始化之后才完成的图片。
document.addEventListener("alpine:init", () => {
Alpine.data("imgLoader", () => ({
loaded: false,
error: false,
init() {
const img = this.$el.querySelector("img")
if (!img) return
if (img.complete) {
this.loaded = img.naturalWidth > 0
this.error = img.naturalWidth === 0
if (this.loaded || this.error) this.$dispatch("img-ready")
}
},
handleLoad() {
this.error = false
this.loaded = true
this.$dispatch("img-ready")
},
handleError() {
this.loaded = false
this.error = true
this.$dispatch("img-ready")
},
}))
})
</script> </script>

View File

@ -45,7 +45,7 @@ for (const d of (article?.details ?? []) as { look_index?: number; images?: unkn
} }
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示, // 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。 // 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5 const PREVIEW_LIMIT = 5
const previewImages = images.slice(0, PREVIEW_LIMIT) const previewImages = images.slice(0, PREVIEW_LIMIT)
@ -214,7 +214,7 @@ const homeHref = href(locale, ROUTES.home)
)) ))
} }
{/* 登录后追加的额外图片:extraImages 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */} {/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url"> <template x-for="(im, i) in extraImages" :key="im.url">
<figure <figure
:data-image-index="5 + i" :data-image-index="5 + i"
@ -585,8 +585,9 @@ const homeHref = href(locale, ROUTES.home)
</div> </div>
<script> <script>
import { getUser, getArticleDetailAuthed, toAbs } from "@/lib/api" import { getArticleDetailAuthed, toAbs } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites" import { getUser } from "@/lib/auth"
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("articleView", () => ({ Alpine.data("articleView", () => ({
@ -609,6 +610,8 @@ const homeHref = href(locale, ROUTES.home)
isAuthed: false, isAuthed: false,
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张) // 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false, preview: false,
// 已登录态图集级(gallery,r=/s=)收藏态:由 loadAuthedState 从详情接口取回,广播给 fav-fab
galleryFav: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染) // 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; thumb: string; raw: string; name: string; lookIndex?: number }[], extraImages: [] as { id: string; url: string; thumb: string; raw: string; name: string; lookIndex?: number }[],
@ -647,20 +650,33 @@ const homeHref = href(locale, ROUTES.home)
this.initImageFavs() this.initImageFavs()
this.maybeLocateImageFromUrl() this.maybeLocateImageFromUrl()
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染 // 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
if (getUser() && this.preview) this.loadFull() if (getUser()) this.loadAuthedState()
// 登录后(如从门禁跳登录回跳)自动补全全量 // 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() }) window.addEventListener("auth:login", () => { this.isAuthed = true; if (getUser()) this.loadAuthedState() })
}, },
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。 // 已登录:用一次详情请求拿回「完整图片集 + 收藏态」,合并原三处 /me/favorites/checks 调用。
// 后端详情接口对已登录(带 Bearer)返回完整图片集,故此处能拿到全量。 // gallery 级(r=)收藏态广播给页面右下角 fav-fab;image 级(i=)收藏态直接重建 favIds。
async loadFull() { async loadAuthedState() {
if (!this.preview) return if (!getUser()) return
try { try {
const full = await getArticleDetailAuthed(this.galleryUid) const full = await getArticleDetailAuthed(this.galleryUid)
if (!full || !Array.isArray(full.images)) return if (!full || !Array.isArray(full.images)) return
const mapped = (full.images || []).slice(5).map((im: any) => ({
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
this.galleryFav = !!full.favorited
window.dispatchEvent(
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
)
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
const favIds: string[] = []
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
// 超预览上限才追加第 6 张起图片(前 5 张已由 SSR 渲染,仅补收藏态)
if (this.preview) {
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "", id: im.id || "",
url: toAbs(im.image || ""), url: toAbs(im.image || ""),
thumb: toAbs(im.thumb || im.image || ""), thumb: toAbs(im.thumb || im.image || ""),
@ -668,18 +684,19 @@ const homeHref = href(locale, ROUTES.home)
name: im.name || "", name: im.name || "",
lookIndex: Number(im.look_index ?? 0), lookIndex: Number(im.look_index ?? 0),
})) }))
this.extraImages = mapped
// 后端详情接口对所有用户统一返回 high 高清样式(付费墙已取消),首屏图即为高清,
// 无需客户端再按 VIP 替换。仅把第 6 张起的图片追加进 extraImages 渲染。
await this.$nextTick() await this.$nextTick()
this.imageCount = document.querySelectorAll( this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]" "#article-gallery [data-image-index]"
).length ).length
this.initImageFavs() ;(full.images || []).slice(5).forEach(mark)
this.initThumbLazyLoading() this.initThumbLazyLoading()
this.maybeLocateImageFromUrl() this.maybeLocateImageFromUrl()
}
// 前 5 张收藏态
;(full.images || []).slice(0, 5).forEach(mark)
this.favIds = favIds
} catch { } catch {
/* 失败维持截断态,门禁仍在 */ /* 失败维持本地/截断态,门禁仍在 */
} }
}, },
@ -768,15 +785,12 @@ const homeHref = href(locale, ROUTES.home)
.filter(Boolean) .filter(Boolean)
}, },
// 本地即时填充收藏态(未登录态,或登录态加载服务端数据前的占位,避免爱心闪烁)。
// 登录态的服务端校正已合并进 loadAuthedState(),不再单独请求 /me/favorites/checks。
initImageFavs() { initImageFavs() {
const ids = this.allImageFavIds() const ids = this.allImageFavIds()
if (!ids.length) return if (!ids.length) return
// 本地先即时填充,避免爱心闪烁
this.favIds = ids.filter((id) => isFavorited(id)) this.favIds = ids.filter((id) => isFavorited(id))
// 登录态用服务端校正(换设备后与服务端一致)
checkFavorited(ids).then((hit) => {
this.favIds = ids.filter((id) => hit.has(id))
})
}, },
isFav(id: string) { isFav(id: string) {

View File

@ -72,7 +72,7 @@ const seasonOptions: { value: string; label: string }[] = [
<span slot="current">{t('Runway looks')}</span> <span slot="current">{t('Runway looks')}</span>
</Breadcrumb> </Breadcrumb>
<h1 class="showsh1">全部的 runway</h1> <h1 class="showsh1">{t('all runway looks')}</h1>
</div> </div>
</header> </header>
@ -265,7 +265,8 @@ const seasonOptions: { value: string; label: string }[] = [
<script> <script>
import { getArticles, getBrands, toAbs, getUser, type ArticleItem, type BrandEntry } from "@/lib/api" import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
import { getUser } from "@/lib/auth"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid" import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites" import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"

View File

@ -31,7 +31,7 @@ const images = (snap?.images ?? [])
.filter((im) => im.url) .filter((im) => im.url)
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示, // 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。 // 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5 const PREVIEW_LIMIT = 5
const previewImages = images.slice(0, PREVIEW_LIMIT) const previewImages = images.slice(0, PREVIEW_LIMIT)
@ -141,7 +141,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
)) ))
} }
{/* 登录后追加的额外图片:extraImages 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */} {/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url"> <template x-for="(im, i) in extraImages" :key="im.url">
<figure <figure
:data-image-index="5 + i" :data-image-index="5 + i"
@ -351,8 +351,9 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
</div> </div>
<script> <script>
import { getUser, getStreetSnapDetailAuthed, toAbs } from "@/lib/api" import { getStreetSnapDetailAuthed, toAbs } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites" import { getUser } from "@/lib/auth"
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("streetSnapView", () => ({ Alpine.data("streetSnapView", () => ({
@ -374,6 +375,8 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
isAuthed: false, isAuthed: false,
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张) // 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false, preview: false,
// 已登录态图集级(gallery,s=)收藏态:由 loadAuthedState 从详情接口取回,广播给 fav-fab
galleryFav: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染) // 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; raw: string; name: string }[], extraImages: [] as { id: string; url: string; raw: string; name: string }[],
@ -392,36 +395,49 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
this.initImageFavs() this.initImageFavs()
this.maybeLocateImageFromUrl() this.maybeLocateImageFromUrl()
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染 // 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
if (getUser() && this.preview) this.loadFull() if (getUser()) this.loadAuthedState()
// 登录后(如从门禁跳登录回跳)自动补全全量 // 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() }) window.addEventListener("auth:login", () => { this.isAuthed = true; if (getUser()) this.loadAuthedState() })
}, },
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。 // 已登录:用一次详情请求拿回「完整图片集 + 收藏态」,合并原三处 /me/favorites/checks 调用。
async loadFull() { // gallery 级(s=)收藏态广播给页面右下角 fav-fab;image 级(j=)收藏态直接重建 favIds。
if (!this.preview) return async loadAuthedState() {
if (!getUser()) return
try { try {
const full = await getStreetSnapDetailAuthed(this.galleryUid) const full = await getStreetSnapDetailAuthed(this.galleryUid)
if (!full || !Array.isArray(full.images)) return if (!full || !Array.isArray(full.images)) return
const mapped = (full.images || []).slice(5).map((im: any) => ({
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
this.galleryFav = !!full.favorited
window.dispatchEvent(
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
)
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
const favIds: string[] = []
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
if (this.preview) {
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "", id: im.id || "",
url: toAbs(im.image || ""), url: toAbs(im.image || ""),
raw: im.image || "", raw: im.image || "",
name: im.name || "", name: im.name || "",
})) }))
this.extraImages = mapped
// 后端详情接口对所有用户统一返回 high 高清样式(付费墙已取消),首屏图即为高清,
// 无需客户端再按 VIP 替换。仅把第 6 张起的图片追加进 extraImages 渲染。
await this.$nextTick() await this.$nextTick()
this.imageCount = document.querySelectorAll( this.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]" "#snap-gallery [data-image-index]"
).length ).length
this.initImageFavs() ;(full.images || []).slice(5).forEach(mark)
this.initThumbLazyLoading() this.initThumbLazyLoading()
this.maybeLocateImageFromUrl() this.maybeLocateImageFromUrl()
}
;(full.images || []).slice(0, 5).forEach(mark)
this.favIds = favIds
} catch { } catch {
/* 失败维持截断态,门禁仍在 */ /* 失败维持本地/截断态,门禁仍在 */
} }
}, },
@ -479,13 +495,12 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
.filter(Boolean) .filter(Boolean)
}, },
// 本地即时填充收藏态(未登录态,或登录态加载服务端数据前的占位,避免爱心闪烁)。
// 登录态的服务端校正已合并进 loadAuthedState(),不再单独请求 /me/favorites/checks。
initImageFavs() { initImageFavs() {
const ids = this.allImageFavIds() const ids = this.allImageFavIds()
if (!ids.length) return if (!ids.length) return
this.favIds = ids.filter((id) => isFavorited(id)) this.favIds = ids.filter((id) => isFavorited(id))
checkFavorited(ids).then((hit) => {
this.favIds = ids.filter((id) => hit.has(id))
})
}, },
isFav(id: string) { isFav(id: string) {

View File

@ -221,7 +221,8 @@ const cityOptions: { value: string; label: string }[] = [
<script> <script>
import { getStreetSnaps, toAbs, getUser, type StreetSnapItem } from "@/lib/api" import { getStreetSnaps, toAbs, type StreetSnapItem } from "@/lib/api"
import { getUser } from "@/lib/auth"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid" import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites" import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"

View File

@ -20,27 +20,20 @@ type TranslationEntry = Partial<Record<OtherLocale, string>>;
export const dict: Record<string, TranslationEntry> = { export const dict: Record<string, TranslationEntry> = {
// ── 导航 / Navigation ── // ── 导航 / Navigation ──
'全部的 runway': { cn: '全部的 runway' },
'home': { cn: '首页' }, 'home': { cn: '首页' },
'login': { cn: '登录' }, 'login': { cn: '登录' },
'portfolio': { cn: '作品集' }, 'portfolio': { cn: '作品集' },
'runway looks': { cn: '秀场造型' },
'runway archive': { cn: '走秀档案' }, 'all runway looks': { cn: '全部的 runway' },
// ── Runway 子分类(header 下拉,与列表页 COLLECTION 筛选一致)── // ── Runway 子分类(header 下拉,与列表页 COLLECTION 筛选一致)──
'ready-to-wear': { cn: '女装成衣' }, 'ready-to-wear': { cn: '女装成衣' },
'menswear': { cn: '男装' }, 'menswear': { cn: '男装' },
'couture': { cn: '高级定制' }, 'couture': { cn: '高级定制' },
'resort': { cn: '度假系列' }, 'resort': { cn: '度假系列' },
'pre-fall': { cn: '早秋系列' },
// ── 账号 / Account ── // ── 账号 / Account ──
'password': { cn: '密码' }, 'password': { cn: '密码' },
'account': { cn: '账户' },
'sign out': { cn: '退出登录' }, 'sign out': { cn: '退出登录' },
'my account': { cn: '我的账户' }, 'my account': { cn: '我的账户' },
'username or email': { cn: '用户名或邮箱' }, 'username or email': { cn: '用户名或邮箱' },
@ -53,19 +46,12 @@ export const dict: Record<string, TranslationEntry> = {
'photos': { cn: '张照片' }, 'photos': { cn: '张照片' },
'members': { cn: '会员专区' }, 'members': { cn: '会员专区' },
'back to site': { cn: '返回网站' }, 'back to site': { cn: '返回网站' },
'email': { cn: '邮箱' },
'my history': { cn: '浏览历史' }, 'my history': { cn: '浏览历史' },
'coming soon': { cn: '敬请期待' },
// ── 账户中心 / Account Center(企业级个人中心)── // ── 账户中心 / Account Center ──
'saved looks': { cn: '收藏造型' }, 'saved looks': { cn: '收藏造型' },
'role': { cn: '角色' },
'items': { cn: '条' }, 'items': { cn: '条' },
'username': { cn: '用户名' },
'browse the archive': { cn: '去浏览档案' }, 'browse the archive': { cn: '去浏览档案' },
'appearance': { cn: '外观' },
'soon': { cn: '即将推出' },
'now': { cn: '刚刚' },
'member profile': { cn: '会员资料' }, 'member profile': { cn: '会员资料' },
'vip': { cn: '高级会员' }, 'vip': { cn: '高级会员' },
'save': { cn: '收藏' }, 'save': { cn: '收藏' },
@ -76,67 +62,41 @@ export const dict: Record<string, TranslationEntry> = {
'clear history': { cn: '清空历史' }, 'clear history': { cn: '清空历史' },
'no saved looks yet': { cn: '暂无收藏造型' }, 'no saved looks yet': { cn: '暂无收藏造型' },
'no history yet': { cn: '暂无浏览记录' }, 'no history yet': { cn: '暂无浏览记录' },
'all': { cn: '全部' },
'image': { cn: '图片' },
'gallery': { cn: '图集' },
'light': { cn: '浅色' },
'on': { cn: '开' },
'off': { cn: '关' },
// ── 控件 / Controls ── // ── 控件 / Controls ──
'back to index': { cn: '返回首页' }, 'back to index': { cn: '返回首页' },
'close': { cn: '关闭' }, 'close': { cn: '关闭' },
'previous image': { cn: '上一张' }, 'previous image': { cn: '上一张' },
'next image': { cn: '下一张' }, 'next image': { cn: '下一张' },
'previous': { cn: '上一组' }, 'previous': { cn: '上一组' },
'next': { cn: '下一组' }, 'next': { cn: '下一组' },
// ── 错误 / Errors ── // ── 错误 / Errors ──
'no article id specified': { cn: '未指定文章 ID' }, 'no article id specified': { cn: '未指定文章 ID' },
'failed to load article. please refresh or try later.': { cn: '加载文章失败,请刷新或稍后再试' }, 'failed to load article. please refresh or try later.': { cn: '加载文章失败,请刷新或稍后再试' },
'click image to zoom · scroll to view · esc to close': { cn: '点击图片放大 · 上下滚动查看 · Esc 关闭' }, 'click image to zoom · scroll to view · esc to close': { cn: '点击图片放大 · 上下滚动查看 · Esc 关闭' },
// ── 页脚 / Footer ── // ── 页脚 / Footer ──
'© 2026 portfolio studio. all rights reserved.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' }, '© 2026 portfolio studio. all rights reserved.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
'default': { cn: '默认' },
// ── 页脚简介 / Manifesto ── // ── 页脚简介 / Manifesto ──
'minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.': 'minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.':
{ cn: '极简编辑风格作品集,记录高级定制秀场、后台试装与街头文化。集成 2026 色彩预测情报与 AI 调色板生成器。' }, { cn: '极简编辑风格作品集,记录高级定制秀场、后台试装与街头文化。集成 2026 色彩预测情报与 AI 调色板生成器。' },
// ── 登录态 / Auth ── // ── 登录态 / Auth ──
'hi': { cn: '你好' },
'logout': { cn: '退出' },
'language': { cn: '语言' }, 'language': { cn: '语言' },
'runway looks': { cn: '秀场造型' },
// ── 街拍 / Street Snaps ── // ── 街拍 / Street Snaps ──
'street style': { cn: '街拍' }, 'street style': { cn: '街拍' },
'street snaps': { cn: '街拍' },
'all street snaps': { cn: '全部街拍' }, 'all street snaps': { cn: '全部街拍' },
'no street snap id specified': { cn: '未指定街拍 ID' }, 'no street snap id specified': { cn: '未指定街拍 ID' },
'failed to load street snap. please refresh or try later.': { cn: '加载街拍失败,请刷新或稍后再试' }, 'failed to load street snap. please refresh or try later.': { cn: '加载街拍失败,请刷新或稍后再试' },
'brands': { cn: '品牌' },
'Year': { cn: '年份' },
'Sort': { cn: '排序' },
'Most photos': { cn: '照片最多' },
'Newest year': { cn: '年份最新' },
'Oldest year': { cn: '年份最早' },
// ── 地区 / Cities(街拍按城市分类,值对齐后端 street_snap.city 列;translate 会小写化 key)── // ── 地区 / Cities(街拍按城市分类,值对齐后端 street_snap.city 列;translate 会小写化 key)──
'city': { cn: '地区' }, 'city': { cn: '地区' },

View File

@ -18,8 +18,8 @@ export function getLocaleFromUrl(url: URL | string): Locale {
/** /**
* 取翻译函数 t():key 即英文原文,按当前语言自动匹配翻译。 * 取翻译函数 t():key 即英文原文,按当前语言自动匹配翻译。
* - 英文环境:t('全部的 runway') 直接返回 '全部的 runway'(key 本身) * - 英文环境:t('all runway looks') 直接返回 'all runway looks'(key 本身)
* - 中文环境:t('全部的 runway') 返回字典里的 '全部的 runway'(取不到则回退英文) * - 中文环境:t('all runway looks') 返回字典里的 '全部的 runway'(取不到则回退英文)
* - 支持 {name} 形式的插值参数。 * - 支持 {name} 形式的插值参数。
*/ */
export function useTranslations(locale: Locale) { export function useTranslations(locale: Locale) {

View File

@ -5,7 +5,7 @@ import AuthModal from '@/components/AuthModal.astro'
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
import { getI18n } from '@/i18n/utils' import { getI18n } from '@/i18n/utils'
import { LOCALE_NAMES } from '@/i18n/config' import { LOCALE_NAMES } from '@/i18n/config'
import { setApiLocale } from '@/lib/api' import { setApiLocale } from '@/lib/locale'
import { i18n } from 'astro:config/client'; import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n'; import { toCodes } from 'astro:i18n';
@ -34,7 +34,7 @@ const headers = {
// 走秀下拉菜单项:方案 A —— 全部 + 4 个真实 collection_type 分类(与列表页 COLLECTION 筛选一致), // 走秀下拉菜单项:方案 A —— 全部 + 4 个真实 collection_type 分类(与列表页 COLLECTION 筛选一致),
// 深链带 ?collection= 参数,由 RunwayLooks 的 init() 读取并自动选中筛选。 // 深链带 ?collection= 参数,由 RunwayLooks 的 init() 读取并自动选中筛选。
runways: [ runways: [
{ label: t('全部的 runway'), href: href(locale, ROUTES.runwayLooks) }, { label: t('all runway looks'), href: href(locale, ROUTES.runwayLooks) },
{ label: t('Ready-to-Wear'), href: href(locale, ROUTES.runwayLooks) + '?collection=rtw' }, { label: t('Ready-to-Wear'), href: href(locale, ROUTES.runwayLooks) + '?collection=rtw' },
{ label: t('Menswear'), href: href(locale, ROUTES.runwayLooks) + '?collection=menswear' }, { label: t('Menswear'), href: href(locale, ROUTES.runwayLooks) + '?collection=menswear' },
{ label: t('Couture'), href: href(locale, ROUTES.runwayLooks) + '?collection=couture' }, { label: t('Couture'), href: href(locale, ROUTES.runwayLooks) + '?collection=couture' },
@ -80,7 +80,7 @@ const headers = {
<!-- 全部的 runway:菜单项用 Astro 渲染(headers.runways.map),开合逻辑用 Alpine.js --> <!-- 全部的 runway:菜单项用 Astro 渲染(headers.runways.map),开合逻辑用 Alpine.js -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false"> <div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<a href={href(locale, ROUTES.runwayLooks)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('全部的 runway')}</a> <a href={href(locale, ROUTES.runwayLooks)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('all runway looks')}</a>
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 runway 列表" aria-haspopup="true" :aria-expanded="open"> <button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 runway 列表" aria-haspopup="true" :aria-expanded="open">
<svg class="w-2.5 h-2.5 transition-transform duration-200" :class="open ? 'rotate-180' : ''" viewBox="0 0 10 6" fill="none"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> <svg class="w-2.5 h-2.5 transition-transform duration-200" :class="open ? 'rotate-180' : ''" viewBox="0 0 10 6" fill="none"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button> </button>
@ -179,7 +179,7 @@ const headers = {
<footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans"> <footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans">
<script> <script>
import { getUser, fetchMe, logout } from "@/lib/api" import { getUser, fetchMe, logout } from "@/lib/auth"
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("authMenu", () => ({ Alpine.data("authMenu", () => ({

View File

@ -1,8 +1,9 @@
// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口) // src/lib/api.ts — 后端「业务数据」API 统一客户端(所有业务取数的唯一出入口)
// //
// 集中封装全部 /api/v1 调用:公开查询(/api/v1/public/*)、账号体系(/api/v1/auth/*)、 // 集中封装全部 /api/v1 业务查询:公开查询(/api/v1/public/*)。
// 以及构建期专用的 SSG 接口(/api/internal/ssg/*,内部契约,仅 Node 构建期使用)。 // 鉴权 / 会话相关(登录、令牌、受保护写请求)已拆到 auth.ts;
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。 // 请求签名(clientSign)已拆到 crypto.ts;请求语言透传已拆到 locale.ts。
// 任何模块需要后端业务数据,都只从这里取,不要再自己写 fetch。
// //
// base URL 解析规则(全项目统一两个基准地址,命名见下): // base URL 解析规则(全项目统一两个基准地址,命名见下):
// BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。 // BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。
@ -12,20 +13,19 @@
// //
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。 // - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。 // - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
// - ssgBase():构建期 Node 调 /api/internal/ssg/*,只读 BASE_API_SSG。
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。 // 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
import "@/lib/http" import "@/lib/http"
// i18n:把"当前语言"透传到后端,让 /api 按 locale 选 *_en / *_cn 列。 // BASE_API:Astro 7 中声明在 env.schema(access:public)的变量,浏览器端须从 astro:env/client 读取。
import { getLocaleFromUrl } from "@/i18n/utils"
import { DEFAULT_LOCALE } from "@/i18n/config"
// 公开/运行期基址 BASE_API:Astro 7 中声明在 env.schema(access:public)的变量,
// 浏览器端须从 astro:env/client 读取(不会像 PUBLIC_ 前缀那样自动进 import.meta.env)。
// 该模块在 SSR(服务端)也会回退到 process.env,因此服务端/客户端共用同一份值。 // 该模块在 SSR(服务端)也会回退到 process.env,因此服务端/客户端共用同一份值。
import { BASE_API } from "astro:env/client" import { BASE_API } from "astro:env/client"
// 跨模块依赖(已拆分的关注点):
import { clientSign } from "./crypto" // 公开请求签名(X-Sign* 头)
import { getAccessToken, getRefreshToken, refreshSession } from "./auth" // 令牌读取 / 续期
import { withLocale, currentLocale } from "./locale" // 请求语言透传
// ===================== 类型 ===================== // ===================== 类型 =====================
export interface BrandEntry { export interface BrandEntry {
@ -68,6 +68,7 @@ export interface RawArticleImage {
name?: string name?: string
is_detail?: number // 0=主图 1=细节图(详情接口 images 仅含主图;细节在 details 分组) is_detail?: number // 0=主图 1=细节图(详情接口 images 仅含主图;细节在 details 分组)
look_index?: number // 细节图归属的主图序号(与 images 主图的 look_index 一致) look_index?: number // 细节图归属的主图序号(与 images 主图的 look_index 一致)
favorited?: boolean // 已登录时该图片是否已收藏(随详情接口返回)
} }
// 某个主图(look)对应的细节图集合(与 RunwayLookDetails 对齐),详情页「细节入口」按需展开。 // 某个主图(look)对应的细节图集合(与 RunwayLookDetails 对齐),详情页「细节入口」按需展开。
@ -89,14 +90,7 @@ export type RawArticle = {
images?: RawArticleImage[] images?: RawArticleImage[]
// 按主图分组的细节图(「细节入口」展开用);无细节时为空数组或不返回。 // 按主图分组的细节图(「细节入口」展开用);无细节时为空数组或不返回。
details?: RawLookDetails[] details?: RawLookDetails[]
} favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
export interface AuthUser {
id: number
username: string
email: string
/** 用户等级:free(普通)| vip(高级,图集详情页可见高清原图)。 */
tier: 'free' | 'vip'
} }
// SSG 构建专用类型 // SSG 构建专用类型
@ -108,8 +102,6 @@ export interface SsgPopularBrand {
title: string title: string
} }
// ===================== base 解析 ===================== // ===================== base 解析 =====================
// 仅本模块内部使用(toAbs / request 等),不对外导出。 // 仅本模块内部使用(toAbs / request 等),不对外导出。
@ -129,64 +121,6 @@ export function ssrBase(): string {
return (BASE_API || "http://localhost:8090").replace(/\/$/, "") return (BASE_API || "http://localhost:8090").replace(/\/$/, "")
} }
// ===================== i18n:请求语言透传 =====================
// 后端 /api/v1/public/* 与 /api/internal/ssg/* 都通过 ?locale=cn|en(优先级最高,高于 Accept-Language)
// 决定 name / title / brand_name 选用 *_en 还是 *_cn 列(service.pickLocale 选列并缺翻译回落),
// 出参字段名保持不变,故前端契约无需改动,仅需在请求上带上当前语言。
//
// 当前语言来源分两端:
// - 浏览器端(<script> 里的 fetch):从 URL path 推断(/cn/ → cn,其余 → en),调用方无需关心。
// - 服务端(SSG 构建 / 按需渲染):由各页面/布局在前置 frontmatter 调 setApiLocale() 注入,
// 因绝大多数页面是 SSG 单线程构建,模块级 _ssrLocale 安全;本文件也允许按需渲染时显式传参。
let _ssrLocale: string | null = null
export function setApiLocale(locale: string | null | undefined): void {
_ssrLocale = locale || DEFAULT_LOCALE
}
// 解析当前语言:浏览器端走 URL path,服务端回落注入值或默认语言。
function currentLocale(): string {
if (typeof window !== "undefined" && window.location) {
return getLocaleFromUrl(window.location.pathname)
}
return _ssrLocale || DEFAULT_LOCALE
}
// 把当前语言追加进 query(locale 非空才加,避免污染 URL;DEFAULT_LOCALE 也显式带,语义清晰)。
function withLocale(params?: URLSearchParams, override?: string): URLSearchParams {
const p = params ? new URLSearchParams(params) : new URLSearchParams()
const loc = override || currentLocale()
if (loc) p.set("locale", loc)
return p
}
// ===================== 前端 JS 请求签名(反爬一层) =====================
// 注意:这是「提高成本」而非「加密」。CLIENT_SIGN_SECRET 必须出现在前端 bundle 才能签名,
// 对浏览器可见;有决心的爬虫可反编译复刻。它用于拖慢 casual 爬虫,配合后端按 IP 限流才有效。
// 必须与后端 client_sign.secret 完全一致。生产请改用随机长串并同步两端。
const CLIENT_SIGN_SECRET = "dev-client-sign-secret-change-me"
const CLIENT_SIGN_TTL = 30
// 计算 HMAC-SHA256 签名。canonical = METHOD + "\n" + Path + "\n" + RawQuery + "\n" + ts + "\n" + nonce
// 与后端 middleware.ClientSign 的拼接方式严格一致(换行符分隔,顺序固定)。
async function clientSign(method: string, fullPath: string, rawQuery: string): Promise<{ sig: string; ts: string; nonce: string }> {
const ts = String(Math.floor(Date.now() / 1000))
const nonce = Math.random().toString(36).slice(2) + Date.now().toString(36)
const msg = [method, fullPath, rawQuery, ts, nonce].join("\n")
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
"raw",
enc.encode(CLIENT_SIGN_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
)
const buf = await crypto.subtle.sign("HMAC", key, enc.encode(msg))
const sig = Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return { sig, ts, nonce }
}
// ===================== 内部请求原语 ===================== // ===================== 内部请求原语 =====================
// 通用请求原语:走 getBase() + /api/v1 + path(公开接口)。GET-only。 // 通用请求原语:走 getBase() + /api/v1 + path(公开接口)。GET-only。
@ -208,7 +142,7 @@ async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
const token = getAccessToken() const token = getAccessToken()
if (token) headers["Authorization"] = `Bearer ${token}` if (token) headers["Authorization"] = `Bearer ${token}`
let res = await fetch(url, { headers }) let res = await fetch(url, { headers })
// 登录态下 access 过期:用 refresh 续期一次后重试(与 authedFetch 行为一致)。 // 登录态下 access 过期:用 refresh 续期一次后重试(与 authJson 行为一致)。
if (res.status === 401 && getRefreshToken()) { if (res.status === 401 && getRefreshToken()) {
try { try {
await refreshSession() await refreshSession()
@ -230,8 +164,6 @@ async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。 // locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
// 对外导出:详情取数 / 个人中心逐篇回查(浏览器端按需取 title/cover 渲染历史卡片)。 // 对外导出:详情取数 / 个人中心逐篇回查(浏览器端按需取 title/cover 渲染历史卡片)。
// 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。 // 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。
// 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。
// locale 显式传入(如 Astro.currentLocale)以绑定当前语言,避免并发串 locale。
export type SsrArticleResult = { export type SsrArticleResult = {
article: RawArticle | null article: RawArticle | null
error: "missing" | "failed" | null error: "missing" | "failed" | null
@ -325,176 +257,6 @@ export async function getBrands(opts?: {
}) })
} }
// ===================== 账号体系(/api/auth/*) =====================
// 双令牌:access_token(短命,请求鉴权)+ refresh_token(长命,落库可吊销)。
// 登录写两者;fetchMe 等受保护请求遇 401 自动用 refresh 续期一次;登出吊销 refresh。
const BASE_API_URL = (BASE_API || "").replace(/\/$/, "")
const TOKEN_KEY = "fa_token" // access token
const REFRESH_KEY = "fa_refresh" // refresh token
const USER_KEY = "fa_user"
// 注意:getUser / clearSession 只读写 localStorage,不发网络请求。
export function getUser(): AuthUser | null {
if (typeof localStorage === "undefined") return null
try {
const raw = localStorage.getItem(USER_KEY)
return raw ? (JSON.parse(raw) as AuthUser) : null
} catch {
return null
}
}
export function getAccessToken(): string | null {
if (typeof localStorage === "undefined") return null
return localStorage.getItem(TOKEN_KEY)
}
export function getRefreshToken(): string | null {
if (typeof localStorage === "undefined") return null
return localStorage.getItem(REFRESH_KEY)
}
export function clearSession(): void {
if (typeof localStorage === "undefined") return
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(USER_KEY)
}
// 写登录态:登录成功后把 access + refresh + user 写入 localStorage。
export function saveSession(access: string, refresh: string, user: AuthUser): void {
if (typeof localStorage === "undefined") return
localStorage.setItem(TOKEN_KEY, access)
localStorage.setItem(REFRESH_KEY, refresh)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
// 刷新 token:用 refresh_token 换新的 access_token(refresh 复用、不轮换)。
export async function refreshSession(): Promise<string> {
const refresh = getRefreshToken()
if (!refresh) throw new Error("no refresh token")
const res = await fetch(`${BASE_API_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
})
if (!res.ok) {
clearSession()
throw new Error("refresh failed")
}
const json: any = await res.json()
const access: string | undefined = json?.access_token
if (!access) throw new Error("refresh failed")
// refresh 复用:仅更新 access,保留原 refresh。
const user = getUser()
if (user) saveSession(access, refresh, user)
return access
}
// 带鉴权的请求:自动附带 Bearer,遇 401 静默刷新一次后重试。
async function authedFetch(url: string, opts: RequestInit = {}): Promise<Response> {
const headers = new Headers(opts.headers)
const token = getAccessToken()
if (token) headers.set("Authorization", `Bearer ${token}`)
let res = await fetch(url, { ...opts, headers })
if (res.status === 401 && getRefreshToken()) {
try {
await refreshSession()
const newToken = getAccessToken()
if (newToken) headers.set("Authorization", `Bearer ${newToken}`)
res = await fetch(url, { ...opts, headers })
} catch {
/* 刷新失败:返回原始 401,由调用方处理 */
}
}
return res
}
// 带鉴权的 JSON 请求:自动附 Bearer、遇 401 静默刷新一次后重试,
// 成功返回响应体中的 data 字段;非 2xx 抛出(含后端 message)。
// 收藏/个人中心等需要登录的写操作统一走这里。
export async function authJson<T = any>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${BASE_API_URL}/api/v1${path}`
const headers: Record<string, string> = { "Content-Type": "application/json" }
const res = await authedFetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let msg = `API ${res.status} ${method} ${path}`
try {
const j = await res.json()
if (j?.message) msg = String(j.message)
} catch {
/* 响应体非 JSON,沿用默认错误信息 */
}
throw new Error(msg)
}
const json: any = await res.json()
return (json?.data ?? null) as T
}
export async function fetchMe(): Promise<AuthUser | null> {
if (typeof localStorage === "undefined") return null
if (!getAccessToken()) return null
try {
const res = await authedFetch(`${BASE_API_URL}/api/v1/me?locale=${encodeURIComponent(currentLocale())}`)
if (!res.ok) {
clearSession()
return null
}
const json: any = await res.json()
const user = json?.data?.user ?? json?.user
return (user as AuthUser) ?? null
} catch {
return null
}
}
// 登录:POST /api/v1/auth/login(公开端点)。成功后写 access+refresh+user 进 localStorage。
export async function login(input: { account: string; password: string }): Promise<AuthUser> {
const res = await fetch(`${BASE_API_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
if (!res.ok) {
clearSession()
throw new Error("login failed")
}
const json: any = await res.json()
const access: string | undefined = json?.access_token
const refresh: string | undefined = json?.refresh_token
const user: any = json?.user
if (!access || !refresh || !user) throw new Error("login failed")
saveSession(access, refresh, user)
// 登录成功广播:列表页在未登录时进页没记图集,登录后由监听补记(与「进页面就记」一致)。
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:login"))
}
return user as AuthUser
}
// 登出:吊销当前 refresh_token,再清本地态。
export async function logout(): Promise<void> {
const refresh = getRefreshToken()
if (refresh) {
try {
// 会话即 refresh token:单设备登出 = 吊销当前会话
await fetch(`${BASE_API_URL}/api/v1/auth/sessions/current`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
})
} catch {
/* 忽略网络错误,仍清本地态 */
}
}
clearSession()
}
// ===================== 浏览历史:单篇详情回查(账户页 My History 渲染用) ===================== // ===================== 浏览历史:单篇详情回查(账户页 My History 渲染用) =====================
// 历史只存文章对外编码串,展示时逐篇回查公开详情接口取 title/cover/brand。 // 历史只存文章对外编码串,展示时逐篇回查公开详情接口取 title/cover/brand。
// 编码串首字符即类型:s=… 实为 s 开头(如 s000zAwwQ)代表街拍,其余(r 开头等)为走秀。 // 编码串首字符即类型:s=… 实为 s 开头(如 s000zAwwQ)代表街拍,其余(r 开头等)为走秀。
@ -617,6 +379,7 @@ export interface RawStreetSnap {
title: string title: string
cover: string cover: string
images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用 images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用
favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
} }
// 接口:GET /api/public/street-snaps // 接口:GET /api/public/street-snaps
@ -707,6 +470,3 @@ export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapIte
images: [], images: [],
})) }))
} }

185
src/lib/auth.ts Normal file
View File

@ -0,0 +1,185 @@
// src/lib/auth.ts — 账号体系与鉴权(登录 / 会话 / 令牌 / 受保护请求)
//
// 双令牌:access_token(短命,请求鉴权)+ refresh_token(长命,落库可吊销)。
// 登录写两者;authJson 等受保护请求遇 401 自动用 refresh 续期一次;登出吊销 refresh。
//
// 本文件只管「我是谁 / 怎么登录 / 怎么带令牌发请求」,不含任何业务数据取数
// (业务数据见 api.ts,SSG 数据见 ssg.ts)。请求语言透传见 locale.ts。
import { BASE_API } from "astro:env/client"
import { currentLocale } from "./locale"
const BASE_API_URL = (BASE_API || "").replace(/\/$/, "")
const TOKEN_KEY = "fa_token" // access token
const REFRESH_KEY = "fa_refresh" // refresh token
const USER_KEY = "fa_user"
export interface AuthUser {
id: number
username: string
email: string
/** 用户等级:free(普通)| vip(高级,图集详情页可见高清原图)。 */
tier: "free" | "vip"
}
// 注意:getUser / clearSession 只读写 localStorage,不发网络请求。
export function getUser(): AuthUser | null {
if (typeof localStorage === "undefined") return null
try {
const raw = localStorage.getItem(USER_KEY)
return raw ? (JSON.parse(raw) as AuthUser) : null
} catch {
return null
}
}
export function getAccessToken(): string | null {
if (typeof localStorage === "undefined") return null
return localStorage.getItem(TOKEN_KEY)
}
export function getRefreshToken(): string | null {
if (typeof localStorage === "undefined") return null
return localStorage.getItem(REFRESH_KEY)
}
export function clearSession(): void {
if (typeof localStorage === "undefined") return
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(USER_KEY)
}
// 写登录态:登录成功后把 access + refresh + user 写入 localStorage。
export function saveSession(access: string, refresh: string, user: AuthUser): void {
if (typeof localStorage === "undefined") return
localStorage.setItem(TOKEN_KEY, access)
localStorage.setItem(REFRESH_KEY, refresh)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
// 刷新 token:用 refresh_token 换新的 access_token(refresh 复用、不轮换)。
export async function refreshSession(): Promise<string> {
const refresh = getRefreshToken()
if (!refresh) throw new Error("no refresh token")
const res = await fetch(`${BASE_API_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
})
if (!res.ok) {
clearSession()
throw new Error("refresh failed")
}
const json: any = await res.json()
const access: string | undefined = json?.access_token
if (!access) throw new Error("refresh failed")
// refresh 复用:仅更新 access,保留原 refresh。
const user = getUser()
if (user) saveSession(access, refresh, user)
return access
}
// 带鉴权的请求:自动附带 Bearer,遇 401 静默刷新一次后重试。
async function authedFetch(url: string, opts: RequestInit = {}): Promise<Response> {
const headers = new Headers(opts.headers)
const token = getAccessToken()
if (token) headers.set("Authorization", `Bearer ${token}`)
let res = await fetch(url, { ...opts, headers })
if (res.status === 401 && getRefreshToken()) {
try {
await refreshSession()
const newToken = getAccessToken()
if (newToken) headers.set("Authorization", `Bearer ${newToken}`)
res = await fetch(url, { ...opts, headers })
} catch {
/* 刷新失败:返回原始 401,由调用方处理 */
}
}
return res
}
// 带鉴权的 JSON 请求:自动附 Bearer、遇 401 静默刷新一次后重试,
// 成功返回响应体中的 data 字段;非 2xx 抛出(含后端 message)。
// 收藏/个人中心等需要登录的写操作统一走这里。
export async function authJson<T = any>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${BASE_API_URL}/api/v1${path}`
const headers: Record<string, string> = { "Content-Type": "application/json" }
const res = await authedFetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let msg = `API ${res.status} ${method} ${path}`
try {
const j = await res.json()
if (j?.message) msg = String(j.message)
} catch {
/* 响应体非 JSON,沿用默认错误信息 */
}
throw new Error(msg)
}
const json: any = await res.json()
return (json?.data ?? null) as T
}
// 取当前登录用户:GET /api/v1/me。无 token 或失败回落 null(调用方容忍)。
export async function fetchMe(): Promise<AuthUser | null> {
if (typeof localStorage === "undefined") return null
if (!getAccessToken()) return null
try {
const res = await authedFetch(`${BASE_API_URL}/api/v1/me?locale=${encodeURIComponent(currentLocale())}`)
if (!res.ok) {
clearSession()
return null
}
const json: any = await res.json()
const user = json?.data?.user ?? json?.user
return (user as AuthUser) ?? null
} catch {
return null
}
}
// 登录:POST /api/v1/auth/login(公开端点)。成功后写 access+refresh+user 进 localStorage。
export async function login(input: { account: string; password: string }): Promise<AuthUser> {
const res = await fetch(`${BASE_API_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
if (!res.ok) {
clearSession()
throw new Error("login failed")
}
const json: any = await res.json()
const access: string | undefined = json?.access_token
const refresh: string | undefined = json?.refresh_token
const user: any = json?.user
if (!access || !refresh || !user) throw new Error("login failed")
saveSession(access, refresh, user)
// 登录成功广播:列表页在未登录时进页没记图集,登录后由监听补记(与「进页面就记」一致)。
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:login"))
}
return user as AuthUser
}
// 登出:吊销当前 refresh_token,再清本地态。
export async function logout(): Promise<void> {
const refresh = getRefreshToken()
if (refresh) {
try {
// 会话即 refresh token:单设备登出 = 吊销当前会话
await fetch(`${BASE_API_URL}/api/v1/auth/sessions/current`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
})
} catch {
/* 忽略网络错误,仍清本地态 */
}
}
clearSession()
}

36
src/lib/crypto.ts Normal file
View File

@ -0,0 +1,36 @@
// src/lib/crypto.ts — 前端 JS 请求签名(反爬一层,非加密)
//
// 仅做 HMAC-SHA256 计算,由 api.ts 的公开请求原语附带 X-Sign / X-Sign-Ts / X-Sign-Nonce 头,
// 供后端 middleware.ClientSign 校验「请求大概率来自真前端」。
//
// 安全说明:secret 必须出现在前端 bundle 才能签名(对浏览器可见),故只是「提高成本」,
// 不能作为唯一防线;真正有效需配合后端按 IP 限流。生产请改用随机长串并同步两端。
const CLIENT_SIGN_SECRET = "dev-client-sign-secret-change-me"
const CLIENT_SIGN_TTL = 30 // 秒,签名有效期(与后端 client_sign.ttl_seconds 一致)
// 计算 HMAC-SHA256 签名。
// canonical = METHOD + "\n" + Path + "\n" + RawQuery + "\n" + ts + "\n" + nonce
// 与后端 middleware.ClientSign 的拼接方式严格一致(换行符分隔,顺序固定)。
export async function clientSign(
method: string,
fullPath: string,
rawQuery: string,
): Promise<{ sig: string; ts: string; nonce: string }> {
const ts = String(Math.floor(Date.now() / 1000))
const nonce = Math.random().toString(36).slice(2) + Date.now().toString(36)
const msg = [method, fullPath, rawQuery, ts, nonce].join("\n")
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
"raw",
enc.encode(CLIENT_SIGN_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
)
const buf = await crypto.subtle.sign("HMAC", key, enc.encode(msg))
const sig = Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return { sig, ts, nonce }
}

View File

@ -19,7 +19,7 @@
// 对外函数:getFavorites / isFavorited / toggleFavorite / toggleImageFavorite / removeFavorite / // 对外函数:getFavorites / isFavorited / toggleFavorite / toggleImageFavorite / removeFavorite /
// fetchFavoritesPage / checkFavorited(列表与个人中心按需调用)。 // fetchFavoritesPage / checkFavorited(列表与个人中心按需调用)。
import { getUser, getAccessToken, authJson } from "./api" import { getUser, getAccessToken, authJson } from "./auth"
export type FavoriteType = "runway" | "street" | "runway_image" | "snap_image" export type FavoriteType = "runway" | "street" | "runway_image" | "snap_image"
export type FavoriteKind = "gallery" | "image" export type FavoriteKind = "gallery" | "image"

View File

@ -10,7 +10,8 @@
// (用户选「只存 id、展示时回查」),历史卡片点击跳回文章详情。 // (用户选「只存 id、展示时回查」),历史卡片点击跳回文章详情。
// - 未登录:纯本地 localStorage(按用户名隔离);登录态写本地 + 后台 POST(失败不回滚本地)。 // - 未登录:纯本地 localStorage(按用户名隔离);登录态写本地 + 后台 POST(失败不回滚本地)。
import { getUser, getAccessToken, authJson, getHistoryMeta } from "./api" import { getHistoryMeta } from "./api"
import { getUser, getAccessToken, authJson } from "./auth"
export interface HistoryData { export interface HistoryData {
title: string title: string

34
src/lib/locale.ts Normal file
View File

@ -0,0 +1,34 @@
// src/lib/locale.ts — 请求语言透传
//
// 把「当前语言」塞进所有 /api 请求的 ?locale= 参数,让后端按 locale 选 *_en / *_cn 列。
//
// 当前语言来源分两端:
// - 浏览器端(<script> 里的 fetch):从 URL path 推断(/cn/ → cn,其余 → en)。
// - 服务端(SSG 构建 / 按需渲染):由页面/布局在前置 frontmatter 调 setApiLocale() 注入,
// 因绝大多数页面是 SSG 单线程构建,模块级 _ssrLocale 安全。
import { getLocaleFromUrl } from "@/i18n/utils"
import { DEFAULT_LOCALE } from "@/i18n/config"
let _ssrLocale: string | null = null
// 服务端注入当前语言(SSG 构建 / 按需渲染前置调用)。
export function setApiLocale(locale: string | null | undefined): void {
_ssrLocale = locale || DEFAULT_LOCALE
}
// 解析当前语言:浏览器端走 URL path,服务端回落注入值或默认语言。
export function currentLocale(): string {
if (typeof window !== "undefined" && window.location) {
return getLocaleFromUrl(window.location.pathname)
}
return _ssrLocale || DEFAULT_LOCALE
}
// 把当前语言追加进 query(locale 非空才加,避免污染 URL;DEFAULT_LOCALE 也显式带,语义清晰)。
export function withLocale(params?: URLSearchParams, override?: string): URLSearchParams {
const p = params ? new URLSearchParams(params) : new URLSearchParams()
const loc = override || currentLocale()
if (loc) p.set("locale", loc)
return p
}

View File

@ -181,7 +181,8 @@ const browseLabel = t("browse the archive")
</style> </style>
<script> <script>
import { getUser, toAbs } from "@/lib/api" import { toAbs } from "@/lib/api"
import { getUser } from "@/lib/auth"
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites" import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history" import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"

View File

@ -73,8 +73,8 @@ const savedLabel = t("saved")
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内, {/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */} 导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
<script> <script>
import { getUser } from "@/lib/api" import { getUser } from "@/lib/auth"
import { isFavorited, toggleFavorite, checkFavorited, hydrateFavorited } from "@/lib/favorites" import { isFavorited, toggleFavorite, hydrateFavorited } from "@/lib/favorites"
import { recordHistory } from "@/lib/history" import { recordHistory } from "@/lib/history"
const btn = document.getElementById("fav-fab") const btn = document.getElementById("fav-fab")
@ -101,15 +101,17 @@ const savedLabel = t("saved")
} }
sync() sync()
// 登录态:异步问服务端当前篇是否已收藏并写回本地(换浏览器/设备后,爱心状态与服务端一致) // 登录态收藏校正已合并进详情接口:articleView / streetSnapView 取到服务端收藏态后,
if (getUser()) { // 通过 fav:set 事件广播当前图集是否已收藏;此处监听并写回本地(换设备后与服务端一致)。
checkFavorited([meta.id]) // 不再单独请求 /me/favorites/checks。
.then((set) => { window.addEventListener("fav:set", (e: Event) => {
if (set.has(meta.id)) hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand) const detail = (e as CustomEvent<{ id: string; fav: boolean }>).detail
if (!detail || detail.id !== meta.id) return
if (detail.fav) {
hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
}
sync() sync()
}) })
.catch(() => {})
}
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
toggleFavorite({ toggleFavorite({

View File

@ -124,7 +124,7 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
</main> </main>
<script> <script>
import { login, getUser } from "@/lib/api" import { login, getUser } from "@/lib/auth"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {

View File

@ -182,7 +182,8 @@ const browseLabel = t("browse the archive")
</style> </style>
<script> <script>
import { getUser, toAbs } from "@/lib/api" import { toAbs } from "@/lib/api"
import { getUser } from "@/lib/auth"
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites" import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history" import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"

View File

@ -73,8 +73,8 @@ const savedLabel = t("saved")
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内, {/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */} 导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
<script> <script>
import { getUser } from "@/lib/api" import { getUser } from "@/lib/auth"
import { isFavorited, toggleFavorite, checkFavorited, hydrateFavorited } from "@/lib/favorites" import { isFavorited, toggleFavorite, hydrateFavorited } from "@/lib/favorites"
import { recordHistory } from "@/lib/history" import { recordHistory } from "@/lib/history"
const btn = document.getElementById("fav-fab") const btn = document.getElementById("fav-fab")
@ -101,15 +101,17 @@ const savedLabel = t("saved")
} }
sync() sync()
// 登录态:异步问服务端当前篇是否已收藏并写回本地(换浏览器/设备后,爱心状态与服务端一致) // 登录态收藏校正已合并进详情接口:articleView / streetSnapView 取到服务端收藏态后,
if (getUser()) { // 通过 fav:set 事件广播当前图集是否已收藏;此处监听并写回本地(换设备后与服务端一致)。
checkFavorited([meta.id]) // 不再单独请求 /me/favorites/checks。
.then((set) => { window.addEventListener("fav:set", (e: Event) => {
if (set.has(meta.id)) hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand) const detail = (e as CustomEvent<{ id: string; fav: boolean }>).detail
if (!detail || detail.id !== meta.id) return
if (detail.fav) {
hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
}
sync() sync()
}) })
.catch(() => {})
}
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
toggleFavorite({ toggleFavorite({

View File

@ -124,7 +124,7 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
</main> </main>
<script> <script>
import { login, getUser } from "@/lib/api" import { login, getUser } from "@/lib/auth"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {