update
This commit is contained in:
33
.workbuddy/memory/2026-09-15.md
Normal file
33
.workbuddy/memory/2026-09-15.md
Normal file
@ -0,0 +1,33 @@
|
||||
# 2026-09-15 工作日志
|
||||
|
||||
## 记住语言偏好功能 ✅(11:43–12:00)
|
||||
用户要求:选过语言后,下次进首页直接用上次的语言。
|
||||
- 关键前提:本项目 `prefixDefaultLocale: true`,/en、/cn 都带前缀,根 `/` 由服务端 302 到 `/en`。故「进首页」实际落在 /en,在此做自动跳转。
|
||||
- `src/lib/i18n/index.ts` 新增 `STORED_LOCALE_KEY='preferred_locale'`、`storeLocale(locale)`、`getStoredLocale()`(localStorage 读写,单一来源)。
|
||||
- `src/layouts/Layout.astro`:
|
||||
- `<head>` 顶部加 `is:inline` + `define:vars` 脚本,进首页(path==='/' 或 '/'+defaultLocale)且记住了非默认语言时 `location.replace('/'+stored)`。脚本在 head 同步执行,先于首屏绘制,基本无闪白。
|
||||
- 语言切换器 `x-data="{ open:false }"` → `langSwitch()`;`<a>` `@click` 改为 `open=false; pick('<code>')`。
|
||||
- footer 脚本 import `storeLocale`,注册 `langSwitch` Alpine 组件(含 `pick(code){ storeLocale(code) }`)。
|
||||
- **设计铁律**:只在「显式点击切换器」时写 localStorage,绝不按页面加载的 URL 写——否则与首页自动跳转冲突(点了英文又被 URL 上的 en 覆盖,首页永远跳不回去)。
|
||||
- 验证:`npm run build` Complete;`astro check` 0/0/0;产物 HTML 确认内联脚本注入 `const defaultLocale="en"; const storedKey="preferred_locale";` 且两个 `pick('cn')`/`pick('en')` 锚点存在。
|
||||
- 顺带整理了被截断的超长 MEMORY.md(重写精简版,保留架构/约定/脚本铁律/类型坑/lib分层/验证手段/门禁/账号/i18n/记住语言)。
|
||||
|
||||
## 语言建议弹窗(未选过语言时按浏览器语言提示)✅(12:11–12:30)
|
||||
用户追加需求:没选过语言时,按浏览器语言自动判断,并弹小窗提示是否切换。
|
||||
- `Layout.astro` footer `<script>`(alpine:init 内)新增 `localeSuggest` Alpine 组件,import 扩展为 `storeLocale, getStoredLocale, DEFAULT_LOCALE, LOCALE_NAMES`。
|
||||
- `init()`:已 `getStoredLocale()` 则返回(交给 head 脚本重定向,不弹);否则读 `navigator.language`,`/^(zh|cmn|zho)/` → 'cn' 否则 'en';仅在首页(`p==='/' || p==='/' + def`)且 `detected !== cur` 时显示。
|
||||
- `message` getter:target='cn' 时中文「检测到您的浏览器语言为「中文」,是否切换到中文界面?」;否则英文兜底。
|
||||
- `switchLang()`:`storeLocale(target)` + `location.replace('/'+target)`;`keep()`:`storeLocale(current)`(记下"保持当前默认"避免再弹)+ 隐藏。
|
||||
- body 末尾加弹窗标记 `x-data="localeSuggest()"` + `x-show="visible"` + `x-cloak` + `x-transition`,两个按钮 `keep()/switchLang()`,标签用 `names[target]/names[current]`(LOCALE_NAMES.nativeName:English / 中文)。
|
||||
- 设计要点:弹窗与 head 重定向互斥(靠 `getStoredLocale()` 判存);点「保持」写 `current`(默认 en)即压制后续弹窗与重定向。
|
||||
- 验证:`npm run build` Complete;`astro check` 0/0/0;产物 HTML 含 popup 标记、JS bundle 含 localeSuggest + 中英文案。
|
||||
|
||||
## 字体统一:移除 font-mono / font-serif ✅(16:16–16:23)
|
||||
用户要求:font-mono 这类字体都不要了,否则字体不统一。
|
||||
- 站里原混用 `font-sans`(系统栈,默认)+ `font-mono`(等宽)+ `font-serif`(衬线)三类,视觉不统一。
|
||||
- 用脚本批量剥除 `src` 下全部 `font-mono`/`font-serif` 工具类(9 个 .astro 文件:Layout/LoginPage/AccountPage/AuthModal/BrandModal/ComingSoon/GalleryLightbox/RunwayLookCard/StreetSnapCard),UTF-8 无 BOM 精确回写,只删 token 不动其余。
|
||||
- 两处"裸"字体声明单独手改:
|
||||
- `LoadingOverlay.astro` `<style>`:删 `#loading-screen h1` 的 `ui-serif,...serif` 与 `.ls-sub` 的 `ui-monospace,...monospace` 两行 `font-family`(改继承 body 的 font-sans);同步更新注释里的"mono 小标"措辞。
|
||||
- `AccountPage.astro` SVG 占位图 `<text font-family="monospace">` → `sans-serif`(SVG 作为 data URI 隔离渲染,必须显式给字体)。
|
||||
- 残留核查:`font-mono|font-serif|monospace|ui-serif|ui-monospace` 在 src 下 0 命中。全站统一为 `font-sans`(系统字体栈)。
|
||||
- 验证:`npm run build` Complete;`astro check` 0/0/0。
|
||||
@ -1,116 +1,63 @@
|
||||
# 项目长期记忆(Project MEMORY.md)
|
||||
|
||||
> 维护约定:本文件只放**跨会话长期有效**的事实与铁律;当日细节写 `.workbuddy/memory/YYYY-MM-DD.md`。
|
||||
> 历次合并去重:2026-09-14(修正 BASE_API 位置、ROUTES.article、ssgRequest、Raleway、View Transitions 等过时记述;补 gallery/灯箱与 Alpine 展开推断坑)。
|
||||
> 历次合并去重:2026-09-15(大幅压缩;保留架构/约定/脚本铁律/类型坑/lib分层/验证手段/门禁/账号/i18n;删冗长示例与已过时待办)。
|
||||
|
||||
## 架构总览
|
||||
- 前端 `d:/project/frontend_v2`:Astro(`adapter: node` standalone,SSR 运行时取数)+ Tailwind v4 + Alpine.js。构建 `npm run build`;类型检查 `npm run check`(= `astro check`)。SSG 取数走 `requestSsg`(src/lib/request.ts),失败 `null` 回落不影响构建。
|
||||
- 前端 `d:/project/frontend_v2`:Astro(`adapter: node` standalone,SSR 取数)+ Tailwind v4 + Alpine.js。构建 `npm run build`;类型检查 `npm run check`(= `astro check`)。
|
||||
- 后端 `d:/project/backend_v2`:Go + Gin + GORM(MySQL)。公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`);SSG 引擎 :8091(仅 127.0.0.1,nginx 不反代)。
|
||||
- 接口 i18n:后端按 `?locale=cn|en` 选列;前端取数复用带 locale 原语(`request` / `requestSsg`),**绝不裸写 fetch**;语言以 URL 前缀为准。
|
||||
- **base 地址全在 `src/lib/config/`(目录)**:`index.ts` 导出 `config`(`validate(isProd?prod:dev)`)、`types.ts`、`env.dev.ts`/`env.prod.ts`。三把址:`config.baseApi`(浏览器运行期)/ `config.baseApiSsr`(SSR 绝对 URL)/ `config.baseApiSsg`(构建期内部端口)。**全站无直接 `import.meta.env.BASE_API` 读取**(值硬编码在 env.*.ts,**不在 `.env`**)。密钥类(`SSG_TOKEN`)故意直读 env,不进客户端 bundle。
|
||||
- **后端编译产物坑(Windows)**:`go build -o server-bin ./cmd/server` 实际产出无扩展名 `server-bin`,但运行的是 `server-bin.exe`。必须 `go build -o server-bin.exe` 显式覆盖,再 `Start-Process server-bin.exe`;杀进程 `(Get-NetTCPConnection -LocalPort 8090).OwningProcess`。本地后端长空闲报 `invalid connection` 先重启后端(连接池 `conn_max_idle_time` 须 < MySQL `wait_timeout`)。
|
||||
- 前端 dev server **不热加载 `.env`**:改 base 后 `astro dev stop`+`astro dev --background`;浏览器旧 bundle 硬刷新、旧 token 失效重登录。
|
||||
- **`504 (Outdated Optimize Dep)` 修法**:动过 `package.json` 依赖后 Vite 会重新预打包,哈希变了而浏览器仍攥着旧哈希 → 控制台报 `GET /node_modules/.vite/deps/xxx.js 504 (Outdated Optimize Dep)`。修:`astro dev stop` → 删 `node_modules/.vite` → `astro dev --background` → 浏览器**硬刷新**(Ctrl+Shift+R)。
|
||||
- 报错里的 **`audit-*.js` 是 Astro 开发工具栏的 chunk**,只在 dev 存在,与站点代码无关,生产构建没有。
|
||||
- 控制台里 `Images loaded lazily and replaced with placeholders. Load events are deferred.` 是 **Chrome DevTools 的提示**(开着 Network/Performance 面板时会把懒加载图临时换成占位图并推迟 load 事件),**不是错误**。
|
||||
- 接口 i18n:后端按 `?locale=cn|en` 选列;**语言以 URL 前缀为准**;`prefixDefaultLocale: true`(/en、/cn 都带前缀;根 `/` 由服务端 302 到 /en)。
|
||||
- **base 地址全在 `src/lib/config/`**:`config.baseApi`(浏览器) / `config.baseApiSsr`(SSR 绝对) / `config.baseApiSsg`(构建期内部端口)。密钥类(SSG_TOKEN)直读 env。
|
||||
- 后端 Windows 编译:`go build -o server-bin.exe`(非 `server-bin`)。前端 dev 不热加载 `.env`,改 base 后 `astro dev stop`+`--background`+浏览器硬刷新。
|
||||
- `504 (Outdated Optimize Dep)`:删 `node_modules/.vite` 后重启 dev。
|
||||
|
||||
## 关键约定
|
||||
- 视觉:黑白极简编辑风(monospace 小标签 + 宽字距 + 细线 + 直角),**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系。
|
||||
- 品牌名前端只显英文(`display=nameEn`);走秀标题/描述按 locale 选列。
|
||||
- API 命名:一个功能一个接口,跨资源/独立功能独立路径(如 `brands/hot` 而非 `?featured=1`)。
|
||||
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`(Tailwind v4 编译期指令,最终 HTML 不出现,样式已生效);Tailwind v4 无零值类。
|
||||
- **跨组件复用的原子样式必须放 `global.css`,不能放组件 `<style>`**:Astro 会给组件样式加作用域哈希,`.fav-btn` 同时出现在详情网格、灯箱(`GalleryLightbox`)、列表卡片,组件作用域**跨组件命中不了**。现 global.css 含 `.fav-btn`、`.locate-flash`、`@media(hover:none)`、`.no-scrollbar`、`[x-cloak]`。
|
||||
- Layout `<slot/>` 包 `flex-1 flex flex-col`;页内根容器**禁用 `m-auto`**(shrink-to-fit 抖动),用 `w-full`+`max-w-[x] mx-auto`。
|
||||
- **Header/下拉 FOUC**:所有 `x-show` 下拉一律带 `x-cloak`。
|
||||
- **加载屏组件化**:`src/components/LoadingScreen.astro` 封装标记+样式+打包 `<script>`(无 `is:inline`)。字体用**系统字体栈**(ui-serif / ui-monospace),**不引外部字体**(Raleway 已移除)。改加载屏只动这一个文件。
|
||||
- 项目**未启用 View Transitions**(Layout 无 `ClientRouter`),故 `astro:page-load` 监听实际不会触发(`Index.astro` 里那条属防御性死代码,保留无害)。
|
||||
- 视觉:黑白极简编辑风,纯黑白禁用暖米色/柔和阴影;字体走 Tailwind 体系(系统字体栈,不引外部字体)。
|
||||
- 跨组件复用原子样式放 `global.css`(Astro 组件 `<style>` 有作用域哈希,跨组件命中不了)。现含 `.fav-btn`/`.locate-flash`/`@media(hover:none)`/`.no-scrollbar`/`[x-cloak]`。
|
||||
- Tailwind v4:`<style>` 顶部 `@reference "tailwindcss";`(编译期指令,不进 HTML);无零值类;能 @apply 就 @apply。
|
||||
- Header 下拉 `x-show` 一律带 `x-cloak` 防 FOUC。Layout `<slot/>` 包 `flex-1 flex flex-col`;页内根容器禁用 `m-auto`(用 `w-full`+`max-w-[x] mx-auto`)。
|
||||
- 未启用 View Transitions。
|
||||
|
||||
## Astro `<script>` 铁律(踩坑汇总)
|
||||
1. 作用域隔离:打包型 `<script>` 拿不到 frontmatter 的 import/变量,需脚本顶部重新 import。
|
||||
2. **`define:vars` + `import` = 致命**:`define:vars` 把脚本包成 IIFE 注入 `const meta=...`,原顶部 `import` 被塞进函数体 → SyntaxError 整段不执行。凡脚本要 import,改用 `data-*` 属性透传(`ItemPage.astro` 的 fav-fab 已用此套路)。
|
||||
3. 片段页(无 `<html>/<head>`)的 `import '@/styles/global.css'` build 时不注入 head → 整页无样式(login.astro 曾踩)。
|
||||
4. **Alpine 属性表达式访问不到模块 import 符号**:Alpine 只认组件 data 属性 / `$magic` / global。**铁律:表达式里只写组件 data 属性或 `$magic`,外部状态/函数先存进 data 属性**。
|
||||
- 正例:`toAbsUrl(url) { return toAbs(url) }` —— 看着像「空包装」,但模板里有 `:src="toAbsUrl(it.cover)"`,**不能删**(删了图片加载不出来)。`isAuthed` 亦为此而生。
|
||||
5. **验证 Alpine 运行时务必真跑浏览器**(或至少确认表达式可解析),只看 SSR 静态 HTML 会漏掉运行时绑定失效。
|
||||
6. 共享 Alpine 组件:`document.addEventListener('alpine:init', () => Alpine.data('name', () => ({...})))` 注册,`x-data="name()"` 引用(`articleView`/`streetSnapView`/`imgLoader`/`showsPage`/`streetSnapsPage`/`accountPage`/`authMenu` 同款)。**若 data 由工厂展开而来,必须显式标注回调返回类型**,见下节。
|
||||
7. **Swiper 生命周期(首页 Index.astro)**:`initSwipers()` 开头先 `destroySwipers()` 防重复/泄漏;统一入口 `setupSwipers()` 用 rAF 延后一帧,挂 `DOMContentLoaded`;`swiper.update()` 用 `updateScheduled` + rAF 合并;Tab 点击 `document` 事件委托(`(e.target as Element|null)?.closest('.tab')`)。
|
||||
8. **IntersectionObserver 要复用**:`initThumbLazyLoading()` 在 `loadAuthedState()` 后会被再调一次;灯箱缩略图的 observer 现在是 `src/lib/gallery.ts` 里的**模块级单例** `thumbObserver`,重建前 `?.disconnect()`(别放 Alpine data 属性,会被响应式代理包一层)。
|
||||
1. 打包型 `<script>` 拿不到 frontmatter 变量,需脚本顶部重新 import。
|
||||
2. **`define:vars` + `import` = 致命**(包成 IIFE 后 import 进函数体 → SyntaxError)。要 import 改用 `data-*` 透传。
|
||||
3. 片段页(无 `<html>`)`import '@/styles/global.css'` build 不注入 head → 整页无样式。
|
||||
4. **Alpine 表达式只认组件 data / `$magic` / global**,外部函数先存进 data 属性(如 `isAuthed`、`toAbsUrl` 看似空包装但不能删)。
|
||||
5. 验证 Alpine 运行时务必真跑浏览器,只看 SSR HTML 会漏掉绑定失效。
|
||||
6. 共享组件 `Alpine.data('name', () => ({...}))` + `x-data="name()"`;由工厂展开须显式标注返回类型。
|
||||
7. Swiper:`initSwipers()` 先 `destroySwipers()`;`setupSwipers()` rAF 延后 + `DOMContentLoaded`;Tab 用 document 事件委托。
|
||||
8. IntersectionObserver 用模块级单例(如 gallery 的 `thumbObserver`),重建前 `?.disconnect()`(别放 Alpine data 属性被代理包一层)。
|
||||
|
||||
## 类型检查与 Alpine 类型推断(2026-09-14 引入 `npm run check`)
|
||||
- devDeps:`@astrojs/check` + `typescript` + `@types/node`。引入时暴露 **60 个既存错误,已全部修到 0**。
|
||||
- **`@types/alpinejs` 的坑 A(当时 90% 报错的根因)**:`data<T extends { [key in keyof T]: T[key] }, A extends unknown[]>(name, cb: (...a:A)=>AlpineComponent<T>)` 的自引用约束使 `T` 推断失败 → **Alpine data 里的属性退化成 `unknown`、方法内 `this` 退化成 `{}`**;`Alpine.store` 则因 `Stores = [key: string|symbol]: unknown` 让内联字面量失去上下文类型。
|
||||
- 修法:属性显式标注(`x as T[]` / `favIds: [] as string[]` / `user: null as AuthUser|null`);`Alpine.store` 先 `const s: Store = {...}` 再注册(`AuthModal.astro` 一处修掉 16 个错)。
|
||||
- **坑 B(2026-09-14 灯箱重构新增):对象展开会让 T 退化成 `{}`。**
|
||||
`Alpine.data("x", () => ({ ...createGalleryView(cfg), density: 5 }))` → T 推断失败 → `this` 变成 `InferInterceptors<{}> & XDataContext & Magics<{}>` → **所有 `this.xxx` 报 ts(2339)**(一次报 69 个)。
|
||||
原因:T 能从「对象字面量」推断,但从「展开表达式」推断不出来。
|
||||
**修法:给回调显式标注返回类型** —— `(): ReturnType<typeof createGalleryView> & ArticleExtras => ({...})`;`this` 取上下文类型后全部解析。Item 为此写了 `ArticleExtras` 类型(约 28 个成员)。
|
||||
- **排查铁律**:见到「Property 'x' does not exist on type '{}'」或「Object of type 'unknown'」,先怀疑这两个推断问题,别怀疑业务代码。
|
||||
- 其他常见修法:`(e.target as Element|null)?.closest()`;`var el` 收窄后另存 `const screen = el` 供闭包用(闭包内不保留收窄);`querySelector<HTMLElement>()` + `?? null`。
|
||||
- **swiper 样式声明必须放「非模块」d.ts**(`src/types/swiper-css.d.ts`):`env.d.ts` 有 `export {}` 是模块,简写式 ambient 声明在模块文件里**不生效**。
|
||||
## 类型检查与 Alpine 类型推断(2026-09-14 引入 `npm run check`,已修到 0)
|
||||
- 坑 A:`@types/alpinejs` 自引用约束使 data 属性退化 `unknown`/`{}` → 属性显式标注(`user: null as AuthUser|null`、`favIds: [] as string[]`);`Alpine.store` 先 `const s: Store = {...}` 再注册。
|
||||
- 坑 B:对象展开让 T 退化 `{}` → 给回调显式标注返回类型 `(): ReturnType<typeof createGalleryView> & X => ({...})`。
|
||||
- 排查铁律:「Property does not exist on type '{}'」/「Object of type 'unknown'」先怀疑这两类,别怀疑业务代码。
|
||||
- `astro check` 的 stdout 会被吞 → `npx astro check 2>&1 | Out-File -FilePath x.txt -Encoding utf8` 再读;`Get-Content` 须带 `-Encoding utf8`。
|
||||
|
||||
## 验证手段(本项目可复用套路)
|
||||
- 改完必跑 `npm run build`(全链路 import 校验 + 路由预渲染)+ `npm run check`。
|
||||
- **预渲染页面:产物 HTML 归一化对比**。`dist/client/{en,cn}/<page>/index.html`,改前 `Copy-Item` 存一份,改后归一化掉 `astro-cid-*` 与 `_astro/<asset>` 再逐字节比。
|
||||
- **`prerender=false` 页面(`item/[id]`):临时验证页法**(2026-09-14 新增)。dist 里没有 HTML,产物对比失效。做法:临时建一个 **prerender 的页面**(如 `src/pages/zzverify.astro`)直接渲染目标组件、喂假数据 → `npm run build` → 用 PowerShell 正则**统计产物 HTML 里的关键类名/绑定出现次数** → 验完**删页并重建**清 dist。
|
||||
例:核对灯箱时统计 `id="lb-thumbs"`×2、`id="lb-details"`×1、`currentFavId()`×4、`aspect-[2/3]`×6 / `aspect-[3/4]`×6(6 = 5 张 SSR + 1 个 x-for 模板);核对旧样式清零。
|
||||
- **`prerender=false` 页面更真实的验法(dev server 起在 :4321 时)**:直接 curl 真实 SSR 页面,比 zzverify 假数据更真。取 id:`curl http://localhost:8090/api/v1/public/runway-looks`(响应是 `{"data":[{...}]}`,**`data` 直接是数组**,不是 `data.list`)→ `data[0].id`;再 `curl http://localhost:4321/en/item/<id>` → 正则统计关键标记。注意匿名请求会带 preview 门禁(只渲染前 5 张)。2026-09-14 灯箱重构即此法验证:`fav-btn` 11 = 3 条内联 CSS + 6 网格(5 SSR + 1 个 x-for 模板)+ 1 画廊灯箱 + 1 细节灯箱。
|
||||
- **PowerShell 取输出**:`astro check` 的 stdout 会被吞 → `npx astro check 2>&1 | Out-File -FilePath x.txt -Encoding utf8` 再用文件工具读;`Get-Content` **必须带 `-Encoding utf8`**(否则被安全策略拦)。HTML 是单行,别直接 grep 打印,用 `[regex]::Matches($h,'...').Count` 统计。
|
||||
## lib 模块分层(2026-09-14 核对磁盘)
|
||||
- `i18n/index.ts`(配置+工具+API语言透传:原 config/utils/lib/locale 三合一)+ `i18n/dictionary.ts`(纯数据,独立保留)。
|
||||
- `auth.ts` 仅会话存储原语;`api.ts` 业务取数+鉴权(`login/logout/fetchMe/enforceIdleLogout` 有意留 api.ts,避免 auth↔request 循环);`ssg.ts` 构建期、`ssr.ts` 运行期、`gallery.ts` 灯箱工厂、`crypto.ts` 签名、`config/` base 址、`endpoints.ts` 后端路径唯一来源、`routes.ts` 前端跳转 URL(带前缀,`href` 用 astro:i18n、`clientHref` 浏览器内自拼)、`http.ts` 调试日志(开关 `DEV||DEBUG_API`)、`favorites.ts`/`history.ts`。
|
||||
- 依赖无环:api→{request,crypto,auth,i18n,endpoints};ssr→{request,endpoints,i18n,api(type)};ssg→{request,api(type),endpoints};gallery→{request,auth,favorites};request→{crypto,auth,i18n,config,endpoints}。
|
||||
|
||||
## 模块策略
|
||||
- 主线:首页 + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。品牌索引页已下线(2026-08-26),品牌经走秀页侧栏 filter + 品牌弹窗(热门 30 SSG,搜索/字母运行时 `/api/v1/public/brands`)。
|
||||
- 导航死链清理(2026-08-30):删 `/latest-projects`/`/about`/`/contact`。保留未动死链 `/portfolio`/`/login`/`/register`。「header 别动」= 不擅改导航结构/视觉。
|
||||
## 验证手段
|
||||
- 改完必跑 `npm run build`(全链路 import + 预渲染)+ `npm run check`。
|
||||
- 预渲染页:`dist/client/{en,cn}/<page>/index.html` 归一化对比(去 `astro-cid-*` 与 `_astro/<asset>` 逐字节比)。
|
||||
- `prerender=false` 页:临时建 prerender 验证页喂假数据,或 dev :4321 时 curl 真实 SSR(`curl :8090/api/v1/public/runway-looks` 的 `data` 直接是数组)。HTML 单行用 `[regex]::Matches($h,'...').Count` 统计关键标记。
|
||||
|
||||
## 未登录预览门禁(详情页,2026-09-03 定稿 + 后续)
|
||||
- 用户意图:列表页全量展示,限制只发生在**点进图集详情后**——未登录仅看前 5 张图,其余锁图遮罩提示登录。
|
||||
- 后端硬截断(真防护):`GET /api/v1/public/runway-looks/:id` 与 `/street-snaps/:id` 按 `middleware.UserIDFrom(c)` 判匿名且 `len(Images)>5` 时 `Images=Images[:5]` 并回 `preview:true` + `image_total`(截断前总数)。已登录(带 Bearer)返回完整图片集、无 preview。列表接口全量,翻页 page>1 仍 401 由 `PublicFirstPageAuth` 兜底。
|
||||
- 前端:详情组件由 SSR `getSsr*` 透传 `preview`/`imageTotal`;画廊 SSR 渲染前 5 张 + 剩余 `(imageTotal-5)` 张空图遮罩(锁图标 + "login to view" + "+N photos",`x-show="!isAuthed && preview"` + `x-cloak`)。已登录/`auth:login` 触发 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed`(带 Bearer 取全量)把第 6 张起追加进 `extraImages`。`getUser()` 读 localStorage `fa_user`。
|
||||
- **底部 Preview 大框门禁已于 2026-09-03 后续移除**,仅保留画廊内遮罩提示。
|
||||
## 未登录预览门禁(详情页)
|
||||
- 后端硬截断:`/runway-looks/:id`、`/street-snaps/:id` 匿名且图>5 时截断前 5 张 + 回 `preview:true`+`image_total`;已登录(Bearer)返回完整。列表接口全量。
|
||||
- 前端:SSR 前 5 张 + 剩余空图遮罩(`x-show="!isAuthed && preview"`+`x-cloak`,"login to view" + "+N photos");登录后取全量追加 `extraImages`。`getUser()` 读 localStorage `fa_user`。
|
||||
|
||||
## 账号体系
|
||||
- 仅预置内部账号不开放注册。`seed_users` 建 `admin`(`SEED_ADMIN_PASSWORD` 缺省 `Studio#2026!Admin`) + `root`/`root`(uid 通常 8,本地 `db_dev` 确有,登录 200)。
|
||||
- 双令牌 JWT:access 7d 无状态 + refresh 30d 仅存 SHA256 落库;登录即吊销该用户全部 refresh(互斥登录)。`/api/v1/auth/`:`/login` `/refresh`(复用不轮换) `/logout` `/logout-all` `/me`(Bearer)。
|
||||
- **前端 lib 分层**(2026-09-14 核对磁盘,纠正此前多次误记):
|
||||
- `src/lib/auth.ts` **仅会话存储原语**:`getUser`/`getAccessToken`/`getRefreshToken`/`getAccessExpiresAt`/`isAccessExpiring`/`getLastActiveAt`/`touchActivity`/`clearSession`/`saveSession`。
|
||||
- `src/lib/api.ts` 业务取数 + 鉴权函数(`login`/`logout`/`fetchMe`/`enforceIdleLogout`)+ 收藏/历史写接口 + 带 Bearer 的 `getArticleDetailAuthed`/`getStreetSnapDetailAuthed`。
|
||||
- **`login`/`logout`/`fetchMe`/`enforceIdleLogout` 有意留在 api.ts,不要搬进 auth.ts**:它们要经 `request()`,而 request.ts 依赖 auth.ts 的令牌读取,搬过去会形成 `auth ↔ request` 循环依赖(09-13 那次拆分没落地就是这个原因)。api.ts 头注释已写明。
|
||||
- `src/lib/ssg.ts` **构建期 SSG 专用**:`getSsgIndexRunway`/`getSsgHotBrands`/`getSsgStreetSnapPopular` + `SsgPopularBrand`;消费 `Index.astro`/`RunwayLooks.astro`。
|
||||
- `src/lib/ssr.ts` **运行期按需 SSR 取数**:`getSsrArticle`/`getSsrStreetSnap` + 两个 Result 类型;消费 `components/pages/ItemPage.astro`。与 ssg.ts 对称(ssg=构建期 / ssr=运行期),**两者都是服务端可信内部取数,不经签名、不带 Bearer**。
|
||||
- **`src/lib/gallery.ts`(2026-09-14 新增)**:`createGalleryView(cfg)` 工厂,装走秀/街拍详情页**逐行同构**的图集+大图灯箱 Alpine 逻辑(约 250 行)。cfg 仅 4 项差异:`rootSelector`/`gallerySelector`/`favType`/`fetchDetail`。导出 `GalleryView`(=`ReturnType<typeof createGalleryView>`)、`GalleryImageRaw`、`GalleryDetailFetcher`、`GalleryExtraImage`。**三条工厂约束(别"顺手改回去")**:① 内部**不用 Alpine 魔法**(`$el`/`$nextTick`)→ 用 `cfg.rootSelector` 查根节点 + `requestAnimationFrame` 等渲染(否则展开后 this 丢魔法);② **getter 会被展开求值成静态值** → `currentFavId`/`currentFavUrl` 是**方法**,模板写 `currentFavId()`;③ 共享初始化叫 **`galleryInit()`** 而非 `init()`(组件的 `init()` 会覆盖它)。
|
||||
- `src/lib/crypto.ts` 签名 `clientSign`;`src/lib/locale.ts` 语言透传 `setApiLocale`/`currentLocale`/`withLocale`。
|
||||
- `src/lib/endpoints.ts` **后端 API 端点路径唯一来源**:分 `public`/`auth`/`me`/`ssg` 四组(动态 :id 用函数如 `public.runwayLook(id)`;`V1="/api/v1"`、`SSG="/api/internal/ssg"` 前缀只此定义一次)。**与 `src/lib/routes.ts` 的 `ROUTES` 严格区分**:`endpoints`=fetch 的后端接口路径(locale 走 ?locale=);`ROUTES`=前端页面跳转 URL(带 /en /cn 前缀)。改接口路径只动 `endpoints.ts` 一处。
|
||||
- `src/lib/http.ts` 全局 fetch 调试日志拦截器(`request.ts` 加载即安装)。**开关只认 `DEV || DEBUG_API==="true"`**——曾含 `import.meta.env.SSR`,而该值在构建出的 Node 端恒 true,导致生产每请求都 `clone()+读 body` 且往容器 `logs/api-debug.log` 无限追加。
|
||||
- 依赖无环:api→{request,crypto,auth,locale,endpoints};ssr→{request,endpoints,locale,api(type-only)};ssg→{request,api(type),endpoints};gallery→{request,auth,favorites};request→{crypto,auth,locale,config,endpoints};auth→locale;endpoints 为叶子。
|
||||
- 硬门禁:RunwayLooks/StreetSnaps 筛选与翻页 `requireLogin()`,未登录弹内嵌登录表单,登录成功 `loadPage(1)`;首屏 `init()` 的 `loadPage(1)` 不拦。
|
||||
- **收藏服务端化**:表 `favorites` + `/api/v1/me/favorites`(GET 分页/POST 幂等/DELETE/`/me/favorites/checks` 批量校验)。按 `user_id` 隔离,`target_uid` 编码串(图集 runway `r=`/street `s=`,图片 `i=`/`j=`)。前端 `src/lib/favorites.ts`:`fetchFavoritesPage()` 服务端分页;`checkFavorited(ids)` 仅发当前可见 id(单发请求,无循环);收藏/取消本地乐观 + 后台同步。
|
||||
- **item 页收藏态已合并进 authed 详情接口(2026-09-13)**:登录态用一次 `get*DetailAuthed` 拿回完整图片集+收藏态:图集级 `fav` 经 `fav:set` 事件广播给 fav-fab、图片级以 `im.favorited` 重建 `favIds`;fav-fab 只听 `fav:set` 不再调 `checkFavorited`。**item 页已登录收藏校验 3→0 次**。列表页仍用 `/me/favorites/checks` 批量校正。**铁律:收藏态只能合并进「带 Bearer 的 authed 详情接口」,匿名公开/列表接口绝不合并**。
|
||||
- **浏览历史**(2026-09-02):记录时机在 item 详情页 `recordHistory(meta.id)`(编码串带类型);列表页不记。后端 `histories` 表唯一键 `uniq(user_id,target_uid)`,`HISTORY_CAP=2000` FIFO。前端 `src/lib/history.ts`:账户页服务端分页 + 逐篇回查 `getHistoryMeta(id)`。
|
||||
- 仅预置内部账号(admin/root)。双令牌 JWT(access 7d 无状态 + refresh 30d 仅存 SHA256 落库);登录即吊销该用户全部 refresh。
|
||||
- 收藏服务端化 `favorites` 表(按 user_id 隔离,编码串 r=/s=、i=/j=);浏览历史 `histories` 表(唯一键 user_id+target_uid,FIFO 上限 2000)。
|
||||
|
||||
## 前端复用与页面结构
|
||||
- 详情页统一 `/item`:页面 `src/pages/{en,cn}/item/[id].astro` 是 4 行薄包装(**`export const prerender = false` 必须留在页面文件**,Astro 只读页面模块的该导出),实现见 `src/components/pages/ItemPage.astro`(id 由页面 `Astro.params.id` 取出后作 prop 透传)。id 类型前缀 r=/s= 直接查对应表无回落。`ROUTES.item(id)`/`ROUTES.streetSnap(id)` 都指向 `/item/${id}`。
|
||||
- **详情页灯箱组件化(2026-09-14)**:`src/components/GalleryLightbox.astro` = 走秀/街拍共享的灯箱**标记**(约 150 行),props 仅 `images`(SSR 首屏图,须带 `thumb`)+ `aspect`(`2/3` 走秀 / `3/4` 街拍,**两个字面量都写在组件源码里**保证 JIT 扫得到);走秀独有的「细节图簇」经具名插槽 `slot="details"` 注入。交互全在 `lib/gallery.ts`。`Item.astro` 1161→约 850 行、`StreetSnap.astro` 645→约 330 行,两者 `<style>` 整块删除(原子样式已上提 global.css)。
|
||||
- **灯箱视觉基准 = Item 的编辑风**(StreetSnap 旧版已对齐):计数零填充 `01 / 05` + `tracking-[0.22em]` 直角细框;缩略图激活态 `ring-1 ring-black`;nav/关闭按钮直角 + `hover:bg-black hover:text-white`;关闭图标用 SVG(不用 `✕` 字符);缩略图条无渐变。
|
||||
- 灯箱的单图收藏按钮是**圆形**(对齐全站收藏语义:列表卡片 `.fav-toggle`、详情页 `#fav-fab` 都 `rounded-full`)。
|
||||
- **cn/en 页面去重(2026-09-14)**:`src/components/pages/{LoginPage,AccountPage,ItemPage}.astro` = 共享实现(语言由 `Astro.currentLocale` 运行时决定,**页面文件里没有任何语言分支**);`pages/{en,cn}/{login,account,item/[id]}.astro` 变薄包装。`pages/{en,cn}/{index,runway-looks,street-snaps}` 本就是 5 行包装,**未去重**(去了反而更长)。`LoginPage` 不走 Layout、自带完整 `<html>` 文档。
|
||||
- 列表组件同构,抽 `src/styles/look-grid.css` + `src/lib/looks-grid.ts`(`SPIN_SVG`/`makeCardSlots`/`buildPageList`/`buildWindowPages`)。注意两个分页函数形态不同:`buildPageList` 输出 `number|"..."`(列表页用),`buildWindowPages` 输出 `{label,page}`(账户页用),勿混用。品牌筛选弹窗 `BrandModal.astro`(仅 RunwayLooks)。卡片标记在 Alpine `x-for` 内客户端渲染,不能提成 Astro 组件。
|
||||
- 账户页 `AccountPage.astro`:报头 + 两 tab(Saved looks / My History)。
|
||||
## i18n 字典约定
|
||||
- `dict: Record<string,{cn:string}>`,key 即英文原文(en 返 key),value 只存非默认语言(仅 cn)。`translate(key,locale)` 对 key `toLowerCase()` 查(大小写不敏感,`t('Home')` 命中 `home`);缺翻译 en 回退 + dev 告警。中文绝不可当 key。
|
||||
|
||||
## i18n 字典约定(`src/i18n/dictionary.ts`)
|
||||
- 结构 `export const dict: Record<string, {cn:string}>`。**key 即英文原文**(en 直接返 key),value 只存非默认语言(仅 cn)。`translate(key,locale)` 对 key `toLowerCase()` 后查,**key 大小写不敏感**;缺翻译 en 回退 + dev 告警。
|
||||
- **勿把 key 大小写不一致当「死 key」**:`t('Home')`/`t('Previous')`/`t('No article ID specified')` 与 dict 全小写不一致但**都能命中**(`toLowerCase()` 查表)。
|
||||
- 调用面:组件用 `t('English phrase')`;部分标题经变量 `t(city)` 传入(城市 18 个,勿因「无字面量 t()」误删)。
|
||||
- **中文绝不可当 key**:`t('中文')` 在 en 态显示中文。硬编中文先改英文 key 并在 dict 补 `{cn:'中文'}`。
|
||||
- 2026-09-13 审计:补 6 缺失 key、中文 key `全部的 runway` 改 `all runway looks`、删 20 个零调用死条目;字典 92→78 条。
|
||||
|
||||
## 部署
|
||||
- Docker + Gitea Actions:Dockerfile/nginx.conf/docker-compose.yml/deploy.sh;nginx 拦截 `/api/v1/ssg/` 404,健康检查 `/api/v1/public/brands`。
|
||||
|
||||
## 数据分类
|
||||
- 走秀 `brand_runway`:`collection_type`(rtw/menswear/couture/resort/pre_fall)+`season`+`season_code`(SS26…),title 规则回填(幂等)。公开列表 `?collection_type=&season=&season_code=&year=` 筛选。
|
||||
- 街拍 `street_snap.city`:VARCHAR(128) 精确匹配 `WHERE city=?`;前端 header 下拉 `?city=` + 侧栏 City pills。规范值「首字母大写英文」(Paris/New York/Copenhagen…),大小写敏感须一致;dev 库仅 12 行样本属预期。
|
||||
|
||||
## 已知待办(尚未做,勿误以为已完成)
|
||||
- 详情页重复 HTML 片段(SSR 首屏图与 `extraImages` 两份 figure 结构)抽组件 —— 灯箱主体已抽,但**网格图那两份 figure 仍在 Item/StreetSnap 各写一遍**。
|
||||
- 列表页分页/收藏 mixin 下沉(净减行数仅约 35 行,收益/风险比低,暂缓)。
|
||||
- `favorites.ts`(8.3KB)、`history.ts`(6.5KB)、`look-grid.css`(8.9KB) 未逐行审。
|
||||
- `account.astro` 残留 `any`(`user: null as any`、`favorites: [] as any[]` 等)未补类型。
|
||||
- P2 未做(按"不改样式"主动跳过):图片 `eager` 数量(现前 6 张)、街拍骨架屏数量(现 4 个 vs 走秀 24 个,加载完会跳)。
|
||||
## 记住语言偏好(2026-09-15 新增)
|
||||
- 需求:选过语言后下次进首页直接用上次语言。
|
||||
- `i18n/index.ts` 新增 `STORED_LOCALE_KEY='preferred_locale'`、`storeLocale(locale)`、`getStoredLocale()`。
|
||||
- Layout `<head>` 顶部 `is:inline`+`define:vars` 脚本:进首页(path==='/' 或 '/'+defaultLocale)且记住非默认语言时 `location.replace('/'+stored)`。
|
||||
- 语言切换器 `x-data="langSwitch()"`;`<a>` `@click="open=false; pick('<code>')"`;footer 注册 `langSwitch`(`pick(code){ storeLocale(code) }`)。
|
||||
- **铁律:只在显式点击切换器时写 localStorage,绝不按页面加载 URL 写**——否则与首页自动跳转冲突(点了英文又被 URL 的 en 覆盖,首页永远跳不回去)。
|
||||
|
||||
@ -32,20 +32,20 @@ const fullLoginLabel = t('go to full login')
|
||||
>
|
||||
<div class="w-full max-w-sm border-2 border-black bg-white p-8">
|
||||
<div class="mb-6 flex items-start justify-between border-b-2 border-black pb-4">
|
||||
<h3 class="font-serif text-2xl font-black uppercase leading-none tracking-tight">{title}</h3>
|
||||
<h3 class="text-2xl font-black uppercase leading-none tracking-tight">{title}</h3>
|
||||
<button type="button" @click="close()" class="-mt-1 text-[11px] uppercase tracking-widest opacity-60 transition-opacity hover:opacity-100">{closeLabel}</button>
|
||||
</div>
|
||||
|
||||
<p class="mb-7 font-mono text-[11px] uppercase tracking-widest text-zinc-400">{prompt}</p>
|
||||
<p class="mb-7 text-[11px] uppercase tracking-widest text-zinc-400">{prompt}</p>
|
||||
|
||||
<form @submit.prevent="submit()" class="space-y-6" novalidate>
|
||||
<div>
|
||||
<label class="mb-2 block font-mono text-[11px] uppercase tracking-widest text-zinc-500">{accountLabel}</label>
|
||||
<label class="mb-2 block text-[11px] uppercase tracking-widest text-zinc-500">{accountLabel}</label>
|
||||
<input type="text" x-model="account" autocomplete="username" required
|
||||
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-2 text-sm outline-none transition-colors focus:border-black focus:ring-0" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block font-mono text-[11px] uppercase tracking-widest text-zinc-500">{passwordLabel}</label>
|
||||
<label class="mb-2 block text-[11px] uppercase tracking-widest 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-2 text-sm outline-none transition-colors focus:border-black focus:ring-0" />
|
||||
</div>
|
||||
@ -53,14 +53,14 @@ const fullLoginLabel = t('go to full login')
|
||||
<p x-show="error" x-text="error" x-cloak class="text-xs uppercase tracking-wide text-red-600"></p>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full border border-black bg-black py-3.5 font-mono text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:opacity-40">
|
||||
class="w-full border border-black bg-black py-3.5 text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:opacity-40">
|
||||
<span x-show="!loading">{title}</span>
|
||||
<span x-show="loading" x-cloak>…</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 border-t border-black/10 pt-4 text-center">
|
||||
<button type="button" @click="goFullLogin()" class="font-mono text-[10px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">{fullLoginLabel}</button>
|
||||
<button type="button" @click="goFullLogin()" class="text-[10px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">{fullLoginLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -102,13 +102,13 @@
|
||||
@apply w-full border border-black/15 px-3 py-2 text-[13px] outline-none bg-[#fafafa] focus:bg-white focus:border-black;
|
||||
}
|
||||
.showsmodal-tip {
|
||||
@apply mt-2 text-[11px] leading-snug text-black/45 font-mono;
|
||||
@apply mt-2 text-[11px] leading-snug text-black/45;
|
||||
}
|
||||
.showsmodal-az {
|
||||
@apply flex flex-wrap gap-1 px-4 pb-3 shrink-0;
|
||||
}
|
||||
.showsmodal-az .az {
|
||||
@apply appearance-none bg-white border border-black/10 w-6 h-6 text-[10px] cursor-pointer leading-none font-mono transition-colors hover:bg-black/5;
|
||||
@apply appearance-none bg-white border border-black/10 w-6 h-6 text-[10px] cursor-pointer leading-none transition-colors hover:bg-black/5;
|
||||
}
|
||||
.showsmodal-az .az.active {
|
||||
@apply bg-black text-white border-black;
|
||||
|
||||
@ -34,7 +34,7 @@ const { locale } = getI18n(Astro)
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 4px 60px;
|
||||
@apply font-mono text-black;
|
||||
@apply text-black;
|
||||
}
|
||||
.cs-kicker {
|
||||
display: flex;
|
||||
@ -44,7 +44,7 @@ const { locale } = getI18n(Astro)
|
||||
@apply text-[11px] tracking-[0.25em] uppercase text-[#666];
|
||||
}
|
||||
.cs-title {
|
||||
@apply font-serif font-black tracking-[-0.02em];
|
||||
@apply font-black tracking-[-0.02em];
|
||||
line-height: 0.9;
|
||||
font-size: clamp(56px, 13vw, 128px);
|
||||
margin: 0.16em 0 0.3em;
|
||||
|
||||
@ -31,7 +31,7 @@ const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
|
||||
>
|
||||
{/* 计数: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"
|
||||
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 border border-black/15 bg-white/90 px-3 py-1 text-[11px] tracking-[0.22em] text-[#111] backdrop-blur-md"
|
||||
x-text="`${String(lbIndex + 1).padStart(2, '0')} / ${String(imageCount).padStart(2, '0')}`"
|
||||
></div>
|
||||
|
||||
@ -75,7 +75,7 @@ const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
|
||||
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">
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</button>
|
||||
@ -91,7 +91,7 @@ const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
|
||||
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>
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 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>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
// 加载屏:首屏/整页切换时遮盖未渲染内容,避免 FOUC。
|
||||
// 轻量原则(用户要求"朴实流畅"):
|
||||
// - 只用静态大字 + mono 小标 + 一条 2px 细线进度(transform/opacity 动画,最便宜);
|
||||
// - 只用静态大字 + 小标 + 一条 2px 细线进度(transform/opacity 动画,最便宜);
|
||||
// - 去掉昂贵的多层 text-shadow 位移动画与外部字体;最短展示时间压缩到 ~350ms,跟手不拖沓。
|
||||
|
||||
import { config } from '@/lib/config';
|
||||
@ -37,7 +37,6 @@ const { t } = getI18n(Astro)
|
||||
}
|
||||
#loading-screen h1 {
|
||||
margin: 0;
|
||||
font-family: ui-serif, Georgia, Cambria, 'Times New Roman', serif;
|
||||
font-size: clamp(2.2rem, 6vw, 4rem);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
@ -46,7 +45,6 @@ const { t } = getI18n(Astro)
|
||||
}
|
||||
#loading-screen .ls-sub {
|
||||
margin: 1.1em 0 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.34em;
|
||||
text-transform: uppercase;
|
||||
|
||||
@ -15,8 +15,8 @@ interface Props {
|
||||
const { article, error = null, preview = false, imageTotal = 0 } = Astro.props
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
// 改造后:后端详情接口 images 仅返回主图(look 图),细节图在 details 分组,需经「细节入口」展开。
|
||||
// 故此处 images 即为主图集合,默认只渲染它们;每张主图带 look_index 用于关联其细节图。
|
||||
// 后端详情接口 images 仅返回主图(look 图),每张主图的细节图嵌套在它自己的 detail 子数组下。
|
||||
// 故此处 images 即为主图集合,默认只渲染它们;detail 随主图一起带上,供「细节入口」展开。
|
||||
const images = (article?.images ?? [])
|
||||
.map((im) => ({
|
||||
id: im.id,
|
||||
@ -27,17 +27,28 @@ const images = (article?.images ?? [])
|
||||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||
name: im.name || "",
|
||||
lookIndex: Number(im.look_index ?? 0),
|
||||
// 该主图下的细节图:与主图同一套字段映射(递归同构)
|
||||
detail: (im.detail ?? []).map((d) => ({
|
||||
id: d.id || "",
|
||||
url: ssrAbs(d.image || ""),
|
||||
thumb: ssrAbs(d.thumb || d.image || ""),
|
||||
raw: d.image || "",
|
||||
name: d.name || "",
|
||||
})),
|
||||
}))
|
||||
.filter((im) => im.url)
|
||||
|
||||
// 每个主图(look_index)对应的细节图数量,用于网格卡片左下角标「该主图有几张细节图」。
|
||||
// 来源与灯箱右下角细节簇一致:后端详情接口的 details 分组(按 look_index 聚合)。
|
||||
// SSR 首屏图直接查这张表;登录后追加的 extraImages 由 Alpine 的 detailCount() 读同一份数据。
|
||||
const detailsByLook = new Map<number, number>()
|
||||
for (const d of (article?.details ?? []) as { look_index?: number; images?: unknown[] }[]) {
|
||||
const li = Number(d.look_index ?? 0)
|
||||
if (li) detailsByLook.set(li, (d.images ?? []).length)
|
||||
}
|
||||
// 网格左下角的「细节图张数」角标交给 Alpine 的 detailCount() 响应式控制(而非 SSR 编译期条件):
|
||||
// 首屏前 PREVIEW_LIMIT 张由下面的 data-details 载荷构建映射,登录后由 gallery:full 用完整图集重建,
|
||||
// 未登录 / 已登录共用同一条渲染路径。
|
||||
|
||||
// 递给浏览器端 Alpine 的细节图数据:只序列化「带细节图的主图」投影(不带主图自身 URL,
|
||||
// 避免把整份 images 重复塞进 HTML)。元素形状与 images 元素一致(look_index + detail),
|
||||
// 故 Alpine 的 buildDetailMapFrom() 能与「登录后拿回的完整 images」共用同一套解析逻辑。
|
||||
// 未登录时后端只返回前 PREVIEW_LIMIT 张主图(细节图随所属主图一起返回),此处即为这几张的细节子树。
|
||||
const detailPayload = (article?.images ?? [])
|
||||
.filter((im) => (im.detail?.length ?? 0) > 0)
|
||||
.map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail }))
|
||||
|
||||
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
|
||||
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
|
||||
@ -64,7 +75,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
data-gallery-title={article?.title ?? ""}
|
||||
data-gallery-brand={article?.brand_name ?? ""}
|
||||
data-preview={preview ? "true" : "false"}
|
||||
data-details={JSON.stringify(article?.details ?? [])}
|
||||
data-details={JSON.stringify(detailPayload)}
|
||||
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
|
||||
>
|
||||
{
|
||||
@ -78,11 +89,11 @@ const homeHref = href(locale, ROUTES.home)
|
||||
<section class="mt-16">
|
||||
{/* 每行张数切换(应对图集图片很多的情况):2/3/4/5 列,移动端固定 2 列 */}
|
||||
<div class="flex items-center justify-between mb-5 border-b border-black/10 pb-3">
|
||||
<span class="text-[11px] font-mono uppercase tracking-[0.22em] text-[#999]">
|
||||
<span class="text-[11px] uppercase tracking-[0.22em] text-[#999]">
|
||||
{(imageTotal || images.length)} LOOKS
|
||||
</span>
|
||||
<div class="flex items-center gap-1" role="group" aria-label="Images per row">
|
||||
<span class="text-[11px] font-mono uppercase tracking-[0.18em] text-[#999] mr-2 hidden sm:inline">PER ROW</span>
|
||||
<span class="text-[11px] uppercase tracking-[0.18em] text-[#999] mr-2 hidden sm:inline">PER ROW</span>
|
||||
{/* 每行张数切换:只有「3 张 / 5 张」两档,用竖条图标直观表达列数(避免文字数字按钮)。
|
||||
density 只影响 md+;移动端固定 2 列(5 列在手机上不可用)。 */}
|
||||
<button
|
||||
@ -125,7 +136,6 @@ const homeHref = href(locale, ROUTES.home)
|
||||
:class="gridClass()"
|
||||
>
|
||||
{previewImages.map((im, i) => {
|
||||
const dc = detailsByLook.get(im.lookIndex) || 0
|
||||
return (
|
||||
<figure
|
||||
data-image-index={i}
|
||||
@ -162,22 +172,24 @@ const homeHref = href(locale, ROUTES.home)
|
||||
</button>
|
||||
|
||||
{/* 细节图数量角标:有细节图的主图在左下角标注「张数」(图层图标 + 数字),
|
||||
点它直接开细节灯箱。纯主图(无细节图)不显示任何角标,保持网格干净。 */}
|
||||
{dc > 0 && (
|
||||
点它直接开细节灯箱。纯主图(无细节图)不显示任何角标,保持网格干净。
|
||||
数量走 Alpine 的 detailCount()(响应式):未登录取前 PREVIEW_LIMIT 张的细节子树,
|
||||
登录后由 gallery:full 重建映射,两态共用同一条渲染路径。 */}
|
||||
<button
|
||||
type="button"
|
||||
x-show={`detailCount(${im.lookIndex}) > 0`}
|
||||
x-cloak
|
||||
@click.stop={`openDetailLb(${im.lookIndex})`}
|
||||
class="absolute left-2 bottom-2 z-10 flex items-center gap-1 px-1.5 py-1 bg-white/85 backdrop-blur-sm border border-black/15 text-[#111] text-[10px] font-mono uppercase tracking-[0.14em] cursor-pointer hover:bg-black hover:text-white transition-colors"
|
||||
title={`${dc} detail${dc > 1 ? "s" : ""}`}
|
||||
aria-label={`${dc} detail${dc > 1 ? "s" : ""}`}
|
||||
:title={`detailCount(${im.lookIndex}) + ' detail' + (detailCount(${im.lookIndex}) > 1 ? 's' : '')`}
|
||||
:aria-label={`detailCount(${im.lookIndex}) + ' detail' + (detailCount(${im.lookIndex}) > 1 ? 's' : '')`}
|
||||
class="absolute left-2 bottom-2 z-10 flex items-center gap-1 px-1.5 py-1 bg-white/85 backdrop-blur-sm border border-black/15 text-[#111] text-[10px] uppercase tracking-[0.14em] cursor-pointer hover:bg-black hover:text-white transition-colors"
|
||||
>
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="13" height="13" rx="1.5" />
|
||||
<path d="M8 21h11a2 2 0 0 0 2-2V8" />
|
||||
</svg>
|
||||
{dc}
|
||||
<span x-text={`detailCount(${im.lookIndex})`}></span>
|
||||
</button>
|
||||
)}
|
||||
</figure>
|
||||
)
|
||||
})}
|
||||
@ -193,18 +205,18 @@ const homeHref = href(locale, ROUTES.home)
|
||||
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
|
||||
>
|
||||
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
|
||||
<div class="w-full aspect-[2/3] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
|
||||
<div class="w-full aspect-[2/3] flex items-center justify-center text-black group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
|
||||
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
|
||||
</svg>
|
||||
<span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
|
||||
<span class="text-[10px] uppercase tracking-[0.18em]">{t('login to view')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 真实图已无 look 序号 caption,占位格也改为绝对定位的 +N photos,
|
||||
不撑高格子,保证遮罩格与真实图片格总高一致 */}
|
||||
<span class="absolute left-2 bottom-2 z-10 text-[10px] font-mono uppercase tracking-[0.14em] text-[#b8b8b8] pointer-events-none">+{lockedCount} {t('photos')}</span>
|
||||
{/* <span class="absolute left-2 bottom-2 z-10 text-[10px] uppercase tracking-[0.14em] text-[#b8b8b8] pointer-events-none">+{lockedCount} {t('photos')}</span> */}
|
||||
</figure>
|
||||
))
|
||||
}
|
||||
@ -248,7 +260,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
type="button"
|
||||
x-show="detailCount(im.lookIndex) > 0"
|
||||
@click.stop="openDetailLb(im.lookIndex)"
|
||||
class="absolute left-2 bottom-2 z-10 flex items-center gap-1 px-1.5 py-1 bg-white/85 backdrop-blur-sm border border-black/15 text-[#111] text-[10px] font-mono uppercase tracking-[0.14em] cursor-pointer hover:bg-black hover:text-white transition-colors"
|
||||
class="absolute left-2 bottom-2 z-10 flex items-center gap-1 px-1.5 py-1 bg-white/85 backdrop-blur-sm border border-black/15 text-[#111] text-[10px] uppercase tracking-[0.14em] cursor-pointer hover:bg-black hover:text-white transition-colors"
|
||||
:title="`${detailCount(im.lookIndex)} detail${detailCount(im.lookIndex) > 1 ? 's' : ''}`"
|
||||
>
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
@ -273,13 +285,13 @@ const homeHref = href(locale, ROUTES.home)
|
||||
</article>
|
||||
) : (
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
|
||||
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">
|
||||
<p class="text-sm text-[#888] uppercase tracking-widest mb-4">
|
||||
{error === "missing" ? t('No article ID specified') : t('Failed to load article. Please refresh or try later.')}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={homeHref}
|
||||
class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
|
||||
class="text-[12px] uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
|
||||
>
|
||||
← {t('Back to Index')}
|
||||
</a>
|
||||
@ -287,8 +299,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
)
|
||||
}
|
||||
|
||||
{/* 大图灯箱:与街拍详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
|
||||
走秀独有的「当前 look 的细节图簇」经 details 插槽注入。 */}
|
||||
{/* 大图灯箱:与街拍详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。 走秀独有的「当前 look 的细节图簇」经 details 插槽注入。 */}
|
||||
{article && previewImages.length > 0 && (
|
||||
<GalleryLightbox images={previewImages} aspect="2/3">
|
||||
{/* 当前 look 的细节图:右下角一小簇缩略图(竖版主图右下角本就是空白,不遮挡主图)。
|
||||
@ -301,7 +312,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
x-cloak
|
||||
class="absolute right-4 sm:right-6 bottom-[72px] z-30 flex flex-col items-end gap-2"
|
||||
>
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.22em] text-[#999]">Details</span>
|
||||
<span class="text-[10px] uppercase tracking-[0.22em] text-[#999]">Details</span>
|
||||
<div class="flex max-w-[min(70vw,440px)] gap-2 overflow-x-auto no-scrollbar">
|
||||
<template x-for="(d, i) in currentDetailList" :key="d.url">
|
||||
<button
|
||||
@ -330,7 +341,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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"
|
||||
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 border border-black/15 bg-white/90 px-3 py-1 text-[11px] tracking-[0.22em] text-[#111] backdrop-blur-md"
|
||||
x-text="`${String(detailLbIndex + 1).padStart(2, '0')} / ${String(detailImages.length).padStart(2, '0')}`"
|
||||
></div>
|
||||
|
||||
@ -373,7 +384,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
x-bind:class="detailLbIndex === i ? 'opacity-100 ring-1 ring-black' : ''"
|
||||
:aria-label="String(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(i + 1).padStart(2, '0')"></span>
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm" x-text="String(i + 1).padStart(2, '0')"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
@ -423,7 +434,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-0 right-0 bottom-4 z-20 px-6 text-center font-mono text-[11px] uppercase tracking-[0.2em] text-black/60 pointer-events-none"
|
||||
class="absolute left-0 right-0 bottom-4 z-20 px-6 text-center text-[11px] uppercase tracking-[0.2em] text-black/60 pointer-events-none"
|
||||
x-text="currentDetailName"
|
||||
></div>
|
||||
</div>
|
||||
@ -464,6 +475,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
getImageLookIndex(index: number): number
|
||||
currentDetailList: DetailImage[]
|
||||
openDetailLbAt(idx: number): void
|
||||
buildDetailMapFrom(images: any[]): void
|
||||
buildDetailMap(): void
|
||||
detailCount(li: number): number
|
||||
openDetailLb(li: number, startIndex?: number, fromLb?: boolean): void
|
||||
@ -511,8 +523,15 @@ const homeHref = href(locale, ROUTES.home)
|
||||
lbReturnIndex: 0,
|
||||
|
||||
init() {
|
||||
// 本组件独有:解析后端 details 分组(按 look_index 聚合细节图),供「细节入口」展开灯箱。
|
||||
// 本组件独有:从 SSR 载荷构建「细节入口」用的 look_index -> 细节图 映射。
|
||||
this.buildDetailMap()
|
||||
// 未登录时后端只返回前 PREVIEW_LIMIT 张主图的细节子树;登录后 loadAuthedState 拿回完整图集,
|
||||
// 需据其重建映射,否则第 6 张起的角标 / 细节灯箱仍为空(详见 lib/gallery.ts 的 gallery:full)。
|
||||
// 先挂监听再初始化:galleryInit() 内部可能立刻触发一次登录态取数。
|
||||
window.addEventListener("gallery:full", ((e: Event) => {
|
||||
const imgs = (e as CustomEvent).detail?.images
|
||||
this.buildDetailMapFrom(Array.isArray(imgs) ? imgs : [])
|
||||
}) as EventListener)
|
||||
// 共享初始化(登录态、图片计数、缩略图懒加载、收藏态、深链定位、登录后补全量)
|
||||
this.galleryInit()
|
||||
},
|
||||
@ -551,24 +570,30 @@ const homeHref = href(locale, ROUTES.home)
|
||||
},
|
||||
|
||||
// ===== 细节图分组 =====
|
||||
// 由根节点 data-details(后端按 look_index 聚合的细节图 JSON)构建 look_index -> 图片数组映射。
|
||||
buildDetailMap() {
|
||||
try {
|
||||
const raw = this.root()?.dataset.details
|
||||
const arr: any[] = raw ? JSON.parse(raw) : []
|
||||
// 由「主图数组」构建 look_index -> 细节图数组 映射。两个来源同构,共用本函数:
|
||||
// ① SSR:根节点 data-details(只含带细节图的主图投影 [{ look_index, detail }])
|
||||
// ② 登录后:详情接口返回的完整 images(细节图已嵌套在每张主图的 detail 下)
|
||||
buildDetailMapFrom(images: any[]) {
|
||||
const map: Record<number, { id: string; url: string; thumb: string; raw: string; name: string }[]> = {}
|
||||
for (const d of arr) {
|
||||
const li = Number(d.look_index ?? 0)
|
||||
if (!li) continue
|
||||
map[li] = (d.images || []).map((im: any) => ({
|
||||
id: im.id || "",
|
||||
url: toAbs(im.image || ""),
|
||||
thumb: toAbs(im.thumb || im.image || ""),
|
||||
raw: im.image || "",
|
||||
name: im.name || "",
|
||||
for (const im of images || []) {
|
||||
const li = Number(im?.look_index ?? 0)
|
||||
const kids: any[] = Array.isArray(im?.detail) ? im.detail : []
|
||||
if (!li || !kids.length) continue
|
||||
map[li] = kids.map((d: any) => ({
|
||||
id: d.id || "",
|
||||
url: toAbs(d.image || ""),
|
||||
thumb: toAbs(d.thumb || d.image || ""),
|
||||
raw: d.image || "",
|
||||
name: d.name || "",
|
||||
}))
|
||||
}
|
||||
this.detailImagesByLook = map
|
||||
},
|
||||
|
||||
buildDetailMap() {
|
||||
try {
|
||||
const raw = this.root()?.dataset.details
|
||||
this.buildDetailMapFrom(raw ? JSON.parse(raw) : [])
|
||||
} catch {
|
||||
this.detailImagesByLook = {}
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
</button>
|
||||
|
||||
{im.name && (
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
|
||||
<figcaption class="text-[11px] uppercase tracking-widest text-[#999] mt-2">
|
||||
{im.name}
|
||||
</figcaption>
|
||||
)}
|
||||
@ -123,12 +123,12 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
|
||||
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
|
||||
</svg>
|
||||
<span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
|
||||
<span class="text-[10px] uppercase tracking-[0.18em]">{t('login to view')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 img01 等小字):
|
||||
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption>
|
||||
<figcaption class="text-[11px] uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption>
|
||||
</figure>
|
||||
))
|
||||
}
|
||||
@ -166,7 +166,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
</svg>
|
||||
</button>
|
||||
<template x-if="im.name">
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption>
|
||||
<figcaption class="text-[11px] uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption>
|
||||
</template>
|
||||
</figure>
|
||||
</template>
|
||||
@ -177,14 +177,14 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
</article>
|
||||
) : (
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
|
||||
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">
|
||||
<p class="text-sm text-[#888] uppercase tracking-widest mb-4">
|
||||
{error === "missing" ? t('No street snap ID specified') : t('Failed to load street snap. Please refresh or try later.')}
|
||||
</p>
|
||||
|
||||
|
||||
<a
|
||||
href={homeHref}
|
||||
class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
|
||||
class="text-[12px] uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
|
||||
>
|
||||
← {t('Back to Index')}
|
||||
</a>
|
||||
|
||||
@ -37,9 +37,9 @@ const browseLabel = t("browse the archive")
|
||||
|
||||
<!-- ===== 未登录:整版引导 ===== -->
|
||||
<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>
|
||||
<p class="mb-5 text-[11px] uppercase tracking-[0.3em] text-zinc-400">{pleaseLabel}</p>
|
||||
<p class="mb-12 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 text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- ===== 已登录 ===== -->
|
||||
@ -48,14 +48,14 @@ const browseLabel = t("browse the archive")
|
||||
<!-- 报头:衬线大标题会员名 + 档案标签 -->
|
||||
<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>
|
||||
<p class="mb-4 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>
|
||||
<h1 class="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>
|
||||
<span x-show="user?.tier === 'vip'" x-cloak class="mb-2 inline-block border border-black bg-black px-2.5 py-1 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">
|
||||
<div class="shrink-0 pb-1 text-right 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>
|
||||
@ -69,8 +69,8 @@ const browseLabel = t("browse the archive")
|
||||
|
||||
<!-- 左:编号目录式垂直导航(移动端折成横向条) -->
|
||||
<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">
|
||||
<p class="mb-3 hidden 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 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>
|
||||
@ -85,20 +85,20 @@ const browseLabel = t("browse the archive")
|
||||
<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>
|
||||
<h2 class="text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
|
||||
<p class="pb-1 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]">
|
||||
<div class="mb-10 flex items-center gap-8 border-b border-black/15 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>
|
||||
<p class="mb-10 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 text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- 收藏网格 -->
|
||||
@ -108,19 +108,19 @@ const browseLabel = t("browse the archive")
|
||||
<a :href="itemHref(f)" target="_blank" rel="noopener" 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>
|
||||
<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 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)" target="_blank" rel="noopener" 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>
|
||||
<p class="mt-1.5 truncate 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">
|
||||
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2" 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>
|
||||
@ -136,17 +136,17 @@ const browseLabel = t("browse the archive")
|
||||
<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>
|
||||
<h2 class="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>
|
||||
<p class="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="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>
|
||||
<p class="mb-10 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 text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- 历史网格 -->
|
||||
@ -156,19 +156,19 @@ const browseLabel = t("browse the archive")
|
||||
<a :href="itemHref(h)" target="_blank" rel="noopener" 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>
|
||||
<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 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)" target="_blank" rel="noopener" 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>
|
||||
<p class="mt-1.5 truncate 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">
|
||||
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2" 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>
|
||||
@ -203,7 +203,7 @@ const browseLabel = t("browse the archive")
|
||||
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>' +
|
||||
'<text x="150" y="200" font-family="sans-serif" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
|
||||
'</svg>'
|
||||
)
|
||||
|
||||
|
||||
@ -52,12 +52,12 @@ const loginKeywords = locale === 'cn'
|
||||
|
||||
<!-- 顶部工具行:返回档案 + 语言切换 -->
|
||||
<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">
|
||||
<a href={homeUrl} class="inline-flex items-center gap-2 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">
|
||||
<button type="button" @click="open = !open" class="inline-flex items-center gap-2 border border-black/15 px-3 py-2 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"/>
|
||||
@ -66,7 +66,7 @@ const loginKeywords = locale === 'cn'
|
||||
<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>
|
||||
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 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>
|
||||
@ -77,10 +77,10 @@ const loginKeywords = locale === 'cn'
|
||||
<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>
|
||||
<p class="mb-4 text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
|
||||
<h1 class="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">
|
||||
<div class="pb-1 text-right 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>
|
||||
@ -94,41 +94,41 @@ const loginKeywords = locale === 'cn'
|
||||
<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>
|
||||
<p class="mb-10 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>
|
||||
<label class="mb-2 block 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>
|
||||
<label class="mb-2 block 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>
|
||||
<p x-show="error" x-text="error" x-cloak class="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">
|
||||
class="w-full border border-black bg-black py-4 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">
|
||||
<a href={homeUrl} class="inline-flex items-center gap-2 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-5 text-[11px] uppercase tracking-[0.3em] text-zinc-400">Archive note</p>
|
||||
<p class="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 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>
|
||||
|
||||
@ -3,7 +3,7 @@ import '@/styles/global.css'
|
||||
import LoadingOverlay from '@/components/LoadingOverlay.astro'
|
||||
import AuthModal from '@/components/AuthModal.astro'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { getI18n, LOCALE_NAMES, setApiLocale } from '@/lib/i18n'
|
||||
import { getI18n, LOCALE_NAMES, setApiLocale, DEFAULT_LOCALE, STORED_LOCALE_KEY } from '@/lib/i18n'
|
||||
import { config } from '@/lib/config'
|
||||
import { i18n } from 'astro:config/client';
|
||||
import { toCodes } from 'astro:i18n';
|
||||
@ -74,6 +74,19 @@ const headers = {
|
||||
<!doctype html>
|
||||
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
|
||||
<head>
|
||||
<script is:inline define:vars={{ defaultLocale: DEFAULT_LOCALE, storedKey: STORED_LOCALE_KEY }}>
|
||||
// 记住语言偏好:若用户曾选过非默认语言,进首页(/ 或 /<default>)时自动跳到该语言首页
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem(storedKey);
|
||||
var def = defaultLocale;
|
||||
var p = location.pathname;
|
||||
if (stored && stored !== def && (p === '/' || p === '/' + def)) {
|
||||
location.replace('/' + stored);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
@ -94,7 +107,7 @@ const headers = {
|
||||
<a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Home')}</a>
|
||||
<a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">
|
||||
{t('Portfolio')}
|
||||
<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
|
||||
<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full tracking-tight leading-none">
|
||||
2026
|
||||
</span>
|
||||
</a>
|
||||
@ -138,7 +151,7 @@ const headers = {
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 语言切换器:恢复为 Alpine.js 控制开合(x-data + x-show + x-transition) -->
|
||||
<div class="relative" id="lang-switch" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<div class="relative" id="lang-switch" x-data="langSwitch()" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-[18px] h-[18px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
@ -156,7 +169,7 @@ const headers = {
|
||||
<li>
|
||||
<a
|
||||
href={getRelativeLocaleUrl(l.code, pathWithoutLocale)}
|
||||
@click="open = false"
|
||||
@click={`open = false; pick('${l.code}')`}
|
||||
class:list={[
|
||||
'block px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors',
|
||||
l.code === locale ? 'font-bold' : 'opacity-60',
|
||||
@ -176,7 +189,7 @@ const headers = {
|
||||
<div x-show="user" x-cloak class="relative" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-haspopup="menu" :aria-expanded="open">
|
||||
<span x-text="shortName"></span>
|
||||
<span x-show="user?.tier === 'vip'" x-cloak class="inline-block border border-black bg-black px-1.5 py-[1px] font-mono text-[9px] uppercase leading-none tracking-[0.2em] text-white" aria-label="VIP">VIP</span>
|
||||
<span x-show="user?.tier === 'vip'" x-cloak class="inline-block border border-black bg-black px-1.5 py-[1px] text-[9px] uppercase leading-none tracking-[0.2em] text-white" aria-label="VIP">VIP</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="acct-chevron w-[14px] h-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
@ -203,11 +216,70 @@ const headers = {
|
||||
import { getUser } from "@/lib/auth"
|
||||
import type { AuthUser } from "@/lib/auth"
|
||||
import { fetchMe, logout, enforceIdleLogout } from "@/lib/api"
|
||||
import { storeLocale, getStoredLocale, DEFAULT_LOCALE, LOCALE_NAMES } from "@/lib/i18n"
|
||||
|
||||
// 空闲登出:超过 config.idleLogoutDays 天没访问则登出(在 Alpine 初始化前先跑,避免登录态闪烁)
|
||||
enforceIdleLogout()
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
// 语言切换器:点击某语言时记住偏好(storeLocale),下次进首页自动跳该语言
|
||||
Alpine.data("langSwitch", () => ({
|
||||
open: false,
|
||||
pick(code: string) {
|
||||
storeLocale(code as "en" | "cn")
|
||||
},
|
||||
}))
|
||||
|
||||
// 语言建议弹窗:未选过语言、且浏览器语言与当前首页语言不符时,提示是否切换
|
||||
Alpine.data("localeSuggest", () => {
|
||||
const names = {
|
||||
en: LOCALE_NAMES.en.nativeName,
|
||||
cn: LOCALE_NAMES.cn.nativeName,
|
||||
}
|
||||
return {
|
||||
visible: false,
|
||||
target: "en" as "en" | "cn",
|
||||
current: "en" as "en" | "cn",
|
||||
names,
|
||||
init() {
|
||||
try {
|
||||
const def = DEFAULT_LOCALE
|
||||
// 已选过偏好(含此前点「保持当前」的标记)→ 交给 head 脚本重定向逻辑,这里不弹
|
||||
if (getStoredLocale()) return
|
||||
const nav = (
|
||||
navigator.language ||
|
||||
(navigator.languages && navigator.languages[0]) ||
|
||||
""
|
||||
).toLowerCase()
|
||||
const detected: "en" | "cn" = /^(zh|cmn|zho)/.test(nav) ? "cn" : "en"
|
||||
const p = location.pathname
|
||||
const onHome = p === "/" || p === "/" + def
|
||||
if (!onHome) return
|
||||
const cur = p === "/" ? def : (p.split("/")[1] as "en" | "cn")
|
||||
if (detected === cur) return
|
||||
this.target = detected
|
||||
this.current = cur
|
||||
this.visible = true
|
||||
} catch (e) {}
|
||||
},
|
||||
get message(): string {
|
||||
if (this.target === "cn") {
|
||||
return "检测到您的浏览器语言为「中文」,是否切换到中文界面?"
|
||||
}
|
||||
return `Your browser language appears to be ${this.names[this.target]}. Switch to ${this.names[this.target]}?`
|
||||
},
|
||||
switchLang() {
|
||||
storeLocale(this.target)
|
||||
location.replace("/" + this.target)
|
||||
},
|
||||
keep() {
|
||||
// 记下来:用户选择保持当前语言,后续不再提示
|
||||
storeLocale(this.current)
|
||||
this.visible = false
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Alpine.data("authMenu", () => ({
|
||||
user: null as AuthUser | null,
|
||||
open: false,
|
||||
@ -245,7 +317,7 @@ const headers = {
|
||||
<span class="text-base font-black tracking-wider uppercase border-b-2 border-black pb-0.5">
|
||||
Portfolio Studio
|
||||
</span>
|
||||
<span class="text-[10px] font-mono bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest">
|
||||
<span class="text-[10px] bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest">
|
||||
2026 Edition
|
||||
</span>
|
||||
</div>
|
||||
@ -255,7 +327,7 @@ const headers = {
|
||||
</p>
|
||||
|
||||
{/* Location Badge */}
|
||||
<div class="flex flex-wrap gap-3 text-[11px] font-mono text-zinc-500 uppercase tracking-widest pt-1">
|
||||
<div class="flex flex-wrap gap-3 text-[11px] text-zinc-500 uppercase tracking-widest pt-1">
|
||||
<span>Paris</span>
|
||||
<span>•</span>
|
||||
<span>Milan</span>
|
||||
@ -267,13 +339,40 @@ const headers = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-[11px] font-mono text-zinc-400 uppercase tracking-widest">
|
||||
<div class="mt-6 text-[11px] text-zinc-400 uppercase tracking-widest">
|
||||
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* 全局登录门禁层(login-required 属性 + 统一登录浮层) */}
|
||||
<AuthModal />
|
||||
|
||||
{/* 语言建议弹窗:未选过语言且浏览器语言与当前首页不符时,提示是否切换到该语言 */}
|
||||
<div
|
||||
x-data="localeSuggest()"
|
||||
x-show="visible"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="fixed bottom-5 left-1/2 -translate-x-1/2 z-[60] w-[min(92vw,420px)] bg-white border border-black/15 shadow-lg px-5 py-4 flex flex-col gap-3"
|
||||
role="dialog"
|
||||
aria-label="Language suggestion"
|
||||
>
|
||||
<p class="text-[13px] leading-snug text-black" x-text="message"></p>
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
@click="keep()"
|
||||
class="px-3 py-1.5 text-[12px] uppercase tracking-[0.5px] border border-black/20 hover:bg-black/5 transition-colors"
|
||||
x-text="names[current]"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="switchLang()"
|
||||
class="px-3 py-1.5 text-[12px] uppercase tracking-[0.5px] bg-black text-white hover:opacity-80 transition-opacity"
|
||||
x-text="names[target]"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@ -67,15 +67,13 @@ export interface RawArticleImage {
|
||||
image: string // 该接口场景的主用地址(详情=展示样式 !style:high;列表=缩略图 !style:thumb)
|
||||
thumb?: string // 缩略图地址(!style:thumb),仅详情接口返回:供大图灯箱左栏缩略图条 / 右下角细节缩略图等小尺寸场景用,省带宽
|
||||
name?: string
|
||||
is_detail?: number // 0=主图 1=细节图(详情接口 images 仅含主图;细节在 details 分组)
|
||||
look_index?: number // 细节图归属的主图序号(与 images 主图的 look_index 一致)
|
||||
// 主图 / 细节图不再靠 is_detail 区分:详情接口 images 只含主图,细节图嵌套在所属主图的 detail 下,
|
||||
// 层级本身已表达类型(前端无任何处读取 is_detail,故不再声明)。
|
||||
look_index?: number // 该图归属的主图序号;细节图与所属主图同值
|
||||
favorited?: boolean // 已登录时该图片是否已收藏(随详情接口返回)
|
||||
}
|
||||
|
||||
// 某个主图(look)对应的细节图集合(与 RunwayLookDetails 对齐),详情页「细节入口」按需展开。
|
||||
export interface RawLookDetails {
|
||||
look_index: number // 主图序号
|
||||
images: RawArticleImage[] // 该主图的细节图列表
|
||||
// 仅详情接口填充:该主图(look)下的细节图子集,元素形状与本类型递归同构。
|
||||
// 前端「细节入口」直接读它,无需再按 look_index 分组查表。
|
||||
detail?: RawArticleImage[]
|
||||
}
|
||||
|
||||
// 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。
|
||||
@ -87,10 +85,8 @@ export type RawArticle = {
|
||||
brand_name: string
|
||||
brand_id?: string | number // hashid 编码串
|
||||
image_count?: number // 列表卡片 "N+" 角标用(仅列表接口返回);改造后仅计主图数
|
||||
// 详情接口 images 仅含主图(look 图),默认展示;细节图在 details 分组,需经「细节入口」展开。
|
||||
// 详情接口 images 仅含主图(look 图),默认展示;每个主图的细节图挂在它自己的 detail 子数组下。
|
||||
images?: RawArticleImage[]
|
||||
// 按主图分组的细节图(「细节入口」展开用);无细节时为空数组或不返回。
|
||||
details?: RawLookDetails[]
|
||||
favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
|
||||
}
|
||||
|
||||
|
||||
@ -23,6 +23,8 @@ export interface GalleryImageRaw {
|
||||
name?: string
|
||||
look_index?: number
|
||||
favorited?: boolean
|
||||
// 详情接口专有:该主图下的细节图(递归同构)。走秀页据此重建「细节入口」映射。
|
||||
detail?: GalleryImageRaw[]
|
||||
}
|
||||
|
||||
/** 已登录全量取数:返回完整图片集与图集级收藏态。 */
|
||||
@ -124,6 +126,10 @@ export function createGalleryView(cfg: GalleryConfig) {
|
||||
if (!full || !Array.isArray(full.images)) return
|
||||
const all = full.images
|
||||
|
||||
// 广播完整图集:宿主组件(走秀页)据此重建各自的派生数据(细节图映射)。
|
||||
// 未登录只拿得到前 PREVIEW_LIMIT 张主图的细节子树,登录后才有全量,故必须在此补一次广播。
|
||||
window.dispatchEvent(new CustomEvent("gallery:full", { detail: { images: all } }))
|
||||
|
||||
// 图集级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
|
||||
this.galleryFav = !!full.favorited
|
||||
window.dispatchEvent(
|
||||
|
||||
@ -26,7 +26,7 @@ export const dict: Record<string, TranslationEntry> = {
|
||||
'login': { cn: '登录' },
|
||||
'portfolio': { cn: '作品集' },
|
||||
'runway looks': { cn: '秀场造型' },
|
||||
'all runway looks': { cn: '全部的 runway' },
|
||||
'all runway looks': { cn: '秀场' },
|
||||
|
||||
// ── Runway 子分类(header 下拉,与列表页 COLLECTION 筛选一致)──
|
||||
'ready-to-wear': { cn: '女装成衣' },
|
||||
|
||||
@ -98,3 +98,31 @@ export function withLocale(params?: URLSearchParams, override?: string): URLSear
|
||||
if (loc) p.set('locale', loc);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ── 记住语言偏好 / Remembered locale ──
|
||||
// 用户在语言切换器里选过语言后,把选择存进 localStorage;
|
||||
// 下次进首页(默认语言首页 /<default> 或根 /)时自动跳到上次选的语言。
|
||||
// 注意:只在「显式点击切换器」时写入,绝不按页面加载时的 URL 写,
|
||||
// 否则会与首页自动跳转形成冲突(点了英文又被 URL 上的 en 覆盖回去)。
|
||||
export const STORED_LOCALE_KEY = 'preferred_locale';
|
||||
|
||||
/** 读取记住的语言偏好;无 / 非法则返回 null。 */
|
||||
export function getStoredLocale(): Locale | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const v = window.localStorage.getItem(STORED_LOCALE_KEY);
|
||||
return (LOCALES as readonly string[]).includes(v ?? '') ? (v as Locale) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 记住语言偏好(语言切换器点击时调用)。 */
|
||||
export function storeLocale(locale: Locale): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(STORED_LOCALE_KEY, locale);
|
||||
} catch {
|
||||
/* localStorage 不可用时静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user