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) => (
@@ -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')}
>
)}
@@ -459,12 +431,12 @@ if (article?.brand_id) {
})
},
+ // TODO 这是干啥的
initThumbLazyLoading() {
const thumbs = document.querySelectorAll