diff --git a/.gitignore b/.gitignore index c99e093..6fc517e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ logs/ api-debug.log *.log +# 临时调试脚本 / 错误日志(勿入库) +_tmp* +*.err + # environment variables .env .env.production diff --git a/.workbuddy/memory/2026-09-14.md b/.workbuddy/memory/2026-09-14.md new file mode 100644 index 0000000..6483e7f --- /dev/null +++ b/.workbuddy/memory/2026-09-14.md @@ -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 数据 ` - - \ No newline at end of file diff --git a/src/components/LoadingScreen.astro b/src/components/LoadingScreen.astro index bfc2cd8..db4eccc 100644 --- a/src/components/LoadingScreen.astro +++ b/src/components/LoadingScreen.astro @@ -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) --- @@ -88,15 +93,16 @@ + @@ -279,7 +279,7 @@ const seasonOptions: { value: string; label: string }[] = [ return { locale: '', - hotBrands, + hotBrands: hotBrands as { id: string; name: string }[], SPIN_SVG, // 侧栏分组折叠状态:默认全部收起(不做 localStorage 持久化,每次进页都是关着的)。 collapsedGroups: { diff --git a/src/components/StreetSnap.astro b/src/components/StreetSnap.astro index f38ea2f..a181e27 100644 --- a/src/components/StreetSnap.astro +++ b/src/components/StreetSnap.astro @@ -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 && ( - + )} - - diff --git a/src/components/StreetSnaps.astro b/src/components/StreetSnaps.astro index 685f986..b793755 100644 --- a/src/components/StreetSnaps.astro +++ b/src/components/StreetSnaps.astro @@ -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 }[] = [ - + @@ -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, diff --git a/src/components/pages/AccountPage.astro b/src/components/pages/AccountPage.astro new file mode 100644 index 0000000..4407318 --- /dev/null +++ b/src/components/pages/AccountPage.astro @@ -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") +--- + + +
+ + +
+

{pleaseLabel}

+

{titleLabel}

+ {loginLabel} +
+ + +
+ + +
+
+

{t("member profile")}

+
+

+ + {t("vip")} +
+
+
+

Portfolio Studio

+

Archive + 2026 +

+

Paris · Milan · Tokyo

+
+
+ + +
+ + + + +
+ +
+ +
+

{savedLooksLabel}

+

{itemsLabel}

+
+ + +
+ + +
+ + +
+

{noSavedLabel}

+ {browseLabel} +
+ + +
+ +
+ + + +
+ + +
+ +
+

{historyLabel}

+
+

{itemsLabel}

+ +
+
+ + +
+

{noHistoryLabel}

+ {browseLabel} +
+ + +
+ +
+ + + +
+
+
+
+
+
+ + + + diff --git a/src/components/pages/ItemPage.astro b/src/components/pages/ItemPage.astro new file mode 100644 index 0000000..0086f3d --- /dev/null +++ b/src/components/pages/ItemPage.astro @@ -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") +--- + + + {article ? : } + + + + +{(article || snap) && ( + +)} + +{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内, + 导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */} + diff --git a/src/components/pages/LoginPage.astro b/src/components/pages/LoginPage.astro new file mode 100644 index 0000000..aa80ce8 --- /dev/null +++ b/src/components/pages/LoginPage.astro @@ -0,0 +1,170 @@ +--- +// 登录页的 en/cn 共享实现(语言由 Astro.currentLocale 运行时决定)。 +// 对应页面:src/pages/{en,cn}/login.astro —— 仅做薄包装。 +// 注意:本页**不走 Layout**,自带完整 文档(独立极简版式,故保留 )。 +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` +--- + + + + + + + {pageTitle} + + + +
+ + +
+ + {backLabel} + + +
+ +
    + {languages.map((l) => ( +
  • + {l.label} +
  • + ))} +
+
+
+ + +
+
+
+

{membersLabel}

+

{loginLabel}

+
+
+

Portfolio Studio

+

Archive + 2026 +

+

Paris · Milan · Tokyo

+
+
+
+ + +
+ +
+

{promptLabel}

+ +
+
+ + +
+ +
+ + +
+ +

+ + +
+ + +
+ + +
+
+ + + + diff --git a/src/env.d.ts b/src/env.d.ts index a0fd8b8..a5cd51e 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -2,10 +2,10 @@ // @astrojs/alpinejs 集成会在运行时把 Alpine 挂到全局(window.Alpine), // 但 @types/alpinejs 是 CJS(export = Alpine),其顶层 declare const Alpine // 不会自动进入模块上下文(.astro 的 + diff --git a/src/pages/cn/item/[id].astro b/src/pages/cn/item/[id].astro index 161bbf4..9d46a04 100644 --- a/src/pages/cn/item/[id].astro +++ b/src/pages/cn/item/[id].astro @@ -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" --- - - {article ? : } - - - - -{(article || snap) && ( - -)} - -{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内, - 导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */} - + diff --git a/src/pages/cn/login.astro b/src/pages/cn/login.astro index 0ff0de3..e565841 100644 --- a/src/pages/cn/login.astro +++ b/src/pages/cn/login.astro @@ -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" --- - - - - - - - {pageTitle} - - - -
- -
- - {backLabel} - - -
- -
    - {languages.map((l) => ( -
  • - {l.label} -
  • - ))} -
-
-
- - -
-
-
-

{membersLabel}

-

{loginLabel}

-
-
-

Portfolio Studio

-

Archive - 2026 -

-

Paris · Milan · Tokyo

-
-
-
- - -
- -
-

{promptLabel}

- -
-
- - -
- -
- - -
- -

- - -
- - -
- - -
-
- - - - + diff --git a/src/pages/en/account.astro b/src/pages/en/account.astro index 591c761..685d9f5 100644 --- a/src/pages/en/account.astro +++ b/src/pages/en/account.astro @@ -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" --- - - -
- -
-

{pleaseLabel}

-

{titleLabel}

- {loginLabel} -
- - -
- - -
-
-

{t("member profile")}

-
-

- - {t("vip")} -
-
-
-

Portfolio Studio

-

Archive - 2026 -

-

Paris · Milan · Tokyo

-
-
- - -
- - - - -
- -
- -
-

{savedLooksLabel}

-

{itemsLabel}

-
- - -
- - -
- - -
-

{noSavedLabel}

- {browseLabel} -
- - -
- -
- - - -
- - -
- -
-

{historyLabel}

-
-

{itemsLabel}

- -
-
- - -
-

{noHistoryLabel}

- {browseLabel} -
- - -
- -
- - - -
-
-
-
-
-
- - - - + diff --git a/src/pages/en/item/[id].astro b/src/pages/en/item/[id].astro index 161bbf4..9d46a04 100644 --- a/src/pages/en/item/[id].astro +++ b/src/pages/en/item/[id].astro @@ -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" --- - - {article ? : } - - - - -{(article || snap) && ( - -)} - -{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内, - 导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */} - + diff --git a/src/pages/en/login.astro b/src/pages/en/login.astro index 0ff0de3..e565841 100644 --- a/src/pages/en/login.astro +++ b/src/pages/en/login.astro @@ -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" --- - - - - - - - {pageTitle} - - - -
- -
- - {backLabel} - - -
- -
    - {languages.map((l) => ( -
  • - {l.label} -
  • - ))} -
-
-
- - -
-
-
-

{membersLabel}

-

{loginLabel}

-
-
-

Portfolio Studio

-

Archive - 2026 -

-

Paris · Milan · Tokyo

-
-
-
- - -
- -
-

{promptLabel}

- -
-
- - -
- -
- - -
- -

- - -
- - -
- - -
-
- - - - + diff --git a/src/pages/index.astro b/src/pages/index.astro index af6db17..ad38c5b 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,6 +1,7 @@ --- -// 应该跳转到 /en +// 根路径 / 直接渲染首页(内容与 /en 一致)。注意:/ 与 /en 同内容会构成 SEO 重复, +// 若需强制跳 /en,应在此加 redirect 或 middleware —— 当前保持现状,未做跳转。 import Layout from '@/layouts/Layout.astro' import Index from '@/components/Index.astro' diff --git a/src/styles/global.css b/src/styles/global.css index 515dbc8..7c43fae 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2,3 +2,27 @@ [x-cloak] { display: none !important; } +/* ===== 共享原子:被多个组件复用的极小样式片段 ===== + 放在全局而非组件