update
This commit is contained in:
57
.workbuddy/memory/2026-09-13.md
Normal file
57
.workbuddy/memory/2026-09-13.md
Normal 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 详情接口」,匿名公开接口与列表接口绝不合并(保缓存/控成本)。
|
||||||
@ -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 条。
|
||||||
|
|||||||
BIN
_tmp_build.log
BIN
_tmp_build.log
Binary file not shown.
@ -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 并开层
|
||||||
|
|||||||
@ -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,393 +41,582 @@ 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">
|
<!-- 二级分类标签页 -->
|
||||||
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
|
<div id="tabbar" class="mt-16 md:mt-24">
|
||||||
<li
|
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
|
||||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
<li
|
||||||
:class="{ 'is-active': activeTab === 'backstage' }"
|
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||||
@click="activeTab = 'backstage'"
|
:class="{ 'is-active': activeTab === 'backstage' }"
|
||||||
>
|
@click="activeTab = 'backstage'"
|
||||||
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> -->
|
Runway
|
||||||
</li>
|
</li>
|
||||||
<li
|
|
||||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
<li
|
||||||
:class="{ 'is-active': activeTab === 'street' }"
|
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||||
@click="activeTab = 'street'"
|
:class="{ 'is-active': activeTab === 'street' }"
|
||||||
>
|
@click="activeTab = 'street'"
|
||||||
Street Style
|
>
|
||||||
</li>
|
Street Style
|
||||||
<li
|
</li>
|
||||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
|
||||||
:class="{ 'is-active': activeTab === 'events' }"
|
<li
|
||||||
@click="activeTab = 'events'"
|
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||||
>
|
:class="{ 'is-active': activeTab === 'events' }"
|
||||||
Social Media
|
@click="activeTab = 'events'"
|
||||||
</li>
|
>
|
||||||
</ul>
|
Social Media
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中间内容展示区 -->
|
||||||
|
<main class="grow min-h-[400px] mt-10">
|
||||||
|
|
||||||
|
<!-- 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 && (
|
||||||
|
<section class="hs-section">
|
||||||
|
<div class="hs-stage">
|
||||||
|
<div class="swiper hs-swiper" id="hsSwiper">
|
||||||
|
<div class="swiper-wrapper">
|
||||||
|
{galleryItems.map((g, i) => (
|
||||||
|
<div class="swiper-slide hs-slide">
|
||||||
|
<a class="hs-item" href={href(locale, ROUTES.item(g.articleId))} data-idx={i}>
|
||||||
|
<div class="hs-img-box" x-data="imgLoader()" :class="{ 'is-loaded': loaded || error }">
|
||||||
|
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
|
||||||
|
|
||||||
|
<span class="hs-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="hs-ring" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={g.src}
|
||||||
|
alt={g.brand}
|
||||||
|
loading={i < 4 ? "eager" : "lazy"}
|
||||||
|
decoding="async"
|
||||||
|
:class="{ 'is-error': error }"
|
||||||
|
@load="handleLoad()"
|
||||||
|
@error="handleError()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="hs-caption font-sans">
|
||||||
|
<span class="hs-cap-brand">{g.brand}</span>
|
||||||
|
<span class="hs-cap-meta">{g.title}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 中间内容展示区 -->
|
<!-- Social Media -->
|
||||||
<main class="grow min-h-[400px] mt-10">
|
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
|
||||||
<!-- 发布会 面板(默认激活) -->
|
<ComingSoon
|
||||||
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
|
module="社媒"
|
||||||
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
|
moduleZh="Social"
|
||||||
|
note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 横向滚动画廊 -->
|
<!-- Street Style -->
|
||||||
{
|
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
|
||||||
galleryItems.length > 0 && (
|
{
|
||||||
<section class="hs-section">
|
streetItems.length > 0 ? (
|
||||||
<div class="hs-stage">
|
<section class="ss-section">
|
||||||
<div class="swiper hs-swiper" id="hsSwiper">
|
<div class="ss-grid">
|
||||||
<div class="swiper-wrapper">
|
{streetItems.map((g) => (
|
||||||
{
|
<a class="ss-card" href={href(locale, ROUTES.streetSnap(g.id))}>
|
||||||
galleryItems.map((g, i) => (
|
<div class="ss-img-box" x-data="imgLoader()" :class="{ 'is-loaded': loaded || error }">
|
||||||
<div class="swiper-slide hs-slide">
|
<div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div>
|
||||||
<a class="hs-item" href={href(locale, ROUTES.item(g.articleId))} data-idx={i}>
|
|
||||||
<!-- Alpine 管理单张图片的加载/失败状态 -->
|
|
||||||
<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>
|
|
||||||
<span class="hs-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="hs-ring" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<img
|
|
||||||
src={g.src}
|
|
||||||
alt={g.brand}
|
|
||||||
loading={i < 4 ? "eager" : "lazy"}
|
|
||||||
decoding="async"
|
|
||||||
:class="{ 'is-error': error }"
|
|
||||||
@load="loaded = true; $dispatch('img-ready')"
|
|
||||||
@error="error = true; $dispatch('img-ready')"
|
|
||||||
/>
|
|
||||||
<span class="hs-caption font-sans">
|
|
||||||
<span class="hs-cap-brand">{g.brand}</span>
|
|
||||||
<span class="hs-cap-meta">{g.title}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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 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>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 社媒 面板 -->
|
<span class="ss-spin" aria-hidden="true" x-show="!loaded && !error">
|
||||||
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
|
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||||
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="ss-ring" />
|
||||||
</div>
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
<!-- 街拍 面板(构建期 SSG 拉取热门街拍封面;去掉 swiper,改为 HBX 大图网格) -->
|
<img
|
||||||
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
|
src={g.src}
|
||||||
{
|
alt={g.loc}
|
||||||
streetItems.length > 0 ? (
|
loading="lazy"
|
||||||
<section class="ss-section">
|
decoding="async"
|
||||||
<!-- 街拍大图卡片网格 -->
|
:class="{ 'is-error': error }"
|
||||||
<div class="ss-grid">
|
@load="handleLoad()"
|
||||||
{
|
@error="handleError()"
|
||||||
streetItems.map((g) => (
|
/>
|
||||||
<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 }">
|
<span class="ss-cap">
|
||||||
<div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div>
|
<span class="ss-cap-loc">{g.loc}</span>
|
||||||
<span class="ss-spin" aria-hidden="true" x-show="!loaded && !error">
|
<span class="ss-cap-event">{g.event}</span>
|
||||||
<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')" />
|
|
||||||
<span class="ss-cap">
|
|
||||||
<span class="ss-cap-loc">{g.loc}</span>
|
|
||||||
<span class="ss-cap-event">{g.event}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</a>
|
||||||
) : (
|
))}
|
||||||
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
</div>
|
||||||
)
|
</section>
|
||||||
}
|
) : (
|
||||||
</div>
|
<ComingSoon
|
||||||
</main>
|
module="街拍"
|
||||||
|
moduleZh="Street"
|
||||||
|
note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</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 {
|
||||||
.tab::after {
|
@apply text-[#666];
|
||||||
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;
|
.tab::after {
|
||||||
}
|
content: "";
|
||||||
.tab.is-active { @apply text-black; }
|
@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::after { @apply scale-x-100; }
|
}
|
||||||
|
.tab.is-active {
|
||||||
|
@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-stage {
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
.hs-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 6;
|
||||||
|
padding: 0 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
mix-blend-mode: difference;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.hs-controls .hs-arrow {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.hs-arrow {
|
||||||
|
@apply select-none;
|
||||||
|
}
|
||||||
|
.hs-arrow.swiper-button-disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hs-swiper {
|
||||||
|
width: 100%;
|
||||||
|
height: clamp(500px, 80vh, 900px);
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.hs-swiper .swiper-wrapper {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.hs-swiper .hs-slide {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.hs-item {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
width: max-content;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.hs-img-box {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: clamp(500px, 80vh, 900px);
|
||||||
|
background: #f0f0f0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hs-img-box > img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
.hs-img-box.is-loaded > img {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.hs-img-box > img.is-error {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* 横向画廊样式 */
|
/* 骨架屏 */
|
||||||
.hs-section { @apply mb-9; }
|
.hs-skel {
|
||||||
.hs-stage { @apply relative; }
|
position: absolute;
|
||||||
.hs-controls {
|
inset: 0;
|
||||||
position: absolute;
|
z-index: 0;
|
||||||
top: 50%;
|
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
|
||||||
left: 0;
|
background-size: 200% 100%;
|
||||||
right: 0;
|
animation: hs-shimmer 1.3s ease-in-out infinite;
|
||||||
transform: translateY(-50%);
|
transition: opacity 0.35s ease;
|
||||||
z-index: 6;
|
}
|
||||||
padding: 0 8px;
|
.hs-img-box.is-loaded .hs-skel {
|
||||||
pointer-events: none;
|
opacity: 0;
|
||||||
mix-blend-mode: difference;
|
}
|
||||||
color: #fff;
|
.hs-spin {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #bdbdbd;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.hs-img-box.is-loaded .hs-spin {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.hs-spin svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.hs-ring {
|
||||||
|
transform-origin: 12px 12px;
|
||||||
|
animation: hs-rotate 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes hs-rotate {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
.hs-controls .hs-arrow { pointer-events: auto; }
|
}
|
||||||
.hs-arrow { @apply select-none; }
|
@keyframes hs-shimmer {
|
||||||
.hs-arrow.swiper-button-disabled { pointer-events: none; }
|
from {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Swiper 尺寸规整 */
|
/* Hot Brand 文案 */
|
||||||
.hs-swiper {
|
.hs-caption {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
height: clamp(500px, 80vh, 900px);
|
bottom: 0;
|
||||||
padding: 0 4px;
|
left: 0;
|
||||||
}
|
right: 0;
|
||||||
.hs-swiper .swiper-wrapper { align-items: center; }
|
padding: 48px 24px 22px;
|
||||||
.hs-swiper .hs-slide {
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.78) 0%, rgba(0, 0, 0, 0.28) 55%, transparent 100%);
|
||||||
width: auto;
|
color: white;
|
||||||
height: auto;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
.hs-item {
|
justify-content: flex-end;
|
||||||
display: block;
|
gap: 6px;
|
||||||
height: auto;
|
opacity: 1;
|
||||||
width: max-content;
|
z-index: 3;
|
||||||
text-decoration: none;
|
pointer-events: none;
|
||||||
color: inherit;
|
}
|
||||||
}
|
.hs-cap-brand {
|
||||||
.hs-img-box {
|
@apply font-sans;
|
||||||
position: relative;
|
font-size: 21px;
|
||||||
display: block;
|
font-weight: 700;
|
||||||
height: clamp(500px, 80vh, 900px);
|
text-transform: uppercase;
|
||||||
/* aspect-ratio: 3 / 4; */
|
letter-spacing: 0.02em;
|
||||||
background: #f0f0f0;
|
line-height: 1.1;
|
||||||
overflow: hidden;
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
.hs-img-box > img {
|
.hs-cap-meta {
|
||||||
width: 100%;
|
@apply font-sans;
|
||||||
height: 100%;
|
font-size: 13px;
|
||||||
display: block;
|
font-weight: 500;
|
||||||
position: relative;
|
opacity: 0.9;
|
||||||
z-index: 1;
|
letter-spacing: 0.04em;
|
||||||
opacity: 0;
|
line-height: 1.3;
|
||||||
transition: opacity 0.4s ease;
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
}
|
max-width: 90%;
|
||||||
.hs-img-box.is-loaded > img { opacity: 1; }
|
white-space: nowrap;
|
||||||
.hs-img-box > img.is-error { opacity: 0 !important; }
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
/* 加载骨架屏与旋转 Spinner */
|
}
|
||||||
.hs-skel {
|
@media (min-width: 768px) {
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 0;
|
|
||||||
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: hs-shimmer 1.3s ease-in-out infinite;
|
|
||||||
transition: opacity 0.35s ease;
|
|
||||||
}
|
|
||||||
.hs-img-box.is-loaded .hs-skel { opacity: 0; }
|
|
||||||
|
|
||||||
.hs-spin {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #bdbdbd;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
.hs-img-box.is-loaded .hs-spin { opacity: 0; }
|
|
||||||
.hs-spin svg { 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 {
|
|
||||||
from { background-position: 200% 0; }
|
|
||||||
to { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 底部品牌/标题信息——左下角大标题+小副标题,始终可见 */
|
|
||||||
.hs-caption {
|
.hs-caption {
|
||||||
position: absolute;
|
padding: 60px 28px 26px;
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 48px 24px 22px;
|
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.78) 0%, rgba(0, 0, 0, 0.28) 55%, transparent 100%);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
opacity: 1;
|
|
||||||
z-index: 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
.hs-cap-brand {
|
.hs-cap-brand {
|
||||||
@apply font-sans;
|
font-size: 26px;
|
||||||
font-size: 21px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
line-height: 1.1;
|
|
||||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
|
|
||||||
}
|
}
|
||||||
.hs-cap-meta {
|
.hs-cap-meta {
|
||||||
@apply font-sans;
|
font-size: 15px;
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.9;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
line-height: 1.3;
|
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
|
||||||
max-width: 90%;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hs-swiper,
|
||||||
|
.hs-img-box {
|
||||||
|
height: clamp(340px, 56vh, 540px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
/* ========================= Street Style ========================= */
|
||||||
.hs-caption { padding: 60px 28px 26px; }
|
.ss-section {
|
||||||
.hs-cap-brand { font-size: 26px; }
|
@apply mb-9;
|
||||||
.hs-cap-meta { font-size: 15px; }
|
}
|
||||||
}
|
.ss-grid {
|
||||||
|
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[14px] md:gap-[18px];
|
||||||
|
}
|
||||||
|
.ss-card {
|
||||||
|
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
}
|
||||||
|
.ss-img-box {
|
||||||
|
@apply absolute inset-0 w-full h-full overflow-hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
.ss-img-box > img {
|
||||||
|
@apply absolute inset-0 w-full h-full object-cover;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.45s ease, transform 0.7s ease;
|
||||||
|
}
|
||||||
|
.ss-img-box.is-loaded > img {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.ss-img-box > img.is-error {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
.ss-card:hover .ss-img-box > img {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
/* Street Style 骨架 */
|
||||||
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
|
.ss-skel {
|
||||||
}
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: hs-shimmer 1.3s ease-in-out infinite;
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
|
}
|
||||||
|
.ss-img-box.is-loaded .ss-skel {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.ss-spin {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #bdbdbd;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.ss-img-box.is-loaded .ss-spin {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.ss-spin svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.ss-ring {
|
||||||
|
transform-origin: 12px 12px;
|
||||||
|
animation: hs-rotate 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== 首页 Street Style 网格(去掉 swiper,改为 HBX 大图卡片,与街拍列表页一致) ===== */
|
/* Street Style 文案 */
|
||||||
.ss-section { @apply mb-9; }
|
.ss-cap {
|
||||||
.ss-grid {
|
position: absolute;
|
||||||
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[14px] md:gap-[18px];
|
bottom: 0;
|
||||||
}
|
left: 0;
|
||||||
.ss-card {
|
right: 0;
|
||||||
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit;
|
padding: 28px 20px 18px;
|
||||||
aspect-ratio: 3 / 4;
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.72) 0%, rgba(0, 0, 0, 0.34) 55%, transparent 100%);
|
||||||
}
|
color: white;
|
||||||
.ss-img-box {
|
display: flex;
|
||||||
@apply absolute inset-0 w-full h-full overflow-hidden;
|
flex-direction: column;
|
||||||
background: #f0f0f0;
|
align-items: flex-start;
|
||||||
}
|
justify-content: flex-end;
|
||||||
.ss-img-box > img {
|
gap: 5px;
|
||||||
@apply absolute inset-0 w-full h-full object-cover;
|
z-index: 3;
|
||||||
opacity: 0;
|
pointer-events: none;
|
||||||
transition: opacity 0.45s ease, transform 0.7s ease;
|
}
|
||||||
}
|
.ss-cap-loc {
|
||||||
.ss-img-box.is-loaded > img { opacity: 1; }
|
font-size: clamp(18px, 2vw, 26px);
|
||||||
.ss-img-box > img.is-error { opacity: 0 !important; }
|
font-weight: 700;
|
||||||
.ss-card:hover .ss-img-box > img { transform: scale(1.04); }
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
/* 骨架屏 + spinner(与首页 hs-* 平行) */
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
|
||||||
.ss-skel {
|
}
|
||||||
position: absolute; inset: 0; z-index: 0;
|
.ss-cap-event {
|
||||||
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
|
font-size: clamp(10px, 0.8vw, 12px);
|
||||||
background-size: 200% 100%;
|
font-weight: 500;
|
||||||
animation: hs-shimmer 1.3s ease-in-out infinite;
|
text-transform: uppercase;
|
||||||
transition: opacity 0.35s ease;
|
letter-spacing: 0.16em;
|
||||||
}
|
line-height: 1.4;
|
||||||
.ss-img-box.is-loaded .ss-skel { opacity: 0; }
|
opacity: 0.85;
|
||||||
.ss-spin {
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
position: absolute; inset: 0; z-index: 2;
|
}
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
color: #bdbdbd; pointer-events: none;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
.ss-img-box.is-loaded .ss-spin { opacity: 0; }
|
|
||||||
.ss-spin svg { display: block; }
|
|
||||||
.ss-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
|
|
||||||
|
|
||||||
/* 底部叠层白字(loc / event) */
|
|
||||||
.ss-cap {
|
|
||||||
position: absolute; bottom: 0; left: 0; right: 0;
|
|
||||||
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%);
|
|
||||||
color: white; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-end;
|
|
||||||
gap: 5px; z-index: 3; pointer-events: none;
|
|
||||||
}
|
|
||||||
.ss-cap-loc {
|
|
||||||
font-size: clamp(18px, 2vw, 26px);
|
|
||||||
font-weight: 700; line-height: 1.1; letter-spacing: -0.01em;
|
|
||||||
text-shadow: 0 1px 4px rgba(0,0,0,0.55);
|
|
||||||
}
|
|
||||||
.ss-cap-event {
|
|
||||||
font-size: clamp(10px, 0.8vw, 12px);
|
|
||||||
font-weight: 500; 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");
|
|
||||||
|
|
||||||
const swiper = new Swiper(el, {
|
// RAF 合并:同一帧内多张图片加载完成 / 多个 resize,只执行一次 swiper.update()
|
||||||
modules: [Navigation, FreeMode, Keyboard],
|
function scheduleSwiperUpdate() {
|
||||||
slidesPerView: "auto",
|
if (updateScheduled) return
|
||||||
spaceBetween: 4,
|
updateScheduled = true
|
||||||
grabCursor: true,
|
requestAnimationFrame(() => {
|
||||||
speed: 220,
|
updateScheduled = false
|
||||||
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 },
|
for (const swiper of swipers) {
|
||||||
keyboard: { enabled: true },
|
if (!swiper.destroyed) swiper.update()
|
||||||
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
|
}
|
||||||
observer: true,
|
})
|
||||||
observeParents: true,
|
}
|
||||||
observeSlideChildren: true,
|
|
||||||
resizeObserver: true,
|
|
||||||
});
|
|
||||||
swipers.push(swiper);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Street Style 等面板默认 x-show 隐藏,Swiper 在 display:none 下初始化会把 slide 算成 0 宽,
|
// 销毁已有实例:防止重复初始化 / View Transitions 重新进入时旧实例泄漏
|
||||||
// 切到对应 tab 后必须 update() 一次重算尺寸,否则图片虽在但宽度 0 不可见。
|
function destroySwipers() {
|
||||||
const updateAllSwipers = () =>
|
for (const swiper of swipers) {
|
||||||
requestAnimationFrame(() => swipers.forEach((s) => s.update()));
|
if (!swiper.destroyed) swiper.destroy(true, true)
|
||||||
document.querySelectorAll<HTMLElement>(".tab").forEach((tab) => {
|
}
|
||||||
tab.addEventListener("click", () => setTimeout(updateAllSwipers, 60));
|
swipers = []
|
||||||
});
|
}
|
||||||
window.addEventListener("resize", updateAllSwipers);
|
|
||||||
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
|
function initSwipers() {
|
||||||
window.addEventListener("img-ready", updateAllSwipers);
|
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, {
|
||||||
|
modules: [Navigation, FreeMode, Keyboard],
|
||||||
|
slidesPerView: "auto",
|
||||||
|
spaceBetween: 4,
|
||||||
|
grabCursor: true,
|
||||||
|
speed: 220,
|
||||||
|
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 },
|
||||||
|
keyboard: { enabled: true },
|
||||||
|
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
|
||||||
|
observer: true,
|
||||||
|
observeParents: true,
|
||||||
|
observeSlideChildren: true,
|
||||||
|
resizeObserver: true,
|
||||||
|
})
|
||||||
|
swipers.push(swiper)
|
||||||
|
})
|
||||||
|
scheduleSwiperUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一入口:延后一帧,避免首次进入时 DOM / Alpine / Swiper 初始化时序互相抢
|
||||||
|
function setupSwipers() {
|
||||||
|
requestAnimationFrame(() => initSwipers())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次普通加载:等 DOMContentLoaded
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", setupSwipers, { once: true })
|
||||||
|
} else {
|
||||||
|
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>
|
||||||
@ -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,39 +650,53 @@ 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) => ({
|
|
||||||
id: im.id || "",
|
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
|
||||||
url: toAbs(im.image || ""),
|
this.galleryFav = !!full.favorited
|
||||||
thumb: toAbs(im.thumb || im.image || ""),
|
window.dispatchEvent(
|
||||||
raw: im.image || "",
|
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
|
||||||
name: im.name || "",
|
)
|
||||||
lookIndex: Number(im.look_index ?? 0),
|
|
||||||
}))
|
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
|
||||||
this.extraImages = mapped
|
const favIds: string[] = []
|
||||||
// 后端详情接口对所有用户统一返回 high 高清样式(付费墙已取消),首屏图即为高清,
|
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
|
||||||
// 无需客户端再按 VIP 替换。仅把第 6 张起的图片追加进 extraImages 渲染。
|
|
||||||
await this.$nextTick()
|
// 超预览上限才追加第 6 张起图片(前 5 张已由 SSR 渲染,仅补收藏态)
|
||||||
this.imageCount = document.querySelectorAll(
|
if (this.preview) {
|
||||||
"#article-gallery [data-image-index]"
|
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
|
||||||
).length
|
id: im.id || "",
|
||||||
this.initImageFavs()
|
url: toAbs(im.image || ""),
|
||||||
this.initThumbLazyLoading()
|
thumb: toAbs(im.thumb || im.image || ""),
|
||||||
this.maybeLocateImageFromUrl()
|
raw: im.image || "",
|
||||||
|
name: im.name || "",
|
||||||
|
lookIndex: Number(im.look_index ?? 0),
|
||||||
|
}))
|
||||||
|
await this.$nextTick()
|
||||||
|
this.imageCount = document.querySelectorAll(
|
||||||
|
"#article-gallery [data-image-index]"
|
||||||
|
).length
|
||||||
|
;(full.images || []).slice(5).forEach(mark)
|
||||||
|
this.initThumbLazyLoading()
|
||||||
|
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) {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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) => ({
|
|
||||||
id: im.id || "",
|
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
|
||||||
url: toAbs(im.image || ""),
|
this.galleryFav = !!full.favorited
|
||||||
raw: im.image || "",
|
window.dispatchEvent(
|
||||||
name: im.name || "",
|
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
|
||||||
}))
|
)
|
||||||
this.extraImages = mapped
|
|
||||||
// 后端详情接口对所有用户统一返回 high 高清样式(付费墙已取消),首屏图即为高清,
|
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
|
||||||
// 无需客户端再按 VIP 替换。仅把第 6 张起的图片追加进 extraImages 渲染。
|
const favIds: string[] = []
|
||||||
await this.$nextTick()
|
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
|
||||||
this.imageCount = document.querySelectorAll(
|
|
||||||
"#snap-gallery [data-image-index]"
|
if (this.preview) {
|
||||||
).length
|
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
|
||||||
this.initImageFavs()
|
id: im.id || "",
|
||||||
this.initThumbLazyLoading()
|
url: toAbs(im.image || ""),
|
||||||
this.maybeLocateImageFromUrl()
|
raw: im.image || "",
|
||||||
|
name: im.name || "",
|
||||||
|
}))
|
||||||
|
await this.$nextTick()
|
||||||
|
this.imageCount = document.querySelectorAll(
|
||||||
|
"#snap-gallery [data-image-index]"
|
||||||
|
).length
|
||||||
|
;(full.images || []).slice(5).forEach(mark)
|
||||||
|
this.initThumbLazyLoading()
|
||||||
|
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) {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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: '地区' },
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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", () => ({
|
||||||
|
|||||||
270
src/lib/api.ts
270
src/lib/api.ts
@ -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
185
src/lib/auth.ts
Normal 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
36
src/lib/crypto.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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
34
src/lib/locale.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
sync()
|
if (!detail || detail.id !== meta.id) return
|
||||||
})
|
if (detail.fav) {
|
||||||
.catch(() => {})
|
hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
|
||||||
}
|
}
|
||||||
|
sync()
|
||||||
|
})
|
||||||
|
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
toggleFavorite({
|
toggleFavorite({
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
sync()
|
if (!detail || detail.id !== meta.id) return
|
||||||
})
|
if (detail.fav) {
|
||||||
.catch(() => {})
|
hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
|
||||||
}
|
}
|
||||||
|
sync()
|
||||||
|
})
|
||||||
|
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
toggleFavorite({
|
toggleFavorite({
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user