update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-09-03 19:45:36 +08:00
parent 6902376bc2
commit 36f3ad039f
14 changed files with 672 additions and 124 deletions

View File

@ -0,0 +1,71 @@
# 2026-09-03 工作日志
## Header 下拉 FOUC + 加载屏接入(12:0x)✅
用户反馈 header 下拉在 CSS 未加载时很丑(裸奔成大块列表),并给了一份 loading 动画页(Raleway 彩虹 text-shadow + Lorem ipsum 文案)要求接入。
- **根因**:Layout 里 Runway/Street/语言/账号 下拉与未登录 Login 链接均用 `x-show` 但**没加 `x-cloak`**,Alpine 接管前 `x-show` 未生效 → 菜单裸奔。global.css 已有 `[x-cloak]{display:none!important}`,补 `x-cloak` 即解决。
- **改动**:
- `src/layouts/Layout.astro`:head 引入 Raleway(`<link>` preconnect+stylesheet);body 顶部插入 `#loading-screen` 动画屏(用户给的 HTML/CSS 还原,h1 文案保留 Lorem ipsum);所有 `x-show` 下拉补 `x-cloak`(含 login.astro 语言 ul);footer 后加 `is:inline` 淡出脚本(`window.load` 后淡出,最少 500ms / 最多 3s 兜底)。
- `src/styles/global.css`:追加**作用域隔离**的 loading 样式(全部在 `#loading-screen` 内;`@keyframes` 改名 `ls-text-shadow`;`prefers-reduced-motion` 仅限加载屏;避免 `#fdf9fd` 背景/`Raleway`/`a` 颜色污染整站黑白调性)。
- **验证**:lint 0;`GET /en` 200,输出含 `id="loading-screen"`、`x-cloak`(×13)、淡出脚本、Raleway link 均到位。
- **提醒点**:loading 屏用彩色(与站点纯黑白调性冲突),是用户明确要求;若要统一风格可改 `#loading-screen` 背景为白/黑。h1 文案是占位 Lorem ipsum,用户可能想换成品牌名。
## 加载屏黑白化 + 文案替换(12:1x)✅
用户要求加载屏改成黑白(与站点纯黑白调性一致)并换文案。改动:
- Layout.astro 加载屏 h1 Lorem ipsum dolor sit amet. → Portfolio Studio(取自 siteTitle 品牌名),删掉 demo 残留(Hover over text to pause / Inspired by Tomas Brunsdon dribbble 链接),加小号副标 <p class=ls-sub>Loading</p>。
- global.css 加载屏配色:背景 #fdf9fd→#fff、文字 #011a32→#0a0a0a、链接 #024794→#0a0a0a;动画 keyframes 彩色 shadow(#0c2ffb/#2cfcfd/#fb203b/#fefc4b)改灰阶(#000/#3a3a3a/#777/#bbb),保留偏移运动;h1 字号 5em→clamp(2.5rem,8vw,5rem) 防移动端溢出;新增 .ls-sub 等宽大写灰字样式。
- 验证:GET /en 200,输出含 Portfolio Studio + ls-sub Loading,已无 Lorem/dribbble;CSS 黑白校验全通过(无 fdf9fd/0c2ffb/024794/011a32)。lint 0。
## 加载屏重构:is:inline → 独立组件 + 打包脚本(12:2x)✅
用户认为 is:inline 不合理,要求优化(Alpine 或组件化)。结论:抽 src/components/LoadingScreen.astro 组件,最合理——不需为小事引入 Alpine(且有 window.load 竞态),普通 Astro 打包 <script> 是 module/defer,在 load 前执行无竞态,比 is:inline 更工程化。改动:
- 新建 LoadingScreen.astro:标记(h1 Portfolio Studio + p.ls-sub Loading) + <style is:global>(原 global.css 加载屏 CSS 整段移入) + 打包 <script> 隐藏逻辑(原 is:inline IIFE 照搬,去掉 is:inline)。Raleway <link> 从 Layout head 移入组件(Astro 自动 hoist 到 head)。
- Layout.astro:引入组件、用 <LoadingScreen /> 替换内联标记、删除底部 is:inline 脚本、删 head Raleway 链。
- global.css:删除加载屏 CSS 块,仅留 @import tailwindcss + [x-cloak]。
- 验证:lint 0;GET /en 200,loading-screen 标记在、无 is:inline、旧 IIFE 已移除、Raleway 仍在。教训:Astro 中全局一次性 UI(loading/overlay)应封装为组件而非散在 Layout+global.css。
## 未登录预览门禁:列表仅显前5张+登录提示(14:2x)✅
需求:未登录用户只能看图集前5张图片,其余提示登录。实现:RunwayLooks/StreetSnaps 两列表页统一加未登录预览门禁。
- 新增 i18n 键 login to view all photos(cn: 登录查看全部图片)。
- 脚本加常量 PREVIEW_LIMIT=5;Alpine data 加 getter visibleItems(getUser() 返回全部,否则 slice(0,5))、showLoginGate(!getUser(); items.length>5)。
- 模板 x-for 源 items→visibleItems;卡片列表后插门禁块(x-show=showLoginGate,含 Preview 标题+loginGateText+登录按钮开 showLoginModal);分页器 x-show 加 getUser() 条件。
- 验证:lint 0;GET /en/runway-looks 与 /en/street-snaps 均 200,含 visibleItems x-for、showLoginGate div、x-text=loginGateText、pager x-show=getUser(); lastPage>1(HTML 转义为 &amp;&amp; &gt;)。登录后门禁消失、分页器出现、显示全部。
## 未登录预览门禁后端硬截断 + 重启踩坑(15:0x)✅
需求:前端门禁只是障眼法,接口一抓全露馅——后端也得改。改动:
- 后端新增 dto.PreviewLimit=5;response.PagePreview 支持 preview 标记;runway/street 列表 handler 未登录时强制 Page=1,Size=5,且 total>5 才回 preview:true。
- 关键修复 PublicFirstPageAuth:原仅 page>1 解析 token,导致 page=1 已登录用户无 Context、被误判匿名全截 5 条;改为始终解析 Bearer(有效则写入 Context),仅 page>1 强制。新增 isAnon(c) 辅助(同包 handler)。
- 前端:getArticles/getStreetSnaps 透传 preview;Alpine loadPage 存 this.preview,showLoginGate 改用 !getUser() && (preview===true || items.length>PREVIEW_LIMIT),visibleItems 仍 slice(0,5) 防御。
- 验证(node 脚本):anon runway/street 各 5 条+preview:true、page=2→401;root 登录回 24/12 条无 preview。前端 /en/runway-looks、/en/street-snaps 均 200,api.ts lint 0。
- 重启大坑(重要):go build -o server-bin 在 Windows 产出无扩展名 server-bin,但运行的是 server-bin.exe(旧)。Start-Process server-bin 被 Windows 解析成旧 .exe,导致首轮没生效。修正:改用 go build -o server-bin.exe 覆盖 .exe 并启 .exe。已记 MEMORY。
## 预览门禁纠正:列表页限制 → 详情页限制(16:3x)✅
用户澄清:之前把"未登录最多看 5 张"做成了列表页限制(RunwayLooks/StreetSnaps 列表只显 5 个图集)是误读。真实意图是**列表页照常展示全部图集,限制发生在点进图集详情后——未登录仅看前 5 张图片**。改动:
- 前端列表页回退:StreetSnaps.astro 删 visibleItems/showLoginGate/preview 字段 + 删门禁块 + x-for 改回 items + 清 PREVIEW_LIMIT/loginGateText 残留常量;RunwayLooks.astro 清 PREVIEW_LIMIT/loginGateText 残留常量(列表逻辑上一轮已回退)。lint 0。
- 详情页(Item.astro runway / StreetSnap.astro street)本就已是「前5张 SSR + extraImages 登录后补拉 + 登录门禁」结构,无需改。
- 后端:源码早已把截断从列表接口移到详情接口(列表 response.Page 全量;详情未登录截断 Images[:5]+preview:true)。但**运行中的二进制是旧的列表截断版**(验证显示列表只回 5 条、详情回全量 6 张且无 preview)→ 重编译 `go build -o server-bin.exe`、杀旧 PID 18912、启新 .exe。
- 验证(node+SSR):anon 列表全量无 preview;anon 详情 5 张+preview:true;root 登录详情完整 6 张无 preview;`/en/item/s000zAwwQ` 与 `/en/item/r0042h1mx` SSR 200、`data-preview="true"`、画廊 5 张图、含 Preview 门禁块;两个列表页已无 visibleItems/showLoginGate。
## 详情页「剩余 N 张」占位遮罩(17:0x)✅
用户补充:详情页未登录只显前 5 张,但用户会以为图集就 5 张——要求对剩余图片做空图遮罩并写"登录后查看"之类提示。改动:
- 后端 `response.DataPreview` 新增参数 `imageTotal int`,响应包写入 `image_total`(截断前的图片真实总数);走秀/街拍两详情 handler 在截断前 `imageTotal:=len(detail.Images)` 透传。前端 `getSsrArticle`/`getSsrStreetSnap` 读 `json.image_total`,`SsrArticleResult`/`SsrStreetSnapResult` 增 `imageTotal?` 字段;en/cn `item/[id].astro` 把 `imageTotal` 传给 `<Item>`/`<StreetSnap>`。
- 详情组件 `Item.astro`/`StreetSnap.astro` 增 `imageTotal` prop,算 `lockedCount=preview?max(0,imageTotal-previewImages.length):0`;画廊在 previewImages 之后、extraImages 之前插入 `lockedCount` 个空图 `<figure>`(锁图标 + "login to view" + "+N photos",`x-show="!getUser()&&preview"`+`x-cloak`,点击 `goLogin()`)。已登录 `getUser()` 为真 → 遮罩隐藏、由 `extraImages` 真实图替补。
- i18n 新增 `login to view`(cn 登录查看)/`photos`(cn 张照片)。
- 后端重编译 `go build -o server-bin.exe`、杀旧 PID 32508、启新 .exe(PID 29468);`Invoke-RestMethod` 验证 anon runway image_total=8 preview=true 返5、street image_total=6 preview=true 返5。
- 验证(node 解析 SSR HTML):走秀页画廊 5 张真实图 + 3 个"login to view +3 photos"遮罩 + 底部 Preview 门禁块;街拍页 5 张 + 1 个"login to view +1 photos"遮罩。功能对任意 image_total 通用(含用户举例的 20→15)。
## 遮罩运行时不显示:Alpine 表达式作用域坑(18:4x)🔧
用户反馈"还是只展示 5 张图"——SSR 静态 HTML 确有遮罩,但浏览器运行时看不到。排查:
- 后端 verified:`GET /api/v1/public/runway-looks/r0042h1mx`(anon)返 `image_total=8, preview:true` 且 images 截断 5 张;已登录(带 Bearer + 前端签名)返 `image_total=8, images.length=8, preview=undefined` → 后端全量正常。
- **根因**:遮罩与底部门禁用 `x-show="!getUser() && preview"`,但 `getUser` 是 `.astro` `<script>` 里 `import` 进来的模块符号——Alpine 属性表达式作用域访问不到它(只认组件 data 属性 / `$magic` / global),求值抛 ReferenceError → 绑定失效、元素卡在 `x-cloak` 的 `display:none`。**上一条"验证"只看 SSR 静态 HTML,没跑 Alpine,所以误判成功**。列表分页器 `StreetSnaps.astro:215` / `RunwayLooks.astro:253` 的 `x-show="getUser() && lastPage>1"` 同病。
- **修复**:详情组件 `Item.astro`/`StreetSnap.astro` 与列表组件 `RunwayLooks.astro`/`StreetSnaps.astro` 各增响应式 data 属性 `isAuthed:false`;`init()` 用 `this.isAuthed = !!getUser()` 初始化、`auth:login` 事件置 `this.isAuthed = true`;所有相关 `x-show` 改用 `isAuthed`(遮罩 `!isAuthed && preview`,分页器 `isAuthed && lastPage>1`)。`getUser()` 仍在组件 method 内调用(作用域OK)。
- 验证:`fetch` 详情页 HTML 确认 `x-show="!isAuthed &amp;&amp; preview"` 出现 4 次(3 遮罩+1 门禁)、`getUser` 在 x-show 中 0 次。dev server (astro dev, :4321) 已热更新新代码。
## 遮罩与真实图不等高:遮罩缺 figcaption 行(18:5x)✅
用户反馈未登录时遮罩图高度与正常图不一样。根因:正常图 `<figure>` = `img aspect-[3/4]` + 底部 `figcaption`(图片 name 如 look01/img01 灰字一行,实测数据每张图都有 name);而遮罩 figure 直接把 `aspect-[3/4]` 套在 figure 上、无 figcaption → 总高比正常图矮一行 caption 高度,grid stretch 行为也不一致。修复:`Item.astro`/`StreetSnap.astro` 遮罩重构为与正常图**同构**——figure 不再带 aspect,内部先放 `div.w-full aspect-[3/4] flex items-center justify-center`(锁图标+login to view 居中的"图位"),再放与正常 figcaption 同 class 的 `figcaption`(灰字 `+N photos`,替代原图内那行 +N 小字,保证 caption 行高一致)。验证:SSR figcaption 顺序 = look01..look05 5 个正常 + "+3 photos"×3 遮罩、遮罩 figure 含 aspect-[3/4] 图位 div(×3),结构已同构。dev server 热更新生效。
## 去掉底部 Preview 大框门禁(19:1x)✅
用户觉得画廊后整块「Preview / login to view all photos + Login 按钮」门禁意义不大(画廊遮罩已有提示),要求去掉。改动:
- `Item.astro`/`StreetSnap.astro`:删除画廊后 `x-show="!isAuthed && preview"` 整块门禁 div(font-serif "Preview" + 副文案 + Login 按钮)及顶部仅供该块使用的 `loginGateText`/`loginModalSubmitLabel` 常量(列表组件 RunwayLooks/StreetSnaps 的 loginModalSubmitLabel 用于内嵌登录弹窗,保留)。
- `dictionary.ts`:删 `'login to view all photos'` key(无引用)。
- 验证:两详情页 SSR 无 gateText/gateDiv、画廊遮罩 x-show 仍在(走秀 3/街拍 1);lint 0。
- **编辑教训**:删除整块时两次把 replace_in_file 的 old/new_str 弄反导致重复插入门禁块,须先读文件确认再一次性删除所有重复块;改文件先想清楚替换方向再提交。

View File

@ -1,53 +1,55 @@
# 项目长期记忆(Project MEMORY.md)
## 模块策略
- 站点按模块逐个完善,当前主线为「发布会模块」:首页 index + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。
- 品牌索引页已下线(2026-08-26):`brands.astro` 与孤儿组件已删;品牌仅经走秀页侧栏 filter + 品牌弹窗呈现(热门 30 走 SSG,搜索/字母走运行时 `/api/v1/public/brands`)。
- 导航死链清理(2026-08-30):删 `/latest-projects`、`/about`、`/contact` 三处(Layout/routes/dictionary 三处同步清)。**保留未动的死链**:`/portfolio`、`/login`、`/register`(用户未要求动)。
- 「header 别动」= 不擅自改导航结构/视觉;但用户主动批准的调整(Runway 下拉分类、Street Style 减城、删死链)均属已授权,不算违规。
## 架构总览
- 前端:`d:/project/frontend_v2` — Astro SSG + 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,`/api/v1/ssg/*`,SSGToken 中间件,nginx 不反代)。
- SSG 端点仅剩:`/api/v1/ssg/index/runway`(首页 runway 区块)与 `/api/v1/ssg/brands/hot`(走秀页热门品牌,固定 30 条)。
- 接口 i18n:后端按 `?locale=cn|en` 选列(pickLocale 缺翻译回落另一语言);前端取数统一复用带 locale 原语(request/ssgRequest),**绝不裸写 fetch**;语言以 URL 前缀为准。
- 走秀标题 `title_cn` 已规则回填(幂等);`description_cn` 空白属预期(中文页回落英文)。
- 后端连接池:已设 `conn_max_idle_time`(config.yml 默认 60s,须 < MySQL wait_timeout)主动回收空闲连接,避免空闲连接被 MySQL 回收后池子持死连接报 `invalid connection`。**本地后端若长时间空闲后接口报 invalid connection,先重启后端**(`cd d:/project/backend_v2 && go build -o server-bin ./cmd/server` 后杀旧进程重启)。
- 前端 dev server **不热加载 `.env`**:改 `BASE_API` 后必须 `astro dev stop` + `astro dev --background` 重启才生效。浏览器旧 bundle 仍用旧 `BASE_API`,需**硬刷新**(Ctrl+Shift+R);旧 token 对新后端无效,需**重新登录**。改 BASE_API 指向本地/线上后端后,若历史/收藏/登录"没反应",先查 dev server 是否重启、浏览器是否硬刷新+重登录。
- 前端 `d:/project/frontend_v2`:Astro SSG + 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 不反代)。
- 接口 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)。
- 前端 dev server **不热加载 `.env`**:改 BASE_API 后需 `astro dev stop`+`astro dev --background` 重启;浏览器旧 bundle 需硬刷新、旧 token 失效需重登录。
## 关键约定
- 视觉语言:黑白极简编辑风(monospace 小标签 + Georgia 衬线大标题 + 细线),HBX / 时尚档案调性。**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系,禁止自写字体栈。
- 品牌名前端一律只显示英文(`display = nameEn`);走秀标题/描述仍按 locale 选列。
- API 命名哲学(用户原则):「一个功能一个接口」,查询参数仅限同资源列表筛选/分页;跨资源/独立功能一律独立路径(如 `brands/hot` 而非 `?featured=1`)。
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`。Tailwind v4 无零值类。
- Layout 把 `<slot/>` 包在 `flex-1 flex flex-col`;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 抖动),用 `w-full` + `max-w-[x] mx-auto`。
- 视觉:黑白极简编辑风(monospace 小标签 + Georgia 衬线大标题 + 细线),HBX/时尚档案调性。**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系。
- 品牌名前端只显英文(`display=nameEn`);走秀标题/描述按 locale 选列。
- API 命名:一个功能一个接口,跨资源/独立功能独立路径(如 `brands/hot` 而非 `?featured=1`)。
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`;Tailwind v4 无零值类。
- 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}`)。
- **加载屏组件化**:`src/components/LoadingScreen.astro` 封装标记+样式+打包 `<script>`(**无 `is:inline`**,module/defer 在 window.load 前执行无竞态),Raleway 字体链随组件 hoist 到 head。文案 Portfolio Studio + 副标 Loading,黑白配色。改加载屏只动这一个文件。
## Astro `<script>` 坑
- 作用域隔离:frontmatter 的 import/变量在打包型 `<script>` 拿不到,需脚本顶部重新 import。
- 不做模板插值:打包型 `<script>` 内 `{t('...')}` 不执行 → `t is not defined`;预翻译用 `define:vars` 或 data 属性注入,模板区 `{t()}` 正常。
- **`define:vars` + `import` = 致命组合(已踩坑)**:`define:vars` 会把脚本包成内联 IIFE 并注入 `const meta=...`,而原本脚本顶部的 `import` 语句被塞进函数体内 → `import` 在 IIFE/非模块上下文非法 → 整段脚本 SyntaxError 完全不执行(连带 `recordHistory`/事件绑定全失效)。**凡脚本里要 `import`,绝不能用 `define:vars`**。改用 `data-*` 属性在模板里透传变量,脚本内 `btn.dataset.xxx` 读取(Layout.astro 已用此模式)。en/cn `item/[id].astro` 曾因此导致详情页浏览历史/收藏全不工作,已修复。
- 片段页(无 `<html>/<head>` 的 .astro)的 `import '@/styles/global.css'` 在 build 时不会被注入 head → 产物缺 `<link rel=stylesheet>` 整页无样式。login.astro 曾因此丢样式,已补完整文档结构修复。
- 作用域隔离:打包型 `<script>` 拿不到 frontmatter 的 import/变量,需脚本顶部重新 import。
- **`define:vars` + `import` = 致命**(已踩坑):`define:vars` 把脚本包成 IIFE 注入 `const meta=...`,原顶部 `import` 被塞进函数体 → 非模块上下文非法 → 整段 SyntaxError 不执行。凡脚本要 import,改用 `data-*` 属性透传(Layout.astro 已用)。en/cn `item/[id].astro` 曾因此详情页历史/收藏全失效,已修。
- 片段页(无 `<html>/<head>` 的 .astro)的 `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 属性。**
- 验证 Alpine 运行时务必真跑浏览器(或至少确认表达式可解析),**只看 SSR 静态 HTML 会漏掉运行时绑定失效**。
## 数据分类
- 走秀 `brand_runway`:`collection_type`(rtw/menswear/couture/resort/pre_fall) + `season`(spring/fall/null) + `season_code`(SS26…),title 规则回填(25,206 行,幂等)。公开列表 `?collection_type=&season=&season_code=&year=` 筛选。地理维度暂不做。
- 街拍 `street_snap.city`(2026-08-30 落地):DB 列 VARCHAR(128) + 精确匹配 `WHERE city = ?`;前端 header 下拉 `?city=` + 侧栏 City pills + 深链选中。规范值存「首字母大写英文」(Paris/New York/Copenhagen…),前后端大小写敏感须一致;dev 库仅 12 行样本(Paris 8 / Copenhagen 4),其余城市本地为空属预期。
## 模块策略
- 主线:首页 index + 走秀档案(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 减城、删死链)属授权。
## 前端复用
- 详情页统一 `/item`:`src/pages/{locale}/item/[id].astro`,服务端 getSsrArticle→getSsrStreetSnap 回落;`ROUTES.article`/`streetSnap` 均指向 `/item/${id}`。列表回链指向 `/runway-looks`、`/street-snaps`。
- 列表组件同构,抽 `src/styles/look-grid.css` + `src/lib/looks-grid.ts`(SPIN_SVG / makeCardSlots / buildPageList);品牌筛选弹窗 `BrandModal.astro`(仅 RunwayLooks)。卡片标记在 Alpine `x-for` 内客户端渲染,不能提成 Astro 组件。
## 未登录预览门禁(详情页,2026-09-03 定稿)
- 用户真实意图:**列表页照常展示全部图集**,限制只发生在**点进图集详情后**——未登录仅看前 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 兜底。
- `PublicFirstPageAuth` 始终解析 Bearer(有效写 Context),仅 page>1 强制;详情路由无 page → 匿名放行、已登录写态。
- 前端:详情组件 `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`。
- 列表页已回退全量展示,仅保留筛选/翻页 `requireLogin()` 硬门禁。
- i18n:`login to view`(cn 登录查看)/`photos`(cn 张照片)/`login to view all photos`(cn 登录查看全部图片,底部门禁用)。
## 账号体系(2026-08-31)
- 仅预置内部账号,不开放注册(前端无注册入口)。`seed_users/main.go` 创建两个账号:`admin`(密码取 `SEED_ADMIN_PASSWORD`,缺省 `Studio#2026!Admin`)+ `root`/`root`(测试弱密码,uid 通常为 8)。**本地库 `db_dev` 确有 root/root**;实测 `POST /api/v1/auth/login {account:"root",password:"root"}` 打到 `http://localhost:8090` 返回 200,`GET /auth/history` 显示 root 账号下已有 `street-snaps`/`runway-looks` 两条真实图集历史。**不要再说"本地无 root / root 登录必失败"——那是误判**(2026-09-02 因一次 401 错判,已纠正)。前端 `.env` 当前 `BASE_API=http://localhost:8090`。
- 双令牌 JWT:access 默认 **7 天(168h)**(无状态,前端请求用)+ refresh 默认 **30d(720h)**(随机串仅存 SHA256 哈希落库 `refresh_tokens`)。登录即吊销该用户全部既有 refresh(互斥登录)。
- 接口(`/api/v1/auth/`,除 /me 外公开):`POST /login`→`{access_token,refresh_token,expires_in,user}`;`POST /refresh`→`{access_token,expires_in}`(refresh 复用不轮换);`POST /logout`(单设备);`POST /logout-all`→`{revoked:N}`(踢下线);`GET /me`(Bearer)。
- 前端 `api.ts`:`saveSession/clearSession/refreshSession/fetchMe(authedFetch 遇 401 静默 refresh 一次)/logout(吊销当前 refresh 再清本地)/logoutAll`。
- 未做 `token_version` 即时吊销(access 在 7 天窗口内仍有效),如需即时失效再加。
- 硬门禁:RunwayLooks/StreetSnaps 筛选与翻页入口调 `requireLogin()`,未登录弹内嵌登录表单,登录成功 `loadPage(1)` 刷新;首屏 `init()` 的 `loadPage(1)` 不拦。
- 个人中心 `account.astro`:报头(方块字母头像+用户名大标题) + 两 tab(**Saved looks / My History**),已移除 Overview/Sessions & Security/Settings/退出入口/偏好设置/编辑资料(2026-09-01 精简)。
- **收藏已服务端化(2026-09-02)**:后端表 `favorites` + `/api/v1/auth/favorites`(GET 分页 `?page&per_page` / POST 新增幂等 / DELETE /:target_uid / POST `/check` 批量校验当前页收藏态),按 `user_id` 隔离,`target_uid` 存前端编码串(runway `r=` / street `s=`)。前端 `src/lib/favorites.ts`:账户页用 `fetchFavoritesPage()` 服务端分页;列表卡片用 `checkFavorited(ids)` 仅把当前页可见 id 发给后端打勾(与收藏总量解耦,10 万级也常数级);收藏/取消本地乐观 + 后台同步(失败不回滚本地)。favorites 表含复合索引 `idx_user_created(user_id,created_at)` 支撑深翻页。
- **浏览历史(2026-09-02 晚收敛为「文章级」,用户澄清纠正)**:语义=「点开过哪篇文章」,记录时机在 **item 详情页**(`recordHistory(meta.id)`,meta.id 是文章对外编码串 `r=`走秀 / `s=`街拍,编码串自带类型、无需 kind);**RunwayLooks/StreetSnaps 列表页进页不再记**(此前按"图集=列表页"实现是理解错,已纠正)。后端 `histories` 表收敛为 `(user_id, target_uid, viewed_at)`:`kind/target_type/title/cover/brand` 五列已 DROP(008 建表脚本同步改 + `migrate_refresh` 对存量库 ALTER,实测 GET items 仅 `{id,viewed_at}`);唯一键 `uniq(user_id,target_uid)`;保留 `HISTORY_CAP=2000` FIFO(文章会积累,有存在意义)。接口 `/api/v1/auth/history`:GET 分页 / POST `{target_uid}` upsert / DELETE /:target_uid / DELETE 清空,按 `user_id` 隔离;kind 参数与校验已随收敛移除(kind=all 字面过滤坑随之消失)。账户页 My History:服务端分页拿 id 列表后**逐篇回查公开详情**(`api.ts` 的 `getHistoryMeta(id)` → `/public/runway-looks|street-snaps/:id` 取 title/cover/brand;用户选"只存 id、展示回查"),卡片跳回文章详情,无 kind 筛选 tab。前端 `src/lib/history.ts`:`recordHistory(id)` / `fetchHistoryPage(page,perPage)`(服务端分页+回查,单条回查失败容忍回落本地)。
- **待办服务端化**:users 加 display_name/bio、refresh_tokens 列表接口。
- 视觉:登录/个人中心沿用站点黑白极简编辑风(monospace 小标签 + 细线下划线输入框 + Georgia 衬线大标题 + hover:opacity-60)。
## 账号体系
- 仅预置内部账号不开放注册。`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`。
- 双令牌 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`。
- 硬门禁: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;收藏/取消本地乐观+后台同步。
- 浏览历史文章级(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()` 时调用。
- 个人中心 `account.astro`:报头 + 两 tab(Saved looks / My History),已精简移除 Overview/Sessions 等。
## 部署
- Docker + Gitea Actions:前端 Dockerfile/nginx.conf/docker-compose.yml/deploy.sh/.gitea/workflows/deploy.yml/.env.example/DEPLOY.md + 后端 Dockerfile。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=` 筛选。
- 街拍 `street_snap.city`(2026-08-30):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}`。
- 列表组件同构,抽 `src/styles/look-grid.css`+`src/lib/looks-grid.ts`;品牌筛选弹窗 `BrandModal.astro`(仅 RunwayLooks)。卡片标记在 Alpine `x-for` 内客户端渲染,不能提成 Astro 组件。

View File

@ -8,9 +8,11 @@ import { ssrBase } from "@/lib/api"
interface Props {
article: RawArticle | null
error?: "missing" | "failed" | null
preview?: boolean // 后端未登录截断图片集时返回 true,渲染「剩余 N 张」占位遮罩
imageTotal?: number // 图片集真实总数(后端截断前),渲染「剩余 N 张」占位遮罩
}
const { article, error = null } = Astro.props
const { article, error = null, preview = false, imageTotal = 0 } = Astro.props
const { locale, t } = getI18n(Astro)
const full = (u: string): string => {
@ -28,6 +30,15 @@ const images = (article?.images ?? [])
}))
.filter((im) => im.url)
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5
const previewImages = images.slice(0, PREVIEW_LIMIT)
// 未登录预览占位遮罩数:补足「剩余 N 张」空图,提示登录后可见,避免误以为图集只有预览上限那几张。
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
const descParas = (article?.description || "")
.split(/\n\n+/)
.map((s) => s.trim())
@ -58,6 +69,7 @@ if (article?.brand_id) {
data-gallery-uid={article?.id ?? ""}
data-gallery-title={article?.title ?? ""}
data-gallery-brand={article?.brand_name ?? ""}
data-preview={preview ? "true" : "false"}
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
>
{
@ -73,13 +85,13 @@ if (article?.brand_id) {
{article.title}
</h1>
{images.length > 0 && (
{previewImages.length > 0 && (
<section class="mt-16">
<div
id="article-gallery"
class="grid grid-cols-2 md:grid-cols-3 gap-4"
>
{images.map((im, i) => (
{previewImages.map((im, i) => (
<figure
data-image-index={i}
data-image-url={im.url}
@ -119,6 +131,70 @@ if (article?.brand_id) {
)}
</figure>
) )}
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
{lockedCount > 0 &&
Array.from({ length: lockedCount }).map(() => (
<figure
x-show="!isAuthed && preview"
x-cloak
@click="goLogin()"
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[3/4] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
<div class="flex flex-col items-center gap-2">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
</svg>
<span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
</div>
</div>
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 look01 等小字):
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption>
</figure>
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url">
<figure
:data-image-index="5 + i"
:data-image-url="im.url"
:data-image-name="im.name"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click="openLb(5 + i)"
class="relative bg-[#f2f2f2] cursor-zoom-in group"
>
<img
:src="im.url"
:alt="im.name"
loading="lazy"
decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
<button
type="button"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
<template x-if="im.name">
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption>
</template>
</figure>
</template>
</div>
</section>
)}
@ -147,7 +223,7 @@ if (article?.brand_id) {
)
}
{article && images.length > 0 && (
{article && previewImages.length > 0 && (
<div
x-show="lbOpen"
x-cloak
@ -188,7 +264,7 @@ if (article?.brand_id) {
id="lb-thumbs"
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar"
>
{images.map((im, i) => (
{previewImages.map((im, i) => (
<button
type="button"
data-lb-index={i}
@ -205,6 +281,22 @@ if (article?.brand_id) {
</span>
</button>
))}
<template x-for="(im, i) in extraImages" :key="im.url">
<button
type="button"
:data-lb-index="5 + i"
:data-thumb-url="im.url"
@click="showLb(5 + i)"
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
: ''"
:aria-label="String(5 + i + 1)"
>
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
</button>
</template>
</div>
<button
@ -316,7 +408,7 @@ if (article?.brand_id) {
// handleTouchEnd(event: TouchEvent): void
// }
import { getUser } from "@/lib/api"
import { getUser, getArticleDetailAuthed, toAbs } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
document.addEventListener("alpine:init", () => {
@ -336,11 +428,22 @@ if (article?.brand_id) {
galleryTitle: "",
galleryBrand: "",
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
isAuthed: false,
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; raw: string; name: string }[],
showAll: false,
init() {
this.favType = this.$el.dataset.favType || "runway_image"
this.galleryUid = this.$el.dataset.galleryUid || ""
this.galleryTitle = this.$el.dataset.galleryTitle || ""
this.galleryBrand = this.$el.dataset.galleryBrand || ""
this.preview = this.$el.dataset.preview === "true"
this.isAuthed = !!getUser()
this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]"
@ -349,6 +452,45 @@ if (article?.brand_id) {
this.initThumbLazyLoading()
this.initImageFavs()
this.maybeLocateImageFromUrl()
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染
if (getUser() && this.preview) this.loadFull()
// 登录后(如从门禁跳登录回跳)自动补全全量
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() })
},
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。
// 后端详情接口对已登录(带 Bearer)返回完整图片集,故此处能拿到全量。
async loadFull() {
if (!this.preview) return
try {
const full = await getArticleDetailAuthed(this.galleryUid)
if (!full || !Array.isArray(full.images)) return
const mapped = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "",
url: toAbs(im.image || ""),
raw: im.image || "",
name: im.name || "",
}))
this.extraImages = mapped
this.showAll = true
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]"
).length
this.initImageFavs()
this.initThumbLazyLoading()
this.maybeLocateImageFromUrl()
} catch {
/* 失败维持截断态,门禁仍在 */
}
},
// 未登录门禁:跳登录并回跳本篇详情
goLogin() {
const back = window.location.pathname + window.location.search
const loc = window.location.pathname.startsWith("/cn") ? "cn" : "en"
window.location.href = `/${loc}/login?redirect=${encodeURIComponent(back)}`
},
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。

View File

@ -0,0 +1,158 @@
---
// 加载屏组件:首屏/路由切换时遮盖未加载内容,避免 FOUC。
// - 样式作用域隔离在 #loading-screen 内(is:global,因选择器基于唯一 id,不影响整站黑白调性)。
// - 隐藏逻辑用 Astro 默认打包的 <script>(module / defer),无需 is:inline;
// 模块脚本在 window.load 之前执行,故 addEventListener('load') 必然先挂上,无竞态。
// - Raleway 字体仅加载屏使用,随组件引入(Astro 自动 hoist 到 head)。
---
<div id="loading-screen" aria-hidden="true">
<div id="wrapper">
<div id="container">
<h1>Portfolio Studio</h1>
<p class="ls-sub">Loading</p>
</div>
</div>
</div>
<style>
#loading-screen {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: center;
background-color: #ffffff;
color: #0a0a0a;
font: 16px/1.25 'Raleway', sans-serif;
text-align: center;
transition: opacity 0.5s ease;
}
#loading-screen.is-hidden {
opacity: 0;
pointer-events: none;
}
#loading-screen *,
#loading-screen *:before,
#loading-screen *:after {
box-sizing: border-box;
}
#loading-screen #wrapper {
margin-left: auto;
margin-right: auto;
max-width: 80em;
}
#loading-screen #container {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100vh;
padding: 1em;
width: 100%;
}
#loading-screen h1 {
animation: ls-text-shadow 1.5s ease-in-out infinite;
font-size: clamp(2.5rem, 8vw, 5rem);
font-weight: 900;
line-height: 1;
}
#loading-screen h1:hover {
animation-play-state: paused;
}
#loading-screen a {
color: #0a0a0a;
}
#loading-screen a:hover {
text-decoration: none;
}
#loading-screen .ls-sub {
margin-top: 1.5em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: #888;
}
/* 关键帧独立命名(ls- 前缀),避免与站点其它 keyframes 冲突 */
@keyframes ls-text-shadow {
0% {
transform: translateY(0);
text-shadow:
0 0 0 #000,
0 0 0 #3a3a3a,
0 0 0 #777,
0 0 0 #bbb;
}
20% {
transform: translateY(-1em);
text-shadow:
0 0.125em 0 #000,
0 0.25em 0 #3a3a3a,
0 -0.125em 0 #777,
0 -0.25em 0 #bbb;
}
40% {
transform: translateY(0.5em);
text-shadow:
0 -0.0625em 0 #000,
0 -0.125em 0 #3a3a3a,
0 0.0625em 0 #777,
0 0.125em 0 #bbb;
}
60% {
transform: translateY(-0.25em);
text-shadow:
0 0.03125em 0 #000,
0 0.0625em 0 #3a3a3a,
0 -0.03125em 0 #777,
0 -0.0625em 0 #bbb;
}
80% {
transform: translateY(0);
text-shadow:
0 0 0 #000,
0 0 0 #3a3a3a,
0 0 0 #777,
0 0 0 #bbb;
}
}
/* 尊重系统“减少动画”偏好:仅作用于加载屏,不波及整站 */
@media (prefers-reduced-motion: reduce) {
#loading-screen *,
#loading-screen *:before,
#loading-screen *:after {
animation: none !important;
transition: none !important;
}
}
</style>
<script>
// 页面 load 后淡出加载屏;最少展示 500ms 防闪烁,最多 3s 兜底。
// 作为 Astro 默认打包的 module 脚本(defer),在 window.load 前执行,监听必然先挂上。
(function () {
var el = document.getElementById('loading-screen');
if (!el) return;
var hidden = false;
function hide() {
if (hidden) return;
hidden = true;
el.classList.add('is-hidden');
setTimeout(function () {
el.style.display = 'none';
}, 600);
}
var start = Date.now();
function tryHide() {
var waited = Date.now() - start;
if (waited >= 500) hide();
else setTimeout(hide, 500 - waited);
}
if (document.readyState === 'complete') tryHide();
else window.addEventListener('load', tryHide);
setTimeout(hide, 3000);
})();
</script>

View File

@ -250,7 +250,7 @@ const seasonOptions: { value: string; label: string }[] = [
</template>
</div>
<nav class="showspager" x-show="lastPage > 1" aria-label="Pagination">
<nav class="showspager" x-show="isAuthed && lastPage > 1" aria-label="Pagination">
<button class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
<template x-for="(p, i) in pageList" :key="i">
<span class="pg-wrap">
@ -305,7 +305,6 @@ const seasonOptions: { value: string; label: string }[] = [
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => {
const hotNode = document.getElementById("showshotbrands-data")
@ -339,6 +338,7 @@ const seasonOptions: { value: string; label: string }[] = [
loading: false,
page: 1,
lastPage: 1,
isAuthed: false,
reqToken: 0,
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
@ -352,6 +352,7 @@ const seasonOptions: { value: string; label: string }[] = [
loginModalLoading: false,
init() {
this.isAuthed = !!getUser();
this.locale = this.$el.dataset.locale || 'en';
// 支持从 header 下拉深链 ?collection=xxx 直接进入对应分类(与侧栏 COLLECTION 筛选一致)
const ct = new URLSearchParams(window.location.search).get('collection')

View File

@ -8,9 +8,11 @@ import { ssrBase } from "@/lib/api"
interface Props {
snap: RawStreetSnap | null
error?: "missing" | "failed" | null
preview?: boolean // 后端未登录截断图片集时返回 true,详情页据此弹「登录查看全部图片」门禁
imageTotal?: number // 图片集真实总数(后端截断前),渲染「剩余 N 张」占位遮罩
}
const { snap, error = null } = Astro.props
const { snap, error = null, preview = false, imageTotal = 0 } = Astro.props
const { locale, t } = getI18n(Astro)
const full = (u: string): string => {
@ -28,6 +30,15 @@ const images = (snap?.images ?? [])
}))
.filter((im) => im.url)
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5
const previewImages = images.slice(0, PREVIEW_LIMIT)
// 未登录预览占位遮罩数:补足「剩余 N 张」空图,提示登录后可见,避免误以为图集只有预览上限那几张。
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
const homeHref = href(locale, ROUTES.home)
const archiveHref = href(locale, ROUTES.streetSnaps)
@ -42,6 +53,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
data-gallery-uid={snap?.id ?? ""}
data-gallery-title={displayTitle}
data-gallery-brand=""
data-preview={preview ? "true" : "false"}
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
>
{
@ -55,13 +67,13 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
{displayTitle}
</h1>
{images.length > 0 && (
{previewImages.length > 0 && (
<section class="mt-16">
<div
id="snap-gallery"
class="grid grid-cols-2 md:grid-cols-3 gap-4"
>
{images.map((im, i) => (
{previewImages.map((im, i) => (
<figure
data-image-index={i}
data-image-url={im.url}
@ -101,9 +113,74 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
)}
</figure>
) )}
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
{lockedCount > 0 &&
Array.from({ length: lockedCount }).map(() => (
<figure
x-show="!isAuthed && preview"
x-cloak
@click="goLogin()"
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[3/4] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
<div class="flex flex-col items-center gap-2">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
</svg>
<span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
</div>
</div>
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 img01 等小字):
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption>
</figure>
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url">
<figure
:data-image-index="5 + i"
:data-image-url="im.url"
:data-image-name="im.name"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click="openLb(5 + i)"
class="relative bg-[#f2f2f2] cursor-zoom-in group"
>
<img
:src="im.url"
:alt="im.name"
loading="lazy"
decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
<button
type="button"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
<template x-if="im.name">
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption>
</template>
</figure>
</template>
</div>
</section>
)}
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
@ -111,6 +188,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
{error === "missing" ? t('No street snap ID specified') : t('Failed to load street snap. Please refresh or try later.')}
</p>
<a
href={homeHref}
class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
@ -121,7 +199,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
)
}
{snap && images.length > 0 && (
{snap && previewImages.length > 0 && (
<div
x-show="lbOpen"
x-cloak
@ -162,7 +240,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
id="lb-thumbs"
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar"
>
{images.map((im, i) => (
{previewImages.map((im, i) => (
<button
type="button"
data-lb-index={i}
@ -179,6 +257,22 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
</span>
</button>
))}
<template x-for="(im, i) in extraImages" :key="im.url">
<button
type="button"
:data-lb-index="5 + i"
:data-thumb-url="im.url"
@click="showLb(5 + i)"
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
: ''"
:aria-label="String(5 + i + 1)"
>
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
</button>
</template>
</div>
<button
@ -255,7 +349,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
</div>
<script>
import { getUser } from "@/lib/api"
import { getUser, getStreetSnapDetailAuthed, toAbs } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
document.addEventListener("alpine:init", () => {
@ -275,11 +369,22 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
galleryTitle: "",
galleryBrand: "",
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
isAuthed: false,
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; raw: string; name: string }[],
showAll: false,
init() {
this.favType = this.$el.dataset.favType || "snap_image"
this.galleryUid = this.$el.dataset.galleryUid || ""
this.galleryTitle = this.$el.dataset.galleryTitle || ""
this.galleryBrand = this.$el.dataset.galleryBrand || ""
this.preview = this.$el.dataset.preview === "true"
this.isAuthed = !!getUser()
this.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]"
@ -287,6 +392,44 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
this.initThumbLazyLoading()
this.initImageFavs()
this.maybeLocateImageFromUrl()
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染
if (getUser() && this.preview) this.loadFull()
// 登录后(如从门禁跳登录回跳)自动补全全量
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() })
},
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。
async loadFull() {
if (!this.preview) return
try {
const full = await getStreetSnapDetailAuthed(this.galleryUid)
if (!full || !Array.isArray(full.images)) return
const mapped = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "",
url: toAbs(im.image || ""),
raw: im.image || "",
name: im.name || "",
}))
this.extraImages = mapped
this.showAll = true
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]"
).length
this.initImageFavs()
this.initThumbLazyLoading()
this.maybeLocateImageFromUrl()
} catch {
/* 失败维持截断态,门禁仍在 */
}
},
// 未登录门禁:跳登录并回跳本篇详情
goLogin() {
const back = window.location.pathname + window.location.search
const loc = window.location.pathname.startsWith("/cn") ? "cn" : "en"
window.location.href = `/${loc}/login?redirect=${encodeURIComponent(back)}`
},
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。

View File

@ -212,7 +212,7 @@ const cityOptions: { value: string; label: string }[] = [
</template>
</div>
<nav class="snappager" x-show="lastPage > 1" aria-label="Pagination">
<nav class="snappager" x-show="isAuthed && lastPage > 1" aria-label="Pagination">
<button class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
<template x-for="(p, i) in pageList" :key="i">
<span class="pg-wrap">
@ -263,7 +263,6 @@ const cityOptions: { value: string; label: string }[] = [
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
const DEFAULT_SORT = "image_count"
document.addEventListener("alpine:init", () => {
@ -288,6 +287,7 @@ const cityOptions: { value: string; label: string }[] = [
loading: false,
page: 1,
lastPage: 1,
isAuthed: false,
reqToken: 0,
// 登录门禁弹窗状态(硬门禁:未登录点筛选/翻页时弹出内联登录表单)
@ -301,6 +301,7 @@ const cityOptions: { value: string; label: string }[] = [
favMap: {} as Record<string, boolean>,
init() {
this.isAuthed = !!getUser()
this.locale = this.$el.dataset.locale || 'en'
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
const city = new URLSearchParams(window.location.search).get('city')

View File

@ -51,6 +51,8 @@ export const dict: Record<string, TranslationEntry> = {
'please log in': { cn: '请先登录' },
'login to continue': { cn: '登录以继续使用筛选与翻页' },
'login prompt': { cn: '登录后可筛选与翻页浏览全部档案' },
'login to view': { cn: '登录查看' },
'photos': { cn: '张照片' },
'members': { cn: '会员专区' },
'back to site': { cn: '返回网站' },
'email': { cn: '邮箱' },

View File

@ -1,5 +1,6 @@
---
import '@/styles/global.css'
import LoadingScreen from '@/components/LoadingScreen.astro'
import { href, ROUTES } from '@/lib/routes'
import { getI18n } from '@/i18n/utils'
import { LOCALE_NAMES } from '@/i18n/config'
@ -63,6 +64,9 @@ const headers = {
</head>
<body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans">
<!-- 加载动画屏:独立组件,标记/样式/隐藏逻辑封装在 LoadingScreen.astro -->
<LoadingScreen />
<!-- 顶部主导航栏 -->
<nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase">
<div class="flex flex-wrap items-center gap-6 md:gap-8">
@ -80,7 +84,7 @@ const headers = {
<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>
</div>
<div x-show="open" x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
<div x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
{
headers.runways.map((item) => (
<a href={item.href} class="block px-4 py-2 text-[12px] uppercase tracking-[0.4px] hover:bg-black/5 transition-colors">
@ -98,7 +102,7 @@ const headers = {
<svg viewBox="0 0 12 12" width="10" height="10"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<div x-show="open" x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
<div x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
{
headers.streets.map((item) => (
<a href={item.href} class="block px-4 py-2 text-[12px] uppercase tracking-[0.4px] hover:bg-black/5 transition-colors">
@ -122,7 +126,7 @@ const headers = {
<span>{LOCALE_NAMES[locale].nativeName}</span>
</button>
<ul
x-show="open"
x-show="open" x-cloak
x-transition.opacity.duration.150ms
class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2"
>
@ -142,17 +146,17 @@ const headers = {
</div>
<div class="flex items-center gap-3" x-data="authMenu">
<!-- 未登录:Login 链接(Alpine 登录后置为隐藏) -->
<a x-show="!user" href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200 uppercase tracking-[0.5px] text-[13px]">{t('Login')}</a>
<a x-show="!user" x-cloak href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200 uppercase tracking-[0.5px] text-[13px]">{t('Login')}</a>
<!-- 已登录:账号下拉(hover 展开,与语言切换器同款交互) -->
<div x-show="user" class="relative" @mouseenter="open = true" @mouseleave="open = false">
<div x-show="user" x-cloak class="relative" @mouseenter="open = true" @mouseleave="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-haspopup="menu" :aria-expanded="open">
<span x-text="shortName"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="acct-chevron w-[14px] h-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<ul x-show="open" x-transition.opacity.duration.150ms class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2" role="menu">
<ul x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2" role="menu">
<li role="none">
<a href={href(locale, ROUTES.account)} @click="open = false" role="menuitem" class="block px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors opacity-60">{t('my account')}</a>
</li>

View File

@ -57,6 +57,7 @@ export interface ArticleQuery {
export interface ArticlePage {
data: ArticleItem[]
last_page: number
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
}
// 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。
@ -208,44 +209,51 @@ async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
// 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
// 对外导出:详情取数 / 个人中心逐篇回查(浏览器端按需取 title/cover 渲染历史卡片)。
export async function fetchArticleById(
id: number | string,
base: string = ssrBase(),
locale?: string
): Promise<RawArticle | null> {
const b = base.replace(/\/$/, "")
if (!b) return null
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try {
const res = await fetch(`${b}/api/v1/public/runway-looks/${id}${qs}`)
if (!res.ok) return null
const json: any = await res.json()
return (json?.data ?? null) as RawArticle | null
} catch {
return null
}
}
// 服务端按需渲染(on-demand)取文章详情:在 fetchArticleById 之上包揽
// 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。
// 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。
// 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。
// locale 显式传入(如 Astro.currentLocale)以绑定当前语言,避免并发串 locale。
export type SsrArticleResult = {
article: RawArticle | null
error: "missing" | "failed" | null
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
}
export async function getSsrArticle(
id: string | null | undefined,
locale?: string
): Promise<SsrArticleResult> {
if (!id) return { article: null, error: "missing" }
if (!id) return { article: null, error: "missing", preview: false, imageTotal: 0 }
const b = ssrBase().replace(/\/$/, "")
if (!b) return { article: null, error: "failed", preview: false, imageTotal: 0 }
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try {
const article = await fetchArticleById(id, ssrBase(), locale)
return { article, error: article ? null : "failed" }
const res = await fetch(`${b}/api/v1/public/runway-looks/${id}${qs}`)
if (!res.ok) return { article: null, error: "failed", preview: false, imageTotal: 0 }
const json: any = await res.json()
return {
article: (json?.data ?? null) as RawArticle | null,
preview: !!json?.preview,
imageTotal: Number(json?.image_total ?? 0),
error: null,
}
} catch {
return { article: null, error: "failed" }
return { article: null, error: "failed", preview: false, imageTotal: 0 }
}
}
// 已登录用户详情取数:走 request()(自动附带 Bearer + locale 签名),
// 后端据此返回完整图片集(未登录截断到 PreviewLimit 张)。详情页未登录→登录后客户端补拉全量用。
export async function getArticleDetailAuthed(
id: string,
locale?: string
): Promise<RawArticle | null> {
try {
const json: any = await request(`/public/runway-looks/${encodeURIComponent(id)}`)
return (json?.data ?? null) as RawArticle | null
} catch {
return null
}
}
@ -260,12 +268,12 @@ export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
if (q.collection) p.set("collection", q.collection)
if (q.season) p.set("season", q.season)
if (q.year) p.set("year", String(q.year))
const j = await request<{ data?: any[]; last_page?: number }>("/public/runway-looks", p)
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>("/public/runway-looks", p)
const mapped: ArticleItem[] = (j.data ?? []).map((it: any) => ({
...it,
gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
}))
return { data: mapped, last_page: Number(j.last_page ?? 1) }
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
}
// ===================== 品牌(/api/public/brands) =====================
@ -575,6 +583,7 @@ export interface StreetSnapItem {
export interface StreetSnapPage {
data: StreetSnapItem[]
last_page: number
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
}
// 街拍详情原始结构(与 PublicStreetSnapDetail 对齐)。
@ -599,7 +608,7 @@ export async function getStreetSnaps(q: {
if (q.sort) p.set("sort", q.sort)
if (q.year) p.set("year", String(q.year))
if (q.city) p.set("city", q.city)
const j = await request<{ data?: any[]; last_page?: number }>("/public/street-snaps", p)
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>("/public/street-snaps", p)
const mapped: StreetSnapItem[] = (j.data ?? []).map((it: any) => ({
id: String(it.id),
title: it.title ?? "",
@ -610,48 +619,54 @@ export async function getStreetSnaps(q: {
? it.images.map((im: any) => ({ id: im.id || "", image: im.image || im, name: im.name || "" }))
: [],
}))
return { data: mapped, last_page: Number(j.last_page ?? 1) }
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
}
// 接口:GET /api/public/street-snaps/:id
// 街拍详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数)。
// 对外导出:详情取数 / 个人中心逐篇回查。
export async function fetchStreetSnapById(
id: number | string,
base: string = ssrBase(),
locale?: string
): Promise<RawStreetSnap | null> {
const b = base.replace(/\/$/, "")
if (!b) return null
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try {
const res = await fetch(`${b}/api/v1/public/street-snaps/${id}${qs}`)
if (!res.ok) return null
const json: any = await res.json()
return (json?.data ?? null) as RawStreetSnap | null
} catch {
return null
}
}
export type SsrStreetSnapResult = {
snap: RawStreetSnap | null
error: "missing" | "failed" | null
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
}
// 服务端按需渲染(on-demand)取街拍详情:在 fetchStreetSnapById 之上包揽
// 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。
// 服务端按需渲染(on-demand)取街拍详情:直接 fetch 详情接口并透传 preview 标记。
export async function getSsrStreetSnap(
id: string | null | undefined,
locale?: string
): Promise<SsrStreetSnapResult> {
if (!id) return { snap: null, error: "missing" }
if (!id) return { snap: null, error: "missing", preview: false, imageTotal: 0 }
const b = ssrBase().replace(/\/$/, "")
if (!b) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try {
const snap = await fetchStreetSnapById(id, ssrBase(), locale)
return { snap, error: snap ? null : "failed" }
const res = await fetch(`${b}/api/v1/public/street-snaps/${id}${qs}`)
if (!res.ok) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
const json: any = await res.json()
return {
snap: (json?.data ?? null) as RawStreetSnap | null,
preview: !!json?.preview,
imageTotal: Number(json?.image_total ?? 0),
error: null,
}
} catch {
return { snap: null, error: "failed" }
return { snap: null, error: "failed", preview: false, imageTotal: 0 }
}
}
// 已登录用户街拍详情取数:走 request()(自动附带 Bearer + locale 签名),返回完整图片集。
export async function getStreetSnapDetailAuthed(
id: string,
locale?: string
): Promise<RawStreetSnap | null> {
try {
const json: any = await request(`/public/street-snaps/${encodeURIComponent(id)}`)
return (json?.data ?? null) as RawStreetSnap | null
} catch {
return null
}
}

View File

@ -18,6 +18,8 @@ let article = null
let articleErr = null
let snap = null
let snapErr = null
let preview = false
let imageTotal = 0
// await 必须位于 frontmatter 顶层(Astro 不支持块内顶层 await)
const data = isSnap
@ -31,6 +33,8 @@ if (isSnap) {
article = data.article
articleErr = data.error
}
preview = data.preview ?? false
imageTotal = data.imageTotal ?? 0
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
const meta = isSnap
@ -41,7 +45,7 @@ const savedLabel = t("saved")
---
<Layout>
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
{article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
</Layout>
<style>

View File

@ -18,6 +18,8 @@ let article = null
let articleErr = null
let snap = null
let snapErr = null
let preview = false
let imageTotal = 0
// await 必须位于 frontmatter 顶层(Astro 不支持块内顶层 await)
const data = isSnap
@ -31,6 +33,8 @@ if (isSnap) {
article = data.article
articleErr = data.error
}
preview = data.preview ?? false
imageTotal = data.imageTotal ?? 0
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
const meta = isSnap
@ -41,7 +45,7 @@ const savedLabel = t("saved")
---
<Layout>
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
{article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
</Layout>
<style>

View File

@ -44,7 +44,7 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
<span>{LOCALE_NAMES[locale].nativeName}</span>
</button>
<ul x-show="open" x-transition.opacity.duration.150ms class="absolute right-0 top-full mt-2 min-w-[170px] border border-black/10 bg-white py-2 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
<ul x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute right-0 top-full mt-2 min-w-[170px] border border-black/10 bg-white py-2 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
{languages.map((l) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 text-[10px] uppercase tracking-[0.22em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/60']}>{l.label}</a>

View File

@ -1,3 +1,4 @@
@import "tailwindcss";
[x-cloak] { display: none !important; }