11
.env.example
11
.env.example
@ -16,9 +16,16 @@ HASHID_SECRET=change-me-in-prod-hashid
|
|||||||
# ---- SSG 内部接口令牌(可选)----
|
# ---- SSG 内部接口令牌(可选)----
|
||||||
# SSG_TOKEN=some-long-random-token
|
# SSG_TOKEN=some-long-random-token
|
||||||
|
|
||||||
|
# ---- 前端开发/构建变量(本地 astro dev / 本地 build 用;Docker 部署见 Dockerfile/docker-compose)----
|
||||||
|
# 公开/运行期接口基址(浏览器运行期 + 按需渲染 SSR)。空 = 同源 /api。
|
||||||
|
# 本地联调后端时填 http://localhost:8090;生产可置空走同源。
|
||||||
|
#BASE_API=http://localhost:8090
|
||||||
|
# 构建期 SSG 内部端口基址,指向后端独立的 SSG 内部端口(只绑宿主回环,nginx 不反代)。
|
||||||
|
#BASE_API_SSG=http://localhost:8091
|
||||||
|
|
||||||
# 说明:
|
# 说明:
|
||||||
# - 前端运行期走同源 /api(nginx 反代到 backend:8090),PUBLIC_API_BASE 在 Dockerfile 中置空
|
# - 前端运行期走同源 /api(nginx 反代到 backend:8090),BASE_API 在 Dockerfile 中置空
|
||||||
# - 前端构建期(SSG)通过 compose 的 build.args.SSG_API=http://localhost:8091 拉数据,
|
# - 前端构建期(SSG)通过 compose 的 build.args.BASE_API_SSG=http://localhost:8091 拉数据,
|
||||||
# 指向后端独立的 SSG 内部端口(只绑宿主回环,nginx 不反代)
|
# 指向后端独立的 SSG 内部端口(只绑宿主回环,nginx 不反代)
|
||||||
# - 若后端 SSG_TOKEN 非空,前端构建需传相同 SSG_TOKEN(compose 已对齐)
|
# - 若后端 SSG_TOKEN 非空,前端构建需传相同 SSG_TOKEN(compose 已对齐)
|
||||||
# - backend 监听 8090 对外 + 8091 对内;生产建议去掉 8090 的 ports 暴露
|
# - backend 监听 8090 对外 + 8091 对内;生产建议去掉 8090 的 ports 暴露
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -13,6 +13,11 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# API 调试日志(前端 http.ts 记录请求/响应)
|
||||||
|
logs/
|
||||||
|
api-debug.log
|
||||||
|
*.log
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
- **前端**:Astro 构建为静态 `dist/`,用 nginx 托管;`/api` 由 nginx 反代到 `backend:8090`(同源,无跨域)。
|
- **前端**:Astro 构建为静态 `dist/`,用 nginx 托管;`/api` 由 nginx 反代到 `backend:8090`(同源,无跨域)。
|
||||||
- **后端**:Go 二进制,监听 `8090`,连 `db` 服务。
|
- **后端**:Go 二进制,监听 `8090`,连 `db` 服务。
|
||||||
- **构建期 SSR**:`brands.astro` 在构建时拉数据,地址用 `API_SSR`(容器内部 `http://localhost:8090`,配合 `build.network: host` 访问已启动的 backend 容器);运行期浏览器走同源 `/api`。
|
- **构建期取数**:品牌页等通过 SSG 内部接口(`/api/ssg/*`,基址 `BASE_API_SSG`,默认 `http://localhost:8091`)在构建时拉数据;运行期浏览器走同源 `/api`(基址 `BASE_API` 置空)。
|
||||||
|
|
||||||
## 二、服务器目录约定
|
## 二、服务器目录约定
|
||||||
|
|
||||||
@ -75,4 +75,4 @@ cd /opt/fashion/test
|
|||||||
- 后端环境变量名(`DB_HOST`/`JWT_SECRET` 等)需与实际 `config` 读取一致;如后端用别的变量名,改 `docker-compose.yml` 的 `environment` 即可。
|
- 后端环境变量名(`DB_HOST`/`JWT_SECRET` 等)需与实际 `config` 读取一致;如后端用别的变量名,改 `docker-compose.yml` 的 `environment` 即可。
|
||||||
- `JWT_SECRET` 生产务必改成随机长串;默认值是占位的。
|
- `JWT_SECRET` 生产务必改成随机长串;默认值是占位的。
|
||||||
- 生产可去掉 `backend.ports` 的 `8090:8090` 暴露,仅由前端 nginx 在同一 compose 网络内反代访问,更安全。
|
- 生产可去掉 `backend.ports` 的 `8090:8090` 暴露,仅由前端 nginx 在同一 compose 网络内反代访问,更安全。
|
||||||
- 前端 `PUBLIC_API_BASE` 在 Dockerfile 中置空(同源 `/api`);如需独立域名,改为绝对地址并在后端已开启 CORS(`*`)。
|
- 前端 `BASE_API` 在 Dockerfile 中置空(同源 `/api`);如需独立域名,改为绝对地址并在后端已开启 CORS(`*`)。
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@ -3,16 +3,12 @@ FROM node:22-alpine AS build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 构建期 SSG 拉数据用的「后端 SSG 内部地址」。
|
# 构建期 SSG 拉数据用的「后端 SSG 内部地址」。
|
||||||
# 在 docker-compose 里通过 build.args.SSG_API 传入(= http://localhost:8091,
|
# 在 docker-compose 里通过 build.args.BASE_API_SSG 传入(= http://localhost:8091,
|
||||||
# 配合 build.network: host,让前端构建容器能访问已启动的 backend 容器的 SSG 内部端口)。
|
# 配合 build.network: host,让前端构建容器能访问已启动的 backend 容器的 SSG 内部端口)。
|
||||||
# 该端口只绑宿主回环(127.0.0.1:8091),nginx 绝不反代,因此 SSG 全量数据不会外泄。
|
# 该端口只绑宿主回环(127.0.0.1:8091),nginx 绝不反代,因此 SSG 全量数据不会外泄。
|
||||||
# SSG_API 与运行期 PUBLIC_API_BASE(8090)完全分离,二者指向不同端口。
|
# BASE_API_SSG 与运行期 BASE_API(8090)完全分离,二者指向不同端口。
|
||||||
ARG SSG_API=http://localhost:8091
|
ARG BASE_API_SSG=http://localhost:8091
|
||||||
ENV SSG_API=$SSG_API
|
ENV BASE_API_SSG=$BASE_API_SSG
|
||||||
|
|
||||||
# 兼容旧命名:API_SSR 仍可作为 SSG 基址(lib/ssg.ts 优先读 SSG_API)。
|
|
||||||
ARG API_SSR=http://localhost:8091
|
|
||||||
ENV API_SSR=$API_SSR
|
|
||||||
|
|
||||||
# SSG 内部端口的可选访问令牌(防御纵深)。仅构建期 node fetch 使用,且不带 PUBLIC_ 前缀,
|
# SSG 内部端口的可选访问令牌(防御纵深)。仅构建期 node fetch 使用,且不带 PUBLIC_ 前缀,
|
||||||
# 故绝不会进入前端 bundle。置空时后端不校验(依赖回环绑定即可)。
|
# 故绝不会进入前端 bundle。置空时后端不校验(依赖回环绑定即可)。
|
||||||
@ -20,7 +16,7 @@ ARG SSG_TOKEN=""
|
|||||||
ENV SSG_TOKEN=$SSG_TOKEN
|
ENV SSG_TOKEN=$SSG_TOKEN
|
||||||
|
|
||||||
# 运行期浏览器调用地址:置空 = 同源相对路径 /api(由下方 nginx 反代到 backend:8090)
|
# 运行期浏览器调用地址:置空 = 同源相对路径 /api(由下方 nginx 反代到 backend:8090)
|
||||||
ENV PUBLIC_API_BASE=""
|
ENV BASE_API=""
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig, envField } from 'astro/config';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
@ -39,5 +39,19 @@ export default defineConfig({
|
|||||||
|
|
||||||
adapter: node({
|
adapter: node({
|
||||||
mode: 'standalone'
|
mode: 'standalone'
|
||||||
})
|
}),
|
||||||
|
|
||||||
|
// 环境变量声明:统一命名规范
|
||||||
|
// BASE_API —— 公开/运行期 API 基址(浏览器 + 按需渲染共用,空 = 同源 /api)
|
||||||
|
// BASE_API_SSG —— 构建期 SSG 内部端口基址(仅服务端,绝不进前端 bundle)
|
||||||
|
// SSG_TOKEN —— SSG 内部接口可选访问令牌(仅服务端)
|
||||||
|
// 注意:Astro 仅自动把 PUBLIC_ 前缀变量暴露给客户端,BASE_API 没有该前缀,
|
||||||
|
// 故必须在 schema 中声明为 access:'public',否则浏览器端 import.meta.env.BASE_API 为 undefined。
|
||||||
|
env: {
|
||||||
|
schema: {
|
||||||
|
BASE_API: envField.string({ context: 'client', access: 'public', optional: true }),
|
||||||
|
BASE_API_SSG: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||||
|
SSG_TOKEN: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@ -28,7 +28,7 @@ for i in $(seq 1 60); do
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "==> [4/5] 构建前端镜像(API_SSR=http://localhost:8090,构建期网络=host)"
|
echo "==> [4/5] 构建前端镜像(构建期网络=host,经 BASE_API_SSG 拉取 SSG 数据)"
|
||||||
docker compose build frontend
|
docker compose build frontend
|
||||||
|
|
||||||
echo "==> [5/5] 启动前端"
|
echo "==> [5/5] 启动前端"
|
||||||
|
|||||||
@ -51,9 +51,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
SSG_API: http://localhost:8091 # 构建期 SSG 拉数据:指向 backend 的 SSG 内部端口(配合 network: host 访问回环绑定的 8091)
|
BASE_API_SSG: http://localhost:8091 # 构建期 SSG 拉数据:指向 backend 的 SSG 内部端口(配合 network: host 访问回环绑定的 8091)
|
||||||
API_SSR: http://localhost:8091 # 兼容旧命名(效果同 SSG_API)
|
SSG_TOKEN: ${SSG_TOKEN:-} # 与 backend 的 SSG_TOKEN 保持一致;为空则构建期无需携带 token
|
||||||
SSG_TOKEN: ${SSG_TOKEN:-} # 与 backend 的 SSG_TOKEN 保持一致;为空则构建期无需携带 token
|
|
||||||
network: host
|
network: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -4,14 +4,14 @@ import ComingSoon from "@/components/ComingSoon.astro"
|
|||||||
import { toAbs } from "@/lib/api"
|
import { toAbs } from "@/lib/api"
|
||||||
import { getI18n } from "@/i18n/utils"
|
import { getI18n } from "@/i18n/utils"
|
||||||
import { href, PATH } from '@/lib/routes'
|
import { href, PATH } from '@/lib/routes'
|
||||||
import { getSsgPopularBrands } from "@/lib/ssg"
|
import { getSsgPopularBrands } from "@/lib/api"
|
||||||
|
|
||||||
// 国际化:一行取当前语言 + 翻译函数
|
// 国际化:一行取当前语言 + 翻译函数
|
||||||
const { locale, t } = getI18n(Astro)
|
const { locale, t } = getI18n(Astro)
|
||||||
|
|
||||||
// 首页「热门品牌」区块:构建期(SSG 内部接口,server-only)拉取 20 个热门品牌,
|
// 首页「热门品牌」区块:构建期(SSG 内部接口,server-only)拉取 20 个热门品牌,
|
||||||
// 封面/标题取自每个品牌代表走秀,直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
|
// 封面/标题取自每个品牌代表走秀,直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
|
||||||
// 具体 base 地址 + token 拼接已在 @/lib/ssg 内封装。
|
// 具体 base 地址 + token 拼接已在 @/lib/api 的 SSG 函数内封装。
|
||||||
const popular = await getSsgPopularBrands(20)
|
const popular = await getSsgPopularBrands(20)
|
||||||
|
|
||||||
type GalleryItem = {
|
type GalleryItem = {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { RawArticle } from "@/lib/data"
|
|||||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||||
import { getI18n } from "@/i18n/utils"
|
import { getI18n } from "@/i18n/utils"
|
||||||
import { href, PATH } from '@/lib/routes'
|
import { href, PATH } from '@/lib/routes'
|
||||||
import { ssrBase } from "@/lib/ssr"
|
import { ssrBase } from "@/lib/api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
article: RawArticle | null
|
article: RawArticle | null
|
||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import type { RawArticle } from "@/lib/data"
|
import type { RawArticle } from "@/lib/data"
|
||||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||||
import { ssrBase } from "@/lib/ssr"
|
import { ssrBase } from "@/lib/api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
article: RawArticle | null
|
article: RawArticle | null
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { getSsgBrands, type SsgBrand } from "@/lib/ssg"
|
import { getSsgBrands, type SsgBrand } from "@/lib/api"
|
||||||
|
import { getI18n } from "@/i18n/utils"
|
||||||
|
import { href, PATH } from "@/lib/routes"
|
||||||
|
|
||||||
type Brand = SsgBrand
|
type Brand = SsgBrand
|
||||||
|
|
||||||
@ -11,7 +13,7 @@ type Brand = SsgBrand
|
|||||||
// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
|
// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
|
||||||
// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
|
// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
|
||||||
// 构建期 SSR 拉数据走 SSG 内部接口 /api/ssg/brands(不对外暴露,位于独立内部端口 8091);
|
// 构建期 SSR 拉数据走 SSG 内部接口 /api/ssg/brands(不对外暴露,位于独立内部端口 8091);
|
||||||
// base 地址 + token 拼接已在 @/lib/ssg 内封装。后端不可达则回落空数组(页面显示空索引)。
|
// base 地址 + token 拼接已在 @/lib/api 的 SSG 函数内封装。后端不可达则回落空数组(页面显示空索引)。
|
||||||
const { locale } = getI18n(Astro)
|
const { locale } = getI18n(Astro)
|
||||||
const brands = await getSsgBrands({ size: 300, only_with_articles: 1, featured: 1 })
|
const brands = await getSsgBrands({ size: 300, only_with_articles: 1, featured: 1 })
|
||||||
const loadError = brands.length === 0
|
const loadError = brands.length === 0
|
||||||
|
|||||||
@ -112,7 +112,7 @@ const queryString = Astro.url.search;
|
|||||||
|
|
||||||
<footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans">
|
<footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans">
|
||||||
<script>
|
<script>
|
||||||
import { getUser, fetchMe, clearSession } from "@/lib/auth.ts"
|
import { getUser, fetchMe, clearSession } from "@/lib/api"
|
||||||
const slot = document.getElementById("auth-slot")
|
const slot = document.getElementById("auth-slot")
|
||||||
function render(user) {
|
function render(user) {
|
||||||
if (!user) return // 未登录:保留 SSR 渲染的 Login/Signup(已带语言前缀,无需重写)
|
if (!user) return // 未登录:保留 SSR 渲染的 Login/Signup(已带语言前缀,无需重写)
|
||||||
|
|||||||
376
src/lib/api.ts
376
src/lib/api.ts
@ -1,10 +1,29 @@
|
|||||||
// src/lib/api.ts — 后端 API 统一客户端
|
// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口)
|
||||||
// 集中所有 /api 调用:base URL 解析、错误处理、端点封装。
|
|
||||||
//
|
//
|
||||||
// base URL 解析规则:
|
// 集中封装全部 /api 调用:公开查询(/api/public/*)、账号体系(/api/auth/*)、
|
||||||
// - DEV(astro dev,前端 4345 + 后端 8090):一律回落 http://localhost:8090(CORS=*)。
|
// 以及构建期专用的 SSG 接口(/api/ssg/*,仅在 Node 构建期使用)。
|
||||||
// - SSR(构建/预渲染期):走 server-only 的 API_SSR,回落 PUBLIC_API_BASE。
|
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。
|
||||||
// - 客户端(浏览器运行期):走 PUBLIC_API_BASE,置空时同源相对 /api(由 nginx 反代到 backend)。
|
//
|
||||||
|
// base URL 解析规则(全项目统一两个基准地址,命名见下):
|
||||||
|
// BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。
|
||||||
|
// 空值 = 同源相对 /api;DEV 构建期固定走线上域名。
|
||||||
|
// BASE_API_SSG —— 构建期 Node 调 /api/ssg/* 的「内部端口」(默认 8091),
|
||||||
|
// 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。
|
||||||
|
//
|
||||||
|
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
|
||||||
|
// - publicBase():客户端运行期 base,仅 BASE_API(空 → 同源相对 /api)。
|
||||||
|
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
|
||||||
|
// - ssgBase():构建期 Node 调 /api/ssg/*,只读 BASE_API_SSG。
|
||||||
|
|
||||||
|
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
|
||||||
|
import "@/lib/http"
|
||||||
|
|
||||||
|
// 公开/运行期基址 BASE_API:Astro 7 中声明在 env.schema(access:public)的变量,
|
||||||
|
// 浏览器端须从 astro:env/client 读取(不会像 PUBLIC_ 前缀那样自动进 import.meta.env)。
|
||||||
|
// 该模块在 SSR(服务端)也会回退到 process.env,因此服务端/客户端共用同一份值。
|
||||||
|
import { BASE_API } from "astro:env/client"
|
||||||
|
|
||||||
|
// ===================== 类型 =====================
|
||||||
|
|
||||||
export interface BrandEntry {
|
export interface BrandEntry {
|
||||||
id: string // 后端返回 hashid 编码串(非自增主键),防爬虫顺序枚举
|
id: string // 后端返回 hashid 编码串(非自增主键),防爬虫顺序枚举
|
||||||
@ -44,36 +63,186 @@ export interface ArticlePage {
|
|||||||
last_page: number
|
last_page: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBase(): string {
|
// 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。
|
||||||
const env = import.meta.env as any
|
export type RawArticle = {
|
||||||
if (env.DEV) return "https://seo.23cm.cn"
|
id: string | number // 后端为 hashid 编码串(string);示例数据回落为数字
|
||||||
if (env.SSR) return (env.API_SSR || env.PUBLIC_API_BASE || "").replace(/\/$/, "")
|
title: string
|
||||||
return (env.PUBLIC_API_BASE || "").replace(/\/$/, "")
|
summary?: string
|
||||||
|
description?: string
|
||||||
|
cover: string
|
||||||
|
brand_name: string
|
||||||
|
brand_id?: string | number // hashid 编码串
|
||||||
|
year: number
|
||||||
|
image_count: number
|
||||||
|
published_at: number
|
||||||
|
created_at?: number
|
||||||
|
source_url?: string
|
||||||
|
collection_type?: string
|
||||||
|
season?: string
|
||||||
|
season_code?: string
|
||||||
|
images?: { id?: number; image: string; name?: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把相对图片路径补成绝对(仅客户端渲染卡片时用;空 base 时保留相对路径走同源)
|
export interface AuthUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSG 构建专用类型
|
||||||
|
export interface SsgPopularBrand {
|
||||||
|
id: string
|
||||||
|
article_id: string
|
||||||
|
brand: string
|
||||||
|
cover: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
export interface SsgBrand {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
show_name: string
|
||||||
|
article_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== base 解析 =====================
|
||||||
|
|
||||||
|
export function getBase(): string {
|
||||||
|
if (import.meta.env.DEV) return "https://seo.23cm.cn"
|
||||||
|
// 运行期/预渲染统一走 BASE_API(空则同源相对 /api)
|
||||||
|
return (BASE_API || "").replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相对图片路径补成绝对(基于 getBase())。
|
||||||
export function toAbs(url: string): string {
|
export function toAbs(url: string): string {
|
||||||
if (!url) return ""
|
if (!url) return ""
|
||||||
return /^https?:\/\//i.test(url) ? url : getBase() + url
|
return /^https?:\/\//i.test(url) ? url : getBase() + url
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端运行期 base:仅 PUBLIC_API_BASE(空 → 同源相对 /api),
|
// 客户端运行期 base:仅 BASE_API(空 → 同源相对 /api)。
|
||||||
// 与 getBase()(含 SSR / DEV 覆盖,用于 RunwayLooks 等)区分,保持 articles 页既有行为。
|
|
||||||
export function publicBase(): string {
|
export function publicBase(): string {
|
||||||
return ((import.meta.env as any).PUBLIC_API_BASE ?? "").replace(/\/$/, "")
|
return (BASE_API || "").replace(/\/$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 相对路径补成绝对(基于 PUBLIC_API_BASE);供 articles.astro 客户端脚本复用,
|
|
||||||
// 避免在每个 .astro 文件的 <script> 里重复写 base URL 解析。
|
|
||||||
export function toAbsPublic(url: string): string {
|
export function toAbsPublic(url: string): string {
|
||||||
if (!url) return ""
|
if (!url) return ""
|
||||||
if (/^https?:\/\//i.test(url)) return url
|
if (/^https?:\/\//i.test(url)) return url
|
||||||
return publicBase() + url
|
return publicBase() + url
|
||||||
}
|
}
|
||||||
|
|
||||||
// 走秀档案列表(articles.astro 客户端按需拉取):保留该页独有的
|
// 按需渲染(on-demand SSR)详情 base:BASE_API > 本地 8090 回落。
|
||||||
// 「单值 brand_id / collection_type / season」参数形态与 summary 字段,
|
export function ssrBase(): string {
|
||||||
// 与 getArticles(RunwayLooks 用,参数形态不同)分开维护。
|
return (BASE_API || "http://localhost:8090").replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 内部请求原语 =====================
|
||||||
|
|
||||||
|
// 通用请求原语:走 getBase() + /api + path(公开接口)。GET-only。
|
||||||
|
async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
|
||||||
|
const qs = params && params.toString() ? "?" + params.toString() : ""
|
||||||
|
const url = getBase() + "/api" + path + qs
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) throw new Error(`API ${res.status} ${url}`)
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 文章(/api/public/articles) =====================
|
||||||
|
|
||||||
|
// 接口:GET /api/public/articles
|
||||||
|
// 原始文章列表(不分页映射),供数据层与各类 getter 复用。
|
||||||
|
export async function getArticlesRaw(opts?: {
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
sort?: string
|
||||||
|
brand_ids?: string[]
|
||||||
|
brand_id?: string
|
||||||
|
collection_type?: string[]
|
||||||
|
season?: string[]
|
||||||
|
year?: string[]
|
||||||
|
with_images?: number
|
||||||
|
keyword?: string
|
||||||
|
}): Promise<{ data: RawArticle[]; total: number; last_page: number }> {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
p.set("page", String(opts?.page ?? 1))
|
||||||
|
p.set("size", String(opts?.size ?? 24))
|
||||||
|
if (opts?.sort) p.set("sort", opts.sort)
|
||||||
|
if (opts?.keyword) p.set("keyword", opts.keyword)
|
||||||
|
if (opts?.with_images) p.set("with_images", String(opts.with_images))
|
||||||
|
if (opts?.brand_id) p.set("brand_id", opts.brand_id)
|
||||||
|
if (opts?.brand_ids?.length) p.set("brand_ids", opts.brand_ids.join(","))
|
||||||
|
for (const v of opts?.collection_type ?? []) p.append("collection_type", v)
|
||||||
|
for (const v of opts?.season ?? []) p.append("season", v)
|
||||||
|
for (const v of opts?.year ?? []) p.append("year", v)
|
||||||
|
const j = await request<{ data?: any[]; total?: number; last_page?: number }>(
|
||||||
|
"/public/articles",
|
||||||
|
p
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
data: (j.data ?? []) as RawArticle[],
|
||||||
|
total: Number(j.total ?? (j.data?.length ?? 0)),
|
||||||
|
last_page: Number(j.last_page ?? 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口:GET /api/public/articles/:id
|
||||||
|
// 文章详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
|
||||||
|
export async function fetchArticleById(
|
||||||
|
id: number | string,
|
||||||
|
base: string = ssrBase()
|
||||||
|
): Promise<RawArticle | null> {
|
||||||
|
const b = base.replace(/\/$/, "")
|
||||||
|
if (!b) return null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${b}/api/public/articles/${id}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const json: any = await res.json()
|
||||||
|
return (json?.data ?? null) as RawArticle | null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端按需渲染(on-demand)取文章详情:在 fetchArticleById 之上包揽
|
||||||
|
// 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。
|
||||||
|
// 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。
|
||||||
|
export type SsrArticleResult = {
|
||||||
|
article: RawArticle | null
|
||||||
|
error: "missing" | "failed" | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSsrArticle(
|
||||||
|
id: string | null | undefined
|
||||||
|
): Promise<SsrArticleResult> {
|
||||||
|
if (!id) return { article: null, error: "missing" }
|
||||||
|
try {
|
||||||
|
const article = await fetchArticleById(id)
|
||||||
|
return { article, error: article ? null : "failed" }
|
||||||
|
} catch {
|
||||||
|
return { article: null, error: "failed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口:GET /api/public/articles(经 getArticlesRaw 透传)
|
||||||
|
// 文章列表(RunwayLooks / shows 用):多选用 repeated params,并把 images 映射成 gallery。
|
||||||
|
export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
|
||||||
|
const { data, total, last_page } = await getArticlesRaw({
|
||||||
|
page: q.page,
|
||||||
|
size: q.size,
|
||||||
|
sort: q.sort,
|
||||||
|
with_images: q.with_images,
|
||||||
|
brand_ids: q.brand_ids,
|
||||||
|
collection_type: q.collection_type,
|
||||||
|
season: q.season,
|
||||||
|
year: q.year,
|
||||||
|
})
|
||||||
|
const mapped: ArticleItem[] = data.map((it: any) => ({
|
||||||
|
...it,
|
||||||
|
gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
|
||||||
|
}))
|
||||||
|
return { data: mapped, total: Number(total ?? mapped.length), last_page: Number(last_page ?? 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口:GET /api/public/articles(客户端运行期,base 走 BASE_API)
|
||||||
|
// articles.astro 客户端按需拉取:保留该页独有的单值参数形态。
|
||||||
export async function fetchArticlesList(opts?: {
|
export async function fetchArticlesList(opts?: {
|
||||||
page?: number
|
page?: number
|
||||||
size?: number
|
size?: number
|
||||||
@ -93,17 +262,35 @@ export async function fetchArticlesList(opts?: {
|
|||||||
return json?.data ?? []
|
return json?.data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
|
// 接口:GET /api/public/articles(经 getArticlesRaw,sort=year_desc)
|
||||||
const qs = params && params.toString() ? "?" + params.toString() : ""
|
// 最近若干条秀场中提取的独特年份(降序去重,最多 6 个),用于筛选年份 chips。
|
||||||
const url = getBase() + "/api" + path + qs
|
export async function getYears(sample = 30): Promise<number[]> {
|
||||||
const res = await fetch(url)
|
const { data } = await getArticlesRaw({ size: sample, sort: "year_desc" })
|
||||||
if (!res.ok) throw new Error(`API ${res.status} ${url}`)
|
const ys = Array.from(
|
||||||
return (await res.json()) as T
|
new Set(data.map((it: any) => Number(it.year)).filter((y: number) => y > 0))
|
||||||
|
) as number[]
|
||||||
|
return ys.slice(0, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 侧边栏品牌清单(含 article_count 计数)
|
// 接口:GET /api/public/colors(客户端运行期,base 走 BASE_API)
|
||||||
// 支持 keyword(模糊搜索 name/show_name)/ letter(A-Z 或 OTHER=中文)/ featured(精选)参数,
|
// 颜色接口(后端暂无独立颜色接口时返回 null,由调用方回落)。
|
||||||
// 用于品牌筛选弹窗的「搜索 + A-Z 跳转 + 热门推荐」三种按需加载,避免一次拉全量。
|
export async function fetchColors(): Promise<any[] | null> {
|
||||||
|
const base = publicBase()
|
||||||
|
if (!base) return null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/api/public/colors`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const json: any = await res.json()
|
||||||
|
const data: any[] = json?.data ?? []
|
||||||
|
return data.length ? data : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 品牌(/api/public/brands) =====================
|
||||||
|
|
||||||
|
// 接口:GET /api/public/brands
|
||||||
export async function getBrands(opts?: {
|
export async function getBrands(opts?: {
|
||||||
size?: number
|
size?: number
|
||||||
only_with_articles?: number
|
only_with_articles?: number
|
||||||
@ -125,33 +312,110 @@ export async function getBrands(opts?: {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最近若干条秀场中提取的独特年份(降序去重,最多 6 个),用于筛选年份 chips
|
// ===================== 账号体系(/api/auth/*) =====================
|
||||||
export async function getYears(sample = 30): Promise<number[]> {
|
// 说明:当前前端只用「取当前用户 + 登出」两个能力(顶栏已登录态)。
|
||||||
const qs = new URLSearchParams({ size: String(sample), sort: "year_desc" })
|
// 登录/注册的表单提交暂未接入,相关接口函数已移除,避免无人调用的死代码。
|
||||||
const j = await request<{ data?: any[] }>("/public/articles", qs)
|
|
||||||
const ys = Array.from(
|
const BASE_API_URL = (BASE_API || "").replace(/\/$/, "")
|
||||||
new Set((j.data ?? []).map((it: any) => Number(it.year)).filter((y: number) => y > 0))
|
const TOKEN_KEY = "fa_token"
|
||||||
) as number[]
|
const USER_KEY = "fa_user"
|
||||||
return ys.slice(0, 6)
|
|
||||||
|
// 注意:getUser / clearSession 只读写 localStorage,不发网络请求。
|
||||||
|
export function getUser(): AuthUser | null {
|
||||||
|
if (typeof localStorage === "undefined") return null
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(USER_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as AuthUser) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文章/秀场列表:统一构造 QueryArray 参数(多选用 repeated params),
|
export function clearSession(): void {
|
||||||
// 并把后端 images[] 映射成 gallery 字符串数组(缩略图条用)。
|
if (typeof localStorage === "undefined") return
|
||||||
export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
const p = new URLSearchParams()
|
localStorage.removeItem(USER_KEY)
|
||||||
p.set("page", String(q.page ?? 1))
|
}
|
||||||
p.set("size", String(q.size ?? 24))
|
|
||||||
if (q.sort) p.set("sort", q.sort)
|
export async function fetchMe(): Promise<AuthUser | null> {
|
||||||
if (q.with_images) p.set("with_images", String(q.with_images))
|
if (typeof localStorage === "undefined") return null
|
||||||
if (q.brand_ids?.length) p.set("brand_ids", q.brand_ids.join(","))
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
// 多选:同一字段用 repeated params(后端 QueryArray 收到数组后 1 个走 = / 多个走 IN)
|
if (!token) return null
|
||||||
for (const v of q.collection_type ?? []) p.append("collection_type", v)
|
try {
|
||||||
for (const v of q.season ?? []) p.append("season", v)
|
// 接口:GET /api/auth/me(需 Bearer token,客户端运行期 base 走 BASE_API)
|
||||||
for (const v of q.year ?? []) p.append("year", v)
|
const res = await fetch(`${BASE_API_URL}/api/auth/me`, {
|
||||||
const j = await request<{ data?: any[]; total?: number; last_page?: number }>("/public/articles", p)
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
const data: ArticleItem[] = (j.data ?? []).map((it: any) => ({
|
})
|
||||||
...it,
|
if (!res.ok) {
|
||||||
gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
|
clearSession()
|
||||||
}))
|
return null
|
||||||
return { data, total: Number(j.total ?? data.length), last_page: Number(j.last_page ?? 1) }
|
}
|
||||||
|
const json: any = await res.json()
|
||||||
|
const user = json?.data?.user ?? json?.user
|
||||||
|
return (user as AuthUser) ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SSG 构建专用(/api/ssg/*,仅 Node 构建期) =====================
|
||||||
|
|
||||||
|
function ssgEnv(name: string): string | undefined {
|
||||||
|
const e = import.meta.env as any
|
||||||
|
if (e[name] != null && e[name] !== "") return e[name] as string
|
||||||
|
if (typeof process !== "undefined" && process.env[name]) return process.env[name] as string
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSG 内部接口 base:只认 BASE_API_SSG 专用端口(默认 8091),不回落 BASE_API/运行期端口。
|
||||||
|
// 绝不可回落到 BASE_API(8090)——那会把内部全量接口暴露到公网端口。
|
||||||
|
function ssgBase(): string {
|
||||||
|
return (ssgEnv("BASE_API_SSG") || "http://localhost:8091").replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ssgToken(): string {
|
||||||
|
return (ssgEnv("SSG_TOKEN") || "").toString().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function withToken(path: string): string {
|
||||||
|
const t = ssgToken()
|
||||||
|
if (!t) return path
|
||||||
|
return path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ssgRequest<T>(path: string): Promise<T | null> {
|
||||||
|
const url = ssgBase() + withToken(path)
|
||||||
|
try {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return (await res.json()) as T
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口:GET /api/ssg/brands/popular?limit=N(SSG 内部端口,构建期 Node 调用)
|
||||||
|
export async function getSsgPopularBrands(limit = 20): Promise<SsgPopularBrand[]> {
|
||||||
|
const j = await ssgRequest<{ data?: SsgPopularBrand[] }>(`/api/ssg/brands/popular?limit=${limit}`)
|
||||||
|
return j?.data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口:GET /api/ssg/brands(SSG 内部端口,构建期 Node 调用)
|
||||||
|
export async function getSsgBrands(opts?: {
|
||||||
|
size?: number
|
||||||
|
only_with_articles?: number
|
||||||
|
featured?: number
|
||||||
|
}): Promise<SsgBrand[]> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts?.size) qs.set("size", String(opts.size))
|
||||||
|
if (opts?.only_with_articles != null) qs.set("only_with_articles", String(opts.only_with_articles))
|
||||||
|
if (opts?.featured != null) qs.set("featured", String(opts.featured))
|
||||||
|
const j = await ssgRequest<{ data?: SsgBrand[] }>(`/api/ssg/brands?${qs.toString()}`)
|
||||||
|
return j?.data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口:GET /api/ssg/articles/ids(SSG 内部端口,构建期 Node 调用,供 getStaticPaths 枚举详情路由)
|
||||||
|
export async function getSsgArticleIds(): Promise<string[]> {
|
||||||
|
const j = await ssgRequest<{ data?: string[] }>(`/api/ssg/articles/ids`)
|
||||||
|
return j?.data ?? []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
// 账号体系前端封装:token 存储 + 注册/登录/当前用户
|
|
||||||
// 与后端 /api/auth/* 交互(JWT 无状态,token 存 localStorage)
|
|
||||||
|
|
||||||
const API_BASE = ((import.meta as any).env?.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
|
|
||||||
const TOKEN_KEY = "fa_token"
|
|
||||||
const USER_KEY = "fa_user"
|
|
||||||
|
|
||||||
export type AuthUser = { id: number; username: string; email: string }
|
|
||||||
|
|
||||||
export function getToken(): string | null {
|
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUser(): AuthUser | null {
|
|
||||||
const raw = localStorage.getItem(USER_KEY)
|
|
||||||
if (!raw) return null
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as AuthUser
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setSession(token: string, user: AuthUser) {
|
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSession() {
|
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
|
||||||
localStorage.removeItem(USER_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(account: string, password: string): Promise<AuthUser> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ account, password }),
|
|
||||||
})
|
|
||||||
const json: any = await res.json().catch(() => ({}))
|
|
||||||
if (!res.ok) throw new Error(json?.error || "登录失败")
|
|
||||||
setSession(json.token, json.user)
|
|
||||||
return json.user as AuthUser
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function register(
|
|
||||||
username: string,
|
|
||||||
email: string,
|
|
||||||
password: string
|
|
||||||
): Promise<AuthUser> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, email, password }),
|
|
||||||
})
|
|
||||||
const json: any = await res.json().catch(() => ({}))
|
|
||||||
if (!res.ok) throw new Error(json?.error || "注册失败")
|
|
||||||
setSession(json.token, json.user)
|
|
||||||
return json.user as AuthUser
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用本地 token 拉取最新用户信息(校验有效性);无效则清空会话
|
|
||||||
export async function fetchMe(): Promise<AuthUser | null> {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) return null
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/api/auth/me`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
clearSession()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const json: any = await res.json()
|
|
||||||
const user = json?.user as AuthUser
|
|
||||||
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user))
|
|
||||||
return user
|
|
||||||
} catch {
|
|
||||||
return getUser()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 带鉴权头的请求头(供将来受保护接口使用)
|
|
||||||
export function authHeaders(): Record<string, string> {
|
|
||||||
const t = getToken()
|
|
||||||
return t ? { Authorization: `Bearer ${t}` } : {}
|
|
||||||
}
|
|
||||||
106
src/lib/data.ts
106
src/lib/data.ts
@ -1,5 +1,5 @@
|
|||||||
// 网站统一数据层
|
// 网站统一数据层
|
||||||
// 从后端公开只读接口 (/api/public/...) 拉取真实数据,并适配为前端各模块
|
// 复用 api.ts 的公开接口拉取真实数据,并适配为前端各模块
|
||||||
// (site / shows / trends / colors / street,以及首页 backstage 面板、文章画廊 reports) 所需结构。
|
// (site / shows / trends / colors / street,以及首页 backstage 面板、文章画廊 reports) 所需结构。
|
||||||
//
|
//
|
||||||
// 设计原则:
|
// 设计原则:
|
||||||
@ -7,33 +7,20 @@
|
|||||||
// 2. 优雅回落:后端不可达或返回空时,回落到内置示例,保证 SSG 构建与离线预览不报错。
|
// 2. 优雅回落:后端不可达或返回空时,回落到内置示例,保证 SSG 构建与离线预览不报错。
|
||||||
// 3. 颜色模块:后端暂无独立颜色接口,getColors() 先尝试 /api/public/colors,
|
// 3. 颜色模块:后端暂无独立颜色接口,getColors() 先尝试 /api/public/colors,
|
||||||
// 失败则从 runway 文章封面派生占位(接入颜色接口后即可自动生效)。
|
// 失败则从 runway 文章封面派生占位(接入颜色接口后即可自动生效)。
|
||||||
|
//
|
||||||
|
// 所有 fetch 都已收敛到 @/lib/api,本文件只做数据映射与缓存。
|
||||||
|
|
||||||
const API_BASE = ((import.meta as any).env?.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
|
import {
|
||||||
|
getArticlesRaw,
|
||||||
|
fetchArticleById,
|
||||||
|
fetchColors,
|
||||||
|
toAbs,
|
||||||
|
type RawArticle,
|
||||||
|
} from "@/lib/api"
|
||||||
|
|
||||||
export type RawArticle = {
|
export type { RawArticle }
|
||||||
id: string | number // 后端为 hashid 编码串(string);示例数据回落为数字
|
// 供仍从 data 取详情的函数(ssr.ts 等)透传
|
||||||
title: string
|
export { fetchArticleById }
|
||||||
summary?: string
|
|
||||||
description?: string
|
|
||||||
cover: string
|
|
||||||
brand_name: string
|
|
||||||
brand_id?: string | number // hashid 编码串
|
|
||||||
year: number
|
|
||||||
image_count: number
|
|
||||||
published_at: number
|
|
||||||
created_at?: number
|
|
||||||
source_url?: string
|
|
||||||
collection_type?: string
|
|
||||||
season?: string
|
|
||||||
season_code?: string
|
|
||||||
images?: { id?: number; image: string; name?: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 通用辅助 ----------
|
|
||||||
export function toAbs(url: string): string {
|
|
||||||
if (!url) return ""
|
|
||||||
return /^https?:\/\//i.test(url) ? url : API_BASE + url
|
|
||||||
}
|
|
||||||
|
|
||||||
export function seasonLabel(y: number | string): string {
|
export function seasonLabel(y: number | string): string {
|
||||||
const n = Number(y)
|
const n = Number(y)
|
||||||
@ -100,14 +87,10 @@ function fromSample(): Loaded {
|
|||||||
export function loadArticles(): Promise<Loaded> {
|
export function loadArticles(): Promise<Loaded> {
|
||||||
if (_loaded) return _loaded
|
if (_loaded) return _loaded
|
||||||
_loaded = (async () => {
|
_loaded = (async () => {
|
||||||
if (!API_BASE) return fromSample()
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=60`)
|
const { data, total } = await getArticlesRaw({ page: 1, size: 60 })
|
||||||
if (!res.ok) return fromSample()
|
if (!data.length) return fromSample()
|
||||||
const json: any = await res.json()
|
return compute(data, Number(total || data.length))
|
||||||
const items: RawArticle[] = json?.data ?? []
|
|
||||||
if (!items.length) return fromSample()
|
|
||||||
return compute(items, Number(json?.total ?? items.length))
|
|
||||||
} catch {
|
} catch {
|
||||||
return fromSample()
|
return fromSample()
|
||||||
}
|
}
|
||||||
@ -115,23 +98,7 @@ export function loadArticles(): Promise<Loaded> {
|
|||||||
return _loaded
|
return _loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchArticleById(id: number | string, apiBase: string = API_BASE): Promise<RawArticle | null> {
|
|
||||||
const base = apiBase.replace(/\/$/, "")
|
|
||||||
if (!base) return null
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${base}/api/public/articles/${id}`)
|
|
||||||
if (!res.ok) return null
|
|
||||||
const json: any = await res.json()
|
|
||||||
return json?.data ?? null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 模块 getter ----------
|
// ---------- 模块 getter ----------
|
||||||
// 统一映射:四块面板共用同一真实文章结构,字段含义清晰、不再错配
|
|
||||||
// (接口仅暴露 runway 文章一类数据,四块面板取真实列表的不同区段,避免重复同一批内容)
|
|
||||||
|
|
||||||
export type ArticleCard = {
|
export type ArticleCard = {
|
||||||
id: string | number
|
id: string | number
|
||||||
brand: string
|
brand: string
|
||||||
@ -186,13 +153,9 @@ let _loadedWithImages: Promise<ArticleCard[]> | null = null
|
|||||||
export function loadArticlesWithImages(size = 40): Promise<ArticleCard[]> {
|
export function loadArticlesWithImages(size = 40): Promise<ArticleCard[]> {
|
||||||
if (_loadedWithImages) return _loadedWithImages
|
if (_loadedWithImages) return _loadedWithImages
|
||||||
_loadedWithImages = (async () => {
|
_loadedWithImages = (async () => {
|
||||||
if (!API_BASE) return []
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=${size}&with_images=5`)
|
const { data } = await getArticlesRaw({ page: 1, size, with_images: 5 })
|
||||||
if (!res.ok) return []
|
return data.map((r) => {
|
||||||
const json: any = await res.json()
|
|
||||||
const items: RawArticle[] = json?.data ?? []
|
|
||||||
return items.map((r: RawArticle) => {
|
|
||||||
const base = mapArticle(r)
|
const base = mapArticle(r)
|
||||||
base.gallery = (r.images || [])
|
base.gallery = (r.images || [])
|
||||||
.map((im: any) => toAbs(im.image || ""))
|
.map((im: any) => toAbs(im.image || ""))
|
||||||
@ -209,7 +172,9 @@ export function loadArticlesWithImages(size = 40): Promise<ArticleCard[]> {
|
|||||||
|
|
||||||
// attachGallery 在构建期(SSG)为每个展示文章预取其真实多图(前 3 张),
|
// attachGallery 在构建期(SSG)为每个展示文章预取其真实多图(前 3 张),
|
||||||
// 供首页卡片直接渲染"图片堆",无需等到悬停才拉取。后端不可达时 gallery 为空。
|
// 供首页卡片直接渲染"图片堆",无需等到悬停才拉取。后端不可达时 gallery 为空。
|
||||||
async function attachGallery<T extends { id: string | number; gallery: string[] }>(items: T[]): Promise<T[]> {
|
async function attachGallery<T extends { id: string | number; gallery: string[] }>(
|
||||||
|
items: T[]
|
||||||
|
): Promise<T[]> {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
items.map(async (it) => {
|
items.map(async (it) => {
|
||||||
let gallery: string[] = []
|
let gallery: string[] = []
|
||||||
@ -234,25 +199,15 @@ export type ColorItem = {
|
|||||||
}
|
}
|
||||||
export async function getColors(limit = 12): Promise<ColorItem[]> {
|
export async function getColors(limit = 12): Promise<ColorItem[]> {
|
||||||
// 后端暂无独立颜色接口:优先尝试 /api/public/colors,失败则从 runway 文章封面派生占位。
|
// 后端暂无独立颜色接口:优先尝试 /api/public/colors,失败则从 runway 文章封面派生占位。
|
||||||
if (API_BASE) {
|
const colors = await fetchColors()
|
||||||
try {
|
if (colors && colors.length) {
|
||||||
const res = await fetch(`${API_BASE}/api/public/colors`)
|
return colors.slice(0, limit).map((c: any) => ({
|
||||||
if (res.ok) {
|
id: c.id ?? 0,
|
||||||
const json: any = await res.json()
|
name: c.name || c.title || "",
|
||||||
const data: any[] = json?.data ?? []
|
brand: c.brand_name || "",
|
||||||
if (data.length) {
|
season: seasonLabel(c.year),
|
||||||
return data.slice(0, limit).map((c) => ({
|
img: toAbs(c.cover || c.image || ""),
|
||||||
id: c.id ?? 0,
|
}))
|
||||||
name: c.name || c.title || "",
|
|
||||||
brand: c.brand_name || "",
|
|
||||||
season: seasonLabel(c.year),
|
|
||||||
img: toAbs(c.cover || c.image || ""),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* 回落到封面派生 */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const { items } = await loadArticles()
|
const { items } = await loadArticles()
|
||||||
return items.slice(0, limit).map((r) => ({
|
return items.slice(0, limit).map((r) => ({
|
||||||
@ -272,7 +227,6 @@ export type SiteInfo = {
|
|||||||
}
|
}
|
||||||
export async function getSite(): Promise<SiteInfo> {
|
export async function getSite(): Promise<SiteInfo> {
|
||||||
const loaded = await loadArticles()
|
const loaded = await loadArticles()
|
||||||
// 落回到示例时,沿用与首页原本一致的示例展示值
|
|
||||||
if (loaded.sampled) {
|
if (loaded.sampled) {
|
||||||
return {
|
return {
|
||||||
totalReports: 12,
|
totalReports: 12,
|
||||||
|
|||||||
208
src/lib/http.ts
Normal file
208
src/lib/http.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
// src/lib/http.ts — 统一 API 调试日志
|
||||||
|
//
|
||||||
|
// 拦截全局 fetch,记录「请求了哪些接口 / 发送了哪些数据 / 响应了哪些数据」,
|
||||||
|
// 解决前后端联调时接口黑盒的问题。使用方式:在任意会被加载到的模块里
|
||||||
|
// `import "@/lib/http"`(本文件会在加载时自动 install),或直接调用 installApiLogger()。
|
||||||
|
//
|
||||||
|
// 日志去向:
|
||||||
|
// - 浏览器(DEV 环境):console.debug 彩色输出,并收集到 window.__apiLog,
|
||||||
|
// 可用 downloadApiLog() 一键导出为 api-debug.log 下载。
|
||||||
|
// - Node(SSR / SSG 构建期):追加写入项目根 logs/api-debug.log。
|
||||||
|
//
|
||||||
|
// 开关:默认在 DEV 或 SSR(含 astro build)开启;生产环境客户端默认关闭,
|
||||||
|
// 可在 .env 设 DEBUG_API=true 强制开启。所有日志都会对 URL 里的 token 脱敏。
|
||||||
|
|
||||||
|
const g = globalThis as any
|
||||||
|
|
||||||
|
const ENABLED =
|
||||||
|
(import.meta as any).env?.DEV ||
|
||||||
|
(import.meta as any).env?.SSR ||
|
||||||
|
(typeof g.process !== "undefined" && g.process.env?.DEBUG_API === "true")
|
||||||
|
|
||||||
|
const nativeFetch: any = g.fetch ? g.fetch.bind(g) : undefined
|
||||||
|
|
||||||
|
function isNode(): boolean {
|
||||||
|
return (
|
||||||
|
typeof g.process !== "undefined" &&
|
||||||
|
!!g.process.versions?.node &&
|
||||||
|
typeof window === "undefined"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(u: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(u)
|
||||||
|
if (url.searchParams.has("token")) url.searchParams.set("token", "***")
|
||||||
|
return url.toString()
|
||||||
|
} catch {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n = 2000): string {
|
||||||
|
return s.length > n ? s.slice(0, n) + ` …(${s.length - n} more chars)` : s
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyToText(body: unknown): string {
|
||||||
|
if (body == null) return ""
|
||||||
|
if (typeof body === "string") return body
|
||||||
|
try {
|
||||||
|
return JSON.stringify(body)
|
||||||
|
} catch {
|
||||||
|
return String(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogEntry = {
|
||||||
|
ts: string
|
||||||
|
method: string
|
||||||
|
url: string
|
||||||
|
status: number
|
||||||
|
elapsedMs?: number
|
||||||
|
req?: string
|
||||||
|
resp?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLine(e: LogEntry): string {
|
||||||
|
let s = `${e.ts} ${e.method} ${e.url} -> ${e.status}`
|
||||||
|
if (e.elapsedMs != null) s += ` (${e.elapsedMs}ms)`
|
||||||
|
if (e.error) s += `\n ERROR: ${e.error}`
|
||||||
|
if (e.req) s += `\n REQ: ${e.req}`
|
||||||
|
if (e.resp) s += `\n RESP: ${e.resp}`
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLog(line: string): Promise<void> {
|
||||||
|
if (!ENABLED) return
|
||||||
|
if (isNode()) {
|
||||||
|
try {
|
||||||
|
const fsMod = await import(/* @vite-ignore */ "node:fs/promises")
|
||||||
|
const pathMod = await import(/* @vite-ignore */ "node:path")
|
||||||
|
const cwd = (g.process && g.process.cwd && g.process.cwd()) || "."
|
||||||
|
const logDir = pathMod.resolve(cwd, "logs")
|
||||||
|
await fsMod.mkdir(logDir, { recursive: true })
|
||||||
|
await fsMod.appendFile(pathMod.join(logDir, "api-debug.log"), line + "\n", "utf8")
|
||||||
|
} catch {
|
||||||
|
// 文件写入失败不应影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushBrowserLog(entry: {
|
||||||
|
method: string
|
||||||
|
url: string
|
||||||
|
status: number
|
||||||
|
req?: string
|
||||||
|
resp?: string
|
||||||
|
}): void {
|
||||||
|
try {
|
||||||
|
g.__apiLog = g.__apiLog || []
|
||||||
|
g.__apiLog.push(entry)
|
||||||
|
if (g.__apiLog.length > 300) g.__apiLog.shift()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loggedFetch(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<Response> {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: (input as Request).url
|
||||||
|
const method = init?.method || (input instanceof Request ? input.method : "GET")
|
||||||
|
const reqText = bodyToText(init?.body)
|
||||||
|
const started = Date.now()
|
||||||
|
const doFetch = nativeFetch || g.fetch
|
||||||
|
|
||||||
|
if (!isNode()) {
|
||||||
|
console.debug(`%c[API] → ${method} ${redact(url)}`, "color:#0a7d3b;font-weight:bold")
|
||||||
|
if (reqText) console.debug(`[API] req:`, truncate(reqText, 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await doFetch(input, init)
|
||||||
|
} catch (err) {
|
||||||
|
const line = formatLine({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
method,
|
||||||
|
url: redact(url),
|
||||||
|
status: 0,
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
await writeLog(line)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - started
|
||||||
|
const cloned = res.clone()
|
||||||
|
let respText = ""
|
||||||
|
try {
|
||||||
|
respText = await cloned.text()
|
||||||
|
} catch {
|
||||||
|
// 某些响应体不可读,忽略
|
||||||
|
}
|
||||||
|
const line = formatLine({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
method,
|
||||||
|
url: redact(url),
|
||||||
|
status: res.status,
|
||||||
|
elapsedMs: elapsed,
|
||||||
|
req: reqText || undefined,
|
||||||
|
resp: truncate(respText, 2000),
|
||||||
|
})
|
||||||
|
await writeLog(line)
|
||||||
|
|
||||||
|
if (!isNode()) {
|
||||||
|
const color = res.ok ? "#0a7d3b" : "#b00020"
|
||||||
|
console.debug(
|
||||||
|
`%c[API] ← ${res.status} ${method} ${redact(url)} (${elapsed}ms)`,
|
||||||
|
`color:${color};font-weight:bold`,
|
||||||
|
truncate(respText, 500)
|
||||||
|
)
|
||||||
|
pushBrowserLog({
|
||||||
|
method,
|
||||||
|
url: redact(url),
|
||||||
|
status: res.status,
|
||||||
|
req: reqText || undefined,
|
||||||
|
resp: truncate(respText, 2000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = false
|
||||||
|
export function installApiLogger(): void {
|
||||||
|
if (installed || !ENABLED) return
|
||||||
|
if (nativeFetch) {
|
||||||
|
g.fetch = (input: any, init?: any) => loggedFetch(input, init)
|
||||||
|
installed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载即自动安装:任何 import "@/lib/http" 的模块都会触发全局 fetch 拦截。
|
||||||
|
installApiLogger()
|
||||||
|
|
||||||
|
// 浏览器端:把已收集的日志导出为 .log 文件下载(仅在浏览器可用)。
|
||||||
|
export function downloadApiLog(): void {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
const logs: any[] = g.__apiLog || []
|
||||||
|
const text = logs
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
`${e.method} ${e.url} -> ${e.status}\nREQ: ${e.req || ""}\nRESP: ${e.resp || ""}`
|
||||||
|
)
|
||||||
|
.join("\n\n")
|
||||||
|
const blob = new Blob([text || "(暂无记录)"], { type: "text/plain" })
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.download = "api-debug.log"
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(a.href)
|
||||||
|
}
|
||||||
@ -1,96 +0,0 @@
|
|||||||
// src/lib/ssg.ts — SSG 构建专用接口客户端(集中封装 /api/ssg/*)
|
|
||||||
//
|
|
||||||
// SSG 接口只在「构建 / 预渲染期」由 Astro 后端(Node)fetch 调用,
|
|
||||||
// 永远不进入浏览器 bundle。它们位于独立的内部端口(默认 8091),不对外暴露、
|
|
||||||
// nginx 绝不反代。本文件与运行期公共 API 客户端(api.ts)完全分离:
|
|
||||||
// - api.ts → 运行期浏览器调用 /api/public/*,base = PUBLIC_API_BASE(同源 /api)
|
|
||||||
// - ssg.ts → 构建期 Node 调用 /api/ssg/*,base = SSG_API(独立内部端口 8091)
|
|
||||||
//
|
|
||||||
// base 解析优先级:
|
|
||||||
// 1) SSG_API —— 构建专用内部地址(部署由 build arg 注入,如 http://localhost:8091)
|
|
||||||
// 2) API_SSR —— 兼容旧命名
|
|
||||||
// 3) http://localhost:8091 —— 本地回落(SSG 引擎默认端口,绝不是 8090)
|
|
||||||
//
|
|
||||||
// token:SSG_TOKEN(server-only,绝不加 PUBLIC_ 前缀,不会进入 bundle);
|
|
||||||
// 为空则后端不校验(依赖回环绑定即可)。
|
|
||||||
|
|
||||||
// 同时读 import.meta.env 与 process.env:
|
|
||||||
// 构建/预渲染在 Node 中运行,.env 里的 server-only 变量两者皆可能承载,合并最稳妥。
|
|
||||||
function env(name: string): string | undefined {
|
|
||||||
const e = import.meta.env as any
|
|
||||||
if (e[name] != null && e[name] !== "") return e[name] as string
|
|
||||||
if (typeof process !== "undefined" && process.env[name]) return process.env[name] as string
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ssgBase(): string {
|
|
||||||
return (env("SSG_API") || env("API_SSR") || "http://localhost:8091").replace(/\/$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function ssgToken(): string {
|
|
||||||
return (env("SSG_TOKEN") || "").toString().trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 若后端 SSG 端口启用了 SSG_TOKEN,则给每个请求附上 ?token=。
|
|
||||||
function withToken(path: string): string {
|
|
||||||
const t = ssgToken()
|
|
||||||
if (!t) return path
|
|
||||||
return path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ssgRequest<T>(path: string): Promise<T | null> {
|
|
||||||
const url = ssgBase() + withToken(path)
|
|
||||||
try {
|
|
||||||
const res = await fetch(url)
|
|
||||||
if (!res.ok) return null
|
|
||||||
return (await res.json()) as T
|
|
||||||
} catch {
|
|
||||||
// 后端不可达 / 超时:返回 null,由各 getter 回落到空数组(构建期不会崩)。
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 类型 ----------
|
|
||||||
// 注意:id / brand_id 是后端 hashid 编码串(string),不是自增数字主键。
|
|
||||||
export interface SsgPopularBrand {
|
|
||||||
id: string
|
|
||||||
article_id: string
|
|
||||||
brand: string
|
|
||||||
cover: string
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SsgBrand {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
show_name: string
|
|
||||||
article_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- getter ----------
|
|
||||||
|
|
||||||
// 首页「热门品牌」区块:按图片数排名前 limit 个品牌,每个附带代表走秀的封面与标题。
|
|
||||||
export async function getSsgPopularBrands(limit = 20): Promise<SsgPopularBrand[]> {
|
|
||||||
const j = await ssgRequest<{ data?: SsgPopularBrand[] }>(`/api/ssg/brands/popular?limit=${limit}`)
|
|
||||||
return j?.data ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 品牌索引页全量:一次拉齐精选集合,前端按首字母分桶渲染。
|
|
||||||
export async function getSsgBrands(opts?: {
|
|
||||||
size?: number
|
|
||||||
only_with_articles?: number
|
|
||||||
featured?: number
|
|
||||||
}): Promise<SsgBrand[]> {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts?.size) qs.set("size", String(opts.size))
|
|
||||||
if (opts?.only_with_articles != null) qs.set("only_with_articles", String(opts.only_with_articles))
|
|
||||||
if (opts?.featured != null) qs.set("featured", String(opts.featured))
|
|
||||||
const j = await ssgRequest<{ data?: SsgBrand[] }>(`/api/ssg/brands?${qs.toString()}`)
|
|
||||||
return j?.data ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStaticPaths 用的轻量枚举:只回文章编码 uid,不带回 cover/description,省带宽。
|
|
||||||
export async function getSsgArticleIds(): Promise<string[]> {
|
|
||||||
const j = await ssgRequest<{ data?: string[] }>(`/api/ssg/articles/ids`)
|
|
||||||
return j?.data ?? []
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
// src/lib/ssr.ts — 服务端(按需 SSR / on-demand render)数据客户端
|
|
||||||
//
|
|
||||||
// 集中封装「服务端取数 + base URL 解析」,让 .astro 页面只调用业务函数,
|
|
||||||
// 不再自己拼 API_SSR / PUBLIC_API_BASE / http://localhost:8090 这类 base URL。
|
|
||||||
//
|
|
||||||
// 与 ssg.ts 的区别:
|
|
||||||
// - ssg.ts → 构建期 Node 调 /api/ssg/*(内部端口 8091,不对外暴露)
|
|
||||||
// - ssr.ts → 运行期(按需渲染)调 /api/public/*,base = API_SSR(回落 PUBLIC_API_BASE)
|
|
||||||
|
|
||||||
import { fetchArticleById, type RawArticle } from "./data"
|
|
||||||
|
|
||||||
// SSR base 解析:API_SSR(部署注入)> PUBLIC_API_BASE > 本地后端回落
|
|
||||||
export function ssrBase(): string {
|
|
||||||
const e = import.meta.env as any
|
|
||||||
return (e.API_SSR || e.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SsrArticleResult = {
|
|
||||||
article: RawArticle | null
|
|
||||||
error: "missing" | "failed" | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按 id 取文章:包揽「缺 id / 取数失败 / 成功」三态,astro 页面只调这一行
|
|
||||||
export async function getSsrArticle(
|
|
||||||
id: string | null | undefined
|
|
||||||
): Promise<SsrArticleResult> {
|
|
||||||
if (!id) return { article: null, error: "missing" }
|
|
||||||
try {
|
|
||||||
const article = await fetchArticleById(id, ssrBase())
|
|
||||||
return { article, error: article ? null : "failed" }
|
|
||||||
} catch {
|
|
||||||
return { article: null, error: "failed" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from "@/layouts/Layout.astro"
|
import Layout from "@/layouts/Layout.astro"
|
||||||
import Article from "@/components/pages/article.astro"
|
import Article from "@/components/pages/article.astro"
|
||||||
import { getSsrArticle } from "@/lib/ssr"
|
import { getSsrArticle } from "@/lib/api"
|
||||||
|
|
||||||
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /cn/article?id= 链接,无需改动其他页面)
|
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /cn/article?id= 链接,无需改动其他页面)
|
||||||
export const prerender = false
|
export const prerender = false
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from "@/layouts/Layout.astro"
|
import Layout from "@/layouts/Layout.astro"
|
||||||
import Article from "@/components/pages/article.astro"
|
import Article from "@/components/pages/article.astro"
|
||||||
import { getSsrArticle } from "@/lib/ssr"
|
import { getSsrArticle } from "@/lib/api"
|
||||||
|
|
||||||
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
|
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
|
||||||
export const prerender = false
|
export const prerender = false
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from "@/layouts/Layout.astro"
|
import Layout from "@/layouts/Layout.astro"
|
||||||
import RunwayLookDetail from "@/components/RunwayLooksDetail.astro"
|
import Item from "@/components/Item.astro"
|
||||||
import { getSsrArticle } from "@/lib/ssr"
|
import { getSsrArticle } from "@/lib/api"
|
||||||
|
|
||||||
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/items/:id 链接,无需改动其他页面)
|
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/items/:id 链接,无需改动其他页面)
|
||||||
export const prerender = false
|
export const prerender = false
|
||||||
@ -11,5 +11,5 @@ const { article, error } = await getSsrArticle(id)
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<RunwayLookDetail article={article} error={error} />
|
<Item article={article} error={error} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user