update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-09-03 00:32:17 +08:00
parent 64af75c0a9
commit 6902376bc2
11 changed files with 685 additions and 259 deletions

View File

@ -23,6 +23,7 @@ const images = (article?.images ?? [])
.map((im) => ({
id: im.id,
url: full(im.image || ""),
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
name: im.name || "",
}))
.filter((im) => im.url)
@ -53,6 +54,10 @@ if (article?.brand_id) {
<div
id="article-view"
x-data="articleView()"
data-fav-type="runway_image"
data-gallery-uid={article?.id ?? ""}
data-gallery-title={article?.title ?? ""}
data-gallery-brand={article?.brand_name ?? ""}
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
>
{
@ -79,8 +84,10 @@ if (article?.brand_id) {
data-image-index={i}
data-image-url={im.url}
data-image-name={im.name}
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`}
class="bg-[#f2f2f2] cursor-zoom-in group"
class="relative bg-[#f2f2f2] cursor-zoom-in group"
>
<img
src={im.url}
@ -90,6 +97,21 @@ if (article?.brand_id) {
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
<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>
{im.name && (
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
{im.name}
@ -140,6 +162,18 @@ if (article?.brand_id) {
x-text="`${lbIndex + 1} / ${imageCount}`"
></div>
<button
type="button"
@click.stop="toggleFav(currentFavId, currentFavUrl)"
:class="isFav(currentFavId) ? 'is-fav' : ''"
class="fav-btn absolute top-[18px] right-[70px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-[18px] h-[18px]" 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>
<button
type="button"
@click="closeLb()"
@ -282,6 +316,9 @@ if (article?.brand_id) {
// handleTouchEnd(event: TouchEvent): void
// }
import { getUser } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
document.addEventListener("alpine:init", () => {
Alpine.data("articleView", () => ({
lbOpen: false,
@ -292,12 +329,50 @@ if (article?.brand_id) {
imageCount: 0,
currentImageName: "",
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
favIds: [],
favType: "runway_image",
galleryUid: "",
galleryTitle: "",
galleryBrand: "",
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.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]"
).length
this.initThumbLazyLoading()
this.initImageFavs()
this.maybeLocateImageFromUrl()
},
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
maybeLocateImageFromUrl() {
if (typeof window === "undefined") return
const q = new URLSearchParams(window.location.search)
const want = q.get("img")
if (!want) return
const figures = Array.from(
document.querySelectorAll<HTMLElement>("#article-gallery [data-fav-id]"),
)
const idx = figures.findIndex((el) => el.dataset.favId === want)
if (idx < 0) return
const fig = figures[idx] as HTMLElement
requestAnimationFrame(() => {
if (q.get("lb") === "1") {
this.openLb(idx)
return
}
fig.scrollIntoView({ behavior: "smooth", block: "center" })
fig.classList.add("locate-flash")
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
})
},
getImageElement(index: number) {
@ -314,6 +389,72 @@ if (article?.brand_id) {
return this.getImageElement(index)?.dataset.imageName || ""
},
getImageFavId(index: number) {
return this.getImageElement(index)?.dataset.favId || ""
},
// 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主)
getImageFavUrl(index: number) {
return this.getImageElement(index)?.dataset.favUrl || ""
},
// ===== 单图收藏 =====
// 只把「当前这篇图集的全部图片 id」发给后端问哪些已收藏,
// 复杂度与图片数成正比,与用户收藏总量无关。
allImageFavIds(): string[] {
return Array.from(
document.querySelectorAll<HTMLElement>("#article-gallery [data-fav-id]"),
)
.map((el) => el.dataset.favId || "")
.filter(Boolean)
},
initImageFavs() {
const ids = this.allImageFavIds()
if (!ids.length) return
// 本地先即时填充,避免爱心闪烁
this.favIds = ids.filter((id) => isFavorited(id))
// 登录态用服务端校正(换设备后与服务端一致)
checkFavorited(ids).then((hit) => {
this.favIds = ids.filter((id) => hit.has(id))
})
},
isFav(id: string) {
return !!id && this.favIds.includes(id)
},
toggleFav(id: string, url: string) {
if (!id) return
// 未登录:收藏无处落盘(服务端无 token、本地无用户 key),引导登录后回跳本篇
if (!getUser()) {
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)}`
return
}
toggleImageFavorite({
imageId: id,
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
imageUrl: url,
parentUid: this.galleryUid,
title: this.galleryTitle,
brand: this.galleryBrand,
})
this.favIds = this.isFav(id)
? this.favIds.filter((x: string) => x !== id)
: [...this.favIds, id]
},
// 灯箱里的收藏按钮跟随当前大图
get currentFavId() {
return this.getImageFavId(this.lbIndex)
},
get currentFavUrl() {
return this.getImageFavUrl(this.lbIndex)
},
openLb(index = 0) {
if (!this.imageCount) return
@ -488,4 +629,25 @@ if (article?.brand_id) {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 单图收藏爱心:默认描边,已收藏实心 */
.fav-btn svg {
fill: none;
}
.fav-btn.is-fav svg {
fill: currentColor;
}
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
.locate-flash {
outline: 3px solid #111;
outline-offset: 4px;
}
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
@media (hover: none) {
.fav-btn {
opacity: 1 !important;
}
}
</style>

View File

@ -23,6 +23,7 @@ const images = (snap?.images ?? [])
.map((im) => ({
id: im.id,
url: full(im.image || ""),
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
name: im.name || "",
}))
.filter((im) => im.url)
@ -37,6 +38,10 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
<div
id="street-snap-view"
x-data="streetSnapView()"
data-fav-type="snap_image"
data-gallery-uid={snap?.id ?? ""}
data-gallery-title={displayTitle}
data-gallery-brand=""
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
>
{
@ -61,8 +66,10 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
data-image-index={i}
data-image-url={im.url}
data-image-name={im.name}
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`}
class="bg-[#f2f2f2] cursor-zoom-in group"
class="relative bg-[#f2f2f2] cursor-zoom-in group"
>
<img
src={im.url}
@ -72,6 +79,21 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
<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>
{im.name && (
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
{im.name}
@ -114,6 +136,18 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
x-text="`${lbIndex + 1} / ${imageCount}`"
></div>
<button
type="button"
@click.stop="toggleFav(currentFavId, currentFavUrl)"
:class="isFav(currentFavId) ? 'is-fav' : ''"
class="fav-btn absolute top-[18px] right-[70px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-[18px] h-[18px]" 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>
<button
type="button"
@click="closeLb()"
@ -221,6 +255,9 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
</div>
<script>
import { getUser } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
document.addEventListener("alpine:init", () => {
Alpine.data("streetSnapView", () => ({
lbOpen: false,
@ -231,11 +268,49 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
imageCount: 0,
currentImageName: "",
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
favIds: [],
favType: "snap_image",
galleryUid: "",
galleryTitle: "",
galleryBrand: "",
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.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]"
).length
this.initThumbLazyLoading()
this.initImageFavs()
this.maybeLocateImageFromUrl()
},
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
maybeLocateImageFromUrl() {
if (typeof window === "undefined") return
const q = new URLSearchParams(window.location.search)
const want = q.get("img")
if (!want) return
const figures = Array.from(
document.querySelectorAll<HTMLElement>("#snap-gallery [data-fav-id]"),
)
const idx = figures.findIndex((el) => el.dataset.favId === want)
if (idx < 0) return
const fig = figures[idx] as HTMLElement
requestAnimationFrame(() => {
if (q.get("lb") === "1") {
this.openLb(idx)
return
}
fig.scrollIntoView({ behavior: "smooth", block: "center" })
fig.classList.add("locate-flash")
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
})
},
getImageElement(index: number) {
@ -250,6 +325,67 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
return this.getImageElement(index)?.dataset.imageName || ""
},
// ===== 单图收藏 =====
getImageFavId(index: number) {
return this.getImageElement(index)?.dataset.favId || ""
},
// 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主)
getImageFavUrl(index: number) {
return this.getImageElement(index)?.dataset.favUrl || ""
},
allImageFavIds(): string[] {
return Array.from(
document.querySelectorAll<HTMLElement>("#snap-gallery [data-fav-id]"),
)
.map((el) => el.dataset.favId || "")
.filter(Boolean)
},
initImageFavs() {
const ids = this.allImageFavIds()
if (!ids.length) return
this.favIds = ids.filter((id) => isFavorited(id))
checkFavorited(ids).then((hit) => {
this.favIds = ids.filter((id) => hit.has(id))
})
},
isFav(id: string) {
return !!id && this.favIds.includes(id)
},
toggleFav(id: string, url: string) {
if (!id) return
// 未登录:收藏无处落盘(服务端无 token、本地无用户 key),引导登录后回跳本篇
if (!getUser()) {
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)}`
return
}
toggleImageFavorite({
imageId: id,
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
imageUrl: url,
parentUid: this.galleryUid,
title: this.galleryTitle,
brand: this.galleryBrand,
})
this.favIds = this.isFav(id)
? this.favIds.filter((x: string) => x !== id)
: [...this.favIds, id]
},
get currentFavId() {
return this.getImageFavId(this.lbIndex)
},
get currentFavUrl() {
return this.getImageFavUrl(this.lbIndex)
},
openLb(index = 0) {
if (!this.imageCount) return
this.lbIndex = this.normalizeIndex(index)
@ -345,4 +481,19 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
[x-cloak] { display: none !important; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* 单图收藏爱心:默认描边,已收藏实心 */
.fav-btn svg { fill: none; }
.fav-btn.is-fav svg { fill: currentColor; }
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
.locate-flash {
outline: 3px solid #111;
outline-offset: 4px;
}
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
@media (hover: none) {
.fav-btn { opacity: 1 !important; }
}
</style>