@ -8,9 +8,11 @@ import { ssrBase } from "@/lib/api"
|
||||
interface Props {
|
||||
article: RawArticle | null
|
||||
error?: "missing" | "failed" | null
|
||||
preview?: boolean // 后端未登录截断图片集时返回 true,渲染「剩余 N 张」占位遮罩
|
||||
imageTotal?: number // 图片集真实总数(后端截断前),渲染「剩余 N 张」占位遮罩
|
||||
}
|
||||
|
||||
const { article, error = null } = Astro.props
|
||||
const { article, error = null, preview = false, imageTotal = 0 } = Astro.props
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const full = (u: string): string => {
|
||||
@ -28,6 +30,15 @@ const images = (article?.images ?? [])
|
||||
}))
|
||||
.filter((im) => im.url)
|
||||
|
||||
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
|
||||
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
|
||||
const PREVIEW_LIMIT = 5
|
||||
const previewImages = images.slice(0, PREVIEW_LIMIT)
|
||||
|
||||
// 未登录预览占位遮罩数:补足「剩余 N 张」空图,提示登录后可见,避免误以为图集只有预览上限那几张。
|
||||
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
|
||||
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
|
||||
|
||||
const descParas = (article?.description || "")
|
||||
.split(/\n\n+/)
|
||||
.map((s) => s.trim())
|
||||
@ -58,6 +69,7 @@ if (article?.brand_id) {
|
||||
data-gallery-uid={article?.id ?? ""}
|
||||
data-gallery-title={article?.title ?? ""}
|
||||
data-gallery-brand={article?.brand_name ?? ""}
|
||||
data-preview={preview ? "true" : "false"}
|
||||
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
|
||||
>
|
||||
{
|
||||
@ -73,13 +85,13 @@ if (article?.brand_id) {
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
{images.length > 0 && (
|
||||
{previewImages.length > 0 && (
|
||||
<section class="mt-16">
|
||||
<div
|
||||
id="article-gallery"
|
||||
class="grid grid-cols-2 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{images.map((im, i) => (
|
||||
{previewImages.map((im, i) => (
|
||||
<figure
|
||||
data-image-index={i}
|
||||
data-image-url={im.url}
|
||||
@ -118,7 +130,71 @@ if (article?.brand_id) {
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
))}
|
||||
) )}
|
||||
|
||||
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
|
||||
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
|
||||
{lockedCount > 0 &&
|
||||
Array.from({ length: lockedCount }).map(() => (
|
||||
<figure
|
||||
x-show="!isAuthed && preview"
|
||||
x-cloak
|
||||
@click="goLogin()"
|
||||
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
|
||||
>
|
||||
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
|
||||
<div class="w-full aspect-[3/4] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
|
||||
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
|
||||
</svg>
|
||||
<span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 look01 等小字):
|
||||
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption>
|
||||
</figure>
|
||||
))
|
||||
}
|
||||
|
||||
{/* 登录后追加的额外图片:extraImages 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */}
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<figure
|
||||
:data-image-index="5 + i"
|
||||
:data-image-url="im.url"
|
||||
:data-image-name="im.name"
|
||||
:data-fav-id="im.id"
|
||||
:data-fav-url="im.raw"
|
||||
@click="openLb(5 + i)"
|
||||
class="relative bg-[#f2f2f2] cursor-zoom-in group"
|
||||
>
|
||||
<img
|
||||
:src="im.url"
|
||||
:alt="im.name"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:data-fav-id="im.id"
|
||||
:data-fav-url="im.raw"
|
||||
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
|
||||
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
|
||||
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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>
|
||||
</button>
|
||||
<template x-if="im.name">
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption>
|
||||
</template>
|
||||
</figure>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@ -147,7 +223,7 @@ if (article?.brand_id) {
|
||||
)
|
||||
}
|
||||
|
||||
{article && images.length > 0 && (
|
||||
{article && previewImages.length > 0 && (
|
||||
<div
|
||||
x-show="lbOpen"
|
||||
x-cloak
|
||||
@ -188,7 +264,7 @@ if (article?.brand_id) {
|
||||
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 bg-gradient-to-b from-black/5 to-transparent no-scrollbar"
|
||||
>
|
||||
{images.map((im, i) => (
|
||||
{previewImages.map((im, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-lb-index={i}
|
||||
@ -205,6 +281,22 @@ if (article?.brand_id) {
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<button
|
||||
type="button"
|
||||
:data-lb-index="5 + i"
|
||||
:data-thumb-url="im.url"
|
||||
@click="showLb(5 + i)"
|
||||
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
|
||||
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
|
||||
: ''"
|
||||
:aria-label="String(5 + i + 1)"
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@ -316,7 +408,7 @@ if (article?.brand_id) {
|
||||
// handleTouchEnd(event: TouchEvent): void
|
||||
// }
|
||||
|
||||
import { getUser } from "@/lib/api"
|
||||
import { getUser, getArticleDetailAuthed, toAbs } from "@/lib/api"
|
||||
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
@ -336,11 +428,22 @@ if (article?.brand_id) {
|
||||
galleryTitle: "",
|
||||
galleryBrand: "",
|
||||
|
||||
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
|
||||
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
|
||||
isAuthed: false,
|
||||
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
|
||||
preview: false,
|
||||
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
|
||||
extraImages: [] as { id: string; url: string; raw: string; name: string }[],
|
||||
showAll: false,
|
||||
|
||||
init() {
|
||||
this.favType = this.$el.dataset.favType || "runway_image"
|
||||
this.galleryUid = this.$el.dataset.galleryUid || ""
|
||||
this.galleryTitle = this.$el.dataset.galleryTitle || ""
|
||||
this.galleryBrand = this.$el.dataset.galleryBrand || ""
|
||||
this.preview = this.$el.dataset.preview === "true"
|
||||
this.isAuthed = !!getUser()
|
||||
|
||||
this.imageCount = document.querySelectorAll(
|
||||
"#article-gallery [data-image-index]"
|
||||
@ -349,6 +452,45 @@ if (article?.brand_id) {
|
||||
this.initThumbLazyLoading()
|
||||
this.initImageFavs()
|
||||
this.maybeLocateImageFromUrl()
|
||||
|
||||
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染
|
||||
if (getUser() && this.preview) this.loadFull()
|
||||
// 登录后(如从门禁跳登录回跳)自动补全全量
|
||||
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() })
|
||||
},
|
||||
|
||||
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。
|
||||
// 后端详情接口对已登录(带 Bearer)返回完整图片集,故此处能拿到全量。
|
||||
async loadFull() {
|
||||
if (!this.preview) return
|
||||
try {
|
||||
const full = await getArticleDetailAuthed(this.galleryUid)
|
||||
if (!full || !Array.isArray(full.images)) return
|
||||
const mapped = (full.images || []).slice(5).map((im: any) => ({
|
||||
id: im.id || "",
|
||||
url: toAbs(im.image || ""),
|
||||
raw: im.image || "",
|
||||
name: im.name || "",
|
||||
}))
|
||||
this.extraImages = mapped
|
||||
this.showAll = true
|
||||
await this.$nextTick()
|
||||
this.imageCount = document.querySelectorAll(
|
||||
"#article-gallery [data-image-index]"
|
||||
).length
|
||||
this.initImageFavs()
|
||||
this.initThumbLazyLoading()
|
||||
this.maybeLocateImageFromUrl()
|
||||
} catch {
|
||||
/* 失败维持截断态,门禁仍在 */
|
||||
}
|
||||
},
|
||||
|
||||
// 未登录门禁:跳登录并回跳本篇详情
|
||||
goLogin() {
|
||||
const back = window.location.pathname + window.location.search
|
||||
const loc = window.location.pathname.startsWith("/cn") ? "cn" : "en"
|
||||
window.location.href = `/${loc}/login?redirect=${encodeURIComponent(back)}`
|
||||
},
|
||||
|
||||
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
||||
|
||||
158
src/components/LoadingScreen.astro
Normal file
158
src/components/LoadingScreen.astro
Normal file
@ -0,0 +1,158 @@
|
||||
---
|
||||
// 加载屏组件:首屏/路由切换时遮盖未加载内容,避免 FOUC。
|
||||
// - 样式作用域隔离在 #loading-screen 内(is:global,因选择器基于唯一 id,不影响整站黑白调性)。
|
||||
// - 隐藏逻辑用 Astro 默认打包的 <script>(module / defer),无需 is:inline;
|
||||
// 模块脚本在 window.load 之前执行,故 addEventListener('load') 必然先挂上,无竞态。
|
||||
// - Raleway 字体仅加载屏使用,随组件引入(Astro 自动 hoist 到 head)。
|
||||
---
|
||||
|
||||
<div id="loading-screen" aria-hidden="true">
|
||||
<div id="wrapper">
|
||||
<div id="container">
|
||||
<h1>Portfolio Studio</h1>
|
||||
<p class="ls-sub">Loading</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#loading-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background-color: #ffffff;
|
||||
color: #0a0a0a;
|
||||
font: 16px/1.25 'Raleway', sans-serif;
|
||||
text-align: center;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
#loading-screen.is-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#loading-screen *,
|
||||
#loading-screen *:before,
|
||||
#loading-screen *:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#loading-screen #wrapper {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 80em;
|
||||
}
|
||||
#loading-screen #container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
#loading-screen h1 {
|
||||
animation: ls-text-shadow 1.5s ease-in-out infinite;
|
||||
font-size: clamp(2.5rem, 8vw, 5rem);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
#loading-screen h1:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
#loading-screen a {
|
||||
color: #0a0a0a;
|
||||
}
|
||||
#loading-screen a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
#loading-screen .ls-sub {
|
||||
margin-top: 1.5em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* 关键帧独立命名(ls- 前缀),避免与站点其它 keyframes 冲突 */
|
||||
@keyframes ls-text-shadow {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
text-shadow:
|
||||
0 0 0 #000,
|
||||
0 0 0 #3a3a3a,
|
||||
0 0 0 #777,
|
||||
0 0 0 #bbb;
|
||||
}
|
||||
20% {
|
||||
transform: translateY(-1em);
|
||||
text-shadow:
|
||||
0 0.125em 0 #000,
|
||||
0 0.25em 0 #3a3a3a,
|
||||
0 -0.125em 0 #777,
|
||||
0 -0.25em 0 #bbb;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(0.5em);
|
||||
text-shadow:
|
||||
0 -0.0625em 0 #000,
|
||||
0 -0.125em 0 #3a3a3a,
|
||||
0 0.0625em 0 #777,
|
||||
0 0.125em 0 #bbb;
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-0.25em);
|
||||
text-shadow:
|
||||
0 0.03125em 0 #000,
|
||||
0 0.0625em 0 #3a3a3a,
|
||||
0 -0.03125em 0 #777,
|
||||
0 -0.0625em 0 #bbb;
|
||||
}
|
||||
80% {
|
||||
transform: translateY(0);
|
||||
text-shadow:
|
||||
0 0 0 #000,
|
||||
0 0 0 #3a3a3a,
|
||||
0 0 0 #777,
|
||||
0 0 0 #bbb;
|
||||
}
|
||||
}
|
||||
|
||||
/* 尊重系统“减少动画”偏好:仅作用于加载屏,不波及整站 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#loading-screen *,
|
||||
#loading-screen *:before,
|
||||
#loading-screen *:after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 页面 load 后淡出加载屏;最少展示 500ms 防闪烁,最多 3s 兜底。
|
||||
// 作为 Astro 默认打包的 module 脚本(defer),在 window.load 前执行,监听必然先挂上。
|
||||
(function () {
|
||||
var el = document.getElementById('loading-screen');
|
||||
if (!el) return;
|
||||
var hidden = false;
|
||||
function hide() {
|
||||
if (hidden) return;
|
||||
hidden = true;
|
||||
el.classList.add('is-hidden');
|
||||
setTimeout(function () {
|
||||
el.style.display = 'none';
|
||||
}, 600);
|
||||
}
|
||||
var start = Date.now();
|
||||
function tryHide() {
|
||||
var waited = Date.now() - start;
|
||||
if (waited >= 500) hide();
|
||||
else setTimeout(hide, 500 - waited);
|
||||
}
|
||||
if (document.readyState === 'complete') tryHide();
|
||||
else window.addEventListener('load', tryHide);
|
||||
setTimeout(hide, 3000);
|
||||
})();
|
||||
</script>
|
||||
@ -250,7 +250,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<nav class="showspager" x-show="lastPage > 1" aria-label="Pagination">
|
||||
<nav class="showspager" x-show="isAuthed && lastPage > 1" aria-label="Pagination">
|
||||
<button class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
|
||||
<template x-for="(p, i) in pageList" :key="i">
|
||||
<span class="pg-wrap">
|
||||
@ -305,7 +305,6 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
|
||||
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
||||
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("showsPage", () => {
|
||||
const hotNode = document.getElementById("showshotbrands-data")
|
||||
@ -339,6 +338,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
loading: false,
|
||||
page: 1,
|
||||
lastPage: 1,
|
||||
isAuthed: false,
|
||||
reqToken: 0,
|
||||
|
||||
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
|
||||
@ -352,6 +352,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
loginModalLoading: false,
|
||||
|
||||
init() {
|
||||
this.isAuthed = !!getUser();
|
||||
this.locale = this.$el.dataset.locale || 'en';
|
||||
// 支持从 header 下拉深链 ?collection=xxx 直接进入对应分类(与侧栏 COLLECTION 筛选一致)
|
||||
const ct = new URLSearchParams(window.location.search).get('collection')
|
||||
|
||||
@ -8,9 +8,11 @@ import { ssrBase } from "@/lib/api"
|
||||
interface Props {
|
||||
snap: RawStreetSnap | null
|
||||
error?: "missing" | "failed" | null
|
||||
preview?: boolean // 后端未登录截断图片集时返回 true,详情页据此弹「登录查看全部图片」门禁
|
||||
imageTotal?: number // 图片集真实总数(后端截断前),渲染「剩余 N 张」占位遮罩
|
||||
}
|
||||
|
||||
const { snap, error = null } = Astro.props
|
||||
const { snap, error = null, preview = false, imageTotal = 0 } = Astro.props
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const full = (u: string): string => {
|
||||
@ -28,6 +30,15 @@ const images = (snap?.images ?? [])
|
||||
}))
|
||||
.filter((im) => im.url)
|
||||
|
||||
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
|
||||
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
|
||||
const PREVIEW_LIMIT = 5
|
||||
const previewImages = images.slice(0, PREVIEW_LIMIT)
|
||||
|
||||
// 未登录预览占位遮罩数:补足「剩余 N 张」空图,提示登录后可见,避免误以为图集只有预览上限那几张。
|
||||
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
|
||||
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
|
||||
|
||||
const homeHref = href(locale, ROUTES.home)
|
||||
const archiveHref = href(locale, ROUTES.streetSnaps)
|
||||
|
||||
@ -42,6 +53,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
data-gallery-uid={snap?.id ?? ""}
|
||||
data-gallery-title={displayTitle}
|
||||
data-gallery-brand=""
|
||||
data-preview={preview ? "true" : "false"}
|
||||
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
|
||||
>
|
||||
{
|
||||
@ -55,13 +67,13 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
{displayTitle}
|
||||
</h1>
|
||||
|
||||
{images.length > 0 && (
|
||||
{previewImages.length > 0 && (
|
||||
<section class="mt-16">
|
||||
<div
|
||||
id="snap-gallery"
|
||||
class="grid grid-cols-2 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{images.map((im, i) => (
|
||||
{previewImages.map((im, i) => (
|
||||
<figure
|
||||
data-image-index={i}
|
||||
data-image-url={im.url}
|
||||
@ -100,10 +112,75 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
))}
|
||||
) )}
|
||||
|
||||
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
|
||||
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
|
||||
{lockedCount > 0 &&
|
||||
Array.from({ length: lockedCount }).map(() => (
|
||||
<figure
|
||||
x-show="!isAuthed && preview"
|
||||
x-cloak
|
||||
@click="goLogin()"
|
||||
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
|
||||
>
|
||||
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
|
||||
<div class="w-full aspect-[3/4] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
|
||||
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
|
||||
</svg>
|
||||
<span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 img01 等小字):
|
||||
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption>
|
||||
</figure>
|
||||
))
|
||||
}
|
||||
|
||||
{/* 登录后追加的额外图片:extraImages 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */}
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<figure
|
||||
:data-image-index="5 + i"
|
||||
:data-image-url="im.url"
|
||||
:data-image-name="im.name"
|
||||
:data-fav-id="im.id"
|
||||
:data-fav-url="im.raw"
|
||||
@click="openLb(5 + i)"
|
||||
class="relative bg-[#f2f2f2] cursor-zoom-in group"
|
||||
>
|
||||
<img
|
||||
:src="im.url"
|
||||
:alt="im.name"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:data-fav-id="im.id"
|
||||
:data-fav-url="im.raw"
|
||||
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
|
||||
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
|
||||
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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>
|
||||
</button>
|
||||
<template x-if="im.name">
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption>
|
||||
</template>
|
||||
</figure>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</article>
|
||||
) : (
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
|
||||
@ -111,6 +188,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
{error === "missing" ? t('No street snap ID specified') : t('Failed to load street snap. Please refresh or try later.')}
|
||||
</p>
|
||||
|
||||
|
||||
<a
|
||||
href={homeHref}
|
||||
class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
|
||||
@ -121,7 +199,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
)
|
||||
}
|
||||
|
||||
{snap && images.length > 0 && (
|
||||
{snap && previewImages.length > 0 && (
|
||||
<div
|
||||
x-show="lbOpen"
|
||||
x-cloak
|
||||
@ -162,7 +240,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
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 bg-gradient-to-b from-black/5 to-transparent no-scrollbar"
|
||||
>
|
||||
{images.map((im, i) => (
|
||||
{previewImages.map((im, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-lb-index={i}
|
||||
@ -179,6 +257,22 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<button
|
||||
type="button"
|
||||
:data-lb-index="5 + i"
|
||||
:data-thumb-url="im.url"
|
||||
@click="showLb(5 + i)"
|
||||
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
|
||||
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
|
||||
: ''"
|
||||
:aria-label="String(5 + i + 1)"
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@ -255,7 +349,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { getUser } from "@/lib/api"
|
||||
import { getUser, getStreetSnapDetailAuthed, toAbs } from "@/lib/api"
|
||||
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
@ -275,11 +369,22 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
galleryTitle: "",
|
||||
galleryBrand: "",
|
||||
|
||||
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
|
||||
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
|
||||
isAuthed: false,
|
||||
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
|
||||
preview: false,
|
||||
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
|
||||
extraImages: [] as { id: string; url: string; raw: string; name: string }[],
|
||||
showAll: false,
|
||||
|
||||
init() {
|
||||
this.favType = this.$el.dataset.favType || "snap_image"
|
||||
this.galleryUid = this.$el.dataset.galleryUid || ""
|
||||
this.galleryTitle = this.$el.dataset.galleryTitle || ""
|
||||
this.galleryBrand = this.$el.dataset.galleryBrand || ""
|
||||
this.preview = this.$el.dataset.preview === "true"
|
||||
this.isAuthed = !!getUser()
|
||||
|
||||
this.imageCount = document.querySelectorAll(
|
||||
"#snap-gallery [data-image-index]"
|
||||
@ -287,6 +392,44 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
this.initThumbLazyLoading()
|
||||
this.initImageFavs()
|
||||
this.maybeLocateImageFromUrl()
|
||||
|
||||
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染
|
||||
if (getUser() && this.preview) this.loadFull()
|
||||
// 登录后(如从门禁跳登录回跳)自动补全全量
|
||||
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() })
|
||||
},
|
||||
|
||||
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。
|
||||
async loadFull() {
|
||||
if (!this.preview) return
|
||||
try {
|
||||
const full = await getStreetSnapDetailAuthed(this.galleryUid)
|
||||
if (!full || !Array.isArray(full.images)) return
|
||||
const mapped = (full.images || []).slice(5).map((im: any) => ({
|
||||
id: im.id || "",
|
||||
url: toAbs(im.image || ""),
|
||||
raw: im.image || "",
|
||||
name: im.name || "",
|
||||
}))
|
||||
this.extraImages = mapped
|
||||
this.showAll = true
|
||||
await this.$nextTick()
|
||||
this.imageCount = document.querySelectorAll(
|
||||
"#snap-gallery [data-image-index]"
|
||||
).length
|
||||
this.initImageFavs()
|
||||
this.initThumbLazyLoading()
|
||||
this.maybeLocateImageFromUrl()
|
||||
} catch {
|
||||
/* 失败维持截断态,门禁仍在 */
|
||||
}
|
||||
},
|
||||
|
||||
// 未登录门禁:跳登录并回跳本篇详情
|
||||
goLogin() {
|
||||
const back = window.location.pathname + window.location.search
|
||||
const loc = window.location.pathname.startsWith("/cn") ? "cn" : "en"
|
||||
window.location.href = `/${loc}/login?redirect=${encodeURIComponent(back)}`
|
||||
},
|
||||
|
||||
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
||||
|
||||
@ -212,7 +212,7 @@ const cityOptions: { value: string; label: string }[] = [
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<nav class="snappager" x-show="lastPage > 1" aria-label="Pagination">
|
||||
<nav class="snappager" x-show="isAuthed && lastPage > 1" aria-label="Pagination">
|
||||
<button class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
|
||||
<template x-for="(p, i) in pageList" :key="i">
|
||||
<span class="pg-wrap">
|
||||
@ -263,7 +263,6 @@ const cityOptions: { value: string; label: string }[] = [
|
||||
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
|
||||
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
||||
|
||||
|
||||
const DEFAULT_SORT = "image_count"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
@ -288,6 +287,7 @@ const cityOptions: { value: string; label: string }[] = [
|
||||
loading: false,
|
||||
page: 1,
|
||||
lastPage: 1,
|
||||
isAuthed: false,
|
||||
reqToken: 0,
|
||||
|
||||
// 登录门禁弹窗状态(硬门禁:未登录点筛选/翻页时弹出内联登录表单)
|
||||
@ -301,6 +301,7 @@ const cityOptions: { value: string; label: string }[] = [
|
||||
favMap: {} as Record<string, boolean>,
|
||||
|
||||
init() {
|
||||
this.isAuthed = !!getUser()
|
||||
this.locale = this.$el.dataset.locale || 'en'
|
||||
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
|
||||
const city = new URLSearchParams(window.location.search).get('city')
|
||||
|
||||
@ -51,6 +51,8 @@ export const dict: Record<string, TranslationEntry> = {
|
||||
'please log in': { cn: '请先登录' },
|
||||
'login to continue': { cn: '登录以继续使用筛选与翻页' },
|
||||
'login prompt': { cn: '登录后可筛选与翻页浏览全部档案' },
|
||||
'login to view': { cn: '登录查看' },
|
||||
'photos': { cn: '张照片' },
|
||||
'members': { cn: '会员专区' },
|
||||
'back to site': { cn: '返回网站' },
|
||||
'email': { cn: '邮箱' },
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
---
|
||||
import '@/styles/global.css'
|
||||
import LoadingScreen from '@/components/LoadingScreen.astro'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { LOCALE_NAMES } from '@/i18n/config'
|
||||
@ -62,7 +63,10 @@ const headers = {
|
||||
<title>{siteTitle}</title>
|
||||
</head>
|
||||
<body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans">
|
||||
|
||||
|
||||
<!-- 加载动画屏:独立组件,标记/样式/隐藏逻辑封装在 LoadingScreen.astro -->
|
||||
<LoadingScreen />
|
||||
|
||||
<!-- 顶部主导航栏 -->
|
||||
<nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase">
|
||||
<div class="flex flex-wrap items-center gap-6 md:gap-8">
|
||||
@ -80,7 +84,7 @@ const headers = {
|
||||
<svg class="w-2.5 h-2.5 transition-transform duration-200" :class="open ? 'rotate-180' : ''" viewBox="0 0 10 6" fill="none"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="open" x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
|
||||
<div x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
|
||||
{
|
||||
headers.runways.map((item) => (
|
||||
<a href={item.href} class="block px-4 py-2 text-[12px] uppercase tracking-[0.4px] hover:bg-black/5 transition-colors">
|
||||
@ -98,7 +102,7 @@ const headers = {
|
||||
<svg viewBox="0 0 12 12" width="10" height="10"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="open" x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
|
||||
<div x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
|
||||
{
|
||||
headers.streets.map((item) => (
|
||||
<a href={item.href} class="block px-4 py-2 text-[12px] uppercase tracking-[0.4px] hover:bg-black/5 transition-colors">
|
||||
@ -122,7 +126,7 @@ const headers = {
|
||||
<span>{LOCALE_NAMES[locale].nativeName}</span>
|
||||
</button>
|
||||
<ul
|
||||
x-show="open"
|
||||
x-show="open" x-cloak
|
||||
x-transition.opacity.duration.150ms
|
||||
class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2"
|
||||
>
|
||||
@ -142,17 +146,17 @@ const headers = {
|
||||
</div>
|
||||
<div class="flex items-center gap-3" x-data="authMenu">
|
||||
<!-- 未登录:Login 链接(Alpine 登录后置为隐藏) -->
|
||||
<a x-show="!user" href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200 uppercase tracking-[0.5px] text-[13px]">{t('Login')}</a>
|
||||
<a x-show="!user" x-cloak href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200 uppercase tracking-[0.5px] text-[13px]">{t('Login')}</a>
|
||||
|
||||
<!-- 已登录:账号下拉(hover 展开,与语言切换器同款交互) -->
|
||||
<div x-show="user" class="relative" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<div x-show="user" x-cloak class="relative" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-haspopup="menu" :aria-expanded="open">
|
||||
<span x-text="shortName"></span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="acct-chevron w-[14px] h-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<ul x-show="open" x-transition.opacity.duration.150ms class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2" role="menu">
|
||||
<ul x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2" role="menu">
|
||||
<li role="none">
|
||||
<a href={href(locale, ROUTES.account)} @click="open = false" role="menuitem" class="block px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors opacity-60">{t('my account')}</a>
|
||||
</li>
|
||||
@ -236,7 +240,7 @@ const headers = {
|
||||
<div class="mt-6 text-[11px] font-mono text-zinc-400 uppercase tracking-widest">
|
||||
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
|
||||
</div>
|
||||
</footer>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
123
src/lib/api.ts
123
src/lib/api.ts
@ -57,6 +57,7 @@ export interface ArticleQuery {
|
||||
export interface ArticlePage {
|
||||
data: ArticleItem[]
|
||||
last_page: number
|
||||
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
|
||||
}
|
||||
|
||||
// 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。
|
||||
@ -208,44 +209,51 @@ async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
|
||||
// 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
|
||||
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
|
||||
// 对外导出:详情取数 / 个人中心逐篇回查(浏览器端按需取 title/cover 渲染历史卡片)。
|
||||
export async function fetchArticleById(
|
||||
id: number | string,
|
||||
base: string = ssrBase(),
|
||||
locale?: string
|
||||
): Promise<RawArticle | null> {
|
||||
const b = base.replace(/\/$/, "")
|
||||
if (!b) return null
|
||||
const p = withLocale(undefined, locale)
|
||||
const qs = p.toString() ? "?" + p.toString() : ""
|
||||
try {
|
||||
const res = await fetch(`${b}/api/v1/public/runway-looks/${id}${qs}`)
|
||||
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 页面只调这一行即可。
|
||||
// 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。
|
||||
// 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。
|
||||
// locale 显式传入(如 Astro.currentLocale)以绑定当前语言,避免并发串 locale。
|
||||
export type SsrArticleResult = {
|
||||
article: RawArticle | null
|
||||
error: "missing" | "failed" | null
|
||||
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
|
||||
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
|
||||
}
|
||||
|
||||
export async function getSsrArticle(
|
||||
id: string | null | undefined,
|
||||
locale?: string
|
||||
): Promise<SsrArticleResult> {
|
||||
if (!id) return { article: null, error: "missing" }
|
||||
if (!id) return { article: null, error: "missing", preview: false, imageTotal: 0 }
|
||||
const b = ssrBase().replace(/\/$/, "")
|
||||
if (!b) return { article: null, error: "failed", preview: false, imageTotal: 0 }
|
||||
const p = withLocale(undefined, locale)
|
||||
const qs = p.toString() ? "?" + p.toString() : ""
|
||||
try {
|
||||
const article = await fetchArticleById(id, ssrBase(), locale)
|
||||
return { article, error: article ? null : "failed" }
|
||||
const res = await fetch(`${b}/api/v1/public/runway-looks/${id}${qs}`)
|
||||
if (!res.ok) return { article: null, error: "failed", preview: false, imageTotal: 0 }
|
||||
const json: any = await res.json()
|
||||
return {
|
||||
article: (json?.data ?? null) as RawArticle | null,
|
||||
preview: !!json?.preview,
|
||||
imageTotal: Number(json?.image_total ?? 0),
|
||||
error: null,
|
||||
}
|
||||
} catch {
|
||||
return { article: null, error: "failed" }
|
||||
return { article: null, error: "failed", preview: false, imageTotal: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录用户详情取数:走 request()(自动附带 Bearer + locale 签名),
|
||||
// 后端据此返回完整图片集(未登录截断到 PreviewLimit 张)。详情页未登录→登录后客户端补拉全量用。
|
||||
export async function getArticleDetailAuthed(
|
||||
id: string,
|
||||
locale?: string
|
||||
): Promise<RawArticle | null> {
|
||||
try {
|
||||
const json: any = await request(`/public/runway-looks/${encodeURIComponent(id)}`)
|
||||
return (json?.data ?? null) as RawArticle | null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@ -260,12 +268,12 @@ export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
|
||||
if (q.collection) p.set("collection", q.collection)
|
||||
if (q.season) p.set("season", q.season)
|
||||
if (q.year) p.set("year", String(q.year))
|
||||
const j = await request<{ data?: any[]; last_page?: number }>("/public/runway-looks", p)
|
||||
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>("/public/runway-looks", p)
|
||||
const mapped: ArticleItem[] = (j.data ?? []).map((it: any) => ({
|
||||
...it,
|
||||
gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
|
||||
}))
|
||||
return { data: mapped, last_page: Number(j.last_page ?? 1) }
|
||||
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
|
||||
}
|
||||
|
||||
// ===================== 品牌(/api/public/brands) =====================
|
||||
@ -575,6 +583,7 @@ export interface StreetSnapItem {
|
||||
export interface StreetSnapPage {
|
||||
data: StreetSnapItem[]
|
||||
last_page: number
|
||||
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
|
||||
}
|
||||
|
||||
// 街拍详情原始结构(与 PublicStreetSnapDetail 对齐)。
|
||||
@ -599,7 +608,7 @@ export async function getStreetSnaps(q: {
|
||||
if (q.sort) p.set("sort", q.sort)
|
||||
if (q.year) p.set("year", String(q.year))
|
||||
if (q.city) p.set("city", q.city)
|
||||
const j = await request<{ data?: any[]; last_page?: number }>("/public/street-snaps", p)
|
||||
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>("/public/street-snaps", p)
|
||||
const mapped: StreetSnapItem[] = (j.data ?? []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
title: it.title ?? "",
|
||||
@ -610,48 +619,54 @@ export async function getStreetSnaps(q: {
|
||||
? it.images.map((im: any) => ({ id: im.id || "", image: im.image || im, name: im.name || "" }))
|
||||
: [],
|
||||
}))
|
||||
return { data: mapped, last_page: Number(j.last_page ?? 1) }
|
||||
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
|
||||
}
|
||||
|
||||
// 接口:GET /api/public/street-snaps/:id
|
||||
// 街拍详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数)。
|
||||
// 对外导出:详情取数 / 个人中心逐篇回查。
|
||||
export async function fetchStreetSnapById(
|
||||
id: number | string,
|
||||
base: string = ssrBase(),
|
||||
locale?: string
|
||||
): Promise<RawStreetSnap | null> {
|
||||
const b = base.replace(/\/$/, "")
|
||||
if (!b) return null
|
||||
const p = withLocale(undefined, locale)
|
||||
const qs = p.toString() ? "?" + p.toString() : ""
|
||||
try {
|
||||
const res = await fetch(`${b}/api/v1/public/street-snaps/${id}${qs}`)
|
||||
if (!res.ok) return null
|
||||
const json: any = await res.json()
|
||||
return (json?.data ?? null) as RawStreetSnap | null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export type SsrStreetSnapResult = {
|
||||
snap: RawStreetSnap | null
|
||||
error: "missing" | "failed" | null
|
||||
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
|
||||
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
|
||||
}
|
||||
|
||||
// 服务端按需渲染(on-demand)取街拍详情:在 fetchStreetSnapById 之上包揽
|
||||
// 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。
|
||||
// 服务端按需渲染(on-demand)取街拍详情:直接 fetch 详情接口并透传 preview 标记。
|
||||
export async function getSsrStreetSnap(
|
||||
id: string | null | undefined,
|
||||
locale?: string
|
||||
): Promise<SsrStreetSnapResult> {
|
||||
if (!id) return { snap: null, error: "missing" }
|
||||
if (!id) return { snap: null, error: "missing", preview: false, imageTotal: 0 }
|
||||
const b = ssrBase().replace(/\/$/, "")
|
||||
if (!b) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
|
||||
const p = withLocale(undefined, locale)
|
||||
const qs = p.toString() ? "?" + p.toString() : ""
|
||||
try {
|
||||
const snap = await fetchStreetSnapById(id, ssrBase(), locale)
|
||||
return { snap, error: snap ? null : "failed" }
|
||||
const res = await fetch(`${b}/api/v1/public/street-snaps/${id}${qs}`)
|
||||
if (!res.ok) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
|
||||
const json: any = await res.json()
|
||||
return {
|
||||
snap: (json?.data ?? null) as RawStreetSnap | null,
|
||||
preview: !!json?.preview,
|
||||
imageTotal: Number(json?.image_total ?? 0),
|
||||
error: null,
|
||||
}
|
||||
} catch {
|
||||
return { snap: null, error: "failed" }
|
||||
return { snap: null, error: "failed", preview: false, imageTotal: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录用户街拍详情取数:走 request()(自动附带 Bearer + locale 签名),返回完整图片集。
|
||||
export async function getStreetSnapDetailAuthed(
|
||||
id: string,
|
||||
locale?: string
|
||||
): Promise<RawStreetSnap | null> {
|
||||
try {
|
||||
const json: any = await request(`/public/street-snaps/${encodeURIComponent(id)}`)
|
||||
return (json?.data ?? null) as RawStreetSnap | null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,8 @@ let article = null
|
||||
let articleErr = null
|
||||
let snap = null
|
||||
let snapErr = null
|
||||
let preview = false
|
||||
let imageTotal = 0
|
||||
|
||||
// await 必须位于 frontmatter 顶层(Astro 不支持块内顶层 await)
|
||||
const data = isSnap
|
||||
@ -31,6 +33,8 @@ if (isSnap) {
|
||||
article = data.article
|
||||
articleErr = data.error
|
||||
}
|
||||
preview = data.preview ?? false
|
||||
imageTotal = data.imageTotal ?? 0
|
||||
|
||||
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
|
||||
const meta = isSnap
|
||||
@ -41,7 +45,7 @@ const savedLabel = t("saved")
|
||||
---
|
||||
|
||||
<Layout>
|
||||
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
|
||||
{article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
|
||||
@ -18,6 +18,8 @@ let article = null
|
||||
let articleErr = null
|
||||
let snap = null
|
||||
let snapErr = null
|
||||
let preview = false
|
||||
let imageTotal = 0
|
||||
|
||||
// await 必须位于 frontmatter 顶层(Astro 不支持块内顶层 await)
|
||||
const data = isSnap
|
||||
@ -31,6 +33,8 @@ if (isSnap) {
|
||||
article = data.article
|
||||
articleErr = data.error
|
||||
}
|
||||
preview = data.preview ?? false
|
||||
imageTotal = data.imageTotal ?? 0
|
||||
|
||||
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
|
||||
const meta = isSnap
|
||||
@ -41,7 +45,7 @@ const savedLabel = t("saved")
|
||||
---
|
||||
|
||||
<Layout>
|
||||
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
|
||||
{article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
|
||||
@ -44,7 +44,7 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
|
||||
<span>{LOCALE_NAMES[locale].nativeName}</span>
|
||||
</button>
|
||||
|
||||
<ul x-show="open" x-transition.opacity.duration.150ms class="absolute right-0 top-full mt-2 min-w-[170px] border border-black/10 bg-white py-2 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
|
||||
<ul x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute right-0 top-full mt-2 min-w-[170px] border border-black/10 bg-white py-2 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
|
||||
{languages.map((l) => (
|
||||
<li>
|
||||
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 text-[10px] uppercase tracking-[0.22em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/60']}>{l.label}</a>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user