update
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@ -18,6 +18,10 @@ logs/
|
||||
api-debug.log
|
||||
*.log
|
||||
|
||||
# 临时调试脚本 / 错误日志(勿入库)
|
||||
_tmp*
|
||||
*.err
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
132
.workbuddy/memory/2026-09-14.md
Normal file
132
.workbuddy/memory/2026-09-14.md
Normal file
@ -0,0 +1,132 @@
|
||||
# 2026-09-14
|
||||
|
||||
## 前端 API 客户端二次拆分:SSG 构建期取数抽到 ssg.ts ✅
|
||||
用户嫌 api.ts 杂乱,要求把构建期 SSG 接口单独成文件。
|
||||
- 新建 `src/lib/ssg.ts`:`SsgPopularBrand` 类型 + `getSsgIndexRunway`/`getSsgHotBrands`/`getSsgStreetSnapPopular`(原 api.ts 末尾「SSG 构建专用」段)。import `requestSsg` from `./request`、`import type { BrandEntry, StreetSnapItem } from "./api"`(类型仅用,不引入运行期依赖,无环)。
|
||||
- `api.ts`:删除上述类型与 3 个函数、`SsgPopularBrand` 类型;import 去掉 `requestSsg`;文件头注释移除 SSG 分组。
|
||||
- 消费方改 import:`Index.astro`、`RunwayLooks.astro` 的 `getSsg*` 改从 `@/lib/ssg` 导入。
|
||||
- 验证:`astro build` 退出码 0,全部路由预渲染成功(Vite 导入解析 = 全链路校验)。lint 0。
|
||||
|
||||
## ⚠️ 重要对账:auth.ts 鉴权函数并未真正拆出(纠正 09-13 总结)
|
||||
09-13 总结称 login/logout/fetchMe 已拆到 `auth.ts`,但 09-14 读磁盘:`auth.ts` 只含会话存储原语(getUser/getAccessToken/saveSession/clearSession 等),`login`/`logout`/`fetchMe`/`enforceIdleLogout` 仍在 `api.ts`(约 387–465 行)。即上次的鉴权拆分未落盘(或已被还原)。已同步修正 MEMORY.md,避免以后再绕回去。
|
||||
|
||||
## 待用户决定是否继续
|
||||
- 是否要把 `login`/`logout`/`fetchMe`/`enforceIdleLogout` 真挪到 `auth.ts`(完成上次未落地的拆分)?
|
||||
- `getSsrArticle`/`getSsrStreetSnap`(运行期 SSR 按需,非 SSG)是否也要单独成 `ssr.ts`?目前与 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed` 成对留在 api.ts。
|
||||
|
||||
## 端点路径集中到 endpoints.ts ✅(11:30)
|
||||
用户要求把 `/api/v1/xxx` 端点字面量用 map/const 集中,且要与页面路由 `ROUTES` 区分。
|
||||
- 新建 `src/lib/endpoints.ts`:导出 `endpoints`(分 `public`/`auth`/`me`/`ssg` 四组;动态 id 用函数如 `runwayLook(id)`;`V1="/api/v1"`、`SSG="/api/internal/ssg"` 前缀仅此定义一次)。`as const` 保证字面路径类型。
|
||||
- 与 `src/lib/routes.ts` 的 `ROUTES`(前端跳转 URL)严格区分:endpoints=fetch 接口路径;ROUTES=页面路由(带 /en /cn)。命名上选 `endpoints` 而非 `apiRoutes`/`routes`,避免与 Astro 页面路由(及既有 `routes.ts`)混淆。
|
||||
- 替换范围:api.ts 21 处、ssg.ts 3 处(含 `?limit=` 模板)、request.ts 1 处(refresh)。共 25 处端点字面量清零;grep 确认仅剩注释与 endpoints.ts 定义常量。
|
||||
- 坑:`enforceIdleLogout` 那行 `auth: false }).catch` 有空格,首轮替换因 old_str 缺空格未命中(工具误报成功),已补替换;`getSsr*` 的 `${b}/api/v1/...` 模板改为 `${b}${endpoints.xxx(id)}`。
|
||||
- 验证:`astro build` 退出码 0,全部 11 路由预渲染成功(SSG 三端点经新常量正常解析)。
|
||||
|
||||
## 用户问「api 地址要不要抽象出来」→ 已落地核查(10:5x)
|
||||
- 结论:地址**已经**集中在 `src/lib/config/`(目录,非单文件):`index.ts` 导出 `config`(`validate(isProd?prod:dev)`)、`types.ts` 定义 `AppConfig`、`env.dev.ts`/`env.prod.ts` 两份值。无 `config.ts` 单文件,import `"./config"` 解析到 `config/index.ts`。
|
||||
- 三把址:`config.baseApi`(浏览器运行期,request.ts:30)/ `config.baseApiSsr`(SSR 绝对 URL,request.ts:41,api.ts 经 ssrBase())/ `config.baseApiSsg`(构建期,request.ts:183)。**全站无直接 `import.meta.env.BASE_API` 读取**(memory 早先说的 `.env BASE_API` 已不实,dev/prod 值在 env.*.ts 硬编码)。
|
||||
- 仅两处「还散」:① `/api/v1` 版本前缀每个端点内联(api.ts 头注释有意为之,便于全局替换升 v2);② `SSG_TOKEN` 故意读 `import.meta.env.SSG_TOKEN`(密钥,不进客户端 bundle,安全设计勿动)。
|
||||
- 用户尚未决定:是否抽 `const API_PREFIX="/api/v1"` 常数。
|
||||
|
||||
## clientSign 安全性讨论(12:0x)→ 未改代码
|
||||
- 实证:`client-sign-secret-change-me` **明文出现在产物** `dist/client/_astro/api.BMQlzPjg.js`(esbuild 只压缩不加密字符串)。`crypto.ts` 是 HMAC 软门槛,非加密。
|
||||
- 结论:拒绝 VM 混淆/指纹(对内容站负收益,且救不了静态密钥);真正提难度靠服务端。建议方向=**服务端下发滚动密钥 + TTL/nonce 防重放 + IP 失败限流**(需动 backend_v2 的 `middleware.ClientSign`)。
|
||||
- 附带发现(**待修**):`crypto.ts:9` `CLIENT_SIGN_SECRET` 是**硬编码 dev 值、未走 env** → 生产要么功能失效要么形同虚设。
|
||||
|
||||
## 全站代码审计(12:19 起,只读,未改任何文件)→ 报告已出
|
||||
范围:js/css/libs/api/复用/目录/命名/性能。产出分级清单(artifact: code-review-plan.md)。核心发现:
|
||||
- **P0-1 ⭐ 生产 SSR 仍在跑调试日志**:`lib/http.ts:16` `ENABLED = DEV || import.meta.env.SSR || DEBUG_API`——`import.meta.env.SSR` 在构建出的 Node 端恒 true,故生产每请求都 `res.clone()+await text()` 且 `writeLog()` 往容器 `logs/api-debug.log` **追加写入**(磁盘无限增长)。改法:ENABLED 去掉 SSR 分支。
|
||||
- **重复地图**:① `Item.astro`(1161行) vs `StreetSnap.astro`(645行) 灯箱逻辑逐行重复(仅 `#article-gallery`↔`#snap-gallery`),Item 多 density/detailLb → 抽 `createGalleryView()`;② `RunwayLooks` vs `StreetSnaps` 的 pills/goPage/prevPage/nextPage/pageList/markFavs/favState/toggleFav 同构 → 继续下沉 `looks-grid.ts`;③ `full()`(ssrBase 绝对化) 在 Item:17 / StreetSnap:18 逐字相同 → 抽 `ssrAbs()` 到 request.ts;④ `toAbsUrl(){return toAbs(url)}` 是空包装可直接删;⑤ account.astro 的 `favPageList`/`histPageList` 同一段逻辑写两遍。
|
||||
- **cn/en 页面**:6 对文件里 8 个**逐字节相同**,`account.astro` 仅差 1 行注释;语言差异 100% 在运行时(`Astro.currentLocale`),页面无任何语言分支 → 可抽共享页面组件。
|
||||
- **项目未启用 View Transitions**(Layout 无 ClientRouter,全局搜无):故 `Index.astro:579` 的 `astro:page-load` 监听是死代码(保留无害)。
|
||||
- **死配置/过时注释**:`astro.config.mjs` 空 `build{}` 块 + 第 29 行「英文在根路径」注释错误(prefixDefaultLocale:true 实际在 /en);`routes.ts:18-20` 过时注释(称街拍详情走 /street-snaps/[id],实际统一 /item/[id]);`Item.astro:1084` `// TODO 这是干啥的`;`pages/index.astro:3` 「应该跳转到 /en」未实现。
|
||||
- **遗留文件**:`_tmp_verify_hist.mjs`/`_tmp_verify2.mjs`/`dev.log`/`server.err`/`footertail.txt`/`README.md`(Astro 模板残留)。`.gitignore` 未覆盖 `_tmp*`/`*.err`/`footertail.txt` → 误入库风险。删除属破坏性操作,**待用户确认**。
|
||||
- **配置**:`package.json` `name:"test"`、**无 lint/format/check 脚本**(tsconfig 用 strict 却无处触发校验)。
|
||||
- **纠正子代理误判**:dictionary 的 key 大小写差异(`t('Home')` vs dict `home`)**不是死 key**——`translate` 走 `key.toLowerCase()` 查表,都能命中。勿再当死 key 处理。
|
||||
- 未深入:`favorites.ts`(8.3KB)、`history.ts`(6.5KB)、`look-grid.css`(8.9KB) 仅做了引用面检查,未逐行审。
|
||||
- 用户尚未选择要做哪几项。
|
||||
|
||||
## i18n 目录迁入 lib 并合并 ✅(17:26,方案 A + 合并 lib/locale)
|
||||
用户问「i18n 移到 lib 合理吗 + 合成 i18n.ts」。判定:移入 lib 合理(本就是基础设施层,且 `lib/locale.ts` 原本反向 import i18n);合成单文件要当心 dictionary 会无限增长。
|
||||
- **新结构**:`src/lib/i18n/index.ts`(配置+工具+API语言透传,原 config.ts+utils.ts+lib/locale.ts 三合一)+ `src/lib/i18n/dictionary.ts`(纯数据,独立保留,随 UI 增长)。
|
||||
- **删除**:`src/i18n/{config,utils,dictionary}.ts` + `src/lib/locale.ts` + 空目录 `src/i18n`。
|
||||
- 消费方 16 处 import 改写:`@/i18n/utils`→`@/lib/i18n`、`@/i18n/config`→`@/lib/i18n`、`@/lib/locale`→`@/lib/i18n`、`lib/{ssr,request}.ts` 内 `./locale`→`./i18n`。`Layout.astro` 三段合并成一行 `import { getI18n, LOCALE_NAMES, setApiLocale } from '@/lib/i18n'`。
|
||||
- **循环依赖注意(良性)**:`index.ts` import `translate` from `./dictionary`,`dictionary.ts` import `DEFAULT_LOCALE/Locale` from `./index`。均「函数体内才用」,模块求值不触发 TDZ,安全。已在 index.ts 头注释标注。
|
||||
- 验证:`npm run build` Complete!;`npx astro check` **0 errors / 0 warnings / 5 hints**(hints 全是预存 `is:inline` 提示,非本次引入)。
|
||||
|
||||
## 清理 5 个 astro check hint ✅(19:18)
|
||||
用户要求清掉上一轮残留的 5 hint。实际构成(非全 is:inline,前轮口误更正):
|
||||
- `env.d.ts:8` ts(80003):原 `import type * as Alpine from "alpinejs"` → 改 `import type Alpine from "alpinejs"`(默认导入)。安全依据:tsconfig 继承 astro/strict 且 TS 主动提示 ts(80003) 即证明 esModuleInterop 已开,默认导入被允许;原注释"不能用默认导入"是过时误解,已重写。
|
||||
- `LoadingScreen.astro:10` ts(6133)×2:`getI18n(Astro)` 解构出的 `locale`/`getRelativeLocaleUrl` 未用 → 改为 `const { t } = getI18n(Astro)`。
|
||||
- `RunwayLooks.astro:263` / `StreetSnaps.astro:219` astro(4000):两个 JSON 数据 `<script>` 带属性被当 is:inline → 显式加 `is:inline` 消除歧义(语义本就该内联)。
|
||||
- 复验 `npx astro check`:**0 errors / 0 warnings / 0 hints**。
|
||||
|
||||
## 清理 lib 未使用导出 ✅(19:46)
|
||||
用户要求删 lib 内没用到的函数。子代理扫全 19 个 lib 文件导出符号后,交叉核对结论:
|
||||
- **唯一真死代码**:`crypto.ts` 的 `CLIENT_SIGN_TTL`(导出 const,全项目零引用,注释称"调试时读取"但无人读)。已删 + 清过时注释。`clientSign` 仍用硬编码 30s 逻辑(TTL 仅服务端校验,前端不回传)。
|
||||
- **导出但只被本模块内部调用(不算没用到,保留)**:`auth.getAccessExpiresAt`(被 isAccessExpiring 调)、`http.loggedFetch`/`installApiLogger`(installApiLogger 模块底自动安装时调)、`i18n.useTranslations`(被 getI18n 调)、`i18n.getLocaleFromUrl`/`LOCALES`(被本模块调)。这些是模块自身 API 面,勿当死代码删。
|
||||
- 验证:`npm run build` Complete,`astro check` **0/0/0**。
|
||||
|
||||
## 详情页灯箱重构:抽共享工厂 + 共享灯箱组件 + 样式统一 ✅(15:5x)
|
||||
用户要求「灯箱也帮我改改,顺便看样式怎么和全站更契合」。
|
||||
- **新建 `src/lib/gallery.ts`**:`createGalleryView(cfg)` 工厂,装走秀/街拍详情页**逐行同构**的图集+大图灯箱逻辑(约 250 行)。cfg 只有 4 项差异:`rootSelector` / `gallerySelector` / `favType` / `fetchDetail`。导出 `GalleryView`(= `ReturnType<typeof createGalleryView>`)、`GalleryImageRaw`、`GalleryDetailFetcher`、`GalleryExtraImage`。
|
||||
- **新建 `src/components/GalleryLightbox.astro`**:共享灯箱标记(约 150 行),props 仅 `images`(SSR 首屏图,需带 thumb)+ `aspect`(`2/3` 走秀 / `3/4` 街拍,两个字面量都写在组件里保证 JIT 扫得到);走秀独有的「细节图簇」经具名插槽 `slot="details"` 注入。
|
||||
- **`Item.astro` 1161→约 850 行、`StreetSnap.astro` 645→约 330 行**;两者 `<style>` 整块删除。
|
||||
- **共用原子样式上提 `src/styles/global.css`**:`.fav-btn`(爱心描边/实心)、`.locate-flash`、`@media (hover:none)`、`.no-scrollbar`。原因:Astro 组件 `<style>` 会加作用域哈希,而 `.fav-btn` 同时出现在网格图、灯箱、列表卡片,组件作用域**跨组件命中不了**。
|
||||
- **`RawStreetSnap.images` 补 `thumb?: string`**(api.ts),使街拍灯箱缩略图条也能用 240px 缩略图。
|
||||
|
||||
### ⭐ 新踩的坑:对象展开会让 `Alpine.data` 的泛型 T 退化成 `{}`
|
||||
`Alpine.data("x", () => ({ ...createGalleryView(cfg), density: 5 }))` → T 推断失败 → `this` 变成 `InferInterceptors<{}> & XDataContext & Magics<{}>` → **所有 `this.xxx` 报 ts(2339)「属性不存在」**(本次一次报 69 个)。
|
||||
**根因**:`@types/alpinejs` 的 `data<T extends {[key in keyof T]: T[key]}>(...)` 自引用约束,从「对象字面量」能推断,从「展开表达式」推断不出来。
|
||||
**修法**:给回调**显式标注返回类型**——`(): ReturnType<typeof createGalleryView> & ArticleExtras => ({...})`。`this` 于是取上下文类型,全部解析。Item 需为此写出 `ArticleExtras` 类型(约 28 个成员)。
|
||||
**顺带三条工厂约束**(已写进 gallery.ts 头注释):
|
||||
1. 工厂内**不用 Alpine 魔法**(`$el`/`$nextTick`)→ 改 `cfg.rootSelector` 查根节点 + `requestAnimationFrame` 等渲染。
|
||||
2. **getter 会被展开求值成静态值** → `currentFavId`/`currentFavUrl` 改成**方法**,模板里写 `currentFavId()`。
|
||||
3. 共享初始化叫 **`galleryInit()`** 而非 `init()`(组件自己的 `init()` 会覆盖它)。
|
||||
|
||||
### 灯箱样式统一(用户明确要求"更契合")
|
||||
以 `Item.astro` 的灯箱为基准(它本来就是编辑风),把 `StreetSnap.astro` 的旧版对齐过来。产物 HTML 核对:旧样式 `hover:scale-105` / `bg-gradient-to-b` / `rounded-[6px]` / `bg-white/75` / `✕`字符 **全部为 0**。
|
||||
统一项:计数改零填充 `01 / 05` + `tracking-[0.22em]` 直角细框;缩略图激活态 `ring-1 ring-black`(去掉 `scale-[1.03]`+`ring-2`);nav/关闭按钮改直角 + `hover:bg-black hover:text-white`(去掉圆角药丸 + scale 悬停);关闭图标改 SVG(去掉 `✕` 字符);缩略图条去掉顶部渐变;文案字号/字距对齐。
|
||||
**唯一一处超出"纯统一"的改动**:灯箱的单图收藏按钮由**直角改圆形**,对齐全站收藏语义(列表卡片 `.fav-toggle`、详情页 `#fav-fab` 都是 `rounded-full`)。用户若不认同一行可回退。
|
||||
|
||||
### 验证手段(新增,值得复用)
|
||||
`item/[id]` 是 `prerender=false`,**dist 里没有 HTML**,没法用产物对比。改法:**临时建一个 prerender 的验证页**(`src/pages/zzverify.astro`)直接渲染目标组件、喂假数据 → `npm run build` → 用 PowerShell 正则统计产物 HTML 里的关键类名/绑定次数 → 验完删页重建。
|
||||
本次核对结果:`id="lb-thumbs"`×2、`id="lb-details"`×1(仅走秀)、`currentFavId()`×4、走秀缩略图 `aspect-[2/3]`×6 / 街拍 `aspect-[3/4]`×6(6 = 5 张 SSR + 1 个 x-for 模板)。
|
||||
- 最终验证:`npm run build` exit 0;`npx astro check` **0 errors / 0 warnings / 3 hints**(与基线一致,hints 是既有 `is:inline` 提示)。
|
||||
- 坑:`astro check` 输出会被 PowerShell 吞;用 `Out-File -Encoding utf8` 落盘再用文件工具读;`Get-Content` 必须带 `-Encoding utf8`(否则被安全策略拦)。
|
||||
|
||||
## 用户「全都帮我改下吧」→ 已落地的优化(13:xx–14:xx)
|
||||
全部通过 `npm run build`(exit 0)+ `npx astro check`(exit 0)+ 产物 HTML 对比验证。
|
||||
|
||||
### P0
|
||||
- **http.ts 生产关闭调试日志**:`ENABLED` 去掉 `import.meta.env.SSR` 分支(该值在构建出的 Node 端恒 true → 生产每请求都 clone+读 body,且 `writeLog` 往容器 `logs/api-debug.log` 无限追加)。现仅 `DEV || DEBUG_API==="true"`。
|
||||
- 清理死配置/过时注释 5 处:`astro.config.mjs` 空 `build{}` 块 + 第29行「英文在根路径」错误注释(实际 /en);`routes.ts:18-20` 过时注释;`Item.astro` 的 `// TODO 这是干啥的`;`pages/index.astro:3`「应该跳转到 /en」未实现。
|
||||
- **抽 `ssrAbs()` 到 request.ts**,删掉 Item.astro/StreetSnap.astro 各自本地 `full()`(逐字相同)。
|
||||
- `package.json`:`name: "test"` → `frontend-v2`,加 `private: true` + `"check": "astro check"`;devDeps 加 `@astrojs/check`/`typescript`/`@types/node`。
|
||||
- `.gitignore` 补 `_tmp*`、`*.err`。
|
||||
- 删遗留文件:`_tmp_verify_hist.mjs`、`_tmp_verify2.mjs`、`dev.log`、`server.err`、`footertail.txt`。**`README.md`(Astro 模板残留)未动**;`_tmp_backfill.log` 非本次创建,保留(已被 ignore)。
|
||||
|
||||
### ⭐ 类型检查从 0 到有:60 errors → 0
|
||||
- **根因**:`@types/alpinejs` 的 `data<T extends { [key in keyof T]: T[key] }, A extends unknown[]>(name, cb: (...a:A)=>AlpineComponent<T>)` 自引用约束使 `T` 推断失败 → 属性退化成 `unknown`、方法内 `this` 退化成 `{}`。`Alpine.store` 则是 `Stores` 为 `[key: string|symbol]: unknown`,内联字面量失去上下文类型。
|
||||
- **修法**:① `Alpine.data` 的属性用 `as` 显式标注(如 `hotBrands as {...}[]`、`favIds: [] as string[]`、`user: null as AuthUser|null`);② `Alpine.store("auth", …)` 改为先 `const authStore: AuthStore = {...}` 再注册(AuthModal 一处修掉 16 个错);③ swiper 样式声明要放**非模块** d.ts(`src/types/swiper-css.d.ts`,env.d.ts 有 `export {}` 是模块,简写 ambient 声明不生效);④ `e.target` 用 `(e.target as Element|null)?.closest()`;⑤ `var el` → 收窄后另存 `const screen = el` 供闭包用(闭包内不保留收窄)。
|
||||
- 剩余 3 hints(`is:inline` 提示、`env.d.ts` 默认导入建议)属噪音,未处理。
|
||||
|
||||
### P1
|
||||
- **新建 `src/lib/ssr.ts`**:把 `getSsrArticle`/`getSsrStreetSnap` + `SsrArticleResult`/`SsrStreetSnapResult` 从 api.ts 迁出,与 ssg.ts 对称(ssg=构建期 / ssr=运行期按需,均服务端可信内部取数、不经签名)。api.ts 去掉 `ssrBase`/`withLocale` 导入。
|
||||
- **`login/logout/fetchMe/enforceIdleLogout` 有意留在 api.ts**:它们要经 `request()`,而 request.ts 依赖 auth.ts 的令牌读取;搬进 auth.ts 会形成 `auth ↔ request` 循环依赖(**这就是 09-13 那次拆分没落地的真正原因**)。已在 api.ts 头注释写明。
|
||||
- **cn/en 页面去重**:新建 `src/components/pages/{LoginPage,AccountPage,ItemPage}.astro`(用 `Copy-Item` 复制而非手抄,避免 600+ 行转写误差),`src/pages/{en,cn}/{login,account,item/[id]}.astro` 变 4 行薄包装。`ItemPage` 收 `id` prop —— **`export const prerender = false` 必须留在页面文件**(Astro 只读页面模块的该导出)。`pages/{en,cn}/{index,runway-looks,street-snaps}` 本来就是 5 行包装,未动(去重后反而更长)。
|
||||
- **account.astro 分页去重**:`favPageList`/`histPageList` 同一段逻辑写两遍 → 抽 `buildWindowPages(cur,last)` 到 `looks-grid.ts`(与 `buildPageList` 形态不同:前者输出 `{label,page}`,后者输出 `number|"..."`,已在注释里写明勿混用)。
|
||||
|
||||
### P2
|
||||
- **IntersectionObserver 泄漏**:`initThumbLazyLoading()` 在 `loadAuthedState()` 后会被再调一次,每次 `new` 一个 observer 且旧的没断 → Item/StreetSnap 各加模块级 `let thumbObserver: IntersectionObserver|null`,重建前 `thumbObserver?.disconnect()`。用模块级单例(非 Alpine data 属性)以免把 observer 塞进响应式代理。
|
||||
|
||||
### ⚠️ 纠正审计报告的两处错误结论(子代理给的,勿照做)
|
||||
1. **dictionary 的「死 key」判断是错的**:`t('Home')`/`t('Previous')`/`t('No article ID specified')` 大小写与 dict 全小写不一致,但 `translate()` 走 `key.toLowerCase()` 查表,**全部能命中,不是死 key**。
|
||||
2. **`toAbsUrl()` 不是「空包装、可直接删」**:模板里有 `:src="toAbsUrl(it.cover)"`(RunwayLooks:219/230),而 **Alpine 表达式访问不到模块 import 符号**(见 MEMORY 铁律 #4),所以 `toAbsUrl` 是必须存在的 data 方法,删了图片就加载不出来。
|
||||
|
||||
### 未做(下次可继续)
|
||||
- **P1-1 详情页灯箱工厂**(Item.astro 1161 行 vs StreetSnap.astro 645 行,约 300 行逐行重复 → 抽 `createGalleryView()`):**最大重复源,但风险最高**。灯箱是交互核心,且 `item/[id]` 是 `prerender=false`(dist 无 HTML)→ **无法用产物 HTML 对比验证**,只能靠真跑浏览器。建议单独一轮做。
|
||||
- **P1-3 详情页重复 HTML 片段**(SSR 首屏图与 extraImages 两份 figure 结构):同上,会动模板结构。
|
||||
- **P0-4 列表页逻辑下沉**(pagination/fav mixin):净减行数只有约 35 行(mixin 机制本身要 ~75 行),且会让 UI 工具层 import 数据层;另有 Alpine `this` 推断的不确定性。收益/风险比不划算,暂缓。
|
||||
- **P2-5 补 any 类型**(account.astro 的 `user: null as any`、`favorites: [] as any[]` 等)未动。
|
||||
- `favorites.ts`(8.3KB)、`history.ts`(6.5KB)、`look-grid.css`(8.9KB) 仍未逐行审。
|
||||
@ -1,29 +1,60 @@
|
||||
# 项目长期记忆(Project MEMORY.md)
|
||||
|
||||
> 维护约定:本文件只放**跨会话长期有效**的事实与铁律;当日细节写 `.workbuddy/memory/YYYY-MM-DD.md`。
|
||||
> 历次合并去重:2026-09-14(修正 BASE_API 位置、ROUTES.article、ssgRequest、Raleway、View Transitions 等过时记述;补 gallery/灯箱与 Alpine 展开推断坑)。
|
||||
|
||||
## 架构总览
|
||||
- 前端 `d:/project/frontend_v2`:Astro(`adapter: node` standalone,SSR 运行时取数)+ 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`;类型检查 `npm run check`(= `astro check`)。SSG 取数走 `requestSsg`(src/lib/request.ts),失败 `null` 回落不影响构建。
|
||||
- 后端 `d:/project/backend_v2`:Go + Gin + GORM(MySQL)。公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`);SSG 引擎 :8091(仅 127.0.0.1,nginx 不反代)。
|
||||
- 接口 i18n:后端按 `?locale=cn|en` 选列;前端取数复用带 locale 原语(request/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`。本地后端长空闲报 `invalid connection` 先重启后端(连接池 `conn_max_idle_time` 须 < MySQL `wait_timeout`)。
|
||||
- 前端 dev server **不热加载 `.env`**:改 BASE_API 后 `astro dev stop`+`astro dev --background`;浏览器旧 bundle 硬刷新、旧 token 失效重登录。
|
||||
- 接口 i18n:后端按 `?locale=cn|en` 选列;前端取数复用带 locale 原语(`request` / `requestSsg`),**绝不裸写 fetch**;语言以 URL 前缀为准。
|
||||
- **base 地址全在 `src/lib/config/`(目录)**:`index.ts` 导出 `config`(`validate(isProd?prod:dev)`)、`types.ts`、`env.dev.ts`/`env.prod.ts`。三把址:`config.baseApi`(浏览器运行期)/ `config.baseApiSsr`(SSR 绝对 URL)/ `config.baseApiSsg`(构建期内部端口)。**全站无直接 `import.meta.env.BASE_API` 读取**(值硬编码在 env.*.ts,**不在 `.env`**)。密钥类(`SSG_TOKEN`)故意直读 env,不进客户端 bundle。
|
||||
- **后端编译产物坑(Windows)**:`go build -o server-bin ./cmd/server` 实际产出无扩展名 `server-bin`,但运行的是 `server-bin.exe`。必须 `go build -o server-bin.exe` 显式覆盖,再 `Start-Process server-bin.exe`;杀进程 `(Get-NetTCPConnection -LocalPort 8090).OwningProcess`。本地后端长空闲报 `invalid connection` 先重启后端(连接池 `conn_max_idle_time` 须 < MySQL `wait_timeout`)。
|
||||
- 前端 dev server **不热加载 `.env`**:改 base 后 `astro dev stop`+`astro dev --background`;浏览器旧 bundle 硬刷新、旧 token 失效重登录。
|
||||
- **`504 (Outdated Optimize Dep)` 修法**:动过 `package.json` 依赖后 Vite 会重新预打包,哈希变了而浏览器仍攥着旧哈希 → 控制台报 `GET /node_modules/.vite/deps/xxx.js 504 (Outdated Optimize Dep)`。修:`astro dev stop` → 删 `node_modules/.vite` → `astro dev --background` → 浏览器**硬刷新**(Ctrl+Shift+R)。
|
||||
- 报错里的 **`audit-*.js` 是 Astro 开发工具栏的 chunk**,只在 dev 存在,与站点代码无关,生产构建没有。
|
||||
- 控制台里 `Images loaded lazily and replaced with placeholders. Load events are deferred.` 是 **Chrome DevTools 的提示**(开着 Network/Performance 面板时会把懒加载图临时换成占位图并推迟 load 事件),**不是错误**。
|
||||
|
||||
## 关键约定
|
||||
- 视觉:黑白极简编辑风(monospace 小标签 + Georgia 衬线大标题 + 细线),**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系。
|
||||
- 视觉:黑白极简编辑风(monospace 小标签 + 宽字距 + 细线 + 直角),**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系。
|
||||
- 品牌名前端只显英文(`display=nameEn`);走秀标题/描述按 locale 选列。
|
||||
- API 命名:一个功能一个接口,跨资源/独立功能独立路径(如 `brands/hot` 而非 `?featured=1`)。
|
||||
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`(Tailwind v4 编译期指令,最终 HTML 不出现,样式已生效);Tailwind v4 无零值类。
|
||||
- **跨组件复用的原子样式必须放 `global.css`,不能放组件 `<style>`**:Astro 会给组件样式加作用域哈希,`.fav-btn` 同时出现在详情网格、灯箱(`GalleryLightbox`)、列表卡片,组件作用域**跨组件命中不了**。现 global.css 含 `.fav-btn`、`.locate-flash`、`@media(hover:none)`、`.no-scrollbar`、`[x-cloak]`。
|
||||
- Layout `<slot/>` 包 `flex-1 flex flex-col`;页内根容器**禁用 `m-auto`**(shrink-to-fit 抖动),用 `w-full`+`max-w-[x] mx-auto`。
|
||||
- **Header/下拉 FOUC**:所有 `x-show` 下拉(Runway/Street/语言/账号/未登录 Login)一律带 `x-cloak`(global.css 已 `[x-cloak]{display:none!important}`)。
|
||||
- **加载屏组件化**:`src/components/LoadingScreen.astro` 封装标记+样式+打包 `<script>`(无 `is:inline`),Raleway 字体链随组件 hoist 到 head。改加载屏只动这一个文件。
|
||||
- **Header/下拉 FOUC**:所有 `x-show` 下拉一律带 `x-cloak`。
|
||||
- **加载屏组件化**:`src/components/LoadingScreen.astro` 封装标记+样式+打包 `<script>`(无 `is:inline`)。字体用**系统字体栈**(ui-serif / ui-monospace),**不引外部字体**(Raleway 已移除)。改加载屏只动这一个文件。
|
||||
- 项目**未启用 View Transitions**(Layout 无 `ClientRouter`),故 `astro:page-load` 监听实际不会触发(`Index.astro` 里那条属防御性死代码,保留无害)。
|
||||
|
||||
## Astro `<script>` 铁律(踩坑汇总)
|
||||
1. 作用域隔离:打包型 `<script>` 拿不到 frontmatter 的 import/变量,需脚本顶部重新 import。
|
||||
2. **`define:vars` + `import` = 致命**:`define:vars` 把脚本包成 IIFE 注入 `const meta=...`,原顶部 `import` 被塞进函数体 → SyntaxError 整段不执行。凡脚本要 import,改用 `data-*` 属性透传(item/[id].astro 的 fav-fab 已用此套路)。
|
||||
2. **`define:vars` + `import` = 致命**:`define:vars` 把脚本包成 IIFE 注入 `const meta=...`,原顶部 `import` 被塞进函数体 → SyntaxError 整段不执行。凡脚本要 import,改用 `data-*` 属性透传(`ItemPage.astro` 的 fav-fab 已用此套路)。
|
||||
3. 片段页(无 `<html>/<head>`)的 `import '@/styles/global.css'` build 时不注入 head → 整页无样式(login.astro 曾踩)。
|
||||
4. **Alpine 属性表达式访问不到模块 import 符号**:`x-show="!getUser() && preview"` 中 `getUser` 是 import 来的自由变量,Alpine 只认组件 data 属性 / `$magic` / global → 求值抛 ReferenceError、元素卡 `x-cloak` 隐藏。**铁律:表达式里只写组件 data 属性或 `$magic`,外部状态先存进 data 属性**(`isAuthed` 即为此而生)。
|
||||
4. **Alpine 属性表达式访问不到模块 import 符号**:Alpine 只认组件 data 属性 / `$magic` / global。**铁律:表达式里只写组件 data 属性或 `$magic`,外部状态/函数先存进 data 属性**。
|
||||
- 正例:`toAbsUrl(url) { return toAbs(url) }` —— 看着像「空包装」,但模板里有 `:src="toAbsUrl(it.cover)"`,**不能删**(删了图片加载不出来)。`isAuthed` 亦为此而生。
|
||||
5. **验证 Alpine 运行时务必真跑浏览器**(或至少确认表达式可解析),只看 SSR 静态 HTML 会漏掉运行时绑定失效。
|
||||
6. 共享 Alpine 组件:`document.addEventListener('alpine:init', () => Alpine.data('name', () => ({...})))` 注册,`x-data="name()"` 引用(与 `articleView`/`streetSnapView`/`imgLoader`/`showsPage` 同款)。
|
||||
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。
|
||||
6. 共享 Alpine 组件:`document.addEventListener('alpine:init', () => Alpine.data('name', () => ({...})))` 注册,`x-data="name()"` 引用(`articleView`/`streetSnapView`/`imgLoader`/`showsPage`/`streetSnapsPage`/`accountPage`/`authMenu` 同款)。**若 data 由工厂展开而来,必须显式标注回调返回类型**,见下节。
|
||||
7. **Swiper 生命周期(首页 Index.astro)**:`initSwipers()` 开头先 `destroySwipers()` 防重复/泄漏;统一入口 `setupSwipers()` 用 rAF 延后一帧,挂 `DOMContentLoaded`;`swiper.update()` 用 `updateScheduled` + rAF 合并;Tab 点击 `document` 事件委托(`(e.target as Element|null)?.closest('.tab')`)。
|
||||
8. **IntersectionObserver 要复用**:`initThumbLazyLoading()` 在 `loadAuthedState()` 后会被再调一次;灯箱缩略图的 observer 现在是 `src/lib/gallery.ts` 里的**模块级单例** `thumbObserver`,重建前 `?.disconnect()`(别放 Alpine data 属性,会被响应式代理包一层)。
|
||||
|
||||
## 类型检查与 Alpine 类型推断(2026-09-14 引入 `npm run check`)
|
||||
- devDeps:`@astrojs/check` + `typescript` + `@types/node`。引入时暴露 **60 个既存错误,已全部修到 0**。
|
||||
- **`@types/alpinejs` 的坑 A(当时 90% 报错的根因)**:`data<T extends { [key in keyof T]: T[key] }, A extends unknown[]>(name, cb: (...a:A)=>AlpineComponent<T>)` 的自引用约束使 `T` 推断失败 → **Alpine data 里的属性退化成 `unknown`、方法内 `this` 退化成 `{}`**;`Alpine.store` 则因 `Stores = [key: string|symbol]: unknown` 让内联字面量失去上下文类型。
|
||||
- 修法:属性显式标注(`x as T[]` / `favIds: [] as string[]` / `user: null as AuthUser|null`);`Alpine.store` 先 `const s: Store = {...}` 再注册(`AuthModal.astro` 一处修掉 16 个错)。
|
||||
- **坑 B(2026-09-14 灯箱重构新增):对象展开会让 T 退化成 `{}`。**
|
||||
`Alpine.data("x", () => ({ ...createGalleryView(cfg), density: 5 }))` → T 推断失败 → `this` 变成 `InferInterceptors<{}> & XDataContext & Magics<{}>` → **所有 `this.xxx` 报 ts(2339)**(一次报 69 个)。
|
||||
原因:T 能从「对象字面量」推断,但从「展开表达式」推断不出来。
|
||||
**修法:给回调显式标注返回类型** —— `(): ReturnType<typeof createGalleryView> & ArticleExtras => ({...})`;`this` 取上下文类型后全部解析。Item 为此写了 `ArticleExtras` 类型(约 28 个成员)。
|
||||
- **排查铁律**:见到「Property 'x' does not exist on type '{}'」或「Object of type 'unknown'」,先怀疑这两个推断问题,别怀疑业务代码。
|
||||
- 其他常见修法:`(e.target as Element|null)?.closest()`;`var el` 收窄后另存 `const screen = el` 供闭包用(闭包内不保留收窄);`querySelector<HTMLElement>()` + `?? null`。
|
||||
- **swiper 样式声明必须放「非模块」d.ts**(`src/types/swiper-css.d.ts`):`env.d.ts` 有 `export {}` 是模块,简写式 ambient 声明在模块文件里**不生效**。
|
||||
|
||||
## 验证手段(本项目可复用套路)
|
||||
- 改完必跑 `npm run build`(全链路 import 校验 + 路由预渲染)+ `npm run check`。
|
||||
- **预渲染页面:产物 HTML 归一化对比**。`dist/client/{en,cn}/<page>/index.html`,改前 `Copy-Item` 存一份,改后归一化掉 `astro-cid-*` 与 `_astro/<asset>` 再逐字节比。
|
||||
- **`prerender=false` 页面(`item/[id]`):临时验证页法**(2026-09-14 新增)。dist 里没有 HTML,产物对比失效。做法:临时建一个 **prerender 的页面**(如 `src/pages/zzverify.astro`)直接渲染目标组件、喂假数据 → `npm run build` → 用 PowerShell 正则**统计产物 HTML 里的关键类名/绑定出现次数** → 验完**删页并重建**清 dist。
|
||||
例:核对灯箱时统计 `id="lb-thumbs"`×2、`id="lb-details"`×1、`currentFavId()`×4、`aspect-[2/3]`×6 / `aspect-[3/4]`×6(6 = 5 张 SSR + 1 个 x-for 模板);核对旧样式清零。
|
||||
- **`prerender=false` 页面更真实的验法(dev server 起在 :4321 时)**:直接 curl 真实 SSR 页面,比 zzverify 假数据更真。取 id:`curl http://localhost:8090/api/v1/public/runway-looks`(响应是 `{"data":[{...}]}`,**`data` 直接是数组**,不是 `data.list`)→ `data[0].id`;再 `curl http://localhost:4321/en/item/<id>` → 正则统计关键标记。注意匿名请求会带 preview 门禁(只渲染前 5 张)。2026-09-14 灯箱重构即此法验证:`fav-btn` 11 = 3 条内联 CSS + 6 网格(5 SSR + 1 个 x-for 模板)+ 1 画廊灯箱 + 1 细节灯箱。
|
||||
- **PowerShell 取输出**:`astro check` 的 stdout 会被吞 → `npx astro check 2>&1 | Out-File -FilePath x.txt -Encoding utf8` 再用文件工具读;`Get-Content` **必须带 `-Encoding utf8`**(否则被安全策略拦)。HTML 是单行,别直接 grep 打印,用 `[regex]::Matches($h,'...').Count` 统计。
|
||||
|
||||
## 模块策略
|
||||
- 主线:首页 + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。品牌索引页已下线(2026-08-26),品牌经走秀页侧栏 filter + 品牌弹窗(热门 30 SSG,搜索/字母运行时 `/api/v1/public/brands`)。
|
||||
@ -31,18 +62,44 @@
|
||||
|
||||
## 未登录预览门禁(详情页,2026-09-03 定稿 + 后续)
|
||||
- 用户意图:列表页全量展示,限制只发生在**点进图集详情后**——未登录仅看前 5 张图,其余锁图遮罩提示登录。
|
||||
- 后端硬截断(真防护):`GET /api/v1/public/runway-looks/:id` 与 `/street-snaps/:id` 按 `middleware.UserIDFrom(c)` 判匿名且 `len(Images)>5` 时 `Images=Images[:5]` 并回 `preview:true` + `image_total`(截断前总数)。已登录(带 Bearer)返回完整图片集、无 preview。列表接口全量,翻页 page>1 仍 401 由 PublicFirstPageAuth 兜底(始终解析 Bearer、仅 page>1 强制)。
|
||||
- 前端:详情组件 `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`。
|
||||
- **底部 Preview 大框门禁已于 2026-09-03 后续移除**(用户认为意义不大),仅保留画廊内遮罩提示;对应 dict key `login to view all photos` 已删。
|
||||
- 后端硬截断(真防护):`GET /api/v1/public/runway-looks/:id` 与 `/street-snaps/:id` 按 `middleware.UserIDFrom(c)` 判匿名且 `len(Images)>5` 时 `Images=Images[:5]` 并回 `preview:true` + `image_total`(截断前总数)。已登录(带 Bearer)返回完整图片集、无 preview。列表接口全量,翻页 page>1 仍 401 由 `PublicFirstPageAuth` 兜底。
|
||||
- 前端:详情组件由 SSR `getSsr*` 透传 `preview`/`imageTotal`;画廊 SSR 渲染前 5 张 + 剩余 `(imageTotal-5)` 张空图遮罩(锁图标 + "login to view" + "+N photos",`x-show="!isAuthed && preview"` + `x-cloak`)。已登录/`auth:login` 触发 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed`(带 Bearer 取全量)把第 6 张起追加进 `extraImages`。`getUser()` 读 localStorage `fa_user`。
|
||||
- **底部 Preview 大框门禁已于 2026-09-03 后续移除**,仅保留画廊内遮罩提示。
|
||||
|
||||
## 账号体系
|
||||
- 仅预置内部账号不开放注册。`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 无状态 + 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。
|
||||
- 仅预置内部账号不开放注册。`seed_users` 建 `admin`(`SEED_ADMIN_PASSWORD` 缺省 `Studio#2026!Admin`) + `root`/`root`(uid 通常 8,本地 `db_dev` 确有,登录 200)。
|
||||
- 双令牌 JWT:access 7d 无状态 + refresh 30d 仅存 SHA256 落库;登录即吊销该用户全部 refresh(互斥登录)。`/api/v1/auth/`:`/login` `/refresh`(复用不轮换) `/logout` `/logout-all` `/me`(Bearer)。
|
||||
- **前端 lib 分层**(2026-09-14 核对磁盘,纠正此前多次误记):
|
||||
- `src/lib/auth.ts` **仅会话存储原语**:`getUser`/`getAccessToken`/`getRefreshToken`/`getAccessExpiresAt`/`isAccessExpiring`/`getLastActiveAt`/`touchActivity`/`clearSession`/`saveSession`。
|
||||
- `src/lib/api.ts` 业务取数 + 鉴权函数(`login`/`logout`/`fetchMe`/`enforceIdleLogout`)+ 收藏/历史写接口 + 带 Bearer 的 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed`。
|
||||
- **`login`/`logout`/`fetchMe`/`enforceIdleLogout` 有意留在 api.ts,不要搬进 auth.ts**:它们要经 `request()`,而 request.ts 依赖 auth.ts 的令牌读取,搬过去会形成 `auth ↔ request` 循环依赖(09-13 那次拆分没落地就是这个原因)。api.ts 头注释已写明。
|
||||
- `src/lib/ssg.ts` **构建期 SSG 专用**:`getSsgIndexRunway`/`getSsgHotBrands`/`getSsgStreetSnapPopular` + `SsgPopularBrand`;消费 `Index.astro`/`RunwayLooks.astro`。
|
||||
- `src/lib/ssr.ts` **运行期按需 SSR 取数**:`getSsrArticle`/`getSsrStreetSnap` + 两个 Result 类型;消费 `components/pages/ItemPage.astro`。与 ssg.ts 对称(ssg=构建期 / ssr=运行期),**两者都是服务端可信内部取数,不经签名、不带 Bearer**。
|
||||
- **`src/lib/gallery.ts`(2026-09-14 新增)**:`createGalleryView(cfg)` 工厂,装走秀/街拍详情页**逐行同构**的图集+大图灯箱 Alpine 逻辑(约 250 行)。cfg 仅 4 项差异:`rootSelector`/`gallerySelector`/`favType`/`fetchDetail`。导出 `GalleryView`(=`ReturnType<typeof createGalleryView>`)、`GalleryImageRaw`、`GalleryDetailFetcher`、`GalleryExtraImage`。**三条工厂约束(别"顺手改回去")**:① 内部**不用 Alpine 魔法**(`$el`/`$nextTick`)→ 用 `cfg.rootSelector` 查根节点 + `requestAnimationFrame` 等渲染(否则展开后 this 丢魔法);② **getter 会被展开求值成静态值** → `currentFavId`/`currentFavUrl` 是**方法**,模板写 `currentFavId()`;③ 共享初始化叫 **`galleryInit()`** 而非 `init()`(组件的 `init()` 会覆盖它)。
|
||||
- `src/lib/crypto.ts` 签名 `clientSign`;`src/lib/locale.ts` 语言透传 `setApiLocale`/`currentLocale`/`withLocale`。
|
||||
- `src/lib/endpoints.ts` **后端 API 端点路径唯一来源**:分 `public`/`auth`/`me`/`ssg` 四组(动态 :id 用函数如 `public.runwayLook(id)`;`V1="/api/v1"`、`SSG="/api/internal/ssg"` 前缀只此定义一次)。**与 `src/lib/routes.ts` 的 `ROUTES` 严格区分**:`endpoints`=fetch 的后端接口路径(locale 走 ?locale=);`ROUTES`=前端页面跳转 URL(带 /en /cn 前缀)。改接口路径只动 `endpoints.ts` 一处。
|
||||
- `src/lib/http.ts` 全局 fetch 调试日志拦截器(`request.ts` 加载即安装)。**开关只认 `DEV || DEBUG_API==="true"`**——曾含 `import.meta.env.SSR`,而该值在构建出的 Node 端恒 true,导致生产每请求都 `clone()+读 body` 且往容器 `logs/api-debug.log` 无限追加。
|
||||
- 依赖无环:api→{request,crypto,auth,locale,endpoints};ssr→{request,endpoints,locale,api(type-only)};ssg→{request,api(type),endpoints};gallery→{request,auth,favorites};request→{crypto,auth,locale,config,endpoints};auth→locale;endpoints 为叶子。
|
||||
- 硬门禁:RunwayLooks/StreetSnaps 筛选与翻页 `requireLogin()`,未登录弹内嵌登录表单,登录成功 `loadPage(1)`;首屏 `init()` 的 `loadPage(1)` 不拦。
|
||||
- **收藏服务端化**:表 `favorites` + `/api/v1/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(单发请求,无循环);收藏/取消本地乐观 + 后台同步。
|
||||
- **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 详情接口」,匿名公开/列表接口绝不合并。
|
||||
- **浏览历史**(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)。
|
||||
- **收藏服务端化**:表 `favorites` + `/api/v1/me/favorites`(GET 分页/POST 幂等/DELETE/`/me/favorites/checks` 批量校验)。按 `user_id` 隔离,`target_uid` 编码串(图集 runway `r=`/street `s=`,图片 `i=`/`j=`)。前端 `src/lib/favorites.ts`:`fetchFavoritesPage()` 服务端分页;`checkFavorited(ids)` 仅发当前可见 id(单发请求,无循环);收藏/取消本地乐观 + 后台同步。
|
||||
- **item 页收藏态已合并进 authed 详情接口(2026-09-13)**:登录态用一次 `get*DetailAuthed` 拿回完整图片集+收藏态:图集级 `fav` 经 `fav:set` 事件广播给 fav-fab、图片级以 `im.favorited` 重建 `favIds`;fav-fab 只听 `fav:set` 不再调 `checkFavorited`。**item 页已登录收藏校验 3→0 次**。列表页仍用 `/me/favorites/checks` 批量校正。**铁律:收藏态只能合并进「带 Bearer 的 authed 详情接口」,匿名公开/列表接口绝不合并**。
|
||||
- **浏览历史**(2026-09-02):记录时机在 item 详情页 `recordHistory(meta.id)`(编码串带类型);列表页不记。后端 `histories` 表唯一键 `uniq(user_id,target_uid)`,`HISTORY_CAP=2000` FIFO。前端 `src/lib/history.ts`:账户页服务端分页 + 逐篇回查 `getHistoryMeta(id)`。
|
||||
|
||||
## 前端复用与页面结构
|
||||
- 详情页统一 `/item`:页面 `src/pages/{en,cn}/item/[id].astro` 是 4 行薄包装(**`export const prerender = false` 必须留在页面文件**,Astro 只读页面模块的该导出),实现见 `src/components/pages/ItemPage.astro`(id 由页面 `Astro.params.id` 取出后作 prop 透传)。id 类型前缀 r=/s= 直接查对应表无回落。`ROUTES.item(id)`/`ROUTES.streetSnap(id)` 都指向 `/item/${id}`。
|
||||
- **详情页灯箱组件化(2026-09-14)**:`src/components/GalleryLightbox.astro` = 走秀/街拍共享的灯箱**标记**(约 150 行),props 仅 `images`(SSR 首屏图,须带 `thumb`)+ `aspect`(`2/3` 走秀 / `3/4` 街拍,**两个字面量都写在组件源码里**保证 JIT 扫得到);走秀独有的「细节图簇」经具名插槽 `slot="details"` 注入。交互全在 `lib/gallery.ts`。`Item.astro` 1161→约 850 行、`StreetSnap.astro` 645→约 330 行,两者 `<style>` 整块删除(原子样式已上提 global.css)。
|
||||
- **灯箱视觉基准 = Item 的编辑风**(StreetSnap 旧版已对齐):计数零填充 `01 / 05` + `tracking-[0.22em]` 直角细框;缩略图激活态 `ring-1 ring-black`;nav/关闭按钮直角 + `hover:bg-black hover:text-white`;关闭图标用 SVG(不用 `✕` 字符);缩略图条无渐变。
|
||||
- 灯箱的单图收藏按钮是**圆形**(对齐全站收藏语义:列表卡片 `.fav-toggle`、详情页 `#fav-fab` 都 `rounded-full`)。
|
||||
- **cn/en 页面去重(2026-09-14)**:`src/components/pages/{LoginPage,AccountPage,ItemPage}.astro` = 共享实现(语言由 `Astro.currentLocale` 运行时决定,**页面文件里没有任何语言分支**);`pages/{en,cn}/{login,account,item/[id]}.astro` 变薄包装。`pages/{en,cn}/{index,runway-looks,street-snaps}` 本就是 5 行包装,**未去重**(去了反而更长)。`LoginPage` 不走 Layout、自带完整 `<html>` 文档。
|
||||
- 列表组件同构,抽 `src/styles/look-grid.css` + `src/lib/looks-grid.ts`(`SPIN_SVG`/`makeCardSlots`/`buildPageList`/`buildWindowPages`)。注意两个分页函数形态不同:`buildPageList` 输出 `number|"..."`(列表页用),`buildWindowPages` 输出 `{label,page}`(账户页用),勿混用。品牌筛选弹窗 `BrandModal.astro`(仅 RunwayLooks)。卡片标记在 Alpine `x-for` 内客户端渲染,不能提成 Astro 组件。
|
||||
- 账户页 `AccountPage.astro`:报头 + 两 tab(Saved looks / My History)。
|
||||
|
||||
## i18n 字典约定(`src/i18n/dictionary.ts`)
|
||||
- 结构 `export const dict: Record<string, {cn:string}>`。**key 即英文原文**(en 直接返 key),value 只存非默认语言(仅 cn)。`translate(key,locale)` 对 key `toLowerCase()` 后查,**key 大小写不敏感**;缺翻译 en 回退 + dev 告警。
|
||||
- **勿把 key 大小写不一致当「死 key」**:`t('Home')`/`t('Previous')`/`t('No article ID specified')` 与 dict 全小写不一致但**都能命中**(`toLowerCase()` 查表)。
|
||||
- 调用面:组件用 `t('English phrase')`;部分标题经变量 `t(city)` 传入(城市 18 个,勿因「无字面量 t()」误删)。
|
||||
- **中文绝不可当 key**:`t('中文')` 在 en 态显示中文。硬编中文先改英文 key 并在 dict 补 `{cn:'中文'}`。
|
||||
- 2026-09-13 审计:补 6 缺失 key、中文 key `全部的 runway` 改 `all runway looks`、删 20 个零调用死条目;字典 92→78 条。
|
||||
|
||||
## 部署
|
||||
- Docker + Gitea Actions:Dockerfile/nginx.conf/docker-compose.yml/deploy.sh;nginx 拦截 `/api/v1/ssg/` 404,健康检查 `/api/v1/public/brands`。
|
||||
@ -51,12 +108,9 @@
|
||||
- 走秀 `brand_runway`:`collection_type`(rtw/menswear/couture/resort/pre_fall)+`season`+`season_code`(SS26…),title 规则回填(幂等)。公开列表 `?collection_type=&season=&season_code=&year=` 筛选。
|
||||
- 街拍 `street_snap.city`:VARCHAR(128) 精确匹配 `WHERE city=?`;前端 header 下拉 `?city=` + 侧栏 City pills。规范值「首字母大写英文」(Paris/New York/Copenhagen…),大小写敏感须一致;dev 库仅 12 行样本属预期。
|
||||
|
||||
## 前端复用
|
||||
- 详情页统一 `/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 组件。
|
||||
|
||||
## 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 条。
|
||||
## 已知待办(尚未做,勿误以为已完成)
|
||||
- 详情页重复 HTML 片段(SSR 首屏图与 `extraImages` 两份 figure 结构)抽组件 —— 灯箱主体已抽,但**网格图那两份 figure 仍在 Item/StreetSnap 各写一遍**。
|
||||
- 列表页分页/收藏 mixin 下沉(净减行数仅约 35 行,收益/风险比低,暂缓)。
|
||||
- `favorites.ts`(8.3KB)、`history.ts`(6.5KB)、`look-grid.css`(8.9KB) 未逐行审。
|
||||
- `account.astro` 残留 `any`(`user: null as any`、`favorites: [] as any[]` 等)未补类型。
|
||||
- P2 未做(按"不改样式"主动跳过):图片 `eager` 数量(现前 6 张)、街拍骨架屏数量(现 4 个 vs 走秀 24 个,加载完会跳)。
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
const BASE = "http://localhost:8090";
|
||||
const login = await (await fetch(BASE + "/api/v1/auth/login", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ account: "root", password: "root" }),
|
||||
})).json();
|
||||
const tok = login.access_token;
|
||||
const H = { "Content-Type": "application/json", Authorization: "Bearer " + tok };
|
||||
if (!tok) { console.log("LOGIN_FAIL", JSON.stringify(login)); process.exit(1); }
|
||||
|
||||
const get = async () => {
|
||||
const r = await fetch(BASE + "/api/v1/auth/history?page=1&per_page=24", { headers: H });
|
||||
const j = await r.json();
|
||||
return { status: r.status, items: j?.data?.items };
|
||||
};
|
||||
|
||||
const post = async (body) => {
|
||||
const r = await fetch(BASE + "/api/v1/auth/history", { method: "POST", headers: H, body: JSON.stringify(body) });
|
||||
return { status: r.status, text: (await r.text()).slice(0, 120) };
|
||||
};
|
||||
|
||||
console.log("1 GET empty =>", JSON.stringify(await get()));
|
||||
console.log("2 NEW fmt POST =>", JSON.stringify(await post({ target_uid: "r0042h1mx" })));
|
||||
console.log("3 GET after =>", JSON.stringify(await get()));
|
||||
console.log("4 OLD fmt POST =>", JSON.stringify(await post({ kind: "gallery", target_type: "runway_looks", target_uid: "s0042h1my", title: "T", cover: "C", brand: "B" })));
|
||||
console.log("5 GET after2 =>", JSON.stringify(await get()));
|
||||
process.exit(0);
|
||||
@ -1,60 +0,0 @@
|
||||
const BASE = "http://localhost:8090";
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function jsonFetch(path, opts = {}, retries = 8) {
|
||||
let lastErr;
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await fetch(BASE + path, opts);
|
||||
if (res.ok) return { status: res.status, data: await res.json() };
|
||||
return { status: res.status, data: await res.json().catch(() => null) };
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
await sleep(800);
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// 1) 登录 root
|
||||
const login = await jsonFetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ account: "root", password: "root" }),
|
||||
});
|
||||
const tok = login?.data?.access_token;
|
||||
if (!tok) { console.log("LOGIN_FAIL", JSON.stringify(login)); process.exit(1); }
|
||||
console.log("LOGIN_OK uid=", login.data.user?.id);
|
||||
|
||||
const auth = (m, p, b) => jsonFetch(p, {
|
||||
method: m,
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer " + tok },
|
||||
body: b ? JSON.stringify(b) : undefined,
|
||||
});
|
||||
|
||||
// 2) 取一篇真实走秀文章的对外编码 id
|
||||
const list = await jsonFetch("/api/v1/public/runway-looks?page=1&size=1&locale=en");
|
||||
const art = list?.data?.data?.[0];
|
||||
if (!art?.id) { console.log("NO_ARTICLE", JSON.stringify(list).slice(0, 300)); process.exit(1); }
|
||||
const articleId = String(art.id);
|
||||
console.log("ARTICLE id=", articleId, "title=", art.title?.slice(0, 30));
|
||||
|
||||
// 3) 记录一次浏览(文章级)
|
||||
const post1 = await auth("POST", "/api/v1/auth/history", { target_uid: articleId });
|
||||
console.log("POST1 status=", post1.status, JSON.stringify(post1.data));
|
||||
|
||||
// 4) 列表应含该文章
|
||||
const get1 = await auth("GET", "/api/v1/auth/history?page=1&per_page=24");
|
||||
const g1 = get1.data?.data;
|
||||
console.log("GET1 total=", g1?.total, "items=", JSON.stringify(g1?.items?.map((x) => x.id)));
|
||||
|
||||
// 5) 再次记录同一篇(应 upsert,total 不增)
|
||||
await auth("POST", "/api/v1/auth/history", { target_uid: articleId });
|
||||
const get2 = await auth("GET", "/api/v1/auth/history?page=1&per_page=24");
|
||||
console.log("GET2(after dup POST) total=", get2.data?.data?.total, "(应为 1,upsert 生效)");
|
||||
|
||||
// 6) 回查详情接口(账户页同款)——确认能取到封面/标题
|
||||
const detail = await jsonFetch("/api/v1/public/runway-looks/" + articleId + "?locale=en");
|
||||
console.log("DETAIL_OK title=", detail.data?.data?.title?.slice(0, 30), "cover=", detail.data?.data?.cover?.slice(0, 40));
|
||||
|
||||
process.exit(0);
|
||||
@ -26,15 +26,10 @@ export default defineConfig({
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'cn'],
|
||||
routing: {
|
||||
prefixDefaultLocale: true, // 英文在根路径 /shows,中文在 /cn/shows
|
||||
prefixDefaultLocale: true, // 两种语言都带前缀:英文 /en/shows,中文 /cn/shows
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
// 示例:在构建过程中生 成`page.html` 而不是 `page/index.html`。
|
||||
// format: 'file'
|
||||
},
|
||||
|
||||
integrations: [alpinejs()],
|
||||
|
||||
adapter: node({
|
||||
|
||||
15
dev.log
15
dev.log
@ -1,15 +0,0 @@
|
||||
|
||||
> test@0.0.1 dev
|
||||
> astro dev
|
||||
|
||||
Another astro dev server is already running.
|
||||
|
||||
URL: http://localhost:4321
|
||||
PID: 19012
|
||||
|
||||
Run `astro dev stop` to stop it, or use `astro dev --force` to replace it.
|
||||
[1mLocation:[22m
|
||||
[4mD:\project\test\test\node_modules\astro\dist\cli\dev\index.js:157:13[24m
|
||||
[1mStack trace:[22m
|
||||
[2m at dev (file:///D:/project/test/test/node_modules/astro/dist/cli/dev/index.js:157:13)
|
||||
at async runCommand (file:///D:/project/test/test/node_modules/astro/dist/cli/index.js:183:22)[22m
|
||||
@ -1 +0,0 @@
|
||||
y-2.5 px-4 flex items-center justify-between transition-colors cursor-pointer" data-astro-cid-ju4pidww><div class="flex items-center gap-2" data-astro-cid-ju4pidww><!-- <Sparkles class="w-3.5 h-3.5 text-amber-300" /> --><span data-astro-cid-ju4pidww>AI Color Trend Generator</span></div><span class="text-[10px] text-zinc-400 font-normal" data-astro-cid-ju4pidww>Gemini 2026</span></button><button id="btn-extractor" class="w-full bg-zinc-100 hover:bg-zinc-200 border border-zinc-300 text-black text-xs font-mono font-bold uppercase tracking-wider py-2.5 px-4 flex items-center justify-between transition-colors cursor-pointer" data-astro-cid-ju4pidww><div class="flex items-center gap-2" data-astro-cid-ju4pidww><!-- <Layers class="w-3.5 h-3.5 text-zinc-600" /> --><span data-astro-cid-ju4pidww>Photo Swatch Extractor</span></div><span class="text-[10px] text-zinc-500 font-normal" data-astro-cid-ju4pidww>Upload</span></button></div></div></div><div class="flex flex-col sm:flex-row justify-between items-center text-[12px] font-mono text-zinc-500 gap-4" data-astro-cid-ju4pidww><div data-astro-cid-ju4pidww><span data-astro-cid-ju4pidww>© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.</span></div><div class="flex items-center gap-6" data-astro-cid-ju4pidww><button data-nav="About" class="nav-btn hover:text-black cursor-pointer uppercase font-bold" data-astro-cid-ju4pidww>About</button><button data-nav="Contact Us" class="nav-btn hover:text-black cursor-pointer uppercase font-bold" data-astro-cid-ju4pidww>Contact</button><button id="back-to-top" class="flex items-center gap-1 font-bold text-black hover:opacity-60 transition-opacity cursor-pointer uppercase" data-astro-cid-ju4pidww><span data-astro-cid-ju4pidww>Back to top</span><!-- <ArrowUp class="w-3.5 h-3.5" /> --></button></div></div>
|
||||
1115
package-lock.json
generated
1115
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "test",
|
||||
"name": "frontend-v2",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
@ -9,6 +10,7 @@
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"check": "astro check",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -20,5 +22,10 @@
|
||||
"astro": "^7.2.9",
|
||||
"swiper": "^11.2.10",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
"@types/node": "^26.5.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
2026/09/02 15:03:50 ✓ 配置文件: D:\project\backend_v2\configs\config.yml
|
||||
2026/09/02 15:03:50 ✓ MySQL 已连接: 127.0.0.1:3306/db_dev
|
||||
2026/09/02 15:03:50 ✓ 静态资源: /uploads → D:\project\backend_v2\uploads
|
||||
2026/09/02 15:03:50 🚀 公开服务已启动: http://localhost:8090
|
||||
2026/09/02 15:03:50 🔒 SSG 内部服务已启动: http://localhost:8091 (仅构建期/回环可达)
|
||||
[mysql] 2026/09/02 15:15:12 connection.go:49: bad connection
|
||||
[mysql] 2026/09/02 15:22:42 connection.go:49: bad connection
|
||||
[mysql] 2026/09/02 15:27:04 connection.go:49: read tcp 127.0.0.1:60958->127.0.0.1:3306: wsarecv: An established connection was aborted by the software in your host machine.
|
||||
[mysql] 2026/09/02 15:27:43 connection.go:49: read tcp 127.0.0.1:55938->127.0.0.1:3306: wsarecv: An established connection was aborted by the software in your host machine.
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
|
||||
// 全站统一登录门禁,两种语义属性(按使用场景二选一,不要混用):
|
||||
// - login-required:需要登录的「动作」(收藏/翻页/看完整图…)。点击时若未登录 →
|
||||
@ -69,9 +69,25 @@ const fullLoginLabel = t('go to full login')
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { login } from "@/lib/api"
|
||||
|
||||
// 全局登录 store 的数据形状。
|
||||
// 必须显式声明并注解:@types/alpinejs 的 Stores 是 `[key: string | symbol]: unknown`,
|
||||
// 内联字面量会失去上下文类型,导致各方法内 this 退化成 {}(原先 16 处报错)。
|
||||
interface AuthStore {
|
||||
isOpen: boolean
|
||||
pending: HTMLElement | null
|
||||
account: string
|
||||
password: string
|
||||
error: string
|
||||
loading: boolean
|
||||
request(el: HTMLElement): boolean
|
||||
close(): void
|
||||
submit(): Promise<void>
|
||||
goFullLogin(): void
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
// 全局登录 store:request(el) 返回是否放行;未登录记录 pending 并开层
|
||||
Alpine.store("auth", {
|
||||
const authStore: AuthStore = {
|
||||
isOpen: false,
|
||||
pending: null as HTMLElement | null,
|
||||
account: "",
|
||||
@ -126,7 +142,9 @@ const fullLoginLabel = t('go to full login')
|
||||
this.close()
|
||||
window.location.assign(`/${loc}/login?redirect=${encodeURIComponent(p)}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Alpine.store("auth", authStore)
|
||||
|
||||
// 全站统一登录门禁:两种语义属性(capture 阶段,先于元素自身 @click 处理)
|
||||
// login-required → 就地弹窗(登录后自动重放原点击)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import { getI18n } from "@/i18n/utils";
|
||||
import { getI18n } from "@/lib/i18n";
|
||||
|
||||
interface Crumb {
|
||||
/** i18n key:英文原文,如 'Home' / 'Runway Archive';中文环境自动取字典翻译 */
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
|
||||
interface Props {
|
||||
|
||||
179
src/components/GalleryLightbox.astro
Normal file
179
src/components/GalleryLightbox.astro
Normal file
@ -0,0 +1,179 @@
|
||||
---
|
||||
// 走秀 / 街拍详情页共享的全屏大图灯箱:左栏缩略图条 + 大图 + 上/下·左/右翻页 + 单图收藏。
|
||||
//
|
||||
// 视觉遵循全站「黑白编辑风」:直角细框、mono 小字 + 宽字距、零填充计数、hover 反色(bg-black + 白字)。
|
||||
// 交互逻辑全部在 src/lib/gallery.ts(createGalleryView);本组件只负责标记。
|
||||
//
|
||||
// 各页独有的附加层通过具名插槽 details 注入(走秀页塞「当前 look 的细节图簇」,街拍页不传)。
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
|
||||
interface Props {
|
||||
/** SSR 首屏图片(前 PREVIEW_LIMIT 张),需带 thumb 供左栏缩略图条使用 */
|
||||
images: { thumb: string }[]
|
||||
/** 缩略图比例:走秀 look 为 2/3,街拍为 3/4 */
|
||||
aspect?: "2/3" | "3/4"
|
||||
}
|
||||
const { images, aspect = "2/3" } = Astro.props
|
||||
const { t } = getI18n(Astro)
|
||||
|
||||
// 两个字面量都出现在本文件源码里,保证 Tailwind v4 JIT 能扫描到
|
||||
const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
|
||||
---
|
||||
|
||||
<div
|
||||
x-show="lbOpen"
|
||||
x-cloak
|
||||
@keydown.window.escape="lbOpen && closeLb()"
|
||||
@keydown.window.arrow-right="lbOpen && nextLb()"
|
||||
@keydown.window.arrow-left="lbOpen && prevLb()"
|
||||
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 计数:mono + 0.22em 字距(全站「小标与计数」档),直角细框,零填充 */}
|
||||
<div
|
||||
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 border border-black/15 bg-white/90 px-3 py-1 font-mono text-[11px] tracking-[0.22em] text-[#111] backdrop-blur-md"
|
||||
x-text="`${String(lbIndex + 1).padStart(2, '0')} / ${String(imageCount).padStart(2, '0')}`"
|
||||
></div>
|
||||
|
||||
{/* 单图收藏:圆形,与列表卡片 .fav-toggle / 详情页右下角 #fav-fab 的全站「收藏=圆形」语义保持一致 */}
|
||||
<button
|
||||
login-required
|
||||
type="button"
|
||||
@click.stop="toggleFav(currentFavId(), currentFavUrl())"
|
||||
:class="isFav(currentFavId()) ? 'is-fav' : ''"
|
||||
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-[18px] h-[18px]" 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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="closeLb()"
|
||||
class="absolute top-5 right-5 z-30 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Close')}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
||||
<div
|
||||
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 no-scrollbar"
|
||||
>
|
||||
{images.map((im, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-lb-index={i}
|
||||
data-thumb-url={im.thumb}
|
||||
@click={`showLb(${i})`}
|
||||
class={`lb-thumb relative block w-full ${thumbAspect} mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[#eee] bg-cover bg-center`}
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
|
||||
aria-label={`${i + 1}`}
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 font-mono text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<button
|
||||
type="button"
|
||||
:data-lb-index="5 + i"
|
||||
:data-thumb-url="im.thumb"
|
||||
@click="showLb(5 + i)"
|
||||
class={`lb-thumb relative block w-full ${thumbAspect} mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[#eee] bg-cover bg-center`}
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
|
||||
:aria-label="String(5 + i + 1)"
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 font-mono text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M6 14l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M6 10l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="lb-main"
|
||||
@click.self="closeLb()"
|
||||
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
|
||||
x-bind:class="lbZoomed
|
||||
? 'overflow-y-auto overflow-x-hidden items-start'
|
||||
: 'items-center'"
|
||||
>
|
||||
<img
|
||||
id="lb-image"
|
||||
src=""
|
||||
alt=""
|
||||
@click="toggleZoom()"
|
||||
decoding="async"
|
||||
class="block select-none transition-opacity duration-200"
|
||||
x-bind:class="lbZoomed
|
||||
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
|
||||
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-0 right-0 bottom-11 z-20 px-6 text-center font-mono text-[11px] uppercase tracking-[0.2em] text-black/60 pointer-events-none"
|
||||
x-text="currentImageName"
|
||||
></div>
|
||||
|
||||
<div
|
||||
x-show="!lbZoomed"
|
||||
class="absolute left-0 right-0 bottom-4 z-20 px-6 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-black/40 pointer-events-none transition-opacity duration-200"
|
||||
>
|
||||
{t('Click image to zoom · Scroll to view · Esc to close')}
|
||||
</div>
|
||||
|
||||
{/* 各页独有的附加层(走秀页注入「当前 look 的细节图簇」;街拍页不传) */}
|
||||
<slot name="details" />
|
||||
</div>
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
import ComingSoon from "@/components/ComingSoon.astro"
|
||||
import { getSsgIndexRunway, getSsgStreetSnapPopular } from "@/lib/api"
|
||||
import { getSsgIndexRunway, getSsgStreetSnapPopular } from "@/lib/ssg"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from "@/lib/routes"
|
||||
|
||||
const { locale, t } = getI18n(Astro)
|
||||
@ -516,7 +516,7 @@ const streetItems: {
|
||||
import "swiper/css/free-mode"
|
||||
|
||||
/* ---------------- Swiper 实例管理 ---------------- */
|
||||
let swipers = []
|
||||
let swipers: Swiper[] = []
|
||||
let updateScheduled = false
|
||||
|
||||
// RAF 合并:同一帧内多张图片加载完成 / 多个 resize,只执行一次 swiper.update()
|
||||
@ -542,9 +542,9 @@ const streetItems: {
|
||||
function initSwipers() {
|
||||
destroySwipers()
|
||||
document.querySelectorAll(".hs-swiper").forEach((el) => {
|
||||
const prevBtn = el.parentElement?.querySelector(".hs-prev")
|
||||
const nextBtn = el.parentElement?.querySelector(".hs-next")
|
||||
const swiper = new Swiper(el, {
|
||||
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev") ?? null
|
||||
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next") ?? null
|
||||
const swiper = new Swiper(el as HTMLElement, {
|
||||
modules: [Navigation, FreeMode, Keyboard],
|
||||
slidesPerView: "auto",
|
||||
spaceBetween: 4,
|
||||
@ -580,7 +580,7 @@ const streetItems: {
|
||||
|
||||
// Tab 切换后重算尺寸(事件委托,兼容 View Transitions 后的新 DOM)
|
||||
document.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".tab")) {
|
||||
if ((e.target as Element | null)?.closest(".tab")) {
|
||||
setTimeout(scheduleSwiperUpdate, 60)
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
---
|
||||
import type { RawArticle } from "@/lib/api"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { ssrBase } from "@/lib/request"
|
||||
import { ssrAbs } from "@/lib/request"
|
||||
|
||||
interface Props {
|
||||
article: RawArticle | null
|
||||
@ -14,21 +15,15 @@ interface Props {
|
||||
const { article, error = null, preview = false, imageTotal = 0 } = Astro.props
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const full = (u: string): string => {
|
||||
if (!u) return ""
|
||||
if (/^https?:\/\//i.test(u)) return u
|
||||
return ssrBase().replace(/\/$/, "") + u
|
||||
}
|
||||
|
||||
// 改造后:后端详情接口 images 仅返回主图(look 图),细节图在 details 分组,需经「细节入口」展开。
|
||||
// 故此处 images 即为主图集合,默认只渲染它们;每张主图带 look_index 用于关联其细节图。
|
||||
const images = (article?.images ?? [])
|
||||
.map((im) => ({
|
||||
id: im.id,
|
||||
url: full(im.image || ""),
|
||||
url: ssrAbs(im.image || ""),
|
||||
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb(240px 样式)即可,
|
||||
// 不必拿 720px 的展示图当缩略图(一场 63 个 look 能省约 1MB)。后端未返回 thumb 时回落展示图。
|
||||
thumb: full(im.thumb || im.image || ""),
|
||||
thumb: ssrAbs(im.thumb || im.image || ""),
|
||||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||
name: im.name || "",
|
||||
lookIndex: Number(im.look_index ?? 0),
|
||||
@ -292,164 +287,15 @@ const homeHref = href(locale, ROUTES.home)
|
||||
)
|
||||
}
|
||||
|
||||
{/* 大图灯箱:与街拍详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
|
||||
走秀独有的「当前 look 的细节图簇」经 details 插槽注入。 */}
|
||||
{article && previewImages.length > 0 && (
|
||||
<div
|
||||
x-show="lbOpen"
|
||||
x-cloak
|
||||
@keydown.window.escape="lbOpen && closeLb()"
|
||||
@keydown.window.arrow-right="lbOpen && nextLb()"
|
||||
@keydown.window.arrow-left="lbOpen && prevLb()"
|
||||
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 计数:mono + 0.22em 字距(全站「小标与计数」档),直角细框,不用圆角药丸 */}
|
||||
<div
|
||||
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 border border-black/15 bg-white/90 px-3 py-1 font-mono text-[11px] tracking-[0.22em] text-[#111] backdrop-blur-md"
|
||||
x-text="`${String(lbIndex + 1).padStart(2, '0')} / ${String(imageCount).padStart(2, '0')}`"
|
||||
></div>
|
||||
|
||||
<button
|
||||
login-required
|
||||
type="button"
|
||||
@click.stop="toggleFav(currentFavId, currentFavUrl)"
|
||||
:class="isFav(currentFavId) ? 'is-fav' : ''"
|
||||
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-[18px] h-[18px]" 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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="closeLb()"
|
||||
class="absolute top-5 right-5 z-30 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Close')}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
||||
<div
|
||||
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 no-scrollbar"
|
||||
>
|
||||
{previewImages.map((im, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-lb-index={i}
|
||||
data-thumb-url={im.thumb}
|
||||
@click={`showLb(${i})`}
|
||||
class="lb-thumb relative block w-full aspect-[2/3] mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[#eee] bg-cover bg-center"
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
|
||||
aria-label={`${i + 1}`}
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 font-mono text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<button
|
||||
type="button"
|
||||
:data-lb-index="5 + i"
|
||||
:data-thumb-url="im.thumb"
|
||||
@click="showLb(5 + i)"
|
||||
class="lb-thumb relative block w-full aspect-[2/3] mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[#eee] bg-cover bg-center"
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
|
||||
:aria-label="String(5 + i + 1)"
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 font-mono text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M6 14l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M6 10l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="lb-main"
|
||||
@click.self="closeLb()"
|
||||
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
|
||||
x-bind:class="lbZoomed
|
||||
? 'overflow-y-auto overflow-x-hidden items-start'
|
||||
: 'items-center'"
|
||||
>
|
||||
<img
|
||||
id="lb-image"
|
||||
src=""
|
||||
alt=""
|
||||
@click="toggleZoom()"
|
||||
decoding="async"
|
||||
class="block select-none transition-opacity duration-200"
|
||||
x-bind:class="lbZoomed
|
||||
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
|
||||
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-0 right-0 bottom-11 z-20 px-6 text-center font-mono text-[11px] uppercase tracking-[0.2em] text-black/60 pointer-events-none"
|
||||
x-text="currentImageName"
|
||||
></div>
|
||||
|
||||
<div
|
||||
x-show="!lbZoomed"
|
||||
class="absolute left-0 right-0 bottom-4 z-20 px-6 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-black/40 pointer-events-none transition-opacity duration-200"
|
||||
>
|
||||
{t('Click image to zoom · Scroll to view · Esc to close')}
|
||||
</div>
|
||||
|
||||
<GalleryLightbox images={previewImages} aspect="2/3">
|
||||
{/* 当前 look 的细节图:右下角一小簇缩略图(竖版主图右下角本就是空白,不遮挡主图)。
|
||||
主图放大(lbZoomed)时整簇隐藏——放大态要专注当前大图;点其中一张进细节灯箱看大图。
|
||||
单个 look 最多 4 张细节图(多数 1~2 张),一排横放即可,不设滚动条。 */}
|
||||
<div
|
||||
slot="details"
|
||||
id="lb-details"
|
||||
x-show="!lbZoomed && currentDetailList.length > 0"
|
||||
x-cloak
|
||||
@ -469,7 +315,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GalleryLightbox>
|
||||
)}
|
||||
|
||||
{/* ===== 细节图灯箱:点大图灯箱里的细节缩略图打开,单张细节图放大查看 ===== */}
|
||||
@ -493,7 +339,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
type="button"
|
||||
@click.stop="toggleFav(currentDetailFavId, currentDetailFavUrl)"
|
||||
:class="isFav(currentDetailFavId) ? 'is-fav' : ''"
|
||||
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
@ -586,41 +432,73 @@ const homeHref = href(locale, ROUTES.home)
|
||||
|
||||
<script>
|
||||
import { getArticleDetailAuthed } from "@/lib/api"
|
||||
import { createGalleryView } from "@/lib/gallery"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
|
||||
|
||||
interface DetailImage {
|
||||
id: string
|
||||
url: string
|
||||
thumb: string
|
||||
raw: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// 走秀页在大图灯箱之上叠加的部分:每行张数(density)+ 细节图灯箱。
|
||||
//
|
||||
// 为什么要把这个类型写出来:`Alpine.data` 的泛型 T 靠**对象字面量**推断,而这里用了对象展开
|
||||
// ({...createGalleryView()}),推断会退化成 {},于是方法里的 this 只剩 Alpine 魔法属性,
|
||||
// 所有 this.xxx 都报「属性不存在」。给回调标注返回类型,T 才有确定值。
|
||||
// (@types/alpinejs 那个自引用泛型约束的老毛病,详见 MEMORY.md。)
|
||||
type ArticleExtras = {
|
||||
density: number
|
||||
currentDetailName: string
|
||||
detailLbOpen: boolean
|
||||
detailLbIndex: number
|
||||
detailLbZoomed: boolean
|
||||
detailImages: DetailImage[]
|
||||
detailImagesByLook: Record<number, DetailImage[]>
|
||||
detailLbFromLb: boolean
|
||||
lbReturnIndex: number
|
||||
init(): void
|
||||
gridClass(): string
|
||||
getImageLookIndex(index: number): number
|
||||
currentDetailList: DetailImage[]
|
||||
openDetailLbAt(idx: number): void
|
||||
buildDetailMap(): void
|
||||
detailCount(li: number): number
|
||||
openDetailLb(li: number, startIndex?: number, fromLb?: boolean): void
|
||||
closeDetailLb(): void
|
||||
showDetailLb(index: number): void
|
||||
nextDetailLb(): void
|
||||
prevDetailLb(): void
|
||||
normalizeDetailIndex(index: number): number
|
||||
updateDetailImage(): void
|
||||
detailImageUrl(index: number): string
|
||||
detailImageName(index: number): string
|
||||
currentDetailFavId: string
|
||||
currentDetailFavUrl: string
|
||||
toggleDetailZoom(): void
|
||||
scrollDetailThumbIntoView(): void
|
||||
preloadDetailAround(): void
|
||||
}
|
||||
|
||||
// 图集 + 大图灯箱的共享逻辑在 lib/gallery.ts;本组件只叠加走秀独有的「每行张数」与「细节图灯箱」。
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("articleView", () => ({
|
||||
lbOpen: false,
|
||||
lbIndex: 0,
|
||||
lbZoomed: false,
|
||||
imageCount: 0,
|
||||
currentImageName: "",
|
||||
currentDetailName: "",
|
||||
|
||||
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
|
||||
favIds: [],
|
||||
Alpine.data(
|
||||
"articleView",
|
||||
(): ReturnType<typeof createGalleryView> & ArticleExtras => ({
|
||||
...createGalleryView({
|
||||
rootSelector: "#article-view",
|
||||
gallerySelector: "#article-gallery",
|
||||
favType: "runway_image",
|
||||
galleryUid: "",
|
||||
galleryTitle: "",
|
||||
galleryBrand: "",
|
||||
|
||||
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
|
||||
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
|
||||
isAuthed: false,
|
||||
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
|
||||
preview: false,
|
||||
// 已登录态图集级(gallery,r=/s=)收藏态:由 loadAuthedState 从详情接口取回,广播给 fav-fab
|
||||
galleryFav: false,
|
||||
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
|
||||
extraImages: [] as { id: string; url: string; thumb: string; raw: string; name: string; lookIndex?: number }[],
|
||||
fetchDetail: getArticleDetailAuthed,
|
||||
}),
|
||||
|
||||
// 每行张数(应对图集图片很多):只有 3 / 5 两档(工具栏图标按钮切换),移动端固定 2 列。
|
||||
// 默认 5 张(进入图集详情页即展示 5 列网格,紧凑浏览)。
|
||||
density: 5,
|
||||
|
||||
// 细节图灯箱:点大图灯箱里的细节缩略图打开,展示该 look 的细节图集合。
|
||||
currentDetailName: "",
|
||||
detailLbOpen: false,
|
||||
detailLbIndex: 0,
|
||||
detailLbZoomed: false,
|
||||
@ -633,119 +511,16 @@ const homeHref = href(locale, ROUTES.home)
|
||||
lbReturnIndex: 0,
|
||||
|
||||
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()
|
||||
|
||||
// 解析后端返回的 details 分组(按 look_index 聚合细节图),供「细节入口」展开灯箱。
|
||||
// 本组件独有:解析后端 details 分组(按 look_index 聚合细节图),供「细节入口」展开灯箱。
|
||||
this.buildDetailMap()
|
||||
|
||||
this.imageCount = document.querySelectorAll(
|
||||
"#article-gallery [data-image-index]"
|
||||
).length
|
||||
|
||||
this.initThumbLazyLoading()
|
||||
this.initImageFavs()
|
||||
this.maybeLocateImageFromUrl()
|
||||
|
||||
// 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
|
||||
if (getUser()) this.loadAuthedState()
|
||||
// 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
|
||||
window.addEventListener("auth:login", () => { this.isAuthed = true; if (getUser()) this.loadAuthedState() })
|
||||
// 共享初始化(登录态、图片计数、缩略图懒加载、收藏态、深链定位、登录后补全量)
|
||||
this.galleryInit()
|
||||
},
|
||||
|
||||
// 已登录:用一次详情请求拿回「完整图片集 + 收藏态」,合并原三处 /me/favorites/checks 调用。
|
||||
// gallery 级(r=)收藏态广播给页面右下角 fav-fab;image 级(i=)收藏态直接重建 favIds。
|
||||
async loadAuthedState() {
|
||||
if (!getUser()) return
|
||||
try {
|
||||
const full = await getArticleDetailAuthed(this.galleryUid)
|
||||
if (!full || !Array.isArray(full.images)) return
|
||||
|
||||
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
|
||||
this.galleryFav = !!full.favorited
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
|
||||
)
|
||||
|
||||
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
|
||||
const favIds: string[] = []
|
||||
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
|
||||
|
||||
// 超预览上限才追加第 6 张起图片(前 5 张已由 SSR 渲染,仅补收藏态)
|
||||
if (this.preview) {
|
||||
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
|
||||
id: im.id || "",
|
||||
url: toAbs(im.image || ""),
|
||||
thumb: toAbs(im.thumb || im.image || ""),
|
||||
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 {
|
||||
/* 失败维持本地/截断态,门禁仍在 */
|
||||
}
|
||||
},
|
||||
|
||||
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
||||
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
|
||||
maybeLocateImageFromUrl() {
|
||||
if (typeof window === "undefined") return
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
const want = q.get("img")
|
||||
if (!want) return
|
||||
const figures = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("#article-gallery [data-fav-id]"),
|
||||
)
|
||||
const idx = figures.findIndex((el) => el.dataset.favId === want)
|
||||
if (idx < 0) return
|
||||
const fig = figures[idx] as HTMLElement
|
||||
requestAnimationFrame(() => {
|
||||
if (q.get("lb") === "1") {
|
||||
this.openLb(idx)
|
||||
return
|
||||
}
|
||||
fig.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
fig.classList.add("locate-flash")
|
||||
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
|
||||
})
|
||||
},
|
||||
|
||||
getImageElement(index: number) {
|
||||
return document.querySelector<HTMLElement>(
|
||||
`#article-gallery [data-image-index="${index}"]`
|
||||
)
|
||||
},
|
||||
|
||||
getImageUrl(index: number) {
|
||||
return this.getImageElement(index)?.dataset.imageUrl || ""
|
||||
},
|
||||
|
||||
getImageName(index: number) {
|
||||
return this.getImageElement(index)?.dataset.imageName || ""
|
||||
},
|
||||
|
||||
getImageFavId(index: number) {
|
||||
return this.getImageElement(index)?.dataset.favId || ""
|
||||
},
|
||||
|
||||
// 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主)
|
||||
getImageFavUrl(index: number) {
|
||||
return this.getImageElement(index)?.dataset.favUrl || ""
|
||||
// ===== 每行张数(密度) =====
|
||||
// 基础 grid-cols-2 与两个 md:grid-cols-* 字面量都出现在源码里,确保 Tailwind v4 JIT 能扫描到。
|
||||
gridClass() {
|
||||
return "grid-cols-2 " + (this.density === 5 ? "md:grid-cols-5" : "md:grid-cols-3")
|
||||
},
|
||||
|
||||
// 读第 index 张大图所属的主图序号(look_index),来自 figure 上的 data-look-index。
|
||||
@ -775,183 +550,11 @@ const homeHref = href(locale, ROUTES.home)
|
||||
this.openDetailLb(li, idx, true)
|
||||
},
|
||||
|
||||
// ===== 单图收藏 =====
|
||||
// 只把「当前这篇图集的全部图片 id」发给后端问哪些已收藏,
|
||||
// 复杂度与图片数成正比,与用户收藏总量无关。
|
||||
allImageFavIds(): string[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>("#article-gallery [data-fav-id]"),
|
||||
)
|
||||
.map((el) => el.dataset.favId || "")
|
||||
.filter(Boolean)
|
||||
},
|
||||
|
||||
// 本地即时填充收藏态(未登录态,或登录态加载服务端数据前的占位,避免爱心闪烁)。
|
||||
// 登录态的服务端校正已合并进 loadAuthedState(),不再单独请求 /me/favorites/checks。
|
||||
initImageFavs() {
|
||||
const ids = this.allImageFavIds()
|
||||
if (!ids.length) return
|
||||
this.favIds = ids.filter((id) => isFavorited(id))
|
||||
},
|
||||
|
||||
isFav(id: string) {
|
||||
return !!id && this.favIds.includes(id)
|
||||
},
|
||||
|
||||
toggleFav(id: string, url: string) {
|
||||
if (!id) return
|
||||
toggleImageFavorite({
|
||||
imageId: id,
|
||||
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
|
||||
imageUrl: url,
|
||||
parentUid: this.galleryUid,
|
||||
title: this.galleryTitle,
|
||||
brand: this.galleryBrand,
|
||||
})
|
||||
this.favIds = this.isFav(id)
|
||||
? this.favIds.filter((x: string) => x !== id)
|
||||
: [...this.favIds, id]
|
||||
},
|
||||
|
||||
// 灯箱里的收藏按钮跟随当前大图
|
||||
get currentFavId() {
|
||||
return this.getImageFavId(this.lbIndex)
|
||||
},
|
||||
|
||||
get currentFavUrl() {
|
||||
return this.getImageFavUrl(this.lbIndex)
|
||||
},
|
||||
|
||||
openLb(index = 0) {
|
||||
if (!this.imageCount) return
|
||||
|
||||
this.lbIndex = this.normalizeIndex(index)
|
||||
this.lbZoomed = false
|
||||
this.lbOpen = true
|
||||
|
||||
document.body.style.overflow = "hidden"
|
||||
|
||||
this.updateLightboxImage()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollThumbIntoView()
|
||||
this.preloadAround()
|
||||
})
|
||||
},
|
||||
|
||||
closeLb() {
|
||||
this.lbOpen = false
|
||||
this.lbZoomed = false
|
||||
document.body.style.overflow = ""
|
||||
},
|
||||
|
||||
showLb(index: number) {
|
||||
if (!this.imageCount) return
|
||||
|
||||
this.lbIndex = this.normalizeIndex(index)
|
||||
this.lbZoomed = false
|
||||
|
||||
this.updateLightboxImage()
|
||||
|
||||
const main = document.getElementById("lb-main")
|
||||
|
||||
if (main) {
|
||||
main.scrollTop = 0
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollThumbIntoView()
|
||||
this.preloadAround()
|
||||
})
|
||||
},
|
||||
|
||||
nextLb() {
|
||||
this.showLb(this.lbIndex + 1)
|
||||
},
|
||||
|
||||
prevLb() {
|
||||
this.showLb(this.lbIndex - 1)
|
||||
},
|
||||
|
||||
normalizeIndex(index: number) {
|
||||
if (!this.imageCount) return 0
|
||||
return ((index % this.imageCount) + this.imageCount) % this.imageCount
|
||||
},
|
||||
|
||||
updateLightboxImage() {
|
||||
const image = document.getElementById("lb-image") as HTMLImageElement | null
|
||||
|
||||
if (!image) return
|
||||
|
||||
const url = this.getImageUrl(this.lbIndex)
|
||||
const name = this.getImageName(this.lbIndex)
|
||||
|
||||
image.src = url
|
||||
image.alt = name
|
||||
this.currentImageName = name
|
||||
},
|
||||
|
||||
toggleZoom() {
|
||||
this.lbZoomed = !this.lbZoomed
|
||||
|
||||
const main = document.getElementById("lb-main")
|
||||
|
||||
if (main) {
|
||||
main.scrollTop = 0
|
||||
}
|
||||
},
|
||||
|
||||
scrollThumbIntoView() {
|
||||
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
|
||||
|
||||
if (!thumbs) return
|
||||
|
||||
const active = thumbs.querySelector<HTMLElement>(
|
||||
`[data-lb-index="${this.lbIndex}"]`
|
||||
)
|
||||
|
||||
if (!active) return
|
||||
|
||||
const target =
|
||||
active.offsetTop -
|
||||
(thumbs.clientHeight - active.clientHeight) / 2
|
||||
|
||||
thumbs.scrollTo({
|
||||
top: Math.max(0, target),
|
||||
behavior: "smooth",
|
||||
})
|
||||
},
|
||||
|
||||
preloadAround() {
|
||||
const indexes = [
|
||||
this.lbIndex,
|
||||
this.normalizeIndex(this.lbIndex - 1),
|
||||
this.normalizeIndex(this.lbIndex + 1),
|
||||
]
|
||||
|
||||
indexes.forEach((index: number) => {
|
||||
const url = this.getImageUrl(index)
|
||||
|
||||
if (!url) return
|
||||
|
||||
const image = new Image()
|
||||
image.decoding = "async"
|
||||
image.src = url
|
||||
})
|
||||
},
|
||||
|
||||
// ===== 每行张数(密度) =====
|
||||
// 只有 3 / 5 两档(工具栏图标按钮切换);移动端固定 2 列(5 列在手机上不可用)。
|
||||
// 基础 grid-cols-2 与两个 md:grid-cols-* 字面量都出现在源码里,确保 Tailwind v4 JIT 能扫描到。
|
||||
gridClass() {
|
||||
return "grid-cols-2 " + (this.density === 5 ? "md:grid-cols-5" : "md:grid-cols-3")
|
||||
},
|
||||
|
||||
// ===== 细节图分组 =====
|
||||
// 由根节点 data-details(后端按 look_index 聚合的细节图 JSON)构建 look_index -> 图片数组映射。
|
||||
buildDetailMap() {
|
||||
try {
|
||||
const raw = this.$el.dataset.details
|
||||
const raw = this.root()?.dataset.details
|
||||
const arr: any[] = raw ? JSON.parse(raw) : []
|
||||
const map: Record<number, { id: string; url: string; thumb: string; raw: string; name: string }[]> = {}
|
||||
for (const d of arr) {
|
||||
@ -1022,8 +625,12 @@ const homeHref = href(locale, ROUTES.home)
|
||||
})
|
||||
},
|
||||
|
||||
nextDetailLb() { this.showDetailLb(this.detailLbIndex + 1) },
|
||||
prevDetailLb() { this.showDetailLb(this.detailLbIndex - 1) },
|
||||
nextDetailLb() {
|
||||
this.showDetailLb(this.detailLbIndex + 1)
|
||||
},
|
||||
prevDetailLb() {
|
||||
this.showDetailLb(this.detailLbIndex - 1)
|
||||
},
|
||||
|
||||
normalizeDetailIndex(index: number) {
|
||||
const n = this.detailImages.length
|
||||
@ -1047,8 +654,12 @@ const homeHref = href(locale, ROUTES.home)
|
||||
return this.detailImages[index]?.name || ""
|
||||
},
|
||||
|
||||
get currentDetailFavId() { return this.detailImages[this.detailLbIndex]?.id || "" },
|
||||
get currentDetailFavUrl() { return this.detailImages[this.detailLbIndex]?.raw || "" },
|
||||
get currentDetailFavId() {
|
||||
return this.detailImages[this.detailLbIndex]?.id || ""
|
||||
},
|
||||
get currentDetailFavUrl() {
|
||||
return this.detailImages[this.detailLbIndex]?.raw || ""
|
||||
},
|
||||
|
||||
toggleDetailZoom() {
|
||||
this.detailLbZoomed = !this.detailLbZoomed
|
||||
@ -1072,7 +683,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
this.normalizeDetailIndex(this.detailLbIndex - 1),
|
||||
this.normalizeDetailIndex(this.detailLbIndex + 1),
|
||||
]
|
||||
indexes.forEach((index: number) => {
|
||||
indexes.forEach((index) => {
|
||||
const url = this.detailImageUrl(index)
|
||||
if (!url) return
|
||||
const image = new Image()
|
||||
@ -1080,82 +691,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
image.src = url
|
||||
})
|
||||
},
|
||||
|
||||
// TODO 这是干啥的
|
||||
initThumbLazyLoading() {
|
||||
const thumbs = document.querySelectorAll<HTMLElement>(
|
||||
"#lb-thumbs [data-thumb-url]"
|
||||
}),
|
||||
)
|
||||
|
||||
if (!thumbs.length) return
|
||||
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
thumbs.forEach((thumb) => {
|
||||
thumb.style.backgroundImage =
|
||||
`url("${thumb.dataset.thumbUrl}")`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries, obs) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return
|
||||
|
||||
const element = entry.target as HTMLElement
|
||||
const url = element.dataset.thumbUrl
|
||||
|
||||
if (url) {
|
||||
element.style.backgroundImage = `url("${url}")`
|
||||
}
|
||||
|
||||
obs.unobserve(element)
|
||||
})
|
||||
},
|
||||
{
|
||||
root: document.getElementById("lb-thumbs"),
|
||||
rootMargin: "300px",
|
||||
}
|
||||
)
|
||||
|
||||
thumbs.forEach((thumb) => observer.observe(thumb))
|
||||
},
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 单图收藏爱心:默认描边,已收藏实心 */
|
||||
.fav-btn svg {
|
||||
fill: none;
|
||||
}
|
||||
.fav-btn.is-fav svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
|
||||
.locate-flash {
|
||||
outline: 3px solid #111;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
|
||||
@media (hover: none) {
|
||||
.fav-btn {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -3,12 +3,17 @@
|
||||
// 轻量原则(用户要求"朴实流畅"):
|
||||
// - 只用静态大字 + mono 小标 + 一条 2px 细线进度(transform/opacity 动画,最便宜);
|
||||
// - 去掉昂贵的多层 text-shadow 位移动画与外部字体;最短展示时间压缩到 ~350ms,跟手不拖沓。
|
||||
|
||||
import { config } from '@/lib/config';
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
|
||||
const { t } = getI18n(Astro)
|
||||
---
|
||||
|
||||
<div id="loading-screen" aria-hidden="true">
|
||||
<div id="ls-box">
|
||||
<h1>Portfolio Studio</h1>
|
||||
<p class="ls-sub">Loading</p>
|
||||
<h1>{config.siteName}</h1>
|
||||
<p class="ls-sub">{t('loading')}</p>
|
||||
<div class="ls-bar" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -88,15 +93,16 @@
|
||||
<script>
|
||||
// load 后淡出;最少展示 350ms 防闪烁,最多 2s 兜底。
|
||||
(function () {
|
||||
var el = document.getElementById('loading-screen');
|
||||
const el = document.getElementById('loading-screen');
|
||||
if (!el) return;
|
||||
const screen = el; // 已收窄为非空;闭包内 TS 不保留对 el 的收窄,故另存一份
|
||||
var hidden = false;
|
||||
function hide() {
|
||||
if (hidden) return;
|
||||
hidden = true;
|
||||
el.classList.add('is-hidden');
|
||||
screen.classList.add('is-hidden');
|
||||
setTimeout(function () {
|
||||
el.style.display = 'none';
|
||||
screen.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
var start = Date.now();
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
---
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import BrandModal from "@/components/BrandModal.astro"
|
||||
import { getSsgHotBrands } from "@/lib/api"
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { getSsgHotBrands } from "@/lib/ssg"
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
import "@/styles/look-grid.css"
|
||||
|
||||
|
||||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
// 热门品牌:走 SSG 专属接口 /api/internal/ssg/brands/popular,后端固定返回热度前 30 个、按热度顺序排列。
|
||||
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
|
||||
@ -260,7 +260,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
</div>
|
||||
|
||||
|
||||
<script id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
|
||||
<script is:inline id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
|
||||
|
||||
|
||||
|
||||
@ -279,7 +279,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
|
||||
return {
|
||||
locale: '',
|
||||
hotBrands,
|
||||
hotBrands: hotBrands as { id: string; name: string }[],
|
||||
SPIN_SVG,
|
||||
// 侧栏分组折叠状态:默认全部收起(不做 localStorage 持久化,每次进页都是关着的)。
|
||||
collapsedGroups: {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
---
|
||||
import type { RawStreetSnap } from "@/lib/api"
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { ssrBase } from "@/lib/request"
|
||||
import { ssrAbs } from "@/lib/request"
|
||||
|
||||
interface Props {
|
||||
snap: RawStreetSnap | null
|
||||
@ -15,16 +16,12 @@ interface Props {
|
||||
const { snap, error = null, preview = false, imageTotal = 0 } = Astro.props
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const full = (u: string): string => {
|
||||
if (!u) return ""
|
||||
if (/^https?:\/\//i.test(u)) return u
|
||||
return ssrBase().replace(/\/$/, "") + u
|
||||
}
|
||||
|
||||
const images = (snap?.images ?? [])
|
||||
.map((im) => ({
|
||||
id: im.id,
|
||||
url: full(im.image || ""),
|
||||
url: ssrAbs(im.image || ""),
|
||||
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb 即可;未返回时回落展示图。
|
||||
thumb: ssrAbs(im.thumb || im.image || ""),
|
||||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||
name: im.name || "",
|
||||
}))
|
||||
@ -200,445 +197,34 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
)
|
||||
}
|
||||
|
||||
{/* 大图灯箱:与走秀详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
|
||||
街拍无细节图分组,故不传 details 插槽。 */}
|
||||
{snap && previewImages.length > 0 && (
|
||||
<div
|
||||
x-show="lbOpen"
|
||||
x-cloak
|
||||
@keydown.window.escape="closeLb()"
|
||||
@keydown.window.arrow-right="nextLb()"
|
||||
@keydown.window.arrow-left="prevLb()"
|
||||
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 font-mono text-[12px] font-semibold tracking-[0.14em] text-[#111] bg-white/75 border border-black/10 px-3 py-1.5 rounded-[6px] backdrop-blur-md"
|
||||
x-text="`${lbIndex + 1} / ${imageCount}`"
|
||||
></div>
|
||||
|
||||
<button
|
||||
login-required
|
||||
type="button"
|
||||
@click.stop="toggleFav(currentFavId, currentFavUrl)"
|
||||
:class="isFav(currentFavId) ? 'is-fav' : ''"
|
||||
class="fav-btn absolute top-[18px] right-[70px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 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-[18px] h-[18px]" 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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="closeLb()"
|
||||
class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 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('Close')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{previewImages.map((im, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-lb-index={i}
|
||||
data-thumb-url={im.url}
|
||||
@click={`showLb(${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={`${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">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</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
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 14l6-6 6 6" /></svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 10l6 6 6-6" /></svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6" /></svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M9 6l6 6 6-6" /></svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="lb-main"
|
||||
@click.self="closeLb()"
|
||||
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
|
||||
x-bind:class="lbZoomed
|
||||
? 'overflow-y-auto overflow-x-hidden items-start'
|
||||
: 'items-center'"
|
||||
>
|
||||
<img
|
||||
id="lb-image"
|
||||
src=""
|
||||
alt=""
|
||||
@click="toggleZoom()"
|
||||
decoding="async"
|
||||
class="block select-none transition-opacity duration-200"
|
||||
x-bind:class="lbZoomed
|
||||
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
|
||||
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-0 right-0 bottom-11 z-20 text-center text-black/60 font-mono text-[11px] uppercase tracking-[0.18em] pointer-events-none px-6"
|
||||
x-text="currentImageName"
|
||||
></div>
|
||||
|
||||
<div
|
||||
x-show="!lbZoomed"
|
||||
class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200"
|
||||
>
|
||||
{t('Click image to zoom · Scroll to view · Esc to close')}
|
||||
</div>
|
||||
</div>
|
||||
<GalleryLightbox images={previewImages} aspect="3/4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { getStreetSnapDetailAuthed } from "@/lib/api"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
|
||||
import { createGalleryView } from "@/lib/gallery"
|
||||
|
||||
// 图集 + 大图灯箱的共享逻辑全在 lib/gallery.ts;街拍详情没有额外扩展,只补一个 init 调共享初始化。
|
||||
// 回调显式标注返回类型:对象展开会让 Alpine.data 的泛型 T 退化成 {},this.xxx 全部报错(见 Item.astro 说明)。
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("streetSnapView", () => ({
|
||||
lbOpen: false,
|
||||
lbIndex: 0,
|
||||
lbZoomed: false,
|
||||
imageCount: 0,
|
||||
currentImageName: "",
|
||||
|
||||
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
|
||||
favIds: [],
|
||||
Alpine.data(
|
||||
"streetSnapView",
|
||||
(): ReturnType<typeof createGalleryView> & { init(): void } => ({
|
||||
...createGalleryView({
|
||||
rootSelector: "#street-snap-view",
|
||||
gallerySelector: "#snap-gallery",
|
||||
favType: "snap_image",
|
||||
galleryUid: "",
|
||||
galleryTitle: "",
|
||||
galleryBrand: "",
|
||||
|
||||
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
|
||||
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
|
||||
isAuthed: false,
|
||||
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
|
||||
preview: false,
|
||||
// 已登录态图集级(gallery,s=)收藏态:由 loadAuthedState 从详情接口取回,广播给 fav-fab
|
||||
galleryFav: false,
|
||||
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
|
||||
extraImages: [] as { id: string; url: string; raw: string; name: string }[],
|
||||
fetchDetail: getStreetSnapDetailAuthed,
|
||||
}),
|
||||
|
||||
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]"
|
||||
).length
|
||||
this.initThumbLazyLoading()
|
||||
this.initImageFavs()
|
||||
this.maybeLocateImageFromUrl()
|
||||
|
||||
// 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
|
||||
if (getUser()) this.loadAuthedState()
|
||||
// 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
|
||||
window.addEventListener("auth:login", () => { this.isAuthed = true; if (getUser()) this.loadAuthedState() })
|
||||
this.galleryInit()
|
||||
},
|
||||
|
||||
// 已登录:用一次详情请求拿回「完整图片集 + 收藏态」,合并原三处 /me/favorites/checks 调用。
|
||||
// gallery 级(s=)收藏态广播给页面右下角 fav-fab;image 级(j=)收藏态直接重建 favIds。
|
||||
async loadAuthedState() {
|
||||
if (!getUser()) return
|
||||
try {
|
||||
const full = await getStreetSnapDetailAuthed(this.galleryUid)
|
||||
if (!full || !Array.isArray(full.images)) return
|
||||
|
||||
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
|
||||
this.galleryFav = !!full.favorited
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
|
||||
}),
|
||||
)
|
||||
|
||||
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
|
||||
const favIds: string[] = []
|
||||
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
|
||||
|
||||
if (this.preview) {
|
||||
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
|
||||
id: im.id || "",
|
||||
url: toAbs(im.image || ""),
|
||||
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 {
|
||||
/* 失败维持本地/截断态,门禁仍在 */
|
||||
}
|
||||
},
|
||||
|
||||
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
||||
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
|
||||
maybeLocateImageFromUrl() {
|
||||
if (typeof window === "undefined") return
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
const want = q.get("img")
|
||||
if (!want) return
|
||||
const figures = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("#snap-gallery [data-fav-id]"),
|
||||
)
|
||||
const idx = figures.findIndex((el) => el.dataset.favId === want)
|
||||
if (idx < 0) return
|
||||
const fig = figures[idx] as HTMLElement
|
||||
requestAnimationFrame(() => {
|
||||
if (q.get("lb") === "1") {
|
||||
this.openLb(idx)
|
||||
return
|
||||
}
|
||||
fig.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
fig.classList.add("locate-flash")
|
||||
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
|
||||
})
|
||||
},
|
||||
|
||||
getImageElement(index: number) {
|
||||
return document.querySelector<HTMLElement>(
|
||||
`#snap-gallery [data-image-index="${index}"]`
|
||||
)
|
||||
},
|
||||
getImageUrl(index: number) {
|
||||
return this.getImageElement(index)?.dataset.imageUrl || ""
|
||||
},
|
||||
getImageName(index: number) {
|
||||
return this.getImageElement(index)?.dataset.imageName || ""
|
||||
},
|
||||
|
||||
// ===== 单图收藏 =====
|
||||
getImageFavId(index: number) {
|
||||
return this.getImageElement(index)?.dataset.favId || ""
|
||||
},
|
||||
|
||||
// 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主)
|
||||
getImageFavUrl(index: number) {
|
||||
return this.getImageElement(index)?.dataset.favUrl || ""
|
||||
},
|
||||
|
||||
allImageFavIds(): string[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>("#snap-gallery [data-fav-id]"),
|
||||
)
|
||||
.map((el) => el.dataset.favId || "")
|
||||
.filter(Boolean)
|
||||
},
|
||||
|
||||
// 本地即时填充收藏态(未登录态,或登录态加载服务端数据前的占位,避免爱心闪烁)。
|
||||
// 登录态的服务端校正已合并进 loadAuthedState(),不再单独请求 /me/favorites/checks。
|
||||
initImageFavs() {
|
||||
const ids = this.allImageFavIds()
|
||||
if (!ids.length) return
|
||||
this.favIds = ids.filter((id) => isFavorited(id))
|
||||
},
|
||||
|
||||
isFav(id: string) {
|
||||
return !!id && this.favIds.includes(id)
|
||||
},
|
||||
|
||||
toggleFav(id: string, url: string) {
|
||||
if (!id) return
|
||||
toggleImageFavorite({
|
||||
imageId: id,
|
||||
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
|
||||
imageUrl: url,
|
||||
parentUid: this.galleryUid,
|
||||
title: this.galleryTitle,
|
||||
brand: this.galleryBrand,
|
||||
})
|
||||
this.favIds = this.isFav(id)
|
||||
? this.favIds.filter((x: string) => x !== id)
|
||||
: [...this.favIds, id]
|
||||
},
|
||||
|
||||
get currentFavId() {
|
||||
return this.getImageFavId(this.lbIndex)
|
||||
},
|
||||
|
||||
get currentFavUrl() {
|
||||
return this.getImageFavUrl(this.lbIndex)
|
||||
},
|
||||
|
||||
openLb(index = 0) {
|
||||
if (!this.imageCount) return
|
||||
this.lbIndex = this.normalizeIndex(index)
|
||||
this.lbZoomed = false
|
||||
this.lbOpen = true
|
||||
document.body.style.overflow = "hidden"
|
||||
this.updateLightboxImage()
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollThumbIntoView()
|
||||
this.preloadAround()
|
||||
})
|
||||
},
|
||||
closeLb() {
|
||||
this.lbOpen = false
|
||||
this.lbZoomed = false
|
||||
document.body.style.overflow = ""
|
||||
},
|
||||
showLb(index: number) {
|
||||
if (!this.imageCount) return
|
||||
this.lbIndex = this.normalizeIndex(index)
|
||||
this.lbZoomed = false
|
||||
this.updateLightboxImage()
|
||||
const main = document.getElementById("lb-main")
|
||||
if (main) main.scrollTop = 0
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollThumbIntoView()
|
||||
this.preloadAround()
|
||||
})
|
||||
},
|
||||
nextLb() { this.showLb(this.lbIndex + 1) },
|
||||
prevLb() { this.showLb(this.lbIndex - 1) },
|
||||
|
||||
normalizeIndex(index: number) {
|
||||
if (!this.imageCount) return 0
|
||||
return ((index % this.imageCount) + this.imageCount) % this.imageCount
|
||||
},
|
||||
updateLightboxImage() {
|
||||
const image = document.getElementById("lb-image") as HTMLImageElement | null
|
||||
if (!image) return
|
||||
image.src = this.getImageUrl(this.lbIndex)
|
||||
image.alt = this.getImageName(this.lbIndex)
|
||||
this.currentImageName = this.getImageName(this.lbIndex)
|
||||
},
|
||||
toggleZoom() {
|
||||
this.lbZoomed = !this.lbZoomed
|
||||
const main = document.getElementById("lb-main")
|
||||
if (main) main.scrollTop = 0
|
||||
},
|
||||
scrollThumbIntoView() {
|
||||
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
|
||||
if (!thumbs) return
|
||||
const active = thumbs.querySelector<HTMLElement>(`[data-lb-index="${this.lbIndex}"]`)
|
||||
if (!active) return
|
||||
const target = active.offsetTop - (thumbs.clientHeight - active.clientHeight) / 2
|
||||
thumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
|
||||
},
|
||||
preloadAround() {
|
||||
const indexes = [this.lbIndex, this.normalizeIndex(this.lbIndex - 1), this.normalizeIndex(this.lbIndex + 1)]
|
||||
indexes.forEach((index: number) => {
|
||||
const url = this.getImageUrl(index)
|
||||
if (!url) return
|
||||
const image = new Image()
|
||||
image.decoding = "async"
|
||||
image.src = url
|
||||
})
|
||||
},
|
||||
initThumbLazyLoading() {
|
||||
const thumbs = document.querySelectorAll<HTMLElement>("#lb-thumbs [data-thumb-url]")
|
||||
if (!thumbs.length) return
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
thumbs.forEach((thumb) => { thumb.style.backgroundImage = `url("${thumb.dataset.thumbUrl}")` })
|
||||
return
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries, obs) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return
|
||||
const element = entry.target as HTMLElement
|
||||
const url = element.dataset.thumbUrl
|
||||
if (url) element.style.backgroundImage = `url("${url}")`
|
||||
obs.unobserve(element)
|
||||
})
|
||||
},
|
||||
{ root: document.getElementById("lb-thumbs"), rootMargin: "300px" }
|
||||
)
|
||||
thumbs.forEach((thumb) => observer.observe(thumb))
|
||||
},
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
/* 单图收藏爱心:默认描边,已收藏实心 */
|
||||
.fav-btn svg { fill: none; }
|
||||
.fav-btn.is-fav svg { fill: currentColor; }
|
||||
|
||||
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
|
||||
.locate-flash {
|
||||
outline: 3px solid #111;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
|
||||
@media (hover: none) {
|
||||
.fav-btn { opacity: 1 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
import "@/styles/look-grid.css"
|
||||
|
||||
const { locale, t } = getI18n(Astro)
|
||||
@ -216,7 +216,7 @@ const cityOptions: { value: string; label: string }[] = [
|
||||
|
||||
</div>
|
||||
|
||||
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
|
||||
<script is:inline id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
|
||||
|
||||
|
||||
|
||||
@ -238,9 +238,9 @@ const cityOptions: { value: string; label: string }[] = [
|
||||
return {
|
||||
locale: '',
|
||||
SPIN_SVG,
|
||||
yearOptions: opts.yearOptions || [],
|
||||
sortOptions: opts.sortOptions || [],
|
||||
cityOptions: opts.cityOptions || [],
|
||||
yearOptions: (opts.yearOptions || []) as { value: number | string; label: string }[],
|
||||
sortOptions: (opts.sortOptions || []) as { value: string; label: string }[],
|
||||
cityOptions: (opts.cityOptions || []) as { value: string; label: string }[],
|
||||
selectedYear: '' as string,
|
||||
selectedSort: DEFAULT_SORT as string,
|
||||
selectedCity: '' as string,
|
||||
|
||||
315
src/components/pages/AccountPage.astro
Normal file
315
src/components/pages/AccountPage.astro
Normal file
@ -0,0 +1,315 @@
|
||||
---
|
||||
// 账户页的 en/cn 共享实现(语言由 Astro.currentLocale 运行时决定)。
|
||||
// 对应页面:src/pages/{en,cn}/account.astro —— 仅做薄包装,避免同一份 300+ 行实现写两遍。
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from "@/lib/routes"
|
||||
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const titleLabel = t("my account")
|
||||
const pleaseLabel = t("please log in")
|
||||
const loginLabel = t("login")
|
||||
const historyLabel = t("my history")
|
||||
const savedLooksLabel = t("saved looks")
|
||||
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
|
||||
const archiveUrl = href(locale, ROUTES.runwayLooks)
|
||||
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
|
||||
const removeLabel = t("remove")
|
||||
const itemsLabel = t("items")
|
||||
const clearHistoryLabel = t("clear history")
|
||||
const noSavedLabel = t("no saved looks yet")
|
||||
const noHistoryLabel = t("no history yet")
|
||||
const browseLabel = t("browse the archive")
|
||||
---
|
||||
<Layout title={titleLabel}>
|
||||
<!-- 页面版心与 Layout 一致:黑白编辑风,衬线大标题 + 等宽小标 + 粗黑细线 -->
|
||||
<main class="account-root mx-auto w-full max-w-[1400px] px-5 py-14 md:px-10 md:py-20 font-sans" x-data="accountPage" data-locale={locale}>
|
||||
|
||||
<!-- ===== 未登录:整版引导 ===== -->
|
||||
<div x-show="!user" x-cloak class="flex flex-col items-center justify-center border border-black/15 px-6 py-32 text-center">
|
||||
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{pleaseLabel}</p>
|
||||
<p class="mb-12 font-serif text-5xl font-black uppercase leading-none tracking-tight md:text-7xl">{titleLabel}</p>
|
||||
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-10 py-4 font-mono text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- ===== 已登录 ===== -->
|
||||
<div x-show="user" x-cloak>
|
||||
|
||||
<!-- 报头:衬线大标题会员名 + 档案标签 -->
|
||||
<header class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8 border-b-2 border-black pb-10 pt-2">
|
||||
<div class="min-w-0">
|
||||
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
|
||||
<div class="flex flex-wrap items-end gap-x-4">
|
||||
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
|
||||
<!-- VIP 徽标:仅高级用户(图集详情页可见高清原图)显示 -->
|
||||
<span x-show="user?.tier === 'vip'" x-cloak class="mb-2 inline-block border border-black bg-black px-2.5 py-1 font-mono text-[10px] font-bold uppercase tracking-[0.3em] text-white">{t("vip")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
|
||||
<p>Portfolio Studio</p>
|
||||
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
||||
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||
</p>
|
||||
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主体:左侧分区索引 + 右侧内容 -->
|
||||
<div class="grid gap-12 pt-12 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16 lg:pt-16">
|
||||
|
||||
<!-- 左:编号目录式垂直导航(移动端折成横向条) -->
|
||||
<nav class="lg:sticky lg:top-12 lg:self-start" aria-label="Account sections">
|
||||
<p class="mb-3 hidden font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400 lg:block">Index</p>
|
||||
<div class="flex gap-10 overflow-x-auto border-b border-black/15 font-mono text-[12px] uppercase tracking-[0.2em] lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0 lg:border-t lg:border-black/10">
|
||||
<button type="button" @click="tab='saved'" :class="tab==='saved' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
|
||||
<span class="mr-3 text-[10px] opacity-60">01</span>{savedLooksLabel}
|
||||
</button>
|
||||
<button type="button" @click="tab='history'" :class="tab==='history' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
|
||||
<span class="mr-3 text-[10px] opacity-60">02</span>{historyLabel}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="min-w-0">
|
||||
<!-- ===== Saved looks ===== -->
|
||||
<div x-show="tab==='saved'" x-cloak>
|
||||
|
||||
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
|
||||
<p class="pb-1 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
|
||||
</div>
|
||||
|
||||
<!-- 粒度切换:图集 / 单图(服务端 kind 过滤分页) -->
|
||||
<div class="mb-10 flex items-center gap-8 border-b border-black/15 font-mono text-[11px] uppercase tracking-[0.2em]">
|
||||
<button type="button" @click="setFavKind('gallery')" :class="favKind==='gallery' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('galleries')}</button>
|
||||
<button type="button" @click="setFavKind('image')" :class="favKind==='image' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('images')}</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noSavedLabel}</p>
|
||||
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- 收藏网格 -->
|
||||
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
|
||||
<template x-for="f in favorites" :key="f.id">
|
||||
<div class="group">
|
||||
<a :href="itemHref(f)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
|
||||
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
|
||||
</a>
|
||||
<button @click="removeFav(f.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
|
||||
<div class="mt-3 min-w-0">
|
||||
<a :href="itemHref(f)" class="block">
|
||||
<p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p>
|
||||
</a>
|
||||
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 收藏分页(服务端分页,与收藏总量解耦) -->
|
||||
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="favLastPage > 1" aria-label="Saved pagination">
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
|
||||
<template x-for="(p, i) in favPageList" :key="i">
|
||||
<span>
|
||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
|
||||
</span>
|
||||
</template>
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ===== History ===== -->
|
||||
<div x-show="tab==='history'" x-cloak>
|
||||
|
||||
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{historyLabel}</h2>
|
||||
<div class="flex items-baseline gap-8 pb-1">
|
||||
<p class="font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="histTotal"></span> {itemsLabel}</p>
|
||||
<button x-show="histTotal > 0" @click="clearAllHistory()" class="font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noHistoryLabel}</p>
|
||||
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- 历史网格 -->
|
||||
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
|
||||
<template x-for="h in history" :key="h.id">
|
||||
<div class="group">
|
||||
<a :href="itemHref(h)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
|
||||
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
|
||||
</a>
|
||||
<button @click="removeHist(h.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
|
||||
<div class="mt-3 min-w-0">
|
||||
<a :href="itemHref(h)" class="block">
|
||||
<p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p>
|
||||
</a>
|
||||
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 历史分页 -->
|
||||
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="histLastPage > 1" aria-label="History pagination">
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
|
||||
<template x-for="(p, i) in histPageList" :key="i">
|
||||
<span>
|
||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
|
||||
</span>
|
||||
</template>
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* 触屏没有 hover:卡片右上角操作常驻显示,否则点不到 */
|
||||
@media (hover: none) {
|
||||
.card-act { opacity: 1 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
|
||||
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
import { buildWindowPages } from "@/lib/looks-grid"
|
||||
|
||||
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
|
||||
'<rect width="300" height="400" fill="#f1f1f1"/>' +
|
||||
'<text x="150" y="200" font-family="monospace" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
|
||||
'</svg>'
|
||||
)
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("accountPage", () => ({
|
||||
user: null as any,
|
||||
locale: "en",
|
||||
tab: "saved",
|
||||
favKind: "gallery", // 收藏子分类:gallery=整篇图集 / image=单张图片
|
||||
favorites: [] as any[],
|
||||
favoritesCount: 0,
|
||||
favPage: 1,
|
||||
favPerPage: 24,
|
||||
favLastPage: 1,
|
||||
history: [] as any[],
|
||||
histPage: 1,
|
||||
histPerPage: 24,
|
||||
histLastPage: 1,
|
||||
histTotal: 0,
|
||||
init() {
|
||||
this.locale = this.$el.dataset.locale || "en"
|
||||
const u = getUser()
|
||||
if (!u) {
|
||||
window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
|
||||
return
|
||||
}
|
||||
this.user = u
|
||||
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
|
||||
this.loadFavPage(1)
|
||||
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
|
||||
this.loadHistPage(1)
|
||||
},
|
||||
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级);kind 决定取图集还是单图
|
||||
async loadFavPage(page: number, kind?: string) {
|
||||
const p = Math.max(1, page | 0)
|
||||
const k = kind || this.favKind
|
||||
try {
|
||||
const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery")
|
||||
this.favorites = r.items
|
||||
this.favLastPage = r.lastPage
|
||||
this.favPage = p
|
||||
this.favoritesCount = r.total
|
||||
} catch {
|
||||
const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery"))
|
||||
this.favorites = local
|
||||
this.favLastPage = 1
|
||||
this.favPage = 1
|
||||
this.favoritesCount = local.length
|
||||
}
|
||||
},
|
||||
setFavKind(k: string) {
|
||||
if (this.favKind === k) return
|
||||
this.favKind = k
|
||||
this.favorites = []
|
||||
this.favoritesCount = 0
|
||||
this.favLastPage = 1
|
||||
this.loadFavPage(1, k)
|
||||
},
|
||||
goFavPage(p: number) {
|
||||
if (p < 1 || p > this.favLastPage || p === this.favPage) return
|
||||
this.loadFavPage(p)
|
||||
},
|
||||
// 窗口化页码(实现见 looks-grid.ts 的 buildWindowPages,与历史分页共用)
|
||||
get favPageList() {
|
||||
return buildWindowPages(this.favPage, this.favLastPage)
|
||||
},
|
||||
coverUrl(x: any): string {
|
||||
const c = x.data?.cover || x.cover
|
||||
return c ? toAbs(c) : PLACEHOLDER
|
||||
},
|
||||
itemHref(f: any): string {
|
||||
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
|
||||
// 图集收藏与历史仍用自身 uid,但历史优先用回查到的 canonId(规范 id)——
|
||||
// 旧的无前缀历史 id 会把用户带回非规范地址,详情页据此又写出一条重复历史。
|
||||
const isImg = f.kind === "image"
|
||||
const uid = isImg && f.parentUid ? f.parentUid : (f.canonId || f.id)
|
||||
const base = clientHref(this.locale, ROUTES.item(uid))
|
||||
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
|
||||
},
|
||||
removeFav(id: string) {
|
||||
removeFavorite(id)
|
||||
this.loadFavPage(this.favPage) // 重新拉当前页,保证分页计数一致
|
||||
},
|
||||
// ===== 浏览历史(服务端分页)=====
|
||||
async loadHistPage(page: number) {
|
||||
const p = Math.max(1, page | 0)
|
||||
try {
|
||||
const r = await fetchHistoryPage(p, this.histPerPage)
|
||||
this.history = r.items
|
||||
this.histTotal = r.total
|
||||
this.histLastPage = r.lastPage
|
||||
this.histPage = p
|
||||
} catch {
|
||||
this.history = []
|
||||
this.histTotal = 0
|
||||
this.histLastPage = 1
|
||||
this.histPage = 1
|
||||
}
|
||||
},
|
||||
goHistPage(p: number) {
|
||||
if (p < 1 || p > this.histLastPage || p === this.histPage) return
|
||||
this.loadHistPage(p)
|
||||
},
|
||||
// 窗口化页码(实现见 looks-grid.ts 的 buildWindowPages,与收藏分页共用)
|
||||
get histPageList() {
|
||||
return buildWindowPages(this.histPage, this.histLastPage)
|
||||
},
|
||||
removeHist(id: string) {
|
||||
removeHistory(id)
|
||||
this.loadHistPage(this.histPage) // 重新拉当前页,保证分页计数一致
|
||||
},
|
||||
clearAllHistory() {
|
||||
clearHistory()
|
||||
this.loadHistPage(1)
|
||||
},
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
135
src/components/pages/ItemPage.astro
Normal file
135
src/components/pages/ItemPage.astro
Normal file
@ -0,0 +1,135 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import Item from "@/components/Item.astro"
|
||||
import StreetSnap from "@/components/StreetSnap.astro"
|
||||
import { getSsrArticle, getSsrStreetSnap } from "@/lib/ssr"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
|
||||
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
|
||||
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
|
||||
//
|
||||
// 本组件是 en/cn 两页的共享实现(语言由 Astro.currentLocale 运行时决定)。
|
||||
// 注意:`export const prerender = false` 必须留在**页面**文件里(Astro 只读页面模块的该导出),
|
||||
// 故 id 由页面从 Astro.params 取出后作为 prop 透传进来。
|
||||
interface Props {
|
||||
id?: string
|
||||
}
|
||||
|
||||
const { id } = Astro.props
|
||||
const isSnap = !!id && id[0] === "s"
|
||||
|
||||
const { t } = getI18n(Astro)
|
||||
|
||||
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
|
||||
? await getSsrStreetSnap(id, Astro.currentLocale)
|
||||
: await getSsrArticle(id, Astro.currentLocale)
|
||||
|
||||
// 用 in 收窄联合类型:isSnap 是运行时判断,TS 无法据此关联 data 的具体分支。
|
||||
if (isSnap) {
|
||||
snap = "snap" in data ? data.snap : null
|
||||
snapErr = data.error
|
||||
} else {
|
||||
article = "article" in data ? data.article : null
|
||||
articleErr = data.error
|
||||
}
|
||||
preview = data.preview ?? false
|
||||
imageTotal = data.imageTotal ?? 0
|
||||
|
||||
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
|
||||
// id 优先取**接口回传的规范 id**(带类型前缀):用户若通过旧的无前缀链接进来(如 /item/0008CZYI),
|
||||
// 也保证收藏 / 历史写入的是规范形式,不会与既有记录形成「同一篇文章两行」。
|
||||
const canonId = String((isSnap ? snap?.id : article?.id) ?? id ?? "")
|
||||
const meta = isSnap
|
||||
? { id: canonId, type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
|
||||
: { id: canonId, type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
|
||||
const saveLabel = t("save")
|
||||
const savedLabel = t("saved")
|
||||
---
|
||||
|
||||
<Layout>
|
||||
{article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
#fav-fab.is-active .fav-heart { fill: currentColor; }
|
||||
</style>
|
||||
|
||||
{(article || snap) && (
|
||||
<button login-required id="fav-fab" type="button"
|
||||
data-id={meta.id}
|
||||
data-type={meta.type}
|
||||
data-title={meta.title}
|
||||
data-cover={meta.cover}
|
||||
data-brand={meta.brand}
|
||||
data-save-label={saveLabel}
|
||||
data-saved-label={savedLabel}
|
||||
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
|
||||
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" 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>
|
||||
<span id="fav-label">{saveLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
|
||||
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
|
||||
<script>
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleFavorite, hydrateFavorited } from "@/lib/favorites"
|
||||
import { recordHistory } from "@/lib/history"
|
||||
|
||||
const btn = document.getElementById("fav-fab")
|
||||
const label = document.getElementById("fav-label")
|
||||
if (btn) {
|
||||
const meta = {
|
||||
id: btn.dataset.id || "",
|
||||
type: btn.dataset.type || "",
|
||||
title: btn.dataset.title || "",
|
||||
cover: btn.dataset.cover || "",
|
||||
brand: btn.dataset.brand || "",
|
||||
}
|
||||
const saveLabel = btn.dataset.saveLabel || "save"
|
||||
const savedLabel = btn.dataset.savedLabel || "saved"
|
||||
|
||||
if (meta.id) {
|
||||
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
|
||||
if (getUser()) recordHistory(meta.id)
|
||||
|
||||
const sync = () => {
|
||||
const fav = isFavorited(meta.id)
|
||||
if (label) label.textContent = fav ? savedLabel : saveLabel
|
||||
btn.classList.toggle("is-active", fav)
|
||||
}
|
||||
sync()
|
||||
|
||||
// 登录态收藏校正已合并进详情接口:articleView / streetSnapView 取到服务端收藏态后,
|
||||
// 通过 fav:set 事件广播当前图集是否已收藏;此处监听并写回本地(换设备后与服务端一致)。
|
||||
// 不再单独请求 /me/favorites/checks。
|
||||
window.addEventListener("fav:set", (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ id: string; fav: boolean }>).detail
|
||||
if (!detail || detail.id !== meta.id) return
|
||||
if (detail.fav) {
|
||||
hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
|
||||
}
|
||||
sync()
|
||||
})
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
toggleFavorite({
|
||||
id: meta.id,
|
||||
type: meta.type,
|
||||
title: meta.title,
|
||||
cover: meta.cover,
|
||||
brand: meta.brand,
|
||||
} as any)
|
||||
sync()
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
170
src/components/pages/LoginPage.astro
Normal file
170
src/components/pages/LoginPage.astro
Normal file
@ -0,0 +1,170 @@
|
||||
---
|
||||
// 登录页的 en/cn 共享实现(语言由 Astro.currentLocale 运行时决定)。
|
||||
// 对应页面:src/pages/{en,cn}/login.astro —— 仅做薄包装。
|
||||
// 注意:本页**不走 Layout**,自带完整 <html> 文档(独立极简版式,故保留 <!doctype html>)。
|
||||
import '@/styles/global.css'
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { LOCALE_NAMES } from '@/lib/i18n'
|
||||
import { i18n } from 'astro:config/client';
|
||||
import { toCodes } from 'astro:i18n';
|
||||
|
||||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||
|
||||
const pathName = Astro.url.pathname
|
||||
const locales = toCodes(i18n!.locales)
|
||||
const pathWithoutLocale = locales.includes(pathName.split('/')[1])
|
||||
? pathName.replace(`/${locale}`, '')
|
||||
: pathName
|
||||
const languages = locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName }))
|
||||
|
||||
const loginLabel = t('login')
|
||||
const promptLabel = t('login prompt')
|
||||
const accountLabel = t('username or email')
|
||||
const passwordLabel = t('password')
|
||||
const failLabel = t('login failed')
|
||||
const membersLabel = t('members')
|
||||
const backLabel = t('back to site')
|
||||
const homeUrl = href(locale, ROUTES.runwayLooks)
|
||||
const pageTitle = `${loginLabel} — Portfolio Studio`
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{pageTitle}</title>
|
||||
</head>
|
||||
<!-- 全站一致:白底 + 衬线大标题 + 等宽小标 + 粗黑细线,直角无阴影 -->
|
||||
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
|
||||
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
|
||||
|
||||
<!-- 顶部工具行:返回档案 + 语言切换 -->
|
||||
<header class="mb-12 flex items-center justify-between gap-6">
|
||||
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black/50 transition-colors hover:text-black">
|
||||
<span aria-hidden="true">←</span>{backLabel}
|
||||
</a>
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false" @click.outside="open = false">
|
||||
<button type="button" @click="open = !open" class="inline-flex items-center gap-2 border border-black/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
|
||||
<span>{LOCALE_NAMES[locale].nativeName}</span>
|
||||
<svg viewBox="0 0 12 12" width="11" height="11" class="transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
|
||||
<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>
|
||||
<ul x-show="open" x-cloak class="absolute right-0 top-full z-20 mt-2 min-w-[170px] border border-black/15 bg-white py-1">
|
||||
{languages.map((l) => (
|
||||
<li>
|
||||
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 font-mono text-[11px] uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/55']}>{l.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 2px 粗黑线) -->
|
||||
<section class="border-b-2 border-black pb-10">
|
||||
<div class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
|
||||
<div>
|
||||
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
|
||||
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
|
||||
</div>
|
||||
<div class="pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
|
||||
<p>Portfolio Studio</p>
|
||||
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
||||
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||
</p>
|
||||
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
|
||||
<div class="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<p class="mb-10 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400">{promptLabel}</p>
|
||||
|
||||
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
|
||||
<div>
|
||||
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{accountLabel}</label>
|
||||
<input type="text" x-model="account" autocomplete="username" required autofocus
|
||||
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{passwordLabel}</label>
|
||||
<input type="password" x-model="password" autocomplete="current-password" required
|
||||
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
|
||||
</div>
|
||||
|
||||
<p x-show="error" x-text="error" x-cloak class="font-mono text-[11px] uppercase tracking-[0.2em] text-red-600"></p>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full border border-black bg-black py-4 font-mono text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<span x-show="!loading">{loginLabel}</span>
|
||||
<span x-show="loading" x-cloak>…</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-10 border-t border-black/15 pt-5">
|
||||
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">
|
||||
<span aria-hidden="true">←</span>{backLabel}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="border-t border-black/10 pt-8 md:border-l md:border-t-0 md:pl-8 md:pt-0">
|
||||
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">Archive note</p>
|
||||
<p class="font-serif text-lg leading-relaxed text-black/80">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
|
||||
<div class="mt-8 border-t border-black/10 pt-5 font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400">
|
||||
<p class="mb-3">Runway · Street · Archive</p>
|
||||
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { login } from "@/lib/api"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("loginForm", () => ({
|
||||
account: "",
|
||||
password: "",
|
||||
error: "",
|
||||
loading: false,
|
||||
|
||||
init() {
|
||||
if (getUser()) {
|
||||
const loc = this.$el.dataset.locale || "en"
|
||||
window.location.href = clientHref(loc, ROUTES.account)
|
||||
}
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.error = ""
|
||||
this.loading = true
|
||||
|
||||
login({ account: this.account, password: this.password })
|
||||
.then(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const redirect = params.get("redirect")
|
||||
const loc = this.$el.dataset.locale || "en"
|
||||
window.location.href = redirect || clientHref(loc, ROUTES.account)
|
||||
})
|
||||
.catch(() => {
|
||||
this.error = this.$el.dataset.failLabel || "login failed"
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
6
src/env.d.ts
vendored
6
src/env.d.ts
vendored
@ -2,10 +2,10 @@
|
||||
// @astrojs/alpinejs 集成会在运行时把 Alpine 挂到全局(window.Alpine),
|
||||
// 但 @types/alpinejs 是 CJS(export = Alpine),其顶层 declare const Alpine
|
||||
// 不会自动进入模块上下文(.astro 的 <script> 是模块),导致 IDE 报 “Cannot find name 'Alpine'”。
|
||||
// 这里用 namespace 导入取真实的 Alpine.Alpine 接口类型作为全局声明,
|
||||
// 用默认类型导入 `import type Alpine from "alpinejs"` 取真实的 Alpine.Alpine 接口类型作为全局声明,
|
||||
// 既消除报错、又让 Alpine.data / .store / .magic 等方法以及 data 组件内的 this 都有完整提示。
|
||||
// 注意:@types/alpinejs 是 export = 形式,不能用默认导入(会解析成 namespace 而非类型)。
|
||||
import type * as Alpine from "alpinejs";
|
||||
// @types/alpinejs 是 export = 形式,esModuleInterop 开启时允许默认导入,TS 亦建议此种写法。
|
||||
import type Alpine from "alpinejs";
|
||||
|
||||
declare global {
|
||||
const Alpine: Alpine.Alpine;
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
// i18n 核心配置:语言列表、默认语言、URL 前缀规则
|
||||
// 改这里即可增减语言;新增语言记得同步 src/i18n/dictionary.ts 里的每个 key(单文件聚合多语言)。
|
||||
|
||||
/** 默认语言(无前缀时使用的语言,如 /shows)。 */
|
||||
export const DEFAULT_LOCALE = 'en' as const;
|
||||
|
||||
/** 支持的语言列表。URL 前缀用的就是这些值,例如 /cn/shows 的 `cn`。 */
|
||||
export const LOCALES = ['en', 'cn'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
/** 语言显示名(用于语言切换器等 UI)。 */
|
||||
export const LOCALE_NAMES: Record<string, { name: string; nativeName: string }> = {
|
||||
cn: {
|
||||
name: '中文',
|
||||
nativeName: '中文',
|
||||
},
|
||||
en: {
|
||||
name: 'English',
|
||||
nativeName: 'English',
|
||||
},
|
||||
} as const;
|
||||
@ -1,45 +0,0 @@
|
||||
import { getLocaleByPath, getRelativeLocaleUrl } from 'astro:i18n';
|
||||
import { DEFAULT_LOCALE, LOCALES, type Locale } from '@/i18n/config';
|
||||
import { translate } from '@/i18n/dictionary';
|
||||
|
||||
/**
|
||||
* 从 URL 解析语言,委托 Astro 内置的 astro:i18n.getLocaleByPath。
|
||||
* 无前缀(默认语言)路径会解析回 DEFAULT_LOCALE。
|
||||
*/
|
||||
export function getLocaleFromUrl(url: URL | string): Locale {
|
||||
const pathname = typeof url === 'string' ? url : url.pathname;
|
||||
try {
|
||||
const locale = getLocaleByPath(pathname);
|
||||
return (LOCALES as readonly string[]).includes(locale) ? (locale as Locale) : DEFAULT_LOCALE;
|
||||
} catch {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取翻译函数 t():key 即英文原文,按当前语言自动匹配翻译。
|
||||
* - 英文环境:t('all runway looks') 直接返回 'all runway looks'(key 本身)
|
||||
* - 中文环境:t('all runway looks') 返回字典里的 '全部的 runway'(取不到则回退英文)
|
||||
* - 支持 {name} 形式的插值参数。
|
||||
*/
|
||||
export function useTranslations(locale: Locale) {
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
return translate(key, locale, params);
|
||||
}
|
||||
return { t, locale };
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行取「当前语言 + 翻译函数」,干掉每个页面重复的样板:
|
||||
* const { locale, t } = getI18n(Astro)
|
||||
* 内部用 Astro.currentLocale(与 Layout 判断语言的方式一致),
|
||||
* 取不到时回退默认语言。参数只要求含 currentLocale 字段,避免耦合 Astro 全局类型。
|
||||
*/
|
||||
export function getI18n(Astro: { currentLocale?: string | undefined }) {
|
||||
const locale = (Astro.currentLocale as Locale) ?? DEFAULT_LOCALE;
|
||||
const { t } = useTranslations(locale);
|
||||
return { locale, t, getRelativeLocaleUrl };
|
||||
}
|
||||
|
||||
export { LOCALES, DEFAULT_LOCALE };
|
||||
export type { Locale };
|
||||
@ -3,9 +3,7 @@ import '@/styles/global.css'
|
||||
import LoadingScreen from '@/components/LoadingScreen.astro'
|
||||
import AuthModal from '@/components/AuthModal.astro'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { LOCALE_NAMES } from '@/i18n/config'
|
||||
import { setApiLocale } from '@/lib/locale'
|
||||
import { getI18n, LOCALE_NAMES, setApiLocale } from '@/lib/i18n'
|
||||
import { i18n } from 'astro:config/client';
|
||||
import { toCodes } from 'astro:i18n';
|
||||
|
||||
@ -180,6 +178,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">
|
||||
<script>
|
||||
import { getUser } from "@/lib/auth"
|
||||
import type { AuthUser } from "@/lib/auth"
|
||||
import { fetchMe, logout, enforceIdleLogout } from "@/lib/api"
|
||||
|
||||
// 空闲登出:超过 config.idleLogoutDays 天没访问则登出(在 Alpine 初始化前先跑,避免登录态闪烁)
|
||||
@ -187,7 +186,7 @@ const headers = {
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("authMenu", () => ({
|
||||
user: null,
|
||||
user: null as AuthUser | null,
|
||||
open: false,
|
||||
|
||||
init() {
|
||||
|
||||
168
src/lib/api.ts
168
src/lib/api.ts
@ -1,23 +1,27 @@
|
||||
// src/lib/api.ts — 后端接口层(纯接口)
|
||||
//
|
||||
// 全站对后端的唯一出入口:公开业务查询 + SSG 构建接口 + 鉴权 + 收藏 + 历史。
|
||||
// 全站对后端的「公开业务 + 鉴权 + 收藏 + 历史」唯一出入口(构建期 SSG 取数见 ssg.ts)。
|
||||
// 本文件只定义「调哪个端点、参数、返回类型」,一律经 request.ts 的统一入口 request() 发出
|
||||
// (签名 / locale / Bearer / 401 续期都在那一层处理),会话令牌的本地读写见 auth.ts。
|
||||
//
|
||||
// 路径一律写「含版本前缀的完整路径」(如 /api/v1/public/runway-looks)——
|
||||
// 版本前缀直接落在各接口上;将来整体升 /api/v2 时全局替换 /api/v1 即可。
|
||||
// 全部端点路径集中在 src/lib/endpoints.ts(含版本前缀 /api/v1、构建期内部前缀 /api/internal/ssg);
|
||||
// 版本前缀只在该文件定义一次,将来升 /api/v2 或调整 SSG 路径改一处即可。
|
||||
//
|
||||
// 分组:
|
||||
// - 公开业务:走秀 / 品牌 / 街拍 列表与详情(/api/v1/public/*)
|
||||
// - 鉴权 :登录 / 登出 / 取当前用户(/api/v1/auth/*、/api/v1/me)
|
||||
// - 收藏 :增删 + 分页拉取 + 批量校验(/api/v1/me/favorites*)
|
||||
// - 历史 :记录 + 分页拉取 + 删除 + 清空(/api/v1/me/history*)
|
||||
// - SSG :构建期内部端口取数(/api/internal/ssg/*,仅 Node 构建期)
|
||||
//
|
||||
// 不在此文件:
|
||||
// - 构建期 SSG 取数 → ssg.ts;运行期 SSR 按需取数 → ssr.ts(均为服务端可信内部取数,不经签名)。
|
||||
// - 鉴权动作(login/logout/fetchMe/enforceIdleLogout)**有意留在本文件**:它们要经 request() 发请求,
|
||||
// 而 request.ts 依赖 auth.ts 的令牌读取;若搬进 auth.ts 会形成 auth ↔ request 循环依赖。
|
||||
|
||||
import { request, ssrBase, requestSsg } from "./request"
|
||||
import { request } from "./request"
|
||||
import { endpoints } from "./endpoints"
|
||||
import { config } from "./config"
|
||||
import { saveSession, clearSession, getUser, getAccessToken, getRefreshToken, getLastActiveAt, touchActivity } from "./auth"
|
||||
import { withLocale } from "./locale"
|
||||
import type { AuthUser } from "./auth"
|
||||
|
||||
// 刷新令牌的实现在 request.ts(属 transport 的 401 重试机制),此处再导出以便「接口集中在 api.ts」。
|
||||
@ -90,15 +94,6 @@ export type RawArticle = {
|
||||
favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
|
||||
}
|
||||
|
||||
// SSG 构建专用类型
|
||||
export interface SsgPopularBrand {
|
||||
id: string
|
||||
article_id: string
|
||||
brand: string
|
||||
cover: string
|
||||
title: string
|
||||
}
|
||||
|
||||
// 浏览历史单篇回查结果(账户页 My History 渲染用)。
|
||||
export interface HistoryMeta {
|
||||
title: string
|
||||
@ -130,7 +125,7 @@ export interface RawStreetSnap {
|
||||
id: string
|
||||
title: string
|
||||
cover: string
|
||||
images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用
|
||||
images?: { id?: string; image: string; thumb?: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用;thumb 供灯箱缩略图条用(后端未返回时前端回落展示图)
|
||||
favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
|
||||
}
|
||||
|
||||
@ -181,47 +176,12 @@ export interface HistoryPage {
|
||||
|
||||
// ===================== 公开业务:走秀 =====================
|
||||
|
||||
export type SsrArticleResult = {
|
||||
article: RawArticle | null
|
||||
error: "missing" | "failed" | null
|
||||
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
|
||||
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
|
||||
}
|
||||
|
||||
// 接口:GET /api/v1/public/runway-looks/:id
|
||||
// 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。
|
||||
// 注意:此处在**服务端**按需渲染时调用,走 ssrBase()(运行期 8090),属可信内部取数,不经前端签名。
|
||||
// locale 显式传入时优先(避免并发串 locale)。
|
||||
export async function getSsrArticle(
|
||||
id: string | null | undefined,
|
||||
locale?: string
|
||||
): Promise<SsrArticleResult> {
|
||||
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 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", preview: false, imageTotal: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 接口:GET /api/v1/public/runway-looks/:id(已登录)
|
||||
// 走统一 request()(自动带 Bearer + 签名 + locale),后端据此返回完整图片集。
|
||||
// 详情页未登录 → 登录后客户端补拉全量用。
|
||||
export async function getArticleDetailAuthed(id: string): Promise<RawArticle | null> {
|
||||
try {
|
||||
const json: any = await request("GET", `/api/v1/public/runway-looks/${encodeURIComponent(id)}`)
|
||||
const json: any = await request("GET", endpoints.public.runwayLook(id))
|
||||
return (json?.data ?? null) as RawArticle | null
|
||||
} catch {
|
||||
return null
|
||||
@ -241,7 +201,7 @@ export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
|
||||
if (q.year) p.set("year", String(q.year))
|
||||
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>(
|
||||
"GET",
|
||||
"/api/v1/public/runway-looks",
|
||||
endpoints.public.runwayLooks,
|
||||
{ params: p }
|
||||
)
|
||||
const mapped: ArticleItem[] = (j.data ?? []).map((it: any) => ({
|
||||
@ -265,7 +225,7 @@ export async function getBrands(opts?: {
|
||||
if (opts?.featured != null) qs.set("featured", String(opts.featured))
|
||||
if (opts?.keyword) qs.set("keyword", opts.keyword)
|
||||
if (opts?.letter) qs.set("letter", opts.letter)
|
||||
const j = await request<{ data?: any[] }>("GET", "/api/v1/public/brands", { params: qs })
|
||||
const j = await request<{ data?: any[] }>("GET", endpoints.public.brands, { params: qs })
|
||||
return (j.data ?? []).map((b: any) => {
|
||||
const nameEn = (b.name_en || b.name || "").toString()
|
||||
const nameCn = (b.name_cn || "").toString()
|
||||
@ -288,8 +248,8 @@ export async function getHistoryMeta(id: string): Promise<HistoryMeta | null> {
|
||||
const type = sid.charAt(0) === "s" ? ("street" as const) : ("runway" as const)
|
||||
const path =
|
||||
type === "street"
|
||||
? `/api/v1/public/street-snaps/${encodeURIComponent(sid)}`
|
||||
: `/api/v1/public/runway-looks/${encodeURIComponent(sid)}`
|
||||
? endpoints.public.streetSnap(sid)
|
||||
: endpoints.public.runwayLook(sid)
|
||||
try {
|
||||
const j = await request<{ data?: any }>("GET", path)
|
||||
const d = j?.data
|
||||
@ -324,7 +284,7 @@ export async function getStreetSnaps(q: {
|
||||
if (q.city) p.set("city", q.city)
|
||||
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>(
|
||||
"GET",
|
||||
"/api/v1/public/street-snaps",
|
||||
endpoints.public.streetSnaps,
|
||||
{ params: p }
|
||||
)
|
||||
const mapped: StreetSnapItem[] = (j.data ?? []).map((it: any) => ({
|
||||
@ -340,44 +300,11 @@ export async function getStreetSnaps(q: {
|
||||
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
|
||||
}
|
||||
|
||||
export type SsrStreetSnapResult = {
|
||||
snap: RawStreetSnap | null
|
||||
error: "missing" | "failed" | null
|
||||
preview?: boolean
|
||||
imageTotal?: number
|
||||
}
|
||||
|
||||
// 接口:GET /api/v1/public/street-snaps/:id
|
||||
// 服务端按需渲染(on-demand)取街拍详情:经 ssrBase() 直接 fetch(可信内部取数,不经签名)。
|
||||
export async function getSsrStreetSnap(
|
||||
id: string | null | undefined,
|
||||
locale?: string
|
||||
): Promise<SsrStreetSnapResult> {
|
||||
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 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", preview: false, imageTotal: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 接口:GET /api/v1/public/street-snaps/:id(已登录)
|
||||
// 走统一 request()(自动带 Bearer + 签名 + locale),返回完整图片集。
|
||||
export async function getStreetSnapDetailAuthed(id: string): Promise<RawStreetSnap | null> {
|
||||
try {
|
||||
const json: any = await request("GET", `/api/v1/public/street-snaps/${encodeURIComponent(id)}`)
|
||||
const json: any = await request("GET", endpoints.public.streetSnap(id))
|
||||
return (json?.data ?? null) as RawStreetSnap | null
|
||||
} catch {
|
||||
return null
|
||||
@ -391,7 +318,7 @@ export async function getStreetSnapDetailAuthed(id: string): Promise<RawStreetSn
|
||||
export async function login(input: { account: string; password: string }): Promise<AuthUser> {
|
||||
let json: any
|
||||
try {
|
||||
json = await request("POST", "/api/v1/auth/login", { body: input, auth: false })
|
||||
json = await request("POST", endpoints.auth.login, { body: input, auth: false })
|
||||
} catch {
|
||||
clearSession()
|
||||
throw new Error("login failed")
|
||||
@ -419,7 +346,7 @@ export async function logout(): Promise<void> {
|
||||
if (refresh) {
|
||||
try {
|
||||
// 会话即 refresh token:单设备登出 = 吊销当前会话
|
||||
await request("DELETE", "/api/v1/auth/sessions/current", { body: { refresh_token: refresh }, auth: false })
|
||||
await request("DELETE", endpoints.auth.session, { body: { refresh_token: refresh }, auth: false })
|
||||
} catch {
|
||||
/* 忽略网络错误,仍清本地态 */
|
||||
}
|
||||
@ -440,7 +367,7 @@ export function enforceIdleLogout(): boolean {
|
||||
// 超窗:后台吊销 refresh(不阻塞),立即清本地会话(同步,避免登录态闪烁)
|
||||
const rt = getRefreshToken()
|
||||
if (rt) {
|
||||
request("DELETE", "/api/v1/auth/sessions/current", { body: { refresh_token: rt }, auth: false }).catch(() => {})
|
||||
request("DELETE", endpoints.auth.session, { body: { refresh_token: rt }, auth: false }).catch(() => {})
|
||||
}
|
||||
clearSession()
|
||||
return true
|
||||
@ -455,7 +382,7 @@ export async function fetchMe(): Promise<AuthUser | null> {
|
||||
if (typeof localStorage === "undefined") return null
|
||||
if (!getAccessToken()) return null
|
||||
try {
|
||||
const j = await request<any>("GET", "/api/v1/me")
|
||||
const j = await request<any>("GET", endpoints.me.profile)
|
||||
const user = j?.data?.user ?? j?.user
|
||||
return (user as AuthUser) ?? null
|
||||
} catch {
|
||||
@ -468,12 +395,12 @@ export async function fetchMe(): Promise<AuthUser | null> {
|
||||
|
||||
// 新增收藏(已收藏则由后端幂等处理)。
|
||||
export async function addFavorite(payload: FavoritePayload): Promise<void> {
|
||||
await request("POST", "/api/v1/me/favorites", { body: payload })
|
||||
await request("POST", endpoints.me.favorites, { body: payload })
|
||||
}
|
||||
|
||||
// 取消收藏(按 target_uid)。
|
||||
export async function deleteFavorite(id: string): Promise<void> {
|
||||
await request("DELETE", `/api/v1/me/favorites/${encodeURIComponent(id)}`)
|
||||
await request("DELETE", endpoints.me.favorite(id))
|
||||
}
|
||||
|
||||
// 分页拉取收藏;kind 传 gallery / image 可只取对应粒度(服务端过滤),留空为全部。
|
||||
@ -486,13 +413,13 @@ export async function fetchFavorites(
|
||||
params.set("page", String(page))
|
||||
params.set("per_page", String(perPage))
|
||||
if (kind) params.set("kind", kind)
|
||||
const j = await request<{ data: FavoritePage }>("GET", "/api/v1/me/favorites", { params })
|
||||
const j = await request<{ data: FavoritePage }>("GET", endpoints.me.favorites, { params })
|
||||
return j.data
|
||||
}
|
||||
|
||||
// 批量校验当前页的收藏态:只对传入的 id 做 WHERE IN 查询(复杂度与收藏总量无关)。
|
||||
export async function checkFavorites(ids: string[]): Promise<string[]> {
|
||||
const j = await request<{ data: string[] }>("POST", "/api/v1/me/favorites/checks", { body: { ids } })
|
||||
const j = await request<{ data: string[] }>("POST", endpoints.me.favoriteChecks, { body: { ids } })
|
||||
const arr = j?.data
|
||||
return Array.isArray(arr) ? arr.map(String) : []
|
||||
}
|
||||
@ -501,7 +428,7 @@ export async function checkFavorites(ids: string[]): Promise<string[]> {
|
||||
|
||||
// 记录一次浏览(upsert 刷新时间)。
|
||||
export async function addHistory(targetUid: string): Promise<void> {
|
||||
await request("POST", "/api/v1/me/history", { body: { target_uid: targetUid } })
|
||||
await request("POST", endpoints.me.history, { body: { target_uid: targetUid } })
|
||||
}
|
||||
|
||||
// 分页拉取浏览历史(仅 id + viewed_at)。
|
||||
@ -509,53 +436,18 @@ export async function fetchHistory(page = 1, perPage = 24): Promise<HistoryPage>
|
||||
const params = new URLSearchParams()
|
||||
params.set("page", String(page))
|
||||
params.set("per_page", String(perPage))
|
||||
const j = await request<{ data: HistoryPage }>("GET", "/api/v1/me/history", { params })
|
||||
const j = await request<{ data: HistoryPage }>("GET", endpoints.me.history, { params })
|
||||
return j.data
|
||||
}
|
||||
|
||||
// 删除一条历史(按文章 id)。
|
||||
export async function deleteHistory(id: string): Promise<void> {
|
||||
await request("DELETE", `/api/v1/me/history/${encodeURIComponent(id)}`)
|
||||
await request("DELETE", endpoints.me.historyItem(id))
|
||||
}
|
||||
|
||||
// 清空全部历史。
|
||||
export async function clearAllHistory(): Promise<void> {
|
||||
await request("DELETE", "/api/v1/me/history")
|
||||
await request("DELETE", endpoints.me.history)
|
||||
}
|
||||
|
||||
// ===================== SSG 构建专用(/api/internal/ssg/*,仅 Node 构建期) =====================
|
||||
|
||||
// 接口:GET /api/internal/ssg/home/runway(SSG 内部端口,构建期 Node 调用)
|
||||
// 首页 runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。
|
||||
export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> {
|
||||
const j = await requestSsg<{ data?: { hotBrand?: SsgPopularBrand[] } }>(`/api/internal/ssg/home/runway`)
|
||||
return j?.data ?? { hotBrand: [] }
|
||||
}
|
||||
|
||||
// 接口:GET /api/internal/ssg/brands/popular(SSG 内部端口,构建期 Node 调用)
|
||||
// 走秀页热门品牌(按图片总数排名,结果按热度顺序)。后端固定返回前 30 个。
|
||||
export async function getSsgHotBrands(): Promise<BrandEntry[]> {
|
||||
const j = await requestSsg<{ data?: any[] }>("/api/internal/ssg/brands/popular")
|
||||
return (j?.data ?? []).map((b: any) => {
|
||||
const nameEn = (b.name_en || b.name || "").toString()
|
||||
return {
|
||||
id: String(b.id),
|
||||
name: nameEn,
|
||||
nameCn: (b.name_cn || "").toString(),
|
||||
display: nameEn,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 接口:GET /api/internal/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用)
|
||||
// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。
|
||||
export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapItem[]> {
|
||||
const j = await requestSsg<{ data?: any[] }>(`/api/internal/ssg/street-snaps/popular?limit=${limit}`)
|
||||
return (j?.data ?? []).map((b: any) => ({
|
||||
id: String(b.id),
|
||||
title: b.title ?? "",
|
||||
cover: b.cover ?? "",
|
||||
image_count: Number(b.image_count ?? 0),
|
||||
images: [],
|
||||
}))
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ const config: AppConfig = {
|
||||
baseApiSsr: "http://localhost:8090", // 按需渲染 SSR 取数(绝对 URL)
|
||||
baseApiSsg: "http://localhost:8091", // 构建期 SSG 内部端口
|
||||
idleLogoutDays: 7, // 超过 7 天没访问则登出(0 = 关闭空闲登出)
|
||||
siteName: "Xisoa",
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
@ -12,4 +12,7 @@ export interface AppConfig {
|
||||
baseApiSsg: string
|
||||
/** 空闲登出:超过这么多天没有访问则登出(0 = 关闭空闲登出)。 */
|
||||
idleLogoutDays: number
|
||||
|
||||
/** 网站名称 */
|
||||
siteName?: string
|
||||
}
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
// 不能作为唯一防线;真正有效需配合后端按 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
|
||||
|
||||
38
src/lib/endpoints.ts
Normal file
38
src/lib/endpoints.ts
Normal file
@ -0,0 +1,38 @@
|
||||
// src/lib/endpoints.ts — 后端 API 端点路径唯一来源
|
||||
//
|
||||
// 与 src/lib/routes.ts(ROUTES,前端页面跳转 URL)严格区分:
|
||||
// - routes.ts 的 ROUTES = 浏览器访问的「页面路由」(带 /en /cn 语言前缀,走 <a>/navigate)
|
||||
// - 本文件的 endpoints = fetch 调用的「后端接口路径」(locale 走 ?locale= 查询参数,不在路径里)
|
||||
// 版本前缀 V1 与构建期内部端口前缀 SSG 只在此定义一次;
|
||||
// 将来升 /api/v2 或调整 SSG 路径,改这一处即可,不用全局搜。
|
||||
|
||||
const V1 = "/api/v1"
|
||||
const SSG = "/api/internal/ssg" // 构建期内部端口(仅 127.0.0.1,nginx 不反代)
|
||||
|
||||
export const endpoints = {
|
||||
public: {
|
||||
runwayLooks: `${V1}/public/runway-looks`,
|
||||
runwayLook: (id: string) => `${V1}/public/runway-looks/${encodeURIComponent(id)}`,
|
||||
brands: `${V1}/public/brands`,
|
||||
streetSnaps: `${V1}/public/street-snaps`,
|
||||
streetSnap: (id: string) => `${V1}/public/street-snaps/${encodeURIComponent(id)}`,
|
||||
},
|
||||
auth: {
|
||||
login: `${V1}/auth/login`,
|
||||
refresh: `${V1}/auth/refresh`,
|
||||
session: `${V1}/auth/sessions/current`, // 吊销当前 refresh(单设备登出)
|
||||
},
|
||||
me: {
|
||||
profile: `${V1}/me`,
|
||||
favorites: `${V1}/me/favorites`,
|
||||
favorite: (id: string) => `${V1}/me/favorites/${encodeURIComponent(id)}`,
|
||||
favoriteChecks: `${V1}/me/favorites/checks`, // 批量校验收藏态
|
||||
history: `${V1}/me/history`,
|
||||
historyItem: (id: string) => `${V1}/me/history/${encodeURIComponent(id)}`,
|
||||
},
|
||||
ssg: {
|
||||
homeRunway: `${SSG}/home/runway`, // 首页 runway 区块(构建期 Node 调用)
|
||||
hotBrands: `${SSG}/brands/popular`, // 热门品牌(构建期 Node 调用)
|
||||
streetSnapPopular: `${SSG}/street-snaps/popular`, // 热门街拍(构建期 Node 调用)
|
||||
},
|
||||
} as const
|
||||
348
src/lib/gallery.ts
Normal file
348
src/lib/gallery.ts
Normal file
@ -0,0 +1,348 @@
|
||||
// src/lib/gallery.ts —— 走秀 / 街拍详情页共享的「图集 + 大图灯箱」Alpine 视图工厂。
|
||||
//
|
||||
// Item.astro(走秀)与 StreetSnap.astro(街拍)的灯箱交互逐行同构,差异只有三处:
|
||||
// ① 根 / 画廊选择器 ② 单图收藏类型 ③ 已登录全量取数函数
|
||||
// 故抽成工厂;各自独有的部分(每行张数、细节图灯箱)由组件在返回值上自行叠加。
|
||||
//
|
||||
// 注意(两处刻意的写法,别"顺手改回去"):
|
||||
// 1. 工厂内**不使用** Alpine 魔法($el / $nextTick)。返回值要被 `{...createGalleryView()}` 展开进
|
||||
// Alpine.data 的对象字面量,而对象展开会让 this 的推断丢掉魔法属性;故根元素改由 cfg.rootSelector
|
||||
// 查(dataset 都挂在根节点上),DOM 刷新等待改用 rAF。
|
||||
// 2. 同理 currentFavId / currentFavUrl 写成**方法**而非 getter —— 对象展开会把 getter 求值成静态值。
|
||||
// 3. 共享初始化叫 galleryInit() 而不是 init() —— 组件自己的 init() 会覆盖它(Alpine 只认 init)。
|
||||
|
||||
import { toAbs } from "./request"
|
||||
import { getUser } from "./auth"
|
||||
import { isFavorited, toggleImageFavorite } from "./favorites"
|
||||
|
||||
/** 后端详情接口 images 元素的公共形状(RawArticleImage 与 RawStreetSnap.images 的并集)。 */
|
||||
export interface GalleryImageRaw {
|
||||
id?: string
|
||||
image?: string
|
||||
thumb?: string
|
||||
name?: string
|
||||
look_index?: number
|
||||
favorited?: boolean
|
||||
}
|
||||
|
||||
/** 已登录全量取数:返回完整图片集与图集级收藏态。 */
|
||||
export type GalleryDetailFetcher = (
|
||||
uid: string,
|
||||
) => Promise<{ images?: GalleryImageRaw[]; favorited?: boolean } | null>
|
||||
|
||||
export interface GalleryConfig {
|
||||
/** 根节点选择器(承载 data-fav-type / data-gallery-* / data-preview) */
|
||||
rootSelector: string
|
||||
/** 图集网格选择器(承载 [data-image-index] / [data-fav-id]) */
|
||||
gallerySelector: string
|
||||
/** 单图收藏类型:runway_image | snap_image */
|
||||
favType: string
|
||||
/** 已登录后拉全量图片 + 收藏态 */
|
||||
fetchDetail: GalleryDetailFetcher
|
||||
}
|
||||
|
||||
/** 客户端补拉的第 6 张起图片(前 PREVIEW_LIMIT 张已由 SSR 渲染)。 */
|
||||
export interface GalleryExtraImage {
|
||||
id: string
|
||||
url: string
|
||||
thumb: string
|
||||
raw: string
|
||||
name: string
|
||||
lookIndex?: number
|
||||
}
|
||||
|
||||
// 缩略图懒加载 observer:同一页面同时只有一个图集灯箱,用模块级单例持有;
|
||||
// 重建前先断开上一个(loadAuthedState 之后会再调一次 initThumbLazyLoading)。
|
||||
let thumbObserver: IntersectionObserver | null = null
|
||||
|
||||
// 等 Alpine 完成一次 DOM 刷新。比 $nextTick 稳:rAF 必然晚于 Alpine 的微任务 flush。
|
||||
const afterRender = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
export function createGalleryView(cfg: GalleryConfig) {
|
||||
return {
|
||||
// ===== 大图灯箱状态 =====
|
||||
lbOpen: false,
|
||||
lbIndex: 0,
|
||||
lbZoomed: false,
|
||||
imageCount: 0,
|
||||
currentImageName: "",
|
||||
|
||||
// ===== 图集 / 收藏元信息 =====
|
||||
favIds: [] as string[],
|
||||
favType: cfg.favType,
|
||||
galleryUid: "",
|
||||
galleryTitle: "",
|
||||
galleryBrand: "",
|
||||
|
||||
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的
|
||||
// getUser,故在此初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
|
||||
isAuthed: false,
|
||||
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
|
||||
preview: false,
|
||||
// 图集级收藏态:由 loadAuthedState 取回,广播给页面右下角 fav-fab
|
||||
galleryFav: false,
|
||||
// 已登录后客户端补拉的全量图片
|
||||
extraImages: [] as GalleryExtraImage[],
|
||||
|
||||
root(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>(cfg.rootSelector)
|
||||
},
|
||||
|
||||
/** 共享初始化。组件自己的 init() 必须先调它,再做各自的扩展初始化。 */
|
||||
galleryInit() {
|
||||
const root = this.root()
|
||||
this.favType = root?.dataset.favType || cfg.favType
|
||||
this.galleryUid = root?.dataset.galleryUid || ""
|
||||
this.galleryTitle = root?.dataset.galleryTitle || ""
|
||||
this.galleryBrand = root?.dataset.galleryBrand || ""
|
||||
this.preview = root?.dataset.preview === "true"
|
||||
this.isAuthed = !!getUser()
|
||||
|
||||
this.imageCount = this.countImages()
|
||||
this.initThumbLazyLoading()
|
||||
this.initImageFavs()
|
||||
this.maybeLocateImageFromUrl()
|
||||
|
||||
// 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
|
||||
if (getUser()) this.loadAuthedState()
|
||||
// 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
|
||||
window.addEventListener("auth:login", () => {
|
||||
this.isAuthed = true
|
||||
if (getUser()) this.loadAuthedState()
|
||||
})
|
||||
},
|
||||
|
||||
countImages(): number {
|
||||
return document.querySelectorAll(`${cfg.gallerySelector} [data-image-index]`).length
|
||||
},
|
||||
|
||||
/** 已登录:用一次详情请求拿回「完整图片集 + 收藏态」。 */
|
||||
async loadAuthedState() {
|
||||
if (!getUser()) return
|
||||
try {
|
||||
const full = await cfg.fetchDetail(this.galleryUid)
|
||||
if (!full || !Array.isArray(full.images)) return
|
||||
const all = full.images
|
||||
|
||||
// 图集级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
|
||||
this.galleryFav = !!full.favorited
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
|
||||
)
|
||||
|
||||
// 图片级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
|
||||
const favIds: string[] = []
|
||||
const mark = (im: GalleryImageRaw) => {
|
||||
if (im?.id && im.favorited) favIds.push(im.id)
|
||||
}
|
||||
|
||||
// 超预览上限才追加第 6 张起图片(前 PREVIEW_LIMIT 张已由 SSR 渲染,仅补收藏态)
|
||||
if (this.preview) {
|
||||
this.extraImages = all.slice(5).map((im) => ({
|
||||
id: im.id || "",
|
||||
url: toAbs(im.image || ""),
|
||||
thumb: toAbs(im.thumb || im.image || ""),
|
||||
raw: im.image || "",
|
||||
name: im.name || "",
|
||||
lookIndex: Number(im.look_index ?? 0),
|
||||
}))
|
||||
await afterRender()
|
||||
this.imageCount = this.countImages()
|
||||
all.slice(5).forEach(mark)
|
||||
this.initThumbLazyLoading()
|
||||
this.maybeLocateImageFromUrl()
|
||||
}
|
||||
all.slice(0, 5).forEach(mark)
|
||||
this.favIds = favIds
|
||||
} catch {
|
||||
/* 失败维持本地/截断态,门禁仍在 */
|
||||
}
|
||||
},
|
||||
|
||||
/** ?img=<图片uid> 深链:默认只定位 + 短暂高亮,不弹灯箱;显式带 &lb=1 才自动开灯箱。 */
|
||||
maybeLocateImageFromUrl() {
|
||||
if (typeof window === "undefined") return
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
const want = q.get("img")
|
||||
if (!want) return
|
||||
const figures = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(`${cfg.gallerySelector} [data-fav-id]`),
|
||||
)
|
||||
const idx = figures.findIndex((el) => el.dataset.favId === want)
|
||||
if (idx < 0) return
|
||||
const fig = figures[idx]
|
||||
requestAnimationFrame(() => {
|
||||
if (q.get("lb") === "1") {
|
||||
this.openLb(idx)
|
||||
return
|
||||
}
|
||||
fig.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
fig.classList.add("locate-flash")
|
||||
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
|
||||
})
|
||||
},
|
||||
|
||||
getImageElement(index: number) {
|
||||
return document.querySelector<HTMLElement>(
|
||||
`${cfg.gallerySelector} [data-image-index="${index}"]`,
|
||||
)
|
||||
},
|
||||
getImageUrl(index: number) {
|
||||
return this.getImageElement(index)?.dataset.imageUrl || ""
|
||||
},
|
||||
getImageName(index: number) {
|
||||
return this.getImageElement(index)?.dataset.imageName || ""
|
||||
},
|
||||
getImageFavId(index: number) {
|
||||
return this.getImageElement(index)?.dataset.favId || ""
|
||||
},
|
||||
/** 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主) */
|
||||
getImageFavUrl(index: number) {
|
||||
return this.getImageElement(index)?.dataset.favUrl || ""
|
||||
},
|
||||
|
||||
// ===== 单图收藏 =====
|
||||
// 只把「当前这篇图集的全部图片 id」交给本地收藏表比对,复杂度与图片数成正比、与用户收藏总量无关。
|
||||
allImageFavIds(): string[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(`${cfg.gallerySelector} [data-fav-id]`),
|
||||
)
|
||||
.map((el) => el.dataset.favId || "")
|
||||
.filter(Boolean)
|
||||
},
|
||||
/** 本地即时填充收藏态(登录态的服务端校正已合并进 loadAuthedState,不再单独请求 checks)。 */
|
||||
initImageFavs() {
|
||||
const ids = this.allImageFavIds()
|
||||
if (!ids.length) return
|
||||
this.favIds = ids.filter((id) => isFavorited(id))
|
||||
},
|
||||
isFav(id: string) {
|
||||
return !!id && this.favIds.includes(id)
|
||||
},
|
||||
toggleFav(id: string, url: string) {
|
||||
if (!id) return
|
||||
toggleImageFavorite({
|
||||
imageId: id,
|
||||
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
|
||||
imageUrl: url,
|
||||
parentUid: this.galleryUid,
|
||||
title: this.galleryTitle,
|
||||
brand: this.galleryBrand,
|
||||
})
|
||||
this.favIds = this.isFav(id)
|
||||
? this.favIds.filter((x) => x !== id)
|
||||
: [...this.favIds, id]
|
||||
},
|
||||
// 灯箱里的收藏按钮跟随当前大图(方法而非 getter,原因见文件头说明 2)
|
||||
currentFavId(): string {
|
||||
return this.getImageFavId(this.lbIndex)
|
||||
},
|
||||
currentFavUrl(): string {
|
||||
return this.getImageFavUrl(this.lbIndex)
|
||||
},
|
||||
|
||||
// ===== 灯箱开关 / 翻页 =====
|
||||
openLb(index = 0) {
|
||||
if (!this.imageCount) return
|
||||
this.lbIndex = this.normalizeIndex(index)
|
||||
this.lbZoomed = false
|
||||
this.lbOpen = true
|
||||
document.body.style.overflow = "hidden"
|
||||
this.updateLightboxImage()
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollThumbIntoView()
|
||||
this.preloadAround()
|
||||
})
|
||||
},
|
||||
closeLb() {
|
||||
this.lbOpen = false
|
||||
this.lbZoomed = false
|
||||
document.body.style.overflow = ""
|
||||
},
|
||||
showLb(index: number) {
|
||||
if (!this.imageCount) return
|
||||
this.lbIndex = this.normalizeIndex(index)
|
||||
this.lbZoomed = false
|
||||
this.updateLightboxImage()
|
||||
const main = document.getElementById("lb-main")
|
||||
if (main) main.scrollTop = 0
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollThumbIntoView()
|
||||
this.preloadAround()
|
||||
})
|
||||
},
|
||||
nextLb() {
|
||||
this.showLb(this.lbIndex + 1)
|
||||
},
|
||||
prevLb() {
|
||||
this.showLb(this.lbIndex - 1)
|
||||
},
|
||||
normalizeIndex(index: number) {
|
||||
if (!this.imageCount) return 0
|
||||
return ((index % this.imageCount) + this.imageCount) % this.imageCount
|
||||
},
|
||||
updateLightboxImage() {
|
||||
const image = document.getElementById("lb-image") as HTMLImageElement | null
|
||||
if (!image) return
|
||||
const url = this.getImageUrl(this.lbIndex)
|
||||
const name = this.getImageName(this.lbIndex)
|
||||
image.src = url
|
||||
image.alt = name
|
||||
this.currentImageName = name
|
||||
},
|
||||
toggleZoom() {
|
||||
this.lbZoomed = !this.lbZoomed
|
||||
const main = document.getElementById("lb-main")
|
||||
if (main) main.scrollTop = 0
|
||||
},
|
||||
scrollThumbIntoView() {
|
||||
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
|
||||
if (!thumbs) return
|
||||
const active = thumbs.querySelector<HTMLElement>(`[data-lb-index="${this.lbIndex}"]`)
|
||||
if (!active) return
|
||||
const target = active.offsetTop - (thumbs.clientHeight - active.clientHeight) / 2
|
||||
thumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
|
||||
},
|
||||
preloadAround() {
|
||||
const indexes = [
|
||||
this.lbIndex,
|
||||
this.normalizeIndex(this.lbIndex - 1),
|
||||
this.normalizeIndex(this.lbIndex + 1),
|
||||
]
|
||||
indexes.forEach((index) => {
|
||||
const url = this.getImageUrl(index)
|
||||
if (!url) return
|
||||
const image = new Image()
|
||||
image.decoding = "async"
|
||||
image.src = url
|
||||
})
|
||||
},
|
||||
/** 缩略图条带:用 IntersectionObserver 懒加载背景图(未进入视口不设 background-image)。 */
|
||||
initThumbLazyLoading() {
|
||||
const thumbs = document.querySelectorAll<HTMLElement>("#lb-thumbs [data-thumb-url]")
|
||||
if (!thumbs.length) return
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
thumbs.forEach((thumb) => {
|
||||
thumb.style.backgroundImage = `url("${thumb.dataset.thumbUrl}")`
|
||||
})
|
||||
return
|
||||
}
|
||||
// 先断开上一个实例,避免重复调用时泄漏
|
||||
thumbObserver?.disconnect()
|
||||
const observer = new IntersectionObserver(
|
||||
(entries, obs) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return
|
||||
const element = entry.target as HTMLElement
|
||||
const url = element.dataset.thumbUrl
|
||||
if (url) element.style.backgroundImage = `url("${url}")`
|
||||
obs.unobserve(element)
|
||||
})
|
||||
},
|
||||
{ root: document.getElementById("lb-thumbs"), rootMargin: "300px" },
|
||||
)
|
||||
thumbObserver = observer
|
||||
thumbs.forEach((thumb) => observer.observe(thumb))
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -5,17 +5,19 @@
|
||||
// `import "@/lib/http"`(本文件会在加载时自动 install),或直接调用 installApiLogger()。
|
||||
//
|
||||
// 日志去向:
|
||||
// - 浏览器(DEV 环境):console.debug 彩色输出,并收集到 window.__apiLog(控制台可查)。
|
||||
// - Node(SSR / SSG 构建期):追加写入项目根 logs/api-debug.log。
|
||||
// - 浏览器(DEV):console.debug 彩色输出,并收集到 window.__apiLog(控制台可查)。
|
||||
// - Node(构建期 / 显式开启时):追加写入项目根 logs/api-debug.log。
|
||||
//
|
||||
// 开关:默认在 DEV 或 SSR(含 astro build)开启;生产环境客户端默认关闭,
|
||||
// 可在 .env 设 DEBUG_API=true 强制开启。所有日志都会对 URL 里的 token 脱敏。
|
||||
// 开关:**仅 DEV 默认开启**。生产(含 SSR 运行时与 astro build)默认关闭 ——
|
||||
// 拦截器会给每个请求增加 res.clone() + 读 body 的开销,并在 Node 下持续追加写日志文件
|
||||
//(容器内会无限增长),故不能在生产常开。
|
||||
// 需要构建期/生产排查时显式开启:`DEBUG_API=true npm run build`。
|
||||
// 所有日志都会对 URL 里的 token 脱敏。
|
||||
|
||||
const g = globalThis as any
|
||||
|
||||
const ENABLED =
|
||||
(import.meta as any).env?.DEV ||
|
||||
(import.meta as any).env?.SSR ||
|
||||
(typeof g.process !== "undefined" && g.process.env?.DEBUG_API === "true")
|
||||
|
||||
const nativeFetch: any = g.fetch ? g.fetch.bind(g) : undefined
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { DEFAULT_LOCALE, type Locale } from '@/i18n/config';
|
||||
import { DEFAULT_LOCALE, type Locale } from './index';
|
||||
|
||||
/**
|
||||
* 单文件翻译字典。
|
||||
@ -19,6 +19,8 @@ type TranslationEntry = Partial<Record<OtherLocale, string>>;
|
||||
|
||||
export const dict: Record<string, TranslationEntry> = {
|
||||
|
||||
'loading': { cn: '加载中' },
|
||||
|
||||
// ── 导航 / Navigation ──
|
||||
'home': { cn: '首页' },
|
||||
'login': { cn: '登录' },
|
||||
100
src/lib/i18n/index.ts
Normal file
100
src/lib/i18n/index.ts
Normal file
@ -0,0 +1,100 @@
|
||||
// src/lib/i18n/index.ts
|
||||
// i18n 核心聚合:配置 + 工具函数 + API 语言透传,统一到本文件。
|
||||
// 翻译字典独立在 ./dictionary.ts(纯数据、随 UI 增长,不混入逻辑,保持可单独维护)。
|
||||
//
|
||||
// 与 dictionary.ts 存在良性循环依赖:本文件导入 translate,dictionary 反向导入本文件的
|
||||
// DEFAULT_LOCALE / Locale。两者均为「函数体内才使用」的绑定,模块求值阶段不会触发 TDZ,安全。
|
||||
|
||||
import { getLocaleByPath, getRelativeLocaleUrl } from 'astro:i18n';
|
||||
import { translate } from './dictionary';
|
||||
|
||||
// ── 配置 / Config ──
|
||||
/** 默认语言(无前缀时使用的语言,如 /shows)。 */
|
||||
export const DEFAULT_LOCALE = 'en' as const;
|
||||
|
||||
/** 支持的语言列表。URL 前缀用的就是这些值,例如 /cn/shows 的 `cn`。 */
|
||||
export const LOCALES = ['en', 'cn'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
/** 语言显示名(用于语言切换器等 UI)。 */
|
||||
export const LOCALE_NAMES: Record<string, { name: string; nativeName: string }> = {
|
||||
cn: {
|
||||
name: '中文',
|
||||
nativeName: '中文',
|
||||
},
|
||||
en: {
|
||||
name: 'English',
|
||||
nativeName: 'English',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── 工具 / Utils ──
|
||||
/**
|
||||
* 从 URL 解析语言,委托 Astro 内置的 astro:i18n.getLocaleByPath。
|
||||
* 无前缀(默认语言)路径会解析回 DEFAULT_LOCALE。
|
||||
*/
|
||||
export function getLocaleFromUrl(url: URL | string): Locale {
|
||||
const pathname = typeof url === 'string' ? url : url.pathname;
|
||||
try {
|
||||
const locale = getLocaleByPath(pathname);
|
||||
return (LOCALES as readonly string[]).includes(locale) ? (locale as Locale) : DEFAULT_LOCALE;
|
||||
} catch {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取翻译函数 t():key 即英文原文,按当前语言自动匹配翻译。
|
||||
* - 英文环境:t('all runway looks') 直接返回 'all runway looks'(key 本身)
|
||||
* - 中文环境:t('all runway looks') 返回字典里的 '全部的 runway'(取不到则回退英文)
|
||||
* - 支持 {name} 形式的插值参数。
|
||||
*/
|
||||
export function useTranslations(locale: Locale) {
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
return translate(key, locale, params);
|
||||
}
|
||||
return { t, locale };
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行取「当前语言 + 翻译函数」,干掉每个页面重复的样板:
|
||||
* const { locale, t } = getI18n(Astro)
|
||||
* 内部用 Astro.currentLocale(与 Layout 判断语言的方式一致),
|
||||
* 取不到时回退默认语言。参数只要求含 currentLocale 字段,避免耦合 Astro 全局类型。
|
||||
*/
|
||||
export function getI18n(Astro: { currentLocale?: string | undefined }) {
|
||||
const locale = (Astro.currentLocale as Locale) ?? DEFAULT_LOCALE;
|
||||
const { t } = useTranslations(locale);
|
||||
return { locale, t, getRelativeLocaleUrl };
|
||||
}
|
||||
|
||||
// ── API 语言透传 / Locale passthrough ──
|
||||
// 把「当前语言」塞进所有 /api 请求的 ?locale= 参数,让后端按 locale 选 *_en / *_cn 列。
|
||||
//
|
||||
// 当前语言来源分两端:
|
||||
// - 浏览器端(<script> 里的 fetch):从 URL path 推断(/cn/ → cn,其余 → en)。
|
||||
// - 服务端(SSG 构建 / 按需渲染):由页面/布局在前置 frontmatter 调 setApiLocale() 注入,
|
||||
// 因绝大多数页面是 SSG 单线程构建,模块级 _ssrLocale 安全。
|
||||
|
||||
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;
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 走秀 / 街拍 列表页共享的 Alpine 工具(从 RunwayLooks.astro / StreetSnaps.astro 抽出)。
|
||||
* 仅承载两个模块同构、且与各自筛选项无关的逻辑:加载转圈 SVG、卡片缩略图槽位、分页页码计算。
|
||||
* 列表页 / 账户页共享的 Alpine 工具(从 RunwayLooks.astro / StreetSnaps.astro / account.astro 抽出)。
|
||||
* 仅承载同构、且与各自筛选项无关的逻辑:加载转圈 SVG、卡片缩略图槽位、分页页码计算。
|
||||
* 页面特有逻辑(品牌弹窗、各筛选维度、取数 API)仍保留在各组件内。
|
||||
*/
|
||||
|
||||
@ -57,3 +57,27 @@ export function buildPageList(total: number, cur: number): (number | "...")[] {
|
||||
pages.push(total)
|
||||
return pages
|
||||
}
|
||||
|
||||
/**
|
||||
* 窗口化页码(对象形态):首尾页 + 当前页 ±1,其余用 … 折叠。
|
||||
* 供账户页(收藏 / 历史)模板直接 x-for 渲染按钮;总页数 <= 1 时返回 []。
|
||||
*
|
||||
* 与 buildPageList 的区别:本函数输出 { label, page } 对象(label 可为 "…"、page 为 null),
|
||||
* buildPageList 输出 number | "..."(列表页 x-for 渲染用)。语义相同、形态不同,勿混用。
|
||||
*/
|
||||
export function buildWindowPages(
|
||||
cur: number,
|
||||
last: number,
|
||||
): ({ label: string; page: number } | { label: string; page: null })[] {
|
||||
if (last <= 1) return []
|
||||
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
|
||||
const arr = [...set].sort((a, b) => a - b)
|
||||
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
|
||||
let prev = 0
|
||||
for (const n of arr) {
|
||||
if (n - prev > 1) out.push({ label: "…", page: null })
|
||||
out.push({ label: String(n), page: n })
|
||||
prev = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -19,9 +19,10 @@
|
||||
import "@/lib/http"
|
||||
|
||||
import { config } from "./config" // dev/prod 配置(baseApi / baseApiSsr / baseApiSsg)
|
||||
import { endpoints } from "./endpoints" // 后端端点路径(见 endpoints.ts)
|
||||
import { clientSign } from "./crypto" // 公开请求签名(X-Sign* 头)
|
||||
import { getAccessToken, getRefreshToken, getUser, saveSession, clearSession, isAccessExpiring } from "./auth" // 令牌读取 / 写入
|
||||
import { withLocale, currentLocale } from "./locale" // 请求语言透传
|
||||
import { withLocale, currentLocale } from "./i18n" // 请求语言透传
|
||||
|
||||
// ===================== base 解析(全部来自 config) =====================
|
||||
|
||||
@ -41,6 +42,14 @@ export function ssrBase(): string {
|
||||
return config.baseApiSsr.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
// 把后端返回的相对图片路径补成 SSR 绝对地址(基于 ssrBase())。
|
||||
// 与 toAbs 的区别:toAbs 基于浏览器运行期的 baseUrl(),本函数基于 SSR 取数 base。
|
||||
export function ssrAbs(url: string): string {
|
||||
if (!url) return ""
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
return ssrBase() + url
|
||||
}
|
||||
|
||||
// ===================== 令牌原语(Bearer + 401 续期重试) =====================
|
||||
|
||||
// 唯一实现「附加 Bearer(若有)+ 遇 401 用 refresh 续期一次后重试」的底层 fetch。
|
||||
@ -143,7 +152,7 @@ async function doRefreshSession(): Promise<string> {
|
||||
let json: any
|
||||
try {
|
||||
// auth: false —— 刷新请求本身不走「Bearer + 401 重试」,否则 401 会再次触发刷新(递归)。
|
||||
json = await request("POST", "/api/v1/auth/refresh", {
|
||||
json = await request("POST", endpoints.auth.refresh, {
|
||||
body: { refresh_token: refresh },
|
||||
auth: false,
|
||||
})
|
||||
|
||||
@ -14,12 +14,9 @@ export const ROUTES = {
|
||||
// 街拍
|
||||
streetSnaps: '/street-snaps',
|
||||
// 动态路由(工厂函数)。
|
||||
// 走秀 lookbook 详情统一收口到 /item/[id](src/pages/{locale}/item/[id].astro)。
|
||||
// 街拍详情独立收口到 /street-snaps/[id](src/pages/{locale}/street-snaps/[id].astro),
|
||||
// 与 runway 分路由是为了避免 HashID 类型无关导致两类 id 在同一 /item 路由里撞车
|
||||
//(runway 与 street_snap 共用一套自增数字 id + 同一盐值,编码串不可区分类型)。
|
||||
// 走秀与街拍详情**统一收口到 /item/[id]**(src/pages/{locale}/item/[id].astro),
|
||||
// 由前端按编码前缀(r=runway / s=snap)分流到对应后端接口。
|
||||
item: (id: string | number) => `/item/${id}`,
|
||||
// 街拍详情与走秀详情统一收口到 /item/[id],由前端按编码前缀(r=runway/s=snap)分流。
|
||||
streetSnap: (id: string | number) => `/item/${id}`,
|
||||
} as const;
|
||||
|
||||
|
||||
56
src/lib/ssg.ts
Normal file
56
src/lib/ssg.ts
Normal file
@ -0,0 +1,56 @@
|
||||
// src/lib/ssg.ts — SSG 构建专用接口层(仅 Node 构建期)
|
||||
//
|
||||
// 本文件只装「构建期 SSG 取数」:所有函数调用 /api/internal/ssg/* 内部端口
|
||||
// (仅 127.0.0.1,nginx 不反代,由 astro build 时的 Node 进程调用),
|
||||
// 走 request.ts 的 requestSsg()(自动带 SSG_TOKEN + ?locale=)。
|
||||
//
|
||||
// 与 api.ts 的「浏览器运行期公开业务 + 鉴权 + 收藏 + 历史」隔离,
|
||||
// 避免「仅构建期才有意义」的代码混进客户端逻辑(这些函数从不进客户端 bundle)。
|
||||
|
||||
import { requestSsg } from "./request"
|
||||
import { endpoints } from "./endpoints"
|
||||
import type { BrandEntry, StreetSnapItem } from "./api"
|
||||
|
||||
// 后端热门品牌项(与 SSG popular brand 对齐)。
|
||||
export interface SsgPopularBrand {
|
||||
id: string
|
||||
article_id: string
|
||||
brand: string
|
||||
cover: string
|
||||
title: string
|
||||
}
|
||||
|
||||
// 接口:GET /api/internal/ssg/home/runway(SSG 内部端口,构建期 Node 调用)
|
||||
// 首页 runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。
|
||||
export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> {
|
||||
const j = await requestSsg<{ data?: { hotBrand?: SsgPopularBrand[] } }>(endpoints.ssg.homeRunway)
|
||||
return { hotBrand: j?.data?.hotBrand ?? [] }
|
||||
}
|
||||
|
||||
// 接口:GET /api/internal/ssg/brands/popular(SSG 内部端口,构建期 Node 调用)
|
||||
// 走秀页热门品牌(按图片总数排名,结果按热度顺序)。后端固定返回前 30 个。
|
||||
export async function getSsgHotBrands(): Promise<BrandEntry[]> {
|
||||
const j = await requestSsg<{ data?: any[] }>(endpoints.ssg.hotBrands)
|
||||
return (j?.data ?? []).map((b: any) => {
|
||||
const nameEn = (b.name_en || b.name || "").toString()
|
||||
return {
|
||||
id: String(b.id),
|
||||
name: nameEn,
|
||||
nameCn: (b.name_cn || "").toString(),
|
||||
display: nameEn,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 接口:GET /api/internal/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用)
|
||||
// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。
|
||||
export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapItem[]> {
|
||||
const j = await requestSsg<{ data?: any[] }>(`${endpoints.ssg.streetSnapPopular}?limit=${limit}`)
|
||||
return (j?.data ?? []).map((b: any) => ({
|
||||
id: String(b.id),
|
||||
title: b.title ?? "",
|
||||
cover: b.cover ?? "",
|
||||
image_count: Number(b.image_count ?? 0),
|
||||
images: [],
|
||||
}))
|
||||
}
|
||||
83
src/lib/ssr.ts
Normal file
83
src/lib/ssr.ts
Normal file
@ -0,0 +1,83 @@
|
||||
// src/lib/ssr.ts — 运行期按需渲染(on-demand SSR)取数
|
||||
//
|
||||
// 与 ssg.ts 对称:
|
||||
// ssg.ts —— **构建期** SSG 取数,走 config.baseApiSsg(内部端口,仅 127.0.0.1)。
|
||||
// 本文件 —— **运行期** SSR 详情取数,走 config.baseApiSsr(绝对 URL,Node fetch 不吃相对路径)。
|
||||
//
|
||||
// 均为「可信内部取数」:直接原生 fetch,不经前端签名(X-Sign*)、不带 Bearer。
|
||||
// 返回结构带 preview / imageTotal:未登录且图片数超预览上限时后端截断图片集并置 preview=true,
|
||||
// 详情页据此渲染「登录查看全部图片」占位遮罩。
|
||||
//
|
||||
// 客户端(浏览器)的接口调用不在此文件 —— 见 api.ts(走 request(),自动带签名/locale/Bearer)。
|
||||
|
||||
import { ssrBase } from "./request"
|
||||
import { endpoints } from "./endpoints"
|
||||
import { withLocale } from "./i18n"
|
||||
import type { RawArticle, RawStreetSnap } from "./api"
|
||||
|
||||
export type SsrArticleResult = {
|
||||
article: RawArticle | null
|
||||
error: "missing" | "failed" | null
|
||||
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
|
||||
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
|
||||
}
|
||||
|
||||
// 接口:GET /api/v1/public/runway-looks/:id
|
||||
// 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。
|
||||
// locale 显式传入时优先(避免并发串 locale)。
|
||||
export async function getSsrArticle(
|
||||
id: string | null | undefined,
|
||||
locale?: string
|
||||
): Promise<SsrArticleResult> {
|
||||
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 res = await fetch(`${b}${endpoints.public.runwayLook(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", preview: false, imageTotal: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
export type SsrStreetSnapResult = {
|
||||
snap: RawStreetSnap | null
|
||||
error: "missing" | "failed" | null
|
||||
preview?: boolean
|
||||
imageTotal?: number
|
||||
}
|
||||
|
||||
// 接口:GET /api/v1/public/street-snaps/:id
|
||||
// 服务端按需渲染(on-demand)取街拍详情:经 ssrBase() 直接 fetch(可信内部取数,不经签名)。
|
||||
export async function getSsrStreetSnap(
|
||||
id: string | null | undefined,
|
||||
locale?: string
|
||||
): Promise<SsrStreetSnapResult> {
|
||||
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 res = await fetch(`${b}${endpoints.public.streetSnap(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", preview: false, imageTotal: 0 }
|
||||
}
|
||||
}
|
||||
@ -1,333 +1,6 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from "@/lib/routes"
|
||||
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const titleLabel = t("my account")
|
||||
const pleaseLabel = t("please log in")
|
||||
const loginLabel = t("login")
|
||||
const historyLabel = t("my history")
|
||||
const savedLooksLabel = t("saved looks")
|
||||
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
|
||||
const archiveUrl = href(locale, ROUTES.runwayLooks)
|
||||
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
|
||||
const removeLabel = t("remove")
|
||||
const itemsLabel = t("items")
|
||||
const clearHistoryLabel = t("clear history")
|
||||
const noSavedLabel = t("no saved looks yet")
|
||||
const noHistoryLabel = t("no history yet")
|
||||
const browseLabel = t("browse the archive")
|
||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/AccountPage.astro(en/cn 共用)。
|
||||
import AccountPage from "@/components/pages/AccountPage.astro"
|
||||
---
|
||||
<Layout title={titleLabel}>
|
||||
<!-- 页面版心与 Layout 一致:黑白编辑风,衬线大标题 + 等宽小标 + 粗黑细线 -->
|
||||
<main class="account-root mx-auto w-full max-w-[1400px] px-5 py-14 md:px-10 md:py-20 font-sans" x-data="accountPage" data-locale={locale}>
|
||||
|
||||
<!-- ===== 未登录:整版引导 ===== -->
|
||||
<div x-show="!user" x-cloak class="flex flex-col items-center justify-center border border-black/15 px-6 py-32 text-center">
|
||||
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{pleaseLabel}</p>
|
||||
<p class="mb-12 font-serif text-5xl font-black uppercase leading-none tracking-tight md:text-7xl">{titleLabel}</p>
|
||||
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-10 py-4 font-mono text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- ===== 已登录 ===== -->
|
||||
<div x-show="user" x-cloak>
|
||||
|
||||
<!-- 报头:衬线大标题会员名 + 档案标签 -->
|
||||
<header class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8 border-b-2 border-black pb-10 pt-2">
|
||||
<div class="min-w-0">
|
||||
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
|
||||
<div class="flex flex-wrap items-end gap-x-4">
|
||||
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
|
||||
<span x-show="user?.tier === 'vip'" x-cloak class="mb-2 inline-block border border-black bg-black px-2.5 py-1 font-mono text-[10px] font-bold uppercase tracking-[0.3em] text-white">{t("vip")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
|
||||
<p>Portfolio Studio</p>
|
||||
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
||||
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||
</p>
|
||||
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主体:左侧分区索引 + 右侧内容 -->
|
||||
<div class="grid gap-12 pt-12 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16 lg:pt-16">
|
||||
|
||||
<!-- 左:编号目录式垂直导航(移动端折成横向条) -->
|
||||
<nav class="lg:sticky lg:top-12 lg:self-start" aria-label="Account sections">
|
||||
<p class="mb-3 hidden font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400 lg:block">Index</p>
|
||||
<div class="flex gap-10 overflow-x-auto border-b border-black/15 font-mono text-[12px] uppercase tracking-[0.2em] lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0 lg:border-t lg:border-black/10">
|
||||
<button type="button" @click="tab='saved'" :class="tab==='saved' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
|
||||
<span class="mr-3 text-[10px] opacity-60">01</span>{savedLooksLabel}
|
||||
</button>
|
||||
<button type="button" @click="tab='history'" :class="tab==='history' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
|
||||
<span class="mr-3 text-[10px] opacity-60">02</span>{historyLabel}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="min-w-0">
|
||||
<!-- ===== Saved looks ===== -->
|
||||
<div x-show="tab==='saved'" x-cloak>
|
||||
|
||||
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
|
||||
<p class="pb-1 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
|
||||
</div>
|
||||
|
||||
<!-- 粒度切换:图集 / 单图(服务端 kind 过滤分页) -->
|
||||
<div class="mb-10 flex items-center gap-8 border-b border-black/15 font-mono text-[11px] uppercase tracking-[0.2em]">
|
||||
<button type="button" @click="setFavKind('gallery')" :class="favKind==='gallery' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('galleries')}</button>
|
||||
<button type="button" @click="setFavKind('image')" :class="favKind==='image' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('images')}</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noSavedLabel}</p>
|
||||
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- 收藏网格 -->
|
||||
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
|
||||
<template x-for="f in favorites" :key="f.id">
|
||||
<div class="group">
|
||||
<a :href="itemHref(f)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
|
||||
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
|
||||
</a>
|
||||
<button @click="removeFav(f.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
|
||||
<div class="mt-3 min-w-0">
|
||||
<a :href="itemHref(f)" class="block">
|
||||
<p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p>
|
||||
</a>
|
||||
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 收藏分页(服务端分页,与收藏总量解耦) -->
|
||||
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="favLastPage > 1" aria-label="Saved pagination">
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
|
||||
<template x-for="(p, i) in favPageList" :key="i">
|
||||
<span>
|
||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
|
||||
</span>
|
||||
</template>
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ===== History ===== -->
|
||||
<div x-show="tab==='history'" x-cloak>
|
||||
|
||||
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{historyLabel}</h2>
|
||||
<div class="flex items-baseline gap-8 pb-1">
|
||||
<p class="font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="histTotal"></span> {itemsLabel}</p>
|
||||
<button x-show="histTotal > 0" @click="clearAllHistory()" class="font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noHistoryLabel}</p>
|
||||
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- 历史网格 -->
|
||||
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
|
||||
<template x-for="h in history" :key="h.id">
|
||||
<div class="group">
|
||||
<a :href="itemHref(h)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
|
||||
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
|
||||
</a>
|
||||
<button @click="removeHist(h.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
|
||||
<div class="mt-3 min-w-0">
|
||||
<a :href="itemHref(h)" class="block">
|
||||
<p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p>
|
||||
</a>
|
||||
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 历史分页 -->
|
||||
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="histLastPage > 1" aria-label="History pagination">
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
|
||||
<template x-for="(p, i) in histPageList" :key="i">
|
||||
<span>
|
||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
|
||||
</span>
|
||||
</template>
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* 触屏没有 hover:卡片右上角操作常驻显示,否则点不到 */
|
||||
@media (hover: none) {
|
||||
.card-act { opacity: 1 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
|
||||
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
|
||||
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
|
||||
'<rect width="300" height="400" fill="#f1f1f1"/>' +
|
||||
'<text x="150" y="200" font-family="monospace" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
|
||||
'</svg>'
|
||||
)
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("accountPage", () => ({
|
||||
user: null as any,
|
||||
locale: "en",
|
||||
tab: "saved",
|
||||
favKind: "gallery", // 收藏子分类:gallery=整篇图集 / image=单张图片
|
||||
favorites: [] as any[],
|
||||
favoritesCount: 0,
|
||||
favPage: 1,
|
||||
favPerPage: 24,
|
||||
favLastPage: 1,
|
||||
history: [] as any[],
|
||||
histPage: 1,
|
||||
histPerPage: 24,
|
||||
histLastPage: 1,
|
||||
histTotal: 0,
|
||||
init() {
|
||||
this.locale = this.$el.dataset.locale || "en"
|
||||
const u = getUser()
|
||||
if (!u) {
|
||||
window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
|
||||
return
|
||||
}
|
||||
this.user = u
|
||||
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
|
||||
this.loadFavPage(1)
|
||||
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
|
||||
this.loadHistPage(1)
|
||||
},
|
||||
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级);kind 决定取图集还是单图
|
||||
async loadFavPage(page: number, kind?: string) {
|
||||
const p = Math.max(1, page | 0)
|
||||
const k = kind || this.favKind
|
||||
try {
|
||||
const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery")
|
||||
this.favorites = r.items
|
||||
this.favLastPage = r.lastPage
|
||||
this.favPage = p
|
||||
this.favoritesCount = r.total
|
||||
} catch {
|
||||
const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery"))
|
||||
this.favorites = local
|
||||
this.favLastPage = 1
|
||||
this.favPage = 1
|
||||
this.favoritesCount = local.length
|
||||
}
|
||||
},
|
||||
setFavKind(k: string) {
|
||||
if (this.favKind === k) return
|
||||
this.favKind = k
|
||||
this.favorites = []
|
||||
this.favoritesCount = 0
|
||||
this.favLastPage = 1
|
||||
this.loadFavPage(1, k)
|
||||
},
|
||||
goFavPage(p: number) {
|
||||
if (p < 1 || p > this.favLastPage || p === this.favPage) return
|
||||
this.loadFavPage(p)
|
||||
},
|
||||
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
|
||||
get favPageList() {
|
||||
const cur = this.favPage, last = this.favLastPage
|
||||
if (last <= 1) return []
|
||||
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
|
||||
const arr = [...set].sort((a, b) => a - b)
|
||||
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
|
||||
let prev = 0
|
||||
for (const n of arr) {
|
||||
if (n - prev > 1) out.push({ label: "…", page: null })
|
||||
out.push({ label: String(n), page: n })
|
||||
prev = n
|
||||
}
|
||||
return out
|
||||
},
|
||||
coverUrl(x: any): string {
|
||||
const c = x.data?.cover || x.cover
|
||||
return c ? toAbs(c) : PLACEHOLDER
|
||||
},
|
||||
itemHref(f: any): string {
|
||||
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
|
||||
// 图集收藏与历史仍用自身 uid,但历史优先用回查到的 canonId(规范 id)——
|
||||
// 旧的无前缀历史 id 会把用户带回非规范地址,详情页据此又写出一条重复历史。
|
||||
const isImg = f.kind === "image"
|
||||
const uid = isImg && f.parentUid ? f.parentUid : (f.canonId || f.id)
|
||||
const base = clientHref(this.locale, ROUTES.item(uid))
|
||||
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
|
||||
},
|
||||
removeFav(id: string) {
|
||||
removeFavorite(id)
|
||||
this.loadFavPage(this.favPage) // 重新拉当前页,保证分页计数一致
|
||||
},
|
||||
// ===== 浏览历史(服务端分页)=====
|
||||
async loadHistPage(page: number) {
|
||||
const p = Math.max(1, page | 0)
|
||||
try {
|
||||
const r = await fetchHistoryPage(p, this.histPerPage)
|
||||
this.history = r.items
|
||||
this.histTotal = r.total
|
||||
this.histLastPage = r.lastPage
|
||||
this.histPage = p
|
||||
} catch {
|
||||
this.history = []
|
||||
this.histTotal = 0
|
||||
this.histLastPage = 1
|
||||
this.histPage = 1
|
||||
}
|
||||
},
|
||||
goHistPage(p: number) {
|
||||
if (p < 1 || p > this.histLastPage || p === this.histPage) return
|
||||
this.loadHistPage(p)
|
||||
},
|
||||
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
|
||||
get histPageList() {
|
||||
const cur = this.histPage, last = this.histLastPage
|
||||
if (last <= 1) return []
|
||||
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
|
||||
const arr = [...set].sort((a, b) => a - b)
|
||||
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
|
||||
let prev = 0
|
||||
for (const n of arr) {
|
||||
if (n - prev > 1) out.push({ label: "…", page: null })
|
||||
out.push({ label: String(n), page: n })
|
||||
prev = n
|
||||
}
|
||||
return out
|
||||
},
|
||||
removeHist(id: string) {
|
||||
removeHistory(id)
|
||||
this.loadHistPage(this.histPage) // 重新拉当前页,保证分页计数一致
|
||||
},
|
||||
clearAllHistory() {
|
||||
clearHistory()
|
||||
this.loadHistPage(1)
|
||||
},
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
<AccountPage />
|
||||
|
||||
@ -1,128 +1,9 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import Item from "@/components/Item.astro"
|
||||
import StreetSnap from "@/components/StreetSnap.astro"
|
||||
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
|
||||
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
|
||||
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
|
||||
export const prerender = false
|
||||
|
||||
const { id } = Astro.params
|
||||
const isSnap = !!id && id[0] === "s"
|
||||
|
||||
const { t } = getI18n(Astro)
|
||||
|
||||
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
|
||||
? await getSsrStreetSnap(id, Astro.currentLocale)
|
||||
: await getSsrArticle(id, Astro.currentLocale)
|
||||
|
||||
if (isSnap) {
|
||||
snap = data.snap
|
||||
snapErr = data.error
|
||||
} else {
|
||||
article = data.article
|
||||
articleErr = data.error
|
||||
}
|
||||
preview = data.preview ?? false
|
||||
imageTotal = data.imageTotal ?? 0
|
||||
|
||||
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
|
||||
// id 优先取**接口回传的规范 id**(带类型前缀):用户若通过旧的无前缀链接进来(如 /item/0008CZYI),
|
||||
// 也保证收藏 / 历史写入的是规范形式,不会与既有记录形成「同一篇文章两行」。
|
||||
const canonId = String((isSnap ? snap?.id : article?.id) ?? id ?? "")
|
||||
const meta = isSnap
|
||||
? { id: canonId, type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
|
||||
: { id: canonId, type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
|
||||
const saveLabel = t("save")
|
||||
const savedLabel = t("saved")
|
||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/ItemPage.astro(en/cn 共用)。
|
||||
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
||||
import ItemPage from "@/components/pages/ItemPage.astro"
|
||||
---
|
||||
|
||||
<Layout>
|
||||
{article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
#fav-fab.is-active .fav-heart { fill: currentColor; }
|
||||
</style>
|
||||
|
||||
{(article || snap) && (
|
||||
<button login-required id="fav-fab" type="button"
|
||||
data-id={meta.id}
|
||||
data-type={meta.type}
|
||||
data-title={meta.title}
|
||||
data-cover={meta.cover}
|
||||
data-brand={meta.brand}
|
||||
data-save-label={saveLabel}
|
||||
data-saved-label={savedLabel}
|
||||
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
|
||||
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" 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>
|
||||
<span id="fav-label">{saveLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
|
||||
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
|
||||
<script>
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleFavorite, hydrateFavorited } from "@/lib/favorites"
|
||||
import { recordHistory } from "@/lib/history"
|
||||
|
||||
const btn = document.getElementById("fav-fab")
|
||||
const label = document.getElementById("fav-label")
|
||||
if (btn) {
|
||||
const meta = {
|
||||
id: btn.dataset.id || "",
|
||||
type: btn.dataset.type || "",
|
||||
title: btn.dataset.title || "",
|
||||
cover: btn.dataset.cover || "",
|
||||
brand: btn.dataset.brand || "",
|
||||
}
|
||||
const saveLabel = btn.dataset.saveLabel || "save"
|
||||
const savedLabel = btn.dataset.savedLabel || "saved"
|
||||
|
||||
if (meta.id) {
|
||||
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
|
||||
if (getUser()) recordHistory(meta.id)
|
||||
|
||||
const sync = () => {
|
||||
const fav = isFavorited(meta.id)
|
||||
if (label) label.textContent = fav ? savedLabel : saveLabel
|
||||
btn.classList.toggle("is-active", fav)
|
||||
}
|
||||
sync()
|
||||
|
||||
// 登录态收藏校正已合并进详情接口:articleView / streetSnapView 取到服务端收藏态后,
|
||||
// 通过 fav:set 事件广播当前图集是否已收藏;此处监听并写回本地(换设备后与服务端一致)。
|
||||
// 不再单独请求 /me/favorites/checks。
|
||||
window.addEventListener("fav:set", (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ id: string; fav: boolean }>).detail
|
||||
if (!detail || detail.id !== meta.id) return
|
||||
if (detail.fav) {
|
||||
hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
|
||||
}
|
||||
sync()
|
||||
})
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
toggleFavorite({
|
||||
id: meta.id,
|
||||
type: meta.type,
|
||||
title: meta.title,
|
||||
cover: meta.cover,
|
||||
brand: meta.brand,
|
||||
} as any)
|
||||
sync()
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<ItemPage id={Astro.params.id} />
|
||||
|
||||
@ -1,167 +1,6 @@
|
||||
---
|
||||
import '@/styles/global.css'
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { LOCALE_NAMES } from '@/i18n/config'
|
||||
import { i18n } from 'astro:config/client';
|
||||
import { toCodes } from 'astro:i18n';
|
||||
|
||||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||
|
||||
const pathName = Astro.url.pathname
|
||||
const locales = toCodes(i18n!.locales)
|
||||
const pathWithoutLocale = locales.includes(pathName.split('/')[1])
|
||||
? pathName.replace(`/${locale}`, '')
|
||||
: pathName
|
||||
const languages = locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName }))
|
||||
|
||||
const loginLabel = t('login')
|
||||
const promptLabel = t('login prompt')
|
||||
const accountLabel = t('username or email')
|
||||
const passwordLabel = t('password')
|
||||
const failLabel = t('login failed')
|
||||
const membersLabel = t('members')
|
||||
const backLabel = t('back to site')
|
||||
const homeUrl = href(locale, ROUTES.runwayLooks)
|
||||
const pageTitle = `${loginLabel} — Portfolio Studio`
|
||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/LoginPage.astro(en/cn 共用)。
|
||||
import LoginPage from "@/components/pages/LoginPage.astro"
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{pageTitle}</title>
|
||||
</head>
|
||||
<!-- 全站一致:白底 + 衬线大标题 + 等宽小标 + 粗黑细线,直角无阴影 -->
|
||||
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
|
||||
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
|
||||
|
||||
<!-- 顶部工具行:返回档案 + 语言切换 -->
|
||||
<header class="mb-12 flex items-center justify-between gap-6">
|
||||
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black/50 transition-colors hover:text-black">
|
||||
<span aria-hidden="true">←</span>{backLabel}
|
||||
</a>
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false" @click.outside="open = false">
|
||||
<button type="button" @click="open = !open" class="inline-flex items-center gap-2 border border-black/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
|
||||
<span>{LOCALE_NAMES[locale].nativeName}</span>
|
||||
<svg viewBox="0 0 12 12" width="11" height="11" class="transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
|
||||
<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>
|
||||
<ul x-show="open" x-cloak class="absolute right-0 top-full z-20 mt-2 min-w-[170px] border border-black/15 bg-white py-1">
|
||||
{languages.map((l) => (
|
||||
<li>
|
||||
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 font-mono text-[11px] uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/55']}>{l.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 2px 粗黑线) -->
|
||||
<section class="border-b-2 border-black pb-10">
|
||||
<div class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
|
||||
<div>
|
||||
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
|
||||
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
|
||||
</div>
|
||||
<div class="pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
|
||||
<p>Portfolio Studio</p>
|
||||
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
||||
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||
</p>
|
||||
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
|
||||
<div class="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<p class="mb-10 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400">{promptLabel}</p>
|
||||
|
||||
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
|
||||
<div>
|
||||
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{accountLabel}</label>
|
||||
<input type="text" x-model="account" autocomplete="username" required autofocus
|
||||
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{passwordLabel}</label>
|
||||
<input type="password" x-model="password" autocomplete="current-password" required
|
||||
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
|
||||
</div>
|
||||
|
||||
<p x-show="error" x-text="error" x-cloak class="font-mono text-[11px] uppercase tracking-[0.2em] text-red-600"></p>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full border border-black bg-black py-4 font-mono text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<span x-show="!loading">{loginLabel}</span>
|
||||
<span x-show="loading" x-cloak>…</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-10 border-t border-black/15 pt-5">
|
||||
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">
|
||||
<span aria-hidden="true">←</span>{backLabel}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="border-t border-black/10 pt-8 md:border-l md:border-t-0 md:pl-8 md:pt-0">
|
||||
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">Archive note</p>
|
||||
<p class="font-serif text-lg leading-relaxed text-black/80">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
|
||||
<div class="mt-8 border-t border-black/10 pt-5 font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400">
|
||||
<p class="mb-3">Runway · Street · Archive</p>
|
||||
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { login } from "@/lib/api"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("loginForm", () => ({
|
||||
account: "",
|
||||
password: "",
|
||||
error: "",
|
||||
loading: false,
|
||||
|
||||
init() {
|
||||
if (getUser()) {
|
||||
const loc = this.$el.dataset.locale || "en"
|
||||
window.location.href = clientHref(loc, ROUTES.account)
|
||||
}
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.error = ""
|
||||
this.loading = true
|
||||
|
||||
login({ account: this.account, password: this.password })
|
||||
.then(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const redirect = params.get("redirect")
|
||||
const loc = this.$el.dataset.locale || "en"
|
||||
window.location.href = redirect || clientHref(loc, ROUTES.account)
|
||||
})
|
||||
.catch(() => {
|
||||
this.error = this.$el.dataset.failLabel || "login failed"
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<LoginPage />
|
||||
|
||||
@ -1,334 +1,6 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from "@/lib/routes"
|
||||
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const titleLabel = t("my account")
|
||||
const pleaseLabel = t("please log in")
|
||||
const loginLabel = t("login")
|
||||
const historyLabel = t("my history")
|
||||
const savedLooksLabel = t("saved looks")
|
||||
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
|
||||
const archiveUrl = href(locale, ROUTES.runwayLooks)
|
||||
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
|
||||
const removeLabel = t("remove")
|
||||
const itemsLabel = t("items")
|
||||
const clearHistoryLabel = t("clear history")
|
||||
const noSavedLabel = t("no saved looks yet")
|
||||
const noHistoryLabel = t("no history yet")
|
||||
const browseLabel = t("browse the archive")
|
||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/AccountPage.astro(en/cn 共用)。
|
||||
import AccountPage from "@/components/pages/AccountPage.astro"
|
||||
---
|
||||
<Layout title={titleLabel}>
|
||||
<!-- 页面版心与 Layout 一致:黑白编辑风,衬线大标题 + 等宽小标 + 粗黑细线 -->
|
||||
<main class="account-root mx-auto w-full max-w-[1400px] px-5 py-14 md:px-10 md:py-20 font-sans" x-data="accountPage" data-locale={locale}>
|
||||
|
||||
<!-- ===== 未登录:整版引导 ===== -->
|
||||
<div x-show="!user" x-cloak class="flex flex-col items-center justify-center border border-black/15 px-6 py-32 text-center">
|
||||
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{pleaseLabel}</p>
|
||||
<p class="mb-12 font-serif text-5xl font-black uppercase leading-none tracking-tight md:text-7xl">{titleLabel}</p>
|
||||
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-10 py-4 font-mono text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- ===== 已登录 ===== -->
|
||||
<div x-show="user" x-cloak>
|
||||
|
||||
<!-- 报头:衬线大标题会员名 + 档案标签 -->
|
||||
<header class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8 border-b-2 border-black pb-10 pt-2">
|
||||
<div class="min-w-0">
|
||||
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
|
||||
<div class="flex flex-wrap items-end gap-x-4">
|
||||
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
|
||||
<!-- VIP 徽标:仅高级用户(图集详情页可见高清原图)显示 -->
|
||||
<span x-show="user?.tier === 'vip'" x-cloak class="mb-2 inline-block border border-black bg-black px-2.5 py-1 font-mono text-[10px] font-bold uppercase tracking-[0.3em] text-white">{t("vip")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
|
||||
<p>Portfolio Studio</p>
|
||||
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
||||
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||
</p>
|
||||
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主体:左侧分区索引 + 右侧内容 -->
|
||||
<div class="grid gap-12 pt-12 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16 lg:pt-16">
|
||||
|
||||
<!-- 左:编号目录式垂直导航(移动端折成横向条) -->
|
||||
<nav class="lg:sticky lg:top-12 lg:self-start" aria-label="Account sections">
|
||||
<p class="mb-3 hidden font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400 lg:block">Index</p>
|
||||
<div class="flex gap-10 overflow-x-auto border-b border-black/15 font-mono text-[12px] uppercase tracking-[0.2em] lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0 lg:border-t lg:border-black/10">
|
||||
<button type="button" @click="tab='saved'" :class="tab==='saved' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
|
||||
<span class="mr-3 text-[10px] opacity-60">01</span>{savedLooksLabel}
|
||||
</button>
|
||||
<button type="button" @click="tab='history'" :class="tab==='history' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
|
||||
<span class="mr-3 text-[10px] opacity-60">02</span>{historyLabel}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="min-w-0">
|
||||
<!-- ===== Saved looks ===== -->
|
||||
<div x-show="tab==='saved'" x-cloak>
|
||||
|
||||
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
|
||||
<p class="pb-1 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
|
||||
</div>
|
||||
|
||||
<!-- 粒度切换:图集 / 单图(服务端 kind 过滤分页) -->
|
||||
<div class="mb-10 flex items-center gap-8 border-b border-black/15 font-mono text-[11px] uppercase tracking-[0.2em]">
|
||||
<button type="button" @click="setFavKind('gallery')" :class="favKind==='gallery' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('galleries')}</button>
|
||||
<button type="button" @click="setFavKind('image')" :class="favKind==='image' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('images')}</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noSavedLabel}</p>
|
||||
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- 收藏网格 -->
|
||||
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
|
||||
<template x-for="f in favorites" :key="f.id">
|
||||
<div class="group">
|
||||
<a :href="itemHref(f)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
|
||||
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
|
||||
</a>
|
||||
<button @click="removeFav(f.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
|
||||
<div class="mt-3 min-w-0">
|
||||
<a :href="itemHref(f)" class="block">
|
||||
<p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p>
|
||||
</a>
|
||||
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 收藏分页(服务端分页,与收藏总量解耦) -->
|
||||
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="favLastPage > 1" aria-label="Saved pagination">
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
|
||||
<template x-for="(p, i) in favPageList" :key="i">
|
||||
<span>
|
||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
|
||||
</span>
|
||||
</template>
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ===== History ===== -->
|
||||
<div x-show="tab==='history'" x-cloak>
|
||||
|
||||
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{historyLabel}</h2>
|
||||
<div class="flex items-baseline gap-8 pb-1">
|
||||
<p class="font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="histTotal"></span> {itemsLabel}</p>
|
||||
<button x-show="histTotal > 0" @click="clearAllHistory()" class="font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noHistoryLabel}</p>
|
||||
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- 历史网格 -->
|
||||
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
|
||||
<template x-for="h in history" :key="h.id">
|
||||
<div class="group">
|
||||
<a :href="itemHref(h)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
|
||||
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
|
||||
</a>
|
||||
<button @click="removeHist(h.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
|
||||
<div class="mt-3 min-w-0">
|
||||
<a :href="itemHref(h)" class="block">
|
||||
<p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p>
|
||||
</a>
|
||||
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 历史分页 -->
|
||||
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="histLastPage > 1" aria-label="History pagination">
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
|
||||
<template x-for="(p, i) in histPageList" :key="i">
|
||||
<span>
|
||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
|
||||
</span>
|
||||
</template>
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* 触屏没有 hover:卡片右上角操作常驻显示,否则点不到 */
|
||||
@media (hover: none) {
|
||||
.card-act { opacity: 1 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
|
||||
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
|
||||
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
|
||||
'<rect width="300" height="400" fill="#f1f1f1"/>' +
|
||||
'<text x="150" y="200" font-family="monospace" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
|
||||
'</svg>'
|
||||
)
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("accountPage", () => ({
|
||||
user: null as any,
|
||||
locale: "en",
|
||||
tab: "saved",
|
||||
favKind: "gallery", // 收藏子分类:gallery=整篇图集 / image=单张图片
|
||||
favorites: [] as any[],
|
||||
favoritesCount: 0,
|
||||
favPage: 1,
|
||||
favPerPage: 24,
|
||||
favLastPage: 1,
|
||||
history: [] as any[],
|
||||
histPage: 1,
|
||||
histPerPage: 24,
|
||||
histLastPage: 1,
|
||||
histTotal: 0,
|
||||
init() {
|
||||
this.locale = this.$el.dataset.locale || "en"
|
||||
const u = getUser()
|
||||
if (!u) {
|
||||
window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
|
||||
return
|
||||
}
|
||||
this.user = u
|
||||
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
|
||||
this.loadFavPage(1)
|
||||
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
|
||||
this.loadHistPage(1)
|
||||
},
|
||||
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级);kind 决定取图集还是单图
|
||||
async loadFavPage(page: number, kind?: string) {
|
||||
const p = Math.max(1, page | 0)
|
||||
const k = kind || this.favKind
|
||||
try {
|
||||
const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery")
|
||||
this.favorites = r.items
|
||||
this.favLastPage = r.lastPage
|
||||
this.favPage = p
|
||||
this.favoritesCount = r.total
|
||||
} catch {
|
||||
const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery"))
|
||||
this.favorites = local
|
||||
this.favLastPage = 1
|
||||
this.favPage = 1
|
||||
this.favoritesCount = local.length
|
||||
}
|
||||
},
|
||||
setFavKind(k: string) {
|
||||
if (this.favKind === k) return
|
||||
this.favKind = k
|
||||
this.favorites = []
|
||||
this.favoritesCount = 0
|
||||
this.favLastPage = 1
|
||||
this.loadFavPage(1, k)
|
||||
},
|
||||
goFavPage(p: number) {
|
||||
if (p < 1 || p > this.favLastPage || p === this.favPage) return
|
||||
this.loadFavPage(p)
|
||||
},
|
||||
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
|
||||
get favPageList() {
|
||||
const cur = this.favPage, last = this.favLastPage
|
||||
if (last <= 1) return []
|
||||
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
|
||||
const arr = [...set].sort((a, b) => a - b)
|
||||
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
|
||||
let prev = 0
|
||||
for (const n of arr) {
|
||||
if (n - prev > 1) out.push({ label: "…", page: null })
|
||||
out.push({ label: String(n), page: n })
|
||||
prev = n
|
||||
}
|
||||
return out
|
||||
},
|
||||
coverUrl(x: any): string {
|
||||
const c = x.data?.cover || x.cover
|
||||
return c ? toAbs(c) : PLACEHOLDER
|
||||
},
|
||||
itemHref(f: any): string {
|
||||
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
|
||||
// 图集收藏与历史仍用自身 uid,但历史优先用回查到的 canonId(规范 id)——
|
||||
// 旧的无前缀历史 id 会把用户带回非规范地址,详情页据此又写出一条重复历史。
|
||||
const isImg = f.kind === "image"
|
||||
const uid = isImg && f.parentUid ? f.parentUid : (f.canonId || f.id)
|
||||
const base = clientHref(this.locale, ROUTES.item(uid))
|
||||
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
|
||||
},
|
||||
removeFav(id: string) {
|
||||
removeFavorite(id)
|
||||
this.loadFavPage(this.favPage) // 重新拉当前页,保证分页计数一致
|
||||
},
|
||||
// ===== 浏览历史(服务端分页)=====
|
||||
async loadHistPage(page: number) {
|
||||
const p = Math.max(1, page | 0)
|
||||
try {
|
||||
const r = await fetchHistoryPage(p, this.histPerPage)
|
||||
this.history = r.items
|
||||
this.histTotal = r.total
|
||||
this.histLastPage = r.lastPage
|
||||
this.histPage = p
|
||||
} catch {
|
||||
this.history = []
|
||||
this.histTotal = 0
|
||||
this.histLastPage = 1
|
||||
this.histPage = 1
|
||||
}
|
||||
},
|
||||
goHistPage(p: number) {
|
||||
if (p < 1 || p > this.histLastPage || p === this.histPage) return
|
||||
this.loadHistPage(p)
|
||||
},
|
||||
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
|
||||
get histPageList() {
|
||||
const cur = this.histPage, last = this.histLastPage
|
||||
if (last <= 1) return []
|
||||
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
|
||||
const arr = [...set].sort((a, b) => a - b)
|
||||
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
|
||||
let prev = 0
|
||||
for (const n of arr) {
|
||||
if (n - prev > 1) out.push({ label: "…", page: null })
|
||||
out.push({ label: String(n), page: n })
|
||||
prev = n
|
||||
}
|
||||
return out
|
||||
},
|
||||
removeHist(id: string) {
|
||||
removeHistory(id)
|
||||
this.loadHistPage(this.histPage) // 重新拉当前页,保证分页计数一致
|
||||
},
|
||||
clearAllHistory() {
|
||||
clearHistory()
|
||||
this.loadHistPage(1)
|
||||
},
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
<AccountPage />
|
||||
|
||||
@ -1,128 +1,9 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import Item from "@/components/Item.astro"
|
||||
import StreetSnap from "@/components/StreetSnap.astro"
|
||||
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
|
||||
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
|
||||
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
|
||||
export const prerender = false
|
||||
|
||||
const { id } = Astro.params
|
||||
const isSnap = !!id && id[0] === "s"
|
||||
|
||||
const { t } = getI18n(Astro)
|
||||
|
||||
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
|
||||
? await getSsrStreetSnap(id, Astro.currentLocale)
|
||||
: await getSsrArticle(id, Astro.currentLocale)
|
||||
|
||||
if (isSnap) {
|
||||
snap = data.snap
|
||||
snapErr = data.error
|
||||
} else {
|
||||
article = data.article
|
||||
articleErr = data.error
|
||||
}
|
||||
preview = data.preview ?? false
|
||||
imageTotal = data.imageTotal ?? 0
|
||||
|
||||
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
|
||||
// id 优先取**接口回传的规范 id**(带类型前缀):用户若通过旧的无前缀链接进来(如 /item/0008CZYI),
|
||||
// 也保证收藏 / 历史写入的是规范形式,不会与既有记录形成「同一篇文章两行」。
|
||||
const canonId = String((isSnap ? snap?.id : article?.id) ?? id ?? "")
|
||||
const meta = isSnap
|
||||
? { id: canonId, type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
|
||||
: { id: canonId, type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
|
||||
const saveLabel = t("save")
|
||||
const savedLabel = t("saved")
|
||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/ItemPage.astro(en/cn 共用)。
|
||||
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
||||
import ItemPage from "@/components/pages/ItemPage.astro"
|
||||
---
|
||||
|
||||
<Layout>
|
||||
{article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
#fav-fab.is-active .fav-heart { fill: currentColor; }
|
||||
</style>
|
||||
|
||||
{(article || snap) && (
|
||||
<button login-required id="fav-fab" type="button"
|
||||
data-id={meta.id}
|
||||
data-type={meta.type}
|
||||
data-title={meta.title}
|
||||
data-cover={meta.cover}
|
||||
data-brand={meta.brand}
|
||||
data-save-label={saveLabel}
|
||||
data-saved-label={savedLabel}
|
||||
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
|
||||
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" 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>
|
||||
<span id="fav-label">{saveLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
|
||||
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
|
||||
<script>
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleFavorite, hydrateFavorited } from "@/lib/favorites"
|
||||
import { recordHistory } from "@/lib/history"
|
||||
|
||||
const btn = document.getElementById("fav-fab")
|
||||
const label = document.getElementById("fav-label")
|
||||
if (btn) {
|
||||
const meta = {
|
||||
id: btn.dataset.id || "",
|
||||
type: btn.dataset.type || "",
|
||||
title: btn.dataset.title || "",
|
||||
cover: btn.dataset.cover || "",
|
||||
brand: btn.dataset.brand || "",
|
||||
}
|
||||
const saveLabel = btn.dataset.saveLabel || "save"
|
||||
const savedLabel = btn.dataset.savedLabel || "saved"
|
||||
|
||||
if (meta.id) {
|
||||
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
|
||||
if (getUser()) recordHistory(meta.id)
|
||||
|
||||
const sync = () => {
|
||||
const fav = isFavorited(meta.id)
|
||||
if (label) label.textContent = fav ? savedLabel : saveLabel
|
||||
btn.classList.toggle("is-active", fav)
|
||||
}
|
||||
sync()
|
||||
|
||||
// 登录态收藏校正已合并进详情接口:articleView / streetSnapView 取到服务端收藏态后,
|
||||
// 通过 fav:set 事件广播当前图集是否已收藏;此处监听并写回本地(换设备后与服务端一致)。
|
||||
// 不再单独请求 /me/favorites/checks。
|
||||
window.addEventListener("fav:set", (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ id: string; fav: boolean }>).detail
|
||||
if (!detail || detail.id !== meta.id) return
|
||||
if (detail.fav) {
|
||||
hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
|
||||
}
|
||||
sync()
|
||||
})
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
toggleFavorite({
|
||||
id: meta.id,
|
||||
type: meta.type,
|
||||
title: meta.title,
|
||||
cover: meta.cover,
|
||||
brand: meta.brand,
|
||||
} as any)
|
||||
sync()
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<ItemPage id={Astro.params.id} />
|
||||
|
||||
@ -1,167 +1,6 @@
|
||||
---
|
||||
import '@/styles/global.css'
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { LOCALE_NAMES } from '@/i18n/config'
|
||||
import { i18n } from 'astro:config/client';
|
||||
import { toCodes } from 'astro:i18n';
|
||||
|
||||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||
|
||||
const pathName = Astro.url.pathname
|
||||
const locales = toCodes(i18n!.locales)
|
||||
const pathWithoutLocale = locales.includes(pathName.split('/')[1])
|
||||
? pathName.replace(`/${locale}`, '')
|
||||
: pathName
|
||||
const languages = locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName }))
|
||||
|
||||
const loginLabel = t('login')
|
||||
const promptLabel = t('login prompt')
|
||||
const accountLabel = t('username or email')
|
||||
const passwordLabel = t('password')
|
||||
const failLabel = t('login failed')
|
||||
const membersLabel = t('members')
|
||||
const backLabel = t('back to site')
|
||||
const homeUrl = href(locale, ROUTES.runwayLooks)
|
||||
const pageTitle = `${loginLabel} — Portfolio Studio`
|
||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/LoginPage.astro(en/cn 共用)。
|
||||
import LoginPage from "@/components/pages/LoginPage.astro"
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{pageTitle}</title>
|
||||
</head>
|
||||
<!-- 全站一致:白底 + 衬线大标题 + 等宽小标 + 粗黑细线,直角无阴影 -->
|
||||
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
|
||||
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
|
||||
|
||||
<!-- 顶部工具行:返回档案 + 语言切换 -->
|
||||
<header class="mb-12 flex items-center justify-between gap-6">
|
||||
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black/50 transition-colors hover:text-black">
|
||||
<span aria-hidden="true">←</span>{backLabel}
|
||||
</a>
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false" @click.outside="open = false">
|
||||
<button type="button" @click="open = !open" class="inline-flex items-center gap-2 border border-black/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
|
||||
<span>{LOCALE_NAMES[locale].nativeName}</span>
|
||||
<svg viewBox="0 0 12 12" width="11" height="11" class="transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
|
||||
<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>
|
||||
<ul x-show="open" x-cloak class="absolute right-0 top-full z-20 mt-2 min-w-[170px] border border-black/15 bg-white py-1">
|
||||
{languages.map((l) => (
|
||||
<li>
|
||||
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 font-mono text-[11px] uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/55']}>{l.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 2px 粗黑线) -->
|
||||
<section class="border-b-2 border-black pb-10">
|
||||
<div class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
|
||||
<div>
|
||||
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
|
||||
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
|
||||
</div>
|
||||
<div class="pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
|
||||
<p>Portfolio Studio</p>
|
||||
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
||||
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||
</p>
|
||||
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
|
||||
<div class="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<p class="mb-10 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400">{promptLabel}</p>
|
||||
|
||||
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
|
||||
<div>
|
||||
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{accountLabel}</label>
|
||||
<input type="text" x-model="account" autocomplete="username" required autofocus
|
||||
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{passwordLabel}</label>
|
||||
<input type="password" x-model="password" autocomplete="current-password" required
|
||||
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
|
||||
</div>
|
||||
|
||||
<p x-show="error" x-text="error" x-cloak class="font-mono text-[11px] uppercase tracking-[0.2em] text-red-600"></p>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full border border-black bg-black py-4 font-mono text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<span x-show="!loading">{loginLabel}</span>
|
||||
<span x-show="loading" x-cloak>…</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-10 border-t border-black/15 pt-5">
|
||||
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">
|
||||
<span aria-hidden="true">←</span>{backLabel}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="border-t border-black/10 pt-8 md:border-l md:border-t-0 md:pl-8 md:pt-0">
|
||||
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">Archive note</p>
|
||||
<p class="font-serif text-lg leading-relaxed text-black/80">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
|
||||
<div class="mt-8 border-t border-black/10 pt-5 font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400">
|
||||
<p class="mb-3">Runway · Street · Archive</p>
|
||||
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { login } from "@/lib/api"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("loginForm", () => ({
|
||||
account: "",
|
||||
password: "",
|
||||
error: "",
|
||||
loading: false,
|
||||
|
||||
init() {
|
||||
if (getUser()) {
|
||||
const loc = this.$el.dataset.locale || "en"
|
||||
window.location.href = clientHref(loc, ROUTES.account)
|
||||
}
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.error = ""
|
||||
this.loading = true
|
||||
|
||||
login({ account: this.account, password: this.password })
|
||||
.then(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const redirect = params.get("redirect")
|
||||
const loc = this.$el.dataset.locale || "en"
|
||||
window.location.href = redirect || clientHref(loc, ROUTES.account)
|
||||
})
|
||||
.catch(() => {
|
||||
this.error = this.$el.dataset.failLabel || "login failed"
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<LoginPage />
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
|
||||
// 应该跳转到 /en
|
||||
// 根路径 / 直接渲染首页(内容与 /en 一致)。注意:/ 与 /en 同内容会构成 SEO 重复,
|
||||
// 若需强制跳 /en,应在此加 redirect 或 middleware —— 当前保持现状,未做跳转。
|
||||
|
||||
import Layout from '@/layouts/Layout.astro'
|
||||
import Index from '@/components/Index.astro'
|
||||
|
||||
@ -2,3 +2,27 @@
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* ===== 共享原子:被多个组件复用的极小样式片段 =====
|
||||
放在全局而非组件 <style> 里:Astro 的组件样式会加作用域哈希,而 .fav-btn 同时出现在
|
||||
详情页网格图、灯箱(GalleryLightbox)与列表卡片中,组件作用域无法跨组件命中。 */
|
||||
|
||||
/* 单图收藏爱心:默认描边,已收藏实心 */
|
||||
.fav-btn svg { fill: none; }
|
||||
.fav-btn.is-fav svg { fill: currentColor; }
|
||||
|
||||
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
|
||||
.locate-flash {
|
||||
outline: 3px solid #111;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
|
||||
@media (hover: none) {
|
||||
.fav-btn { opacity: 1 !important; }
|
||||
}
|
||||
|
||||
/* 隐藏滚动条(灯箱缩略图条带等) */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
|
||||
|
||||
8
src/types/swiper-css.d.ts
vendored
Normal file
8
src/types/swiper-css.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
// Swiper 的样式副作用导入(swiper/css、swiper/css/navigation、swiper/css/free-mode …)
|
||||
// 在包内没有对应的 .d.ts,且模块说明符不带 .css 后缀,无法被 Astro 自带的 `declare module "*.css"` 覆盖。
|
||||
// 这里补全局模块声明,消除 ts(2882)。纯类型声明,不影响运行时(CSS 仍由 Vite 正常处理)。
|
||||
//
|
||||
// 注意:本文件**不能**出现顶层 import/export —— 一旦成为模块,简写式 ambient 声明就不再生效。
|
||||
|
||||
declare module "swiper/css";
|
||||
declare module "swiper/css/*";
|
||||
Reference in New Issue
Block a user