diff --git a/.env.example b/.env.example index 5a91d60..f77dba8 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,16 @@ DB_NAME=fashionadmin # ---- 后端 JWT 密钥(生产务必改为随机长字符串)---- JWT_SECRET=change-me-in-prod +# ---- 公开 ID 混淆盐值(生产务必改为随机串)---- +# 决定对外 id/brand_id 编码结果;变更会使线上旧链接全部失效,一旦上生产请固定。 +HASHID_SECRET=change-me-in-prod-hashid + +# ---- SSG 内部接口令牌(可选)---- +# SSG_TOKEN=some-long-random-token + # 说明: -# - 前端构建期通过 compose 的 build.args.API_SSR=http://localhost:8090 拉数据 # - 前端运行期走同源 /api(nginx 反代到 backend:8090),PUBLIC_API_BASE 在 Dockerfile 中置空 -# - backend 监听 8090;生产建议去掉 ports 暴露,仅由前端 nginx 反代访问 +# - 前端构建期(SSG)通过 compose 的 build.args.SSG_API=http://localhost:8091 拉数据, +# 指向后端独立的 SSG 内部端口(只绑宿主回环,nginx 不反代) +# - 若后端 SSG_TOKEN 非空,前端构建需传相同 SSG_TOKEN(compose 已对齐) +# - backend 监听 8090 对外 + 8091 对内;生产建议去掉 8090 的 ports 暴露 diff --git a/Dockerfile b/Dockerfile index 1d13c03..9eac3e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,23 @@ FROM node:22-alpine AS build WORKDIR /app -# 构建期 SSR 拉数据用的「后端内部地址」。 -# 在 docker-compose 里通过 build.args.API_SSR 传入(= http://localhost:8090, -# 配合 build.network: host,让前端构建容器能访问已启动的 backend 容器)。 -ARG API_SSR=http://localhost:8090 +# 构建期 SSG 拉数据用的「后端 SSG 内部地址」。 +# 在 docker-compose 里通过 build.args.SSG_API 传入(= http://localhost:8091, +# 配合 build.network: host,让前端构建容器能访问已启动的 backend 容器的 SSG 内部端口)。 +# 该端口只绑宿主回环(127.0.0.1:8091),nginx 绝不反代,因此 SSG 全量数据不会外泄。 +# SSG_API 与运行期 PUBLIC_API_BASE(8090)完全分离,二者指向不同端口。 +ARG SSG_API=http://localhost:8091 +ENV SSG_API=$SSG_API + +# 兼容旧命名:API_SSR 仍可作为 SSG 基址(lib/ssg.ts 优先读 SSG_API)。 +ARG API_SSR=http://localhost:8091 ENV API_SSR=$API_SSR +# SSG 内部端口的可选访问令牌(防御纵深)。仅构建期 node fetch 使用,且不带 PUBLIC_ 前缀, +# 故绝不会进入前端 bundle。置空时后端不校验(依赖回环绑定即可)。 +ARG SSG_TOKEN="" +ENV SSG_TOKEN=$SSG_TOKEN + # 运行期浏览器调用地址:置空 = 同源相对路径 /api(由下方 nginx 反代到 backend:8090) ENV PUBLIC_API_BASE="" diff --git a/docker-compose.yml b/docker-compose.yml index 0549bc1..7361943 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ # 生产部署 compose(放在前端仓库根,服务器结构见 DEPLOY.md) # 前端仓: /opt/fashion/test (本文件所在) -# 后端仓: /opt/fashion/admin/backend +# 后端仓: /opt/fashion/backend (注意:已对齐到当前活跃开发的后端,原 admin/backend 为旧扁平版) services: # ---- 数据库 ---- db: @@ -20,10 +20,13 @@ services: # ---- 后端 (Go) ---- backend: build: - context: ../admin/backend + context: ../backend restart: unless-stopped environment: SERVER_PORT: 8090 + SSG_PORT: 8091 # SSG 内部端口(见下 ports 绑定) + SSG_TOKEN: ${SSG_TOKEN:-} # 可选:非空则 SSG 接口需带 token(防御纵深) + HASHID_SECRET: ${HASHID_SECRET:-} # 公开 ID 混淆盐值:生产务必注入随机串(改值会使旧链接失效,谨慎变更) GIN_MODE: release JWT_SECRET: ${JWT_SECRET:-change-me-in-prod} DB_HOST: db @@ -32,7 +35,8 @@ services: DB_PASSWORD: ${DB_PASSWORD:-root} DB_NAME: ${DB_NAME:-fashionadmin} ports: - - "8090:8090" # 仅构建期前端拉数据用;生产可去掉,改由前端 nginx 反代 + - "8090:8090" # 对外公开 API(运行期由 nginx 反代);生产可去掉此发布,改由前端 nginx 直接反代 backend:8090 + - "127.0.0.1:8091:8091" # ⚠️ SSG 内部端口:只绑宿主回环,外部网络不可达;nginx 也绝不反代它 depends_on: db: condition: service_healthy @@ -47,7 +51,9 @@ services: build: context: . args: - API_SSR: http://localhost:8090 # 构建期 SSR 拉数据:配合 network: host 访问已起的 backend + SSG_API: http://localhost:8091 # 构建期 SSG 拉数据:指向 backend 的 SSG 内部端口(配合 network: host 访问回环绑定的 8091) + API_SSR: http://localhost:8091 # 兼容旧命名(效果同 SSG_API) + SSG_TOKEN: ${SSG_TOKEN:-} # 与 backend 的 SSG_TOKEN 保持一致;为空则构建期无需携带 token network: host restart: unless-stopped ports: diff --git a/nginx.conf b/nginx.conf index 1254516..b6a1028 100644 --- a/nginx.conf +++ b/nginx.conf @@ -10,7 +10,13 @@ server { try_files $uri $uri/ /index.html; } - # 反向代理后端 API:浏览器走同源 /api,由 nginx 转发到 backend 容器 + # 网关隔离:SSG 内部接口只存在于 backend 的 8091 端口(回环绑定,nginx 不反代)。 + # 若有人从公网域名探测 /api/ssg/*,此处直接 404,确保构建期全量数据绝不从公网出口。 + location /api/ssg/ { + return 404; + } + + # 反向代理后端 API:浏览器走同源 /api,由 nginx 转发到 backend 容器(仅 8090 公开端口) location /api/ { proxy_pass http://backend:8090/; proxy_set_header Host $host; diff --git a/src/components/Index.astro b/src/components/Index.astro index f55bc2c..f606121 100644 --- a/src/components/Index.astro +++ b/src/components/Index.astro @@ -1,37 +1,34 @@ --- import ComingSoon from "@/components/ComingSoon.astro" -import { getShows, seriesLabel } from "@/lib/data" +import { toAbs } from "@/lib/api" import { getI18n } from "@/i18n/utils" import { getRelativeLocaleUrl } from 'astro:i18n' - - -const shows = await getShows(20) +import { getSsgPopularBrands } from "@/lib/ssg" // 国际化:一行取当前语言 + 翻译函数 const { locale, t } = getI18n(Astro) -type GalleryItem = { - src: string; - brand: string; - articleId: number; - year: number; - title: string; - collection_type?: string +// 首页「热门品牌」区块:构建期(SSG 内部接口,server-only)拉取 20 个热门品牌, +// 封面/标题取自每个品牌代表走秀,直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。 +// 具体 base 地址 + token 拼接已在 @/lib/ssg 内封装。 +const popular = await getSsgPopularBrands(20) + +type GalleryItem = { + src: string + brand: string + articleId: string // 代表走秀 hashid 编码串(后端返回 string) + title: string } -const galleryItems: GalleryItem[] = [] -for (const s of shows) { - if (!s.img) continue - galleryItems.push({ - src: s.img, - brand: s.brand, - articleId: s.id, - year: s.year, - title: s.title, - collection_type: s.collection_type, - }) -} +const galleryItems: GalleryItem[] = popular + .filter((b) => b.cover && b.article_id) + .map((b) => ({ + src: toAbs(b.cover || ""), + brand: b.brand, + articleId: b.article_id, + title: b.title, + })) ---
@@ -89,7 +86,7 @@ for (const s of shows) { { galleryItems.map((g, i) => (
- +
- + {g.brand} - - {g.year ? g.year : ""} - {g.collection_type ? " · " + seriesLabel(g.collection_type) : ""} - + {g.title}
@@ -269,36 +263,51 @@ for (const s of shows) { to { background-position: -200% 0; } } - /* 悬停品牌遮罩 */ + /* 底部品牌/标题信息——左下角大标题+小副标题,始终可见 */ .hs-caption { position: absolute; bottom: 0; left: 0; right: 0; - padding: 28px 16px 12px; - background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent); + padding: 48px 24px 22px; + background: linear-gradient(to top, rgba(0, 0, 0, 0.78) 0%, rgba(0, 0, 0, 0.28) 55%, transparent 100%); color: white; display: flex; - justify-content: space-between; - align-items: flex-end; - gap: 12px; - opacity: 0; - transition: opacity 0.25s ease; + flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + gap: 6px; + opacity: 1; z-index: 3; - font-family: monospace; pointer-events: none; } - .hs-item:hover .hs-caption { opacity: 1; } .hs-cap-brand { - font-size: 13px; + @apply font-sans; + font-size: 21px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.02em; + line-height: 1.1; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55); } .hs-cap-meta { - font-size: 11px; - opacity: 0.85; - letter-spacing: 0.12em; + @apply font-sans; + font-size: 13px; + font-weight: 500; + opacity: 0.9; + letter-spacing: 0.04em; + line-height: 1.3; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + max-width: 90%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + @media (min-width: 768px) { + .hs-caption { padding: 60px 28px 26px; } + .hs-cap-brand { font-size: 26px; } + .hs-cap-meta { font-size: 15px; } } @media (max-width: 768px) { diff --git a/src/components/RunwayLooks.astro b/src/components/RunwayLooks.astro index c6b7331..991e5ad 100644 --- a/src/components/RunwayLooks.astro +++ b/src/components/RunwayLooks.astro @@ -1,5 +1,4 @@ --- -import Layout from "@/layouts/Layout.astro" import Breadcrumb from "@/components/Breadcrumb.astro" import { getBrands, getYears, type BrandEntry } from "@/lib/api" import { getI18n } from '@/i18n/utils' @@ -71,26 +70,24 @@ if (!yearChips.length) yearChips = yearSeed
    -
  • - - + - -
  • + + -->
diff --git a/src/components/RunwayLooksDetail.astro b/src/components/RunwayLooksDetail.astro index 0f48da9..309f711 100644 --- a/src/components/RunwayLooksDetail.astro +++ b/src/components/RunwayLooksDetail.astro @@ -1,20 +1,16 @@ --- import type { RawArticle } from "@/lib/data" import Breadcrumb from "@/components/Breadcrumb.astro" +import { getI18n } from "@/i18n/utils" interface Props { article: RawArticle | null - locale?: "en" | "cn" apiBase?: string error?: "missing" | "failed" | null } -const { - article, - locale = "en", - apiBase = "", - error = null, -} = Astro.props +const { article, apiBase = "", error = null } = Astro.props +const { locale, t, getRelativeLocaleUrl } = getI18n(Astro) const full = (u: string): string => { if (!u) return "" @@ -35,36 +31,12 @@ const descParas = (article?.description || "") .map((s) => s.trim()) .filter(Boolean) -const L = { - en: { - back: "← Back to Index", - missing: "No article ID specified", - failed: "Failed to load article. Please refresh or try later.", - close: "Close", - previous: "Previous image", - next: "Next image", - zoomTip: "Click image to zoom · Scroll to view · Esc to close", - }, - cn: { - back: "← 返回首页", - missing: "未指定文章 ID", - failed: "加载文章失败,请刷新或稍后再试", - close: "关闭", - previous: "上一张", - next: "下一张", - zoomTip: "点击图片放大 · 上下滚动查看 · Esc 关闭", - }, -} - -const lbl = L[locale === "cn" ? "cn" : "en"] -const backLabel = lbl.back.replace("← ", "") - -const homeHref = locale === "cn" ? "/cn" : "/en/" -const archiveHref = locale === "cn" ? "/cn/shows" : "/en/shows" +const homeHref = getRelativeLocaleUrl(locale, "/") +const archiveHref = getRelativeLocaleUrl(locale, "/shows") const crumbs: { label: string; href: string }[] = [ { - label: "Runway Looks", + label: t("Runway looks"), href: archiveHref, }, ] @@ -139,14 +111,14 @@ if (article?.brand_id) { ) : (

- {error === "missing" ? lbl.missing : lbl.failed} + {error === "missing" ? t('No article ID specified') : t('Failed to load article. Please refresh or try later.')}

- ← {backLabel} + ← {t('Back to Index')}
) @@ -171,7 +143,7 @@ if (article?.brand_id) { type="button" @click="closeLb()" class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer" - aria-label={lbl.close} + aria-label={t('Close')} > ✕ @@ -204,7 +176,7 @@ if (article?.brand_id) { type="button" @click.stop="prevLb()" class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer" - aria-label={lbl.previous} + aria-label={t('Previous image')} > @@ -215,7 +187,7 @@ if (article?.brand_id) { type="button" @click.stop="nextLb()" class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer" - aria-label={lbl.next} + aria-label={t('Next image')} > @@ -226,7 +198,7 @@ if (article?.brand_id) { type="button" @click.stop="prevLb()" class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer" - aria-label={lbl.previous} + aria-label={t('Previous image')} > @@ -237,7 +209,7 @@ if (article?.brand_id) { type="button" @click.stop="nextLb()" class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer" - aria-label={lbl.next} + aria-label={t('Next image')} > @@ -275,7 +247,7 @@ if (article?.brand_id) { x-show="!lbZoomed" class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200" > - {lbl.zoomTip} + {t('Click image to zoom · Scroll to view · Esc to close')}
)} @@ -459,12 +431,12 @@ if (article?.brand_id) { }) }, + // TODO 这是干啥的 initThumbLazyLoading() { const thumbs = document.querySelectorAll( "#lb-thumbs [data-thumb-url]" ) - console.log(thumbs) if (!thumbs.length) return if (!("IntersectionObserver" in window)) { diff --git a/src/components/pages/articles.astro b/src/components/pages/articles.astro index 2216719..e80528b 100644 --- a/src/components/pages/articles.astro +++ b/src/components/pages/articles.astro @@ -1,5 +1,6 @@ --- import { getReports, fmtDate } from "@/lib/data" +import { getI18n } from "@/i18n/utils" // 文章展示创意布局页:复用你现有的 Layout 外壳。 // 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts); @@ -7,9 +8,11 @@ import { getReports, fmtDate } from "@/lib/data" const reports = await getReports(30) const fmt = fmtDate +const { locale, getRelativeLocaleUrl } = getI18n(Astro) +const itemBase = getRelativeLocaleUrl(locale, "/items/").replace(/\/$/, "") --- -
+
@@ -65,7 +68,7 @@ const fmt = fmtDate style={`--ratio:${s.ratio}`} >
{fmt(s.created_at)}
- + {s.title} {s.featured && Featured} @@ -269,6 +272,7 @@ const fmt = fmtDate const filterbar = document.getElementById("filterbar") const viewBtns = Array.from(document.querySelectorAll(".viewswitch button")) const API_BASE = (import.meta.env.PUBLIC_API_BASE ?? "").replace(/\/$/, "") + const itemBase = archive?.dataset.itemBase || "/items" // 封面可能是完整外链(https://...),也可能是相对路径(/uploads/...),统一处理 const toAbs = (url: string) => { if (!url) return "" @@ -310,7 +314,7 @@ const fmt = fmtDate
${fmt(it.created_at)}
- + ${it.title} ${it.featured ? 'Featured' : ""} diff --git a/src/components/pages/brands.astro b/src/components/pages/brands.astro index 4d9aee8..a1f48b9 100644 --- a/src/components/pages/brands.astro +++ b/src/components/pages/brands.astro @@ -1,37 +1,19 @@ --- +import { getSsgBrands, type SsgBrand } from "@/lib/ssg" + +type Brand = SsgBrand // 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开) -// - 数据:一次 SSR 拉完全部精选集合(?size=300&only_with_articles=1&featured=1), -// 前端按 name 首字母分桶渲染;198 个品牌数据量小,无需懒加载。 +// - 数据:一次 SSR 拉完全部精选集合,前端按 name 首字母分桶渲染;数据量小,无需懒加载。 // - 风格:极简编辑风(黑白 + monospace + 细线)。 // · 顶部单行 BRANDS 标题 + 总数 // · sticky A-Z 字母条(点击平滑滚动) // · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列 // · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框 -// 构建期 SSR 拉数据用 API_SSR(容器内部后端地址,如 http://backend:8090); -// 运行期浏览器走同源 /api(由 nginx 反代),PUBLIC_API_BASE 置空即可。 -const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "") - -interface Brand { - id: number - name: string - show_name: string - article_count: number -} - -let brands: Brand[] = [] -let loadError = false -try { - const res = await fetch(`${API}/api/public/brands?size=300&only_with_articles=1&featured=1`) - if (res.ok) { - const j = await res.json() - brands = (j?.data ?? []) as Brand[] - } else { - loadError = true - } -} catch { - loadError = true -} +// 构建期 SSR 拉数据走 SSG 内部接口 /api/ssg/brands(不对外暴露,位于独立内部端口 8091); +// base 地址 + token 拼接已在 @/lib/ssg 内封装。后端不可达则回落空数组(页面显示空索引)。 +const brands = await getSsgBrands({ size: 300, only_with_articles: 1, featured: 1 }) +const loadError = brands.length === 0 const LETTERS = Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZ") diff --git a/src/i18n/dictionary.ts b/src/i18n/dictionary.ts index 0248ae6..8032213 100644 --- a/src/i18n/dictionary.ts +++ b/src/i18n/dictionary.ts @@ -31,9 +31,18 @@ export const dict: Record = { 'Signup': { cn: '注册' }, // ── 控件 / Controls ── + 'Back to Index': { cn: '返回首页' }, 'Back to top': { cn: '回到顶部' }, - 'Next': { cn: '下一组' }, + 'Close': { cn: '关闭' }, + 'Previous image': { cn: '上一张' }, + 'Next image': { cn: '下一张' }, 'Previous': { cn: '上一组' }, + 'Next': { cn: '下一组' }, + + // ── 错误 / Errors ── + 'No article ID specified': { cn: '未指定文章 ID' }, + 'Failed to load article. Please refresh or try later.': { cn: '加载文章失败,请刷新或稍后再试' }, + 'Click image to zoom · Scroll to view · Esc to close': { cn: '点击图片放大 · 上下滚动查看 · Esc 关闭' }, // ── 页脚 / Footer ── '© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' }, diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index f37ad6c..cb7eff4 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -62,7 +62,7 @@ const queryString = Astro.url.search; -
- - - -
-
- - - - - - - - - -
- -
- -
-
-
- - -
-
-
-
-

全部品牌

- -
- -
- -
-
-
热门推荐
-
-
    - -
  • 未找到匹配的品牌
  • -
-
-
- - -
-
-
- -
- - - - - - - \ No newline at end of file