update
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -31,3 +31,5 @@ _tmp*
|
|||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.idea/
|
||||||
|
.playwright-cli
|
||||||
|
.workbuddy
|
||||||
@ -3,20 +3,20 @@
|
|||||||
// 纯主图(无细节图)不渲染。数量走 Alpine 的 detailCount()(响应式)—— 未登录取前 PREVIEW_LIMIT 张
|
// 纯主图(无细节图)不渲染。数量走 Alpine 的 detailCount()(响应式)—— 未登录取前 PREVIEW_LIMIT 张
|
||||||
// 的细节子树,登录后由 gallery:full 重建映射,两态共用同一条渲染路径。
|
// 的细节子树,登录后由 gallery:full 重建映射,两态共用同一条渲染路径。
|
||||||
//
|
//
|
||||||
// look:Alpine 表达式,表示该主图的 look_index。SSR 首屏传字面量(如 "3"),
|
// mainId:Alpine 表达式,表示该主图自身 id。SSR 首屏传字面量(如 "i0008CZYI"),
|
||||||
// 客户端补拉的 x-for 里传表达式(如 "im.lookIndex")。
|
// 客户端补拉的 x-for 里传表达式(如 "im.id")。
|
||||||
interface Props {
|
interface Props {
|
||||||
look: string
|
mainId: string
|
||||||
}
|
}
|
||||||
const { look } = Astro.props
|
const { mainId } = Astro.props
|
||||||
const count = `detailCount(${look})`
|
const count = `detailCount(${mainId})`
|
||||||
---
|
---
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-show={`${count} > 0`}
|
x-show={`${count} > 0`}
|
||||||
x-cloak
|
x-cloak
|
||||||
@click.stop={`openDetailLb(${look})`}
|
@click.stop={`openDetailLb(${mainId})`}
|
||||||
:title={`${count} + ' detail' + (${count} > 1 ? 's' : '')`}
|
:title={`${count} + ' detail' + (${count} > 1 ? 's' : '')`}
|
||||||
:aria-label={`${count} + ' detail' + (${count} > 1 ? 's' : '')`}
|
:aria-label={`${count} + ' detail' + (${count} > 1 ? 's' : '')`}
|
||||||
class="absolute left-2 bottom-2 z-10 flex items-center gap-1 px-1.5 py-1 bg-white/85 backdrop-blur-sm border border-black/15 text-[#111] text-[10px] uppercase tracking-[0.14em] cursor-pointer hover:bg-black hover:text-white transition-colors"
|
class="absolute left-2 bottom-2 z-10 flex items-center gap-1 px-1.5 py-1 bg-white/85 backdrop-blur-sm border border-black/15 text-[#111] text-[10px] uppercase tracking-[0.14em] cursor-pointer hover:bg-black hover:text-white transition-colors"
|
||||||
|
|||||||
@ -32,7 +32,6 @@ interface GalleryImage {
|
|||||||
thumb: string
|
thumb: string
|
||||||
raw: string
|
raw: string
|
||||||
name: string
|
name: string
|
||||||
lookIndex: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -68,7 +67,7 @@ interface Props {
|
|||||||
preview: boolean
|
preview: boolean
|
||||||
/** 占位遮罩格数(补足「剩余 N 张」) */
|
/** 占位遮罩格数(补足「剩余 N 张」) */
|
||||||
lockedCount: number
|
lockedCount: number
|
||||||
/** 递给客户端的副图投影(look_index + detail),与登录后完整 images 同构解析 */
|
/** 递给客户端的副图投影(id + detail),与登录后完整 images 同构解析 */
|
||||||
detailPayload: unknown
|
detailPayload: unknown
|
||||||
/** 图集元信息:写进 data-*,供收藏 / 灯箱使用 */
|
/** 图集元信息:写进 data-*,供收藏 / 灯箱使用 */
|
||||||
galleryUid: string
|
galleryUid: string
|
||||||
@ -186,7 +185,7 @@ const hasImages = previewImages.length > 0
|
|||||||
{previewImages.map((im, i) => (
|
{previewImages.map((im, i) => (
|
||||||
<figure
|
<figure
|
||||||
data-image-index={i}
|
data-image-index={i}
|
||||||
data-look-index={im.lookIndex}
|
data-main-id={im.id}
|
||||||
data-image-url={im.url}
|
data-image-url={im.url}
|
||||||
data-image-name={im.name}
|
data-image-name={im.name}
|
||||||
data-fav-id={im.id}
|
data-fav-id={im.id}
|
||||||
@ -222,7 +221,7 @@ const hasImages = previewImages.length > 0
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 副图数量角标(共享组件):有副图的主图左下角标「张数」,点它直接开副图灯箱 */}
|
{/* 副图数量角标(共享组件):有副图的主图左下角标「张数」,点它直接开副图灯箱 */}
|
||||||
<DetailCountBadge look={String(im.lookIndex)} />
|
<DetailCountBadge mainId={im.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCaptions && im.name && (
|
{showCaptions && im.name && (
|
||||||
@ -264,7 +263,7 @@ const hasImages = previewImages.length > 0
|
|||||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||||
<figure
|
<figure
|
||||||
:data-image-index="5 + i"
|
:data-image-index="5 + i"
|
||||||
:data-look-index="im.lookIndex"
|
:data-main-id="im.id"
|
||||||
:data-image-url="im.url"
|
:data-image-url="im.url"
|
||||||
:data-image-name="im.name"
|
:data-image-name="im.name"
|
||||||
:data-fav-id="im.id"
|
:data-fav-id="im.id"
|
||||||
@ -294,7 +293,7 @@ const hasImages = previewImages.length > 0
|
|||||||
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
|
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<DetailCountBadge look="im.lookIndex" />
|
<DetailCountBadge mainId="im.id" />
|
||||||
</div>
|
</div>
|
||||||
{showCaptions && (
|
{showCaptions && (
|
||||||
<template x-if="im.name">
|
<template x-if="im.name">
|
||||||
@ -325,9 +324,9 @@ const hasImages = previewImages.length > 0
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
{/* 大图灯箱(共享):当前主图的副图经 details 插槽注入。 */}
|
{/* 大图灯箱(共享):当前主图的副图经 details 插槽注入。左栏缩略图条已移除(见 GalleryLightbox)。 */}
|
||||||
{hasImages && (
|
{hasImages && (
|
||||||
<GalleryLightbox images={previewImages} aspect={aspect}>
|
<GalleryLightbox>
|
||||||
<LbDetailsStrip slot="details" aspect={aspect} />
|
<LbDetailsStrip slot="details" aspect={aspect} />
|
||||||
</GalleryLightbox>
|
</GalleryLightbox>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
---
|
---
|
||||||
// 走秀 / 街拍详情页共享的全屏大图灯箱:左栏缩略图条 + 大图 + 上/下·左/右翻页 + 单图收藏。
|
// 走秀 / 街拍详情页共享的全屏大图灯箱:大图 + 左/右翻页 + 单图收藏。
|
||||||
|
//
|
||||||
|
// 左栏「所有图片缩略图条」已移除:翻页/定位已由顶部计数 + 左右箭头 + 键盘 ←/→ + ?img= 深链完整覆盖,
|
||||||
|
// 缩略条属冗余导航;去掉后主图可用面积更大。主图翻页实际读的是图集网格里 [data-image-index] 的
|
||||||
|
// figure(第 6 张起的 extraImages 也已在网格里渲染),与左栏无关,故移除不影响翻页。
|
||||||
//
|
//
|
||||||
// 视觉遵循全站「黑白编辑风」:直角细框、小字 + 宽字距、零填充计数、hover 反色(bg-black + 白字)。
|
// 视觉遵循全站「黑白编辑风」:直角细框、小字 + 宽字距、零填充计数、hover 反色(bg-black + 白字)。
|
||||||
// 交互逻辑全部在 src/lib/gallery.ts(createGalleryView);本组件只负责标记。
|
// 交互逻辑全部在 src/lib/gallery.ts(createGalleryView);本组件只负责标记。
|
||||||
@ -7,17 +11,7 @@
|
|||||||
// 各页独有的附加层通过具名插槽 details 注入(走秀页塞「当前 look 的细节图簇」,街拍页不传)。
|
// 各页独有的附加层通过具名插槽 details 注入(走秀页塞「当前 look 的细节图簇」,街拍页不传)。
|
||||||
import { getI18n } from "@/lib/i18n"
|
import { getI18n } from "@/lib/i18n"
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** SSR 首屏图片(前 PREVIEW_LIMIT 张),需带 thumb 供左栏缩略图条使用 */
|
|
||||||
images: { thumb: string }[]
|
|
||||||
/** 缩略图比例。后端素材统一 2:3(说明见 DetailGalleryView 的 aspect prop),默认值即可 */
|
|
||||||
aspect?: "2/3" | "3/4"
|
|
||||||
}
|
|
||||||
const { images, aspect = "2/3" } = Astro.props
|
|
||||||
const { t } = getI18n(Astro)
|
const { t } = getI18n(Astro)
|
||||||
|
|
||||||
// 两个字面量都出现在本文件源码里,保证 Tailwind v4 JIT 能扫描到
|
|
||||||
const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -61,67 +55,10 @@ const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
||||||
<div
|
|
||||||
id="lb-thumbs"
|
|
||||||
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 no-scrollbar"
|
|
||||||
>
|
|
||||||
{images.map((im, i) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-lb-index={i}
|
|
||||||
data-thumb-url={im.thumb}
|
|
||||||
@click={`showLb(${i})`}
|
|
||||||
class={`lb-thumb relative block w-full ${thumbAspect} mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[var(--c-gray-200)] bg-cover bg-center`}
|
|
||||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
|
|
||||||
aria-label={`${i + 1}`}
|
|
||||||
>
|
|
||||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm">
|
|
||||||
{String(i + 1).padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:data-lb-index="5 + i"
|
|
||||||
:data-thumb-url="im.thumb"
|
|
||||||
@click="showLb(5 + i)"
|
|
||||||
class={`lb-thumb relative block w-full ${thumbAspect} mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[var(--c-gray-200)] bg-cover bg-center`}
|
|
||||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
|
|
||||||
:aria-label="String(5 + i + 1)"
|
|
||||||
>
|
|
||||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click.stop="prevLb()"
|
@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/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
class="absolute left-2 sm:left-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||||
aria-label={t('Previous image')}
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 14l6-6 6 6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
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/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
|
||||||
aria-label={t('Next image')}
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 10l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
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 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
|
||||||
aria-label={t('Previous image')}
|
aria-label={t('Previous image')}
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||||
|
|||||||
@ -21,7 +21,7 @@ interface Props {
|
|||||||
const { article, error = null, preview = false, imageTotal = 0 } = Astro.props
|
const { article, error = null, preview = false, imageTotal = 0 } = Astro.props
|
||||||
const { locale, t } = getI18n(Astro)
|
const { locale, t } = getI18n(Astro)
|
||||||
|
|
||||||
// 后端详情接口 images 仅返回主图(look 图),每张主图的细节图嵌套在它自己的 detail 子数组下。
|
// 后端详情接口 images 仅返回主图(走秀主图),每张主图的细节图嵌套在它自己的 detail 子数组下。
|
||||||
// 故此处 images 即为主图集合,默认只渲染它们;detail 随主图一起带上,供「细节入口」展开。
|
// 故此处 images 即为主图集合,默认只渲染它们;detail 随主图一起带上,供「细节入口」展开。
|
||||||
const images = (article?.images ?? [])
|
const images = (article?.images ?? [])
|
||||||
.map((im) => ({
|
.map((im) => ({
|
||||||
@ -32,16 +32,15 @@ const images = (article?.images ?? [])
|
|||||||
thumb: ssrAbs(im.thumb || im.image || ""),
|
thumb: ssrAbs(im.thumb || im.image || ""),
|
||||||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||||
name: im.name || "",
|
name: im.name || "",
|
||||||
lookIndex: Number(im.look_index ?? 0),
|
|
||||||
}))
|
}))
|
||||||
.filter((im) => im.url)
|
.filter((im) => im.url)
|
||||||
|
|
||||||
// 递给浏览器端 Alpine 的细节图数据:只序列化「带细节图的主图」投影(不带主图自身 URL,
|
// 递给浏览器端 Alpine 的细节图数据:只序列化「带细节图的主图」投影(不带主图自身 URL,
|
||||||
// 避免把整份 images 重复塞进 HTML)。元素形状与 images 元素一致(look_index + detail),
|
// 避免把整份 images 重复塞进 HTML)。元素形状与 images 元素一致(id + detail),
|
||||||
// 故 Alpine 的 buildDetailMapFrom() 能与「登录后拿回的完整 images」共用同一套解析逻辑。
|
// 故 Alpine 的 buildDetailMapFrom() 能与「登录后拿回的完整 images」共用同一套解析逻辑。
|
||||||
const detailPayload = (article?.images ?? [])
|
const detailPayload = (article?.images ?? [])
|
||||||
.filter((im) => (im.detail?.length ?? 0) > 0)
|
.filter((im) => (im.detail?.length ?? 0) > 0)
|
||||||
.map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail }))
|
.map((im) => ({ id: im.id ?? "", detail: im.detail }))
|
||||||
|
|
||||||
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
|
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
|
||||||
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
|
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
|
||||||
|
|||||||
@ -30,15 +30,14 @@ const images = (snap?.images ?? [])
|
|||||||
thumb: ssrAbs(im.thumb || im.image || ""),
|
thumb: ssrAbs(im.thumb || im.image || ""),
|
||||||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||||
name: im.name || "",
|
name: im.name || "",
|
||||||
lookIndex: Number(im.look_index ?? 0),
|
|
||||||
}))
|
}))
|
||||||
.filter((im) => im.url)
|
.filter((im) => im.url)
|
||||||
|
|
||||||
// 递给浏览器端 Alpine 的副图数据:只序列化「带副图的主图」投影(look_index + detail),
|
// 递给浏览器端 Alpine 的副图数据:只序列化「带副图的主图」投影(id + detail),
|
||||||
// 供 lib/galleryDetail.ts 构建 look_index → 副图 映射(与登录后完整 images 同构解析)。
|
// 供 lib/galleryDetail.ts 构建 主图id → 副图 映射(与登录后完整 images 同构解析)。
|
||||||
const detailPayload = (snap?.images ?? [])
|
const detailPayload = (snap?.images ?? [])
|
||||||
.filter((im) => (im.detail?.length ?? 0) > 0)
|
.filter((im) => (im.detail?.length ?? 0) > 0)
|
||||||
.map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail }))
|
.map((im) => ({ id: im.id ?? "", detail: im.detail }))
|
||||||
|
|
||||||
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
|
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
|
||||||
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
|
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
|
||||||
|
|||||||
@ -36,7 +36,7 @@ const defaultKeywords =
|
|||||||
const metaDescription = description ?? defaultDescription
|
const metaDescription = description ?? defaultDescription
|
||||||
const metaKeywords = keywords ?? defaultKeywords
|
const metaKeywords = keywords ?? defaultKeywords
|
||||||
|
|
||||||
// 把当前语言注入 API 请求层:所有服务端(SSG/SSR)取数统一带上 ?locale=,
|
// 把当前语言注入 API 请求层:所有服务端(SSG/SSR)取数统一走 X-Locale 头,
|
||||||
// 后端据此选 *_en / *_cn 列。客户端 fetch 则自行按 URL path 推断,无需此处。
|
// 后端据此选 *_en / *_cn 列。客户端 fetch 则自行按 URL path 推断,无需此处。
|
||||||
setApiLocale(locale)
|
setApiLocale(locale)
|
||||||
|
|
||||||
|
|||||||
@ -69,10 +69,9 @@ export interface RawArticleImage {
|
|||||||
name?: string
|
name?: string
|
||||||
// 主图 / 细节图不再靠 is_detail 区分:详情接口 images 只含主图,细节图嵌套在所属主图的 detail 下,
|
// 主图 / 细节图不再靠 is_detail 区分:详情接口 images 只含主图,细节图嵌套在所属主图的 detail 下,
|
||||||
// 层级本身已表达类型(前端无任何处读取 is_detail,故不再声明)。
|
// 层级本身已表达类型(前端无任何处读取 is_detail,故不再声明)。
|
||||||
look_index?: number // 该图归属的主图序号;细节图与所属主图同值
|
|
||||||
favorited?: boolean // 已登录时该图片是否已收藏(随详情接口返回)
|
favorited?: boolean // 已登录时该图片是否已收藏(随详情接口返回)
|
||||||
// 仅详情接口填充:该主图(look)下的细节图子集,元素形状与本类型递归同构。
|
// 仅详情接口填充:该主图下的细节图子集,元素形状与本类型递归同构。
|
||||||
// 前端「细节入口」直接读它,无需再按 look_index 分组查表。
|
// 前端「细节入口」直接读它,无需再按主图 id 分组查表(主图主键即 im.id)。
|
||||||
detail?: RawArticleImage[]
|
detail?: RawArticleImage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +121,7 @@ export interface RawStreetSnap {
|
|||||||
title: string
|
title: string
|
||||||
cover: string
|
cover: string
|
||||||
// 详情接口 images 仅含主图(is_detail=0),每张主图的副图(细节图)嵌套在它自己的 detail 子数组下。
|
// 详情接口 images 仅含主图(is_detail=0),每张主图的副图(细节图)嵌套在它自己的 detail 子数组下。
|
||||||
// 复用 RawArticleImage(与走秀详情同形):look_index 标识主图序号,detail 为副图。
|
// 复用 RawArticleImage(与走秀详情同形):detail 为副图,主图以自身 id 为归并键(不再有 look_index)。
|
||||||
images?: RawArticleImage[]
|
images?: RawArticleImage[]
|
||||||
favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
|
favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// 与 src/lib/routes.ts(ROUTES,前端页面跳转 URL)严格区分:
|
// 与 src/lib/routes.ts(ROUTES,前端页面跳转 URL)严格区分:
|
||||||
// - routes.ts 的 ROUTES = 浏览器访问的「页面路由」(带 /en /cn 语言前缀,走 <a>/navigate)
|
// - routes.ts 的 ROUTES = 浏览器访问的「页面路由」(带 /en /cn 语言前缀,走 <a>/navigate)
|
||||||
// - 本文件的 endpoints = fetch 调用的「后端接口路径」(locale 走 ?locale= 查询参数,不在路径里)
|
// - 本文件的 endpoints = fetch 调用的「后端接口路径」(语言经 X-Locale 头透传,不在路径/查询里)
|
||||||
// 版本前缀 V1 与构建期内部端口前缀 SSG 只在此定义一次;
|
// 版本前缀 V1 与构建期内部端口前缀 SSG 只在此定义一次;
|
||||||
// 将来升 /api/v2 或调整 SSG 路径,改这一处即可,不用全局搜。
|
// 将来升 /api/v2 或调整 SSG 路径,改这一处即可,不用全局搜。
|
||||||
|
|
||||||
|
|||||||
@ -21,9 +21,8 @@ export interface GalleryImageRaw {
|
|||||||
image?: string
|
image?: string
|
||||||
thumb?: string
|
thumb?: string
|
||||||
name?: string
|
name?: string
|
||||||
look_index?: number
|
|
||||||
favorited?: boolean
|
favorited?: boolean
|
||||||
// 详情接口专有:该主图下的细节图(递归同构)。走秀页据此重建「细节入口」映射。
|
// 详情接口专有:该主图下的细节图(递归同构)。走秀/街拍页据此重建「细节入口」映射。
|
||||||
detail?: GalleryImageRaw[]
|
detail?: GalleryImageRaw[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,13 +49,8 @@ export interface GalleryExtraImage {
|
|||||||
thumb: string
|
thumb: string
|
||||||
raw: string
|
raw: string
|
||||||
name: string
|
name: string
|
||||||
lookIndex?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缩略图懒加载 observer:同一页面同时只有一个图集灯箱,用模块级单例持有;
|
|
||||||
// 重建前先断开上一个(loadAuthedState 之后会再调一次 initThumbLazyLoading)。
|
|
||||||
let thumbObserver: IntersectionObserver | null = null
|
|
||||||
|
|
||||||
// 等 Alpine 完成一次 DOM 刷新。比 $nextTick 稳:rAF 必然晚于 Alpine 的微任务 flush。
|
// 等 Alpine 完成一次 DOM 刷新。比 $nextTick 稳:rAF 必然晚于 Alpine 的微任务 flush。
|
||||||
const afterRender = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
const afterRender = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||||
|
|
||||||
@ -103,7 +97,6 @@ export function createGalleryView(cfg: GalleryConfig) {
|
|||||||
this.isAuthed = !!getUser()
|
this.isAuthed = !!getUser()
|
||||||
|
|
||||||
this.imageCount = this.countImages()
|
this.imageCount = this.countImages()
|
||||||
this.initThumbLazyLoading()
|
|
||||||
this.initImageFavs()
|
this.initImageFavs()
|
||||||
this.maybeLocateImageFromUrl()
|
this.maybeLocateImageFromUrl()
|
||||||
|
|
||||||
@ -163,12 +156,10 @@ export function createGalleryView(cfg: GalleryConfig) {
|
|||||||
thumb: toAbs(im.thumb || im.image || ""),
|
thumb: toAbs(im.thumb || im.image || ""),
|
||||||
raw: im.image || "",
|
raw: im.image || "",
|
||||||
name: im.name || "",
|
name: im.name || "",
|
||||||
lookIndex: Number(im.look_index ?? 0),
|
|
||||||
}))
|
}))
|
||||||
await afterRender()
|
await afterRender()
|
||||||
this.imageCount = this.countImages()
|
this.imageCount = this.countImages()
|
||||||
all.slice(5).forEach(mark)
|
all.slice(5).forEach(mark)
|
||||||
this.initThumbLazyLoading()
|
|
||||||
this.maybeLocateImageFromUrl()
|
this.maybeLocateImageFromUrl()
|
||||||
}
|
}
|
||||||
all.slice(0, 5).forEach(mark)
|
all.slice(0, 5).forEach(mark)
|
||||||
@ -269,7 +260,6 @@ export function createGalleryView(cfg: GalleryConfig) {
|
|||||||
document.body.style.overflow = "hidden"
|
document.body.style.overflow = "hidden"
|
||||||
this.updateLightboxImage()
|
this.updateLightboxImage()
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this.scrollThumbIntoView()
|
|
||||||
this.preloadAround()
|
this.preloadAround()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -286,7 +276,6 @@ export function createGalleryView(cfg: GalleryConfig) {
|
|||||||
const main = document.getElementById("lb-main")
|
const main = document.getElementById("lb-main")
|
||||||
if (main) main.scrollTop = 0
|
if (main) main.scrollTop = 0
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this.scrollThumbIntoView()
|
|
||||||
this.preloadAround()
|
this.preloadAround()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -314,14 +303,6 @@ export function createGalleryView(cfg: GalleryConfig) {
|
|||||||
const main = document.getElementById("lb-main")
|
const main = document.getElementById("lb-main")
|
||||||
if (main) main.scrollTop = 0
|
if (main) main.scrollTop = 0
|
||||||
},
|
},
|
||||||
scrollThumbIntoView() {
|
|
||||||
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
|
|
||||||
if (!thumbs) return
|
|
||||||
const active = thumbs.querySelector<HTMLElement>(`[data-lb-index="${this.lbIndex}"]`)
|
|
||||||
if (!active) return
|
|
||||||
const target = active.offsetTop - (thumbs.clientHeight - active.clientHeight) / 2
|
|
||||||
thumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
|
|
||||||
},
|
|
||||||
preloadAround() {
|
preloadAround() {
|
||||||
const indexes = [
|
const indexes = [
|
||||||
this.lbIndex,
|
this.lbIndex,
|
||||||
@ -336,32 +317,5 @@ export function createGalleryView(cfg: GalleryConfig) {
|
|||||||
image.src = url
|
image.src = url
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
/** 缩略图条带:用 IntersectionObserver 懒加载背景图(未进入视口不设 background-image)。 */
|
|
||||||
initThumbLazyLoading() {
|
|
||||||
const thumbs = document.querySelectorAll<HTMLElement>("#lb-thumbs [data-thumb-url]")
|
|
||||||
if (!thumbs.length) return
|
|
||||||
if (!("IntersectionObserver" in window)) {
|
|
||||||
thumbs.forEach((thumb) => {
|
|
||||||
thumb.style.backgroundImage = `url("${thumb.dataset.thumbUrl}")`
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 先断开上一个实例,避免重复调用时泄漏
|
|
||||||
thumbObserver?.disconnect()
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries, obs) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry.isIntersecting) return
|
|
||||||
const element = entry.target as HTMLElement
|
|
||||||
const url = element.dataset.thumbUrl
|
|
||||||
if (url) element.style.backgroundImage = `url("${url}")`
|
|
||||||
obs.unobserve(element)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ root: document.getElementById("lb-thumbs"), rootMargin: "300px" },
|
|
||||||
)
|
|
||||||
thumbObserver = observer
|
|
||||||
thumbs.forEach((thumb) => observer.observe(thumb))
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// src/lib/galleryDetail.ts —— 详情页的「细节图 / 副图」扩展(走秀与街拍共用)。
|
// src/lib/galleryDetail.ts —— 详情页的「细节图 / 副图」扩展(走秀与街拍共用)。
|
||||||
//
|
//
|
||||||
// 叠加在 lib/gallery.ts 的 createGalleryView 之上,提供:
|
// 叠加在 lib/gallery.ts 的 createGalleryView 之上,提供:
|
||||||
// - 由 SSR 载荷(根节点 data-details)或登录后完整 images 构建 look_index → 细节图 映射
|
// - 由 SSR 载荷(根节点 data-details)或登录后完整 images 构建 主图id → 细节图 映射
|
||||||
// - 网格角标用的 detailCount()
|
// - 网格角标用的 detailCount()
|
||||||
// - 细节图灯箱(打开 / 翻页 / 缩放 / 收藏 / 退回主图)
|
// - 细节图灯箱(打开 / 翻页 / 缩放 / 收藏 / 退回主图)
|
||||||
//
|
//
|
||||||
@ -35,8 +35,8 @@ export interface DetailGalleryExtras {
|
|||||||
detailLbIndex: number
|
detailLbIndex: number
|
||||||
detailLbZoomed: boolean
|
detailLbZoomed: boolean
|
||||||
detailImages: DetailImage[]
|
detailImages: DetailImage[]
|
||||||
/** look_index → 该主图的细节图数组(init 由 data-details 构建,登录后由 gallery:full 重建)。 */
|
/** 主图 id → 该主图的细节图数组(init 由 data-details 构建,登录后由 gallery:full 重建)。 */
|
||||||
detailImagesByLook: Record<number, DetailImage[]>
|
detailImagesByMain: Record<string, DetailImage[]>
|
||||||
/** 细节灯箱是否由主图大图灯箱打开(true 时关闭要退回主图,而不是掉回网格)。 */
|
/** 细节灯箱是否由主图大图灯箱打开(true 时关闭要退回主图,而不是掉回网格)。 */
|
||||||
detailLbFromLb: boolean
|
detailLbFromLb: boolean
|
||||||
lbReturnIndex: number
|
lbReturnIndex: number
|
||||||
@ -50,12 +50,12 @@ export interface DetailGalleryExtras {
|
|||||||
currentDetailFavUrl(): string
|
currentDetailFavUrl(): string
|
||||||
|
|
||||||
init(): void
|
init(): void
|
||||||
getImageLookIndex(index: number): number
|
getImageMainId(index: number): string
|
||||||
openDetailLbAt(idx: number): void
|
openDetailLbAt(idx: number): void
|
||||||
buildDetailMapFrom(images: any[]): void
|
buildDetailMapFrom(images: any[]): void
|
||||||
buildDetailMap(): void
|
buildDetailMap(): void
|
||||||
detailCount(li: number): number
|
detailCount(mainId: string): number
|
||||||
openDetailLb(li: number, startIndex?: number, fromLb?: boolean): void
|
openDetailLb(mainId: string, startIndex?: number, fromLb?: boolean): void
|
||||||
closeDetailLb(): void
|
closeDetailLb(): void
|
||||||
showDetailLb(index: number): void
|
showDetailLb(index: number): void
|
||||||
nextDetailLb(): void
|
nextDetailLb(): void
|
||||||
@ -84,12 +84,12 @@ export function createDetailGallery<T extends ReturnType<typeof createGalleryVie
|
|||||||
detailLbIndex: 0,
|
detailLbIndex: 0,
|
||||||
detailLbZoomed: false,
|
detailLbZoomed: false,
|
||||||
detailImages: [] as DetailImage[],
|
detailImages: [] as DetailImage[],
|
||||||
detailImagesByLook: {} as Record<number, DetailImage[]>,
|
detailImagesByMain: {} as Record<string, DetailImage[]>,
|
||||||
detailLbFromLb: false,
|
detailLbFromLb: false,
|
||||||
lbReturnIndex: 0,
|
lbReturnIndex: 0,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// 本扩展独有:从 SSR 载荷构建「细节入口」用的 look_index → 细节图 映射。
|
// 本扩展独有:从 SSR 载荷构建「细节入口」用的 主图id → 细节图 映射。
|
||||||
this.buildDetailMap()
|
this.buildDetailMap()
|
||||||
// 未登录时后端只返回前 PREVIEW_LIMIT 张主图的细节子树;登录后 loadAuthedState 拿回完整图集,
|
// 未登录时后端只返回前 PREVIEW_LIMIT 张主图的细节子树;登录后 loadAuthedState 拿回完整图集,
|
||||||
// 需据其重建映射,否则第 6 张起的角标 / 细节灯箱仍为空(详见 lib/gallery.ts 的 gallery:full)。
|
// 需据其重建映射,否则第 6 张起的角标 / 细节灯箱仍为空(详见 lib/gallery.ts 的 gallery:full)。
|
||||||
@ -102,10 +102,10 @@ export function createDetailGallery<T extends ReturnType<typeof createGalleryVie
|
|||||||
this.galleryInit()
|
this.galleryInit()
|
||||||
},
|
},
|
||||||
|
|
||||||
// 读第 index 张大图所属的主图序号(look_index),来自 figure 上的 data-look-index。
|
// 读第 index 张大图所属的主图 id,来自 figure 上的 data-main-id。
|
||||||
getImageLookIndex(index: number): number {
|
getImageMainId(index: number): string {
|
||||||
const li = Number(this.getImageElement(index)?.dataset.lookIndex ?? 0)
|
const id = this.getImageElement(index)?.dataset.mainId ?? ""
|
||||||
return Number.isFinite(li) ? li : 0
|
return id || ""
|
||||||
},
|
},
|
||||||
|
|
||||||
// 当前大图所属主图的细节图列表;该主图没有细节图时返回空数组(整簇隐藏)。
|
// 当前大图所属主图的细节图列表;该主图没有细节图时返回空数组(整簇隐藏)。
|
||||||
@ -113,31 +113,31 @@ export function createDetailGallery<T extends ReturnType<typeof createGalleryVie
|
|||||||
// 而对象展开会对 getter 立即求值 —— 写成 getter 会永久冻结成创建那一刻的空数组,
|
// 而对象展开会对 getter 立即求值 —— 写成 getter 会永久冻结成创建那一刻的空数组,
|
||||||
// 导致大图灯箱右下角的 Details 缩略簇永远不显示(模板写 currentDetailList())。
|
// 导致大图灯箱右下角的 Details 缩略簇永远不显示(模板写 currentDetailList())。
|
||||||
currentDetailList(): DetailImage[] {
|
currentDetailList(): DetailImage[] {
|
||||||
const li = this.getImageLookIndex(this.lbIndex)
|
const mainId = this.getImageMainId(this.lbIndex)
|
||||||
return li ? this.detailImagesByLook[li] || [] : []
|
return mainId ? this.detailImagesByMain[mainId] || [] : []
|
||||||
},
|
},
|
||||||
|
|
||||||
// 点大图灯箱里的细节缩略图:先关大图灯箱(同为 z-[100],避免叠层),再开细节灯箱定位到 idx。
|
// 点大图灯箱里的细节缩略图:先关大图灯箱(同为 z-[100],避免叠层),再开细节灯箱定位到 idx。
|
||||||
openDetailLbAt(idx: number) {
|
openDetailLbAt(idx: number) {
|
||||||
const li = this.getImageLookIndex(this.lbIndex)
|
const mainId = this.getImageMainId(this.lbIndex)
|
||||||
const list = this.detailImagesByLook[li]
|
const list = this.detailImagesByMain[mainId]
|
||||||
if (!list || !list.length) return
|
if (!list || !list.length) return
|
||||||
this.detailLbFromLb = true
|
this.detailLbFromLb = true
|
||||||
this.lbReturnIndex = this.lbIndex
|
this.lbReturnIndex = this.lbIndex
|
||||||
this.closeLb()
|
this.closeLb()
|
||||||
this.openDetailLb(li, idx, true)
|
this.openDetailLb(mainId, idx, true)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 由「主图数组」构建 look_index → 细节图数组 映射。两个来源同构,共用本函数:
|
// 由「主图数组」构建 主图id → 细节图数组 映射。两个来源同构,共用本函数:
|
||||||
// ① SSR:根节点 data-details(只含带细节图的主图投影 [{ look_index, detail }])
|
// ① SSR:根节点 data-details(只含带细节图的主图投影 [{ id, detail }])
|
||||||
// ② 登录后:详情接口返回的完整 images(细节图已嵌套在每张主图的 detail 下)
|
// ② 登录后:详情接口返回的完整 images(细节图已嵌套在每张主图的 detail 下)
|
||||||
buildDetailMapFrom(images: any[]) {
|
buildDetailMapFrom(images: any[]) {
|
||||||
const map: Record<number, DetailImage[]> = {}
|
const map: Record<string, DetailImage[]> = {}
|
||||||
for (const im of images || []) {
|
for (const im of images || []) {
|
||||||
const li = Number(im?.look_index ?? 0)
|
const mainId = String(im?.id ?? "")
|
||||||
const kids: any[] = Array.isArray(im?.detail) ? im.detail : []
|
const kids: any[] = Array.isArray(im?.detail) ? im.detail : []
|
||||||
if (!li || !kids.length) continue
|
if (!mainId || !kids.length) continue
|
||||||
map[li] = kids.map((d: any) => ({
|
map[mainId] = kids.map((d: any) => ({
|
||||||
id: d.id || "",
|
id: d.id || "",
|
||||||
url: toAbs(d.image || ""),
|
url: toAbs(d.image || ""),
|
||||||
thumb: toAbs(d.thumb || d.image || ""),
|
thumb: toAbs(d.thumb || d.image || ""),
|
||||||
@ -145,7 +145,7 @@ export function createDetailGallery<T extends ReturnType<typeof createGalleryVie
|
|||||||
name: d.name || "",
|
name: d.name || "",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
this.detailImagesByLook = map
|
this.detailImagesByMain = map
|
||||||
},
|
},
|
||||||
|
|
||||||
buildDetailMap() {
|
buildDetailMap() {
|
||||||
@ -153,19 +153,19 @@ export function createDetailGallery<T extends ReturnType<typeof createGalleryVie
|
|||||||
const raw = this.root()?.dataset.details
|
const raw = this.root()?.dataset.details
|
||||||
this.buildDetailMapFrom(raw ? JSON.parse(raw) : [])
|
this.buildDetailMapFrom(raw ? JSON.parse(raw) : [])
|
||||||
} catch {
|
} catch {
|
||||||
this.detailImagesByLook = {}
|
this.detailImagesByMain = {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 网格角标用:某主图(look_index)的细节图数量。无细节图返回 0。
|
// 网格角标用:某主图(主图 id)的细节图数量。无细节图返回 0。
|
||||||
detailCount(li: number): number {
|
detailCount(mainId: string): number {
|
||||||
const arr = this.detailImagesByLook[Number(li) || 0]
|
const arr = this.detailImagesByMain[String(mainId) || ""]
|
||||||
return arr ? arr.length : 0
|
return arr ? arr.length : 0
|
||||||
},
|
},
|
||||||
|
|
||||||
// 打开细节灯箱。fromLb=true 表示由大图灯箱进入(关闭时退回主图);false 表示从网格角标直接进。
|
// 打开细节灯箱。fromLb=true 表示由大图灯箱进入(关闭时退回主图);false 表示从网格角标直接进。
|
||||||
openDetailLb(li: number, startIndex = 0, fromLb = false) {
|
openDetailLb(mainId: string, startIndex = 0, fromLb = false) {
|
||||||
const list = this.detailImagesByLook[li]
|
const list = this.detailImagesByMain[mainId]
|
||||||
if (!list || !list.length) return
|
if (!list || !list.length) return
|
||||||
this.detailLbFromLb = fromLb
|
this.detailLbFromLb = fromLb
|
||||||
this.detailImages = list
|
this.detailImages = list
|
||||||
|
|||||||
@ -69,7 +69,9 @@ export function getI18n(Astro: { currentLocale?: string | undefined }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── API 语言透传 / Locale passthrough ──
|
// ── API 语言透传 / Locale passthrough ──
|
||||||
// 把「当前语言」塞进所有 /api 请求的 ?locale= 参数,让后端按 locale 选 *_en / *_cn 列。
|
// 把「当前页面语言」放进所有 /api 请求的 X-Locale 头,让后端按 locale 选 *_en / *_cn 列。
|
||||||
|
// 用自定义头而非 ?locale= 查询参数:API URL 更干净,且显式由页面语言驱动
|
||||||
|
// (浏览器取 URL path、服务端取注入值),不会被浏览器语言设置串味。
|
||||||
//
|
//
|
||||||
// 当前语言来源分两端:
|
// 当前语言来源分两端:
|
||||||
// - 浏览器端(<script> 里的 fetch):从 URL path 推断(/cn/ → cn,其余 → en)。
|
// - 浏览器端(<script> 里的 fetch):从 URL path 推断(/cn/ → cn,其余 → en)。
|
||||||
@ -91,12 +93,11 @@ export function currentLocale(): string {
|
|||||||
return _ssrLocale || DEFAULT_LOCALE;
|
return _ssrLocale || DEFAULT_LOCALE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把当前语言追加进 query(locale 非空才加,避免污染 URL;DEFAULT_LOCALE 也显式带,语义清晰)。
|
// 当前语言对应的请求头:{ "X-Locale": "cn" | "en" };取不到则返回空对象(后端回退默认 en)。
|
||||||
export function withLocale(params?: URLSearchParams, override?: string): URLSearchParams {
|
// 所有传输层(浏览器 request / SSG requestSsg / SSR 取数)统一经此透传,避免各处手写 locale。
|
||||||
const p = params ? new URLSearchParams(params) : new URLSearchParams();
|
export function localeHeader(): Record<string, string> {
|
||||||
const loc = override || currentLocale();
|
const loc = currentLocale();
|
||||||
if (loc) p.set('locale', loc);
|
return loc ? { "X-Locale": loc } : {};
|
||||||
return p;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 记住语言偏好 / Remembered locale ──
|
// ── 记住语言偏好 / Remembered locale ──
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
// 设计:**全站只有一个公开请求入口 `request()`**,统一附带:
|
// 设计:**全站只有一个公开请求入口 `request()`**,统一附带:
|
||||||
// - 前端反爬签名(X-Sign / X-Sign-Ts / X-Sign-Nonce)—— 所有请求一律带;
|
// - 前端反爬签名(X-Sign / X-Sign-Ts / X-Sign-Nonce)—— 所有请求一律带;
|
||||||
// 后端只在「/public 列表」路由校验,其余路由忽略该头即可。
|
// 后端只在「/public 列表」路由校验,其余路由忽略该头即可。
|
||||||
// - ?locale=(当前语言)。
|
// - X-Locale 头(当前语言)。
|
||||||
// - 已登录时的 Bearer(opts.auth !== false 时经 fetchWithToken 附),遇 401 自动续期一次后重试。
|
// - 已登录时的 Bearer(opts.auth !== false 时经 fetchWithToken 附),遇 401 自动续期一次后重试。
|
||||||
// base 地址全部来自 src/lib/config(dev/prod 两份,缺则抛);密钥不进 config。
|
// base 地址全部来自 src/lib/config(dev/prod 两份,缺则抛);密钥不进 config。
|
||||||
//
|
//
|
||||||
@ -22,7 +22,7 @@ import { config } from "./config" // dev/prod 配置(baseApi / baseApiSsr / ba
|
|||||||
import { endpoints } from "./endpoints" // 后端端点路径(见 endpoints.ts)
|
import { endpoints } from "./endpoints" // 后端端点路径(见 endpoints.ts)
|
||||||
import { clientSign } from "./crypto" // 公开请求签名(X-Sign* 头)
|
import { clientSign } from "./crypto" // 公开请求签名(X-Sign* 头)
|
||||||
import { getAccessToken, getRefreshToken, getUser, saveSession, clearSession, isAccessExpiring } from "./auth" // 令牌读取 / 写入
|
import { getAccessToken, getRefreshToken, getUser, saveSession, clearSession, isAccessExpiring } from "./auth" // 令牌读取 / 写入
|
||||||
import { withLocale, currentLocale } from "./i18n" // 请求语言透传
|
import { localeHeader } from "./i18n" // 请求语言透传(X-Locale 头)
|
||||||
|
|
||||||
// ===================== base 解析(全部来自 config) =====================
|
// ===================== base 解析(全部来自 config) =====================
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ async function ensureFreshToken(): Promise<void> {
|
|||||||
// ===================== 唯一请求入口 =====================
|
// ===================== 唯一请求入口 =====================
|
||||||
|
|
||||||
// 全站统一请求:走 baseUrl() + path(path 为含 /api/v1 前缀的完整路径)。
|
// 全站统一请求:走 baseUrl() + path(path 为含 /api/v1 前缀的完整路径)。
|
||||||
// 一律附带「反爬签名 + ?locale=(当前语言)」。
|
// 一律附带「反爬签名 + X-Locale 头(当前语言)」。
|
||||||
// opts.auth 默认 true:附 Bearer + 遇 401 续期重试;显式 false 时走裸 fetch
|
// opts.auth 默认 true:附 Bearer + 遇 401 续期重试;显式 false 时走裸 fetch
|
||||||
// (用于登录/刷新/登出等 auth 端点:它们本就不需要 Bearer,且可避免「刷新请求触发刷新」的递归)。
|
// (用于登录/刷新/登出等 auth 端点:它们本就不需要 Bearer,且可避免「刷新请求触发刷新」的递归)。
|
||||||
// 查询参数走 opts.params(URLSearchParams),不要拼进 path。
|
// 查询参数走 opts.params(URLSearchParams),不要拼进 path。
|
||||||
@ -96,7 +96,7 @@ export async function request<T = any>(
|
|||||||
path: string,
|
path: string,
|
||||||
opts?: { params?: URLSearchParams; body?: unknown; auth?: boolean },
|
opts?: { params?: URLSearchParams; body?: unknown; auth?: boolean },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const params = withLocale(opts?.params)
|
const params = opts?.params ?? new URLSearchParams()
|
||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
const fullPath = path
|
const fullPath = path
|
||||||
const { sig, ts, nonce } = await clientSign(method, fullPath, query)
|
const { sig, ts, nonce } = await clientSign(method, fullPath, query)
|
||||||
@ -105,6 +105,7 @@ export async function request<T = any>(
|
|||||||
"X-Sign": sig,
|
"X-Sign": sig,
|
||||||
"X-Sign-Ts": ts,
|
"X-Sign-Ts": ts,
|
||||||
"X-Sign-Nonce": nonce,
|
"X-Sign-Nonce": nonce,
|
||||||
|
...localeHeader(),
|
||||||
}
|
}
|
||||||
const body = opts?.body
|
const body = opts?.body
|
||||||
if (body !== undefined) headers["Content-Type"] = "application/json"
|
if (body !== undefined) headers["Content-Type"] = "application/json"
|
||||||
@ -183,16 +184,15 @@ function ssgToken(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function requestSsg<T>(path: string): Promise<T | null> {
|
export async function requestSsg<T>(path: string): Promise<T | null> {
|
||||||
// 组合 SSG_TOKEN 与 ?locale=(构建期按当前语言选列,后端 parseLocale 已支持)。
|
// SSG_TOKEN 经 query 传入(密钥不进客户端 bundle,仅构建期从 env 读取);
|
||||||
|
// 语言经 X-Locale 头透传(后端 parseLocale 优先读此头,按当前构建语言选列)。
|
||||||
const t = ssgToken()
|
const t = ssgToken()
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (t) parts.push("token=" + encodeURIComponent(t))
|
if (t) parts.push("token=" + encodeURIComponent(t))
|
||||||
const loc = currentLocale()
|
|
||||||
if (loc) parts.push("locale=" + encodeURIComponent(loc))
|
|
||||||
const base = config.baseApiSsg.replace(/\/$/, "")
|
const base = config.baseApiSsg.replace(/\/$/, "")
|
||||||
const url = base + path + (parts.length ? (path.includes("?") ? "&" : "?") + parts.join("&") : "")
|
const url = base + path + (parts.length ? (path.includes("?") ? "&" : "?") + parts.join("&") : "")
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url)
|
const res = await fetch(url, { headers: localeHeader() })
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
return (await res.json()) as T
|
return (await res.json()) as T
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// 本文件只装「构建期 SSG 取数」:所有函数调用 /api/internal/ssg/* 内部端口
|
// 本文件只装「构建期 SSG 取数」:所有函数调用 /api/internal/ssg/* 内部端口
|
||||||
// (仅 127.0.0.1,nginx 不反代,由 astro build 时的 Node 进程调用),
|
// (仅 127.0.0.1,nginx 不反代,由 astro build 时的 Node 进程调用),
|
||||||
// 走 request.ts 的 requestSsg()(自动带 SSG_TOKEN + ?locale=)。
|
// 走 request.ts 的 requestSsg()(自动带 SSG_TOKEN + X-Locale 头)。
|
||||||
//
|
//
|
||||||
// 与 api.ts 的「浏览器运行期公开业务 + 鉴权 + 收藏 + 历史」隔离,
|
// 与 api.ts 的「浏览器运行期公开业务 + 鉴权 + 收藏 + 历史」隔离,
|
||||||
// 避免「仅构建期才有意义」的代码混进客户端逻辑(这些函数从不进客户端 bundle)。
|
// 避免「仅构建期才有意义」的代码混进客户端逻辑(这些函数从不进客户端 bundle)。
|
||||||
@ -44,6 +44,7 @@ export async function getSsgHotBrands(): Promise<BrandEntry[]> {
|
|||||||
|
|
||||||
// 接口:GET /api/internal/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用)
|
// 接口:GET /api/internal/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用)
|
||||||
// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。
|
// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。
|
||||||
|
// locale 透传:后端按 locale 返回中/英标题(中文为空回落英文)。
|
||||||
export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapItem[]> {
|
export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapItem[]> {
|
||||||
const j = await requestSsg<{ data?: any[] }>(`${endpoints.ssg.streetSnapPopular}?limit=${limit}`)
|
const j = await requestSsg<{ data?: any[] }>(`${endpoints.ssg.streetSnapPopular}?limit=${limit}`)
|
||||||
return (j?.data ?? []).map((b: any) => ({
|
return (j?.data ?? []).map((b: any) => ({
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import { ssrBase } from "./request"
|
import { ssrBase } from "./request"
|
||||||
import { endpoints } from "./endpoints"
|
import { endpoints } from "./endpoints"
|
||||||
import { withLocale } from "./i18n"
|
import { currentLocale } from "./i18n"
|
||||||
import type { RawArticle, RawStreetSnap } from "./api"
|
import type { RawArticle, RawStreetSnap } from "./api"
|
||||||
|
|
||||||
export type SsrArticleResult = {
|
export type SsrArticleResult = {
|
||||||
@ -32,10 +32,9 @@ export async function getSsrArticle(
|
|||||||
if (!id) return { article: null, error: "missing", preview: false, imageTotal: 0 }
|
if (!id) return { article: null, error: "missing", preview: false, imageTotal: 0 }
|
||||||
const b = ssrBase().replace(/\/$/, "")
|
const b = ssrBase().replace(/\/$/, "")
|
||||||
if (!b) return { article: null, error: "failed", preview: false, imageTotal: 0 }
|
if (!b) return { article: null, error: "failed", preview: false, imageTotal: 0 }
|
||||||
const p = withLocale(undefined, locale)
|
const headers = locale ? { "X-Locale": locale } : { "X-Locale": currentLocale() }
|
||||||
const qs = p.toString() ? "?" + p.toString() : ""
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${b}${endpoints.public.runwayLook(id)}${qs}`)
|
const res = await fetch(`${b}${endpoints.public.runwayLook(id)}`, { headers })
|
||||||
if (!res.ok) return { article: null, error: "failed", preview: false, imageTotal: 0 }
|
if (!res.ok) return { article: null, error: "failed", preview: false, imageTotal: 0 }
|
||||||
const json: any = await res.json()
|
const json: any = await res.json()
|
||||||
return {
|
return {
|
||||||
@ -65,10 +64,9 @@ export async function getSsrStreetSnap(
|
|||||||
if (!id) return { snap: null, error: "missing", preview: false, imageTotal: 0 }
|
if (!id) return { snap: null, error: "missing", preview: false, imageTotal: 0 }
|
||||||
const b = ssrBase().replace(/\/$/, "")
|
const b = ssrBase().replace(/\/$/, "")
|
||||||
if (!b) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
|
if (!b) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
|
||||||
const p = withLocale(undefined, locale)
|
const headers = locale ? { "X-Locale": locale } : { "X-Locale": currentLocale() }
|
||||||
const qs = p.toString() ? "?" + p.toString() : ""
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${b}${endpoints.public.streetSnap(id)}${qs}`)
|
const res = await fetch(`${b}${endpoints.public.streetSnap(id)}`, { headers })
|
||||||
if (!res.ok) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
|
if (!res.ok) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
|
||||||
const json: any = await res.json()
|
const json: any = await res.json()
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user