@ -23,6 +23,7 @@ const images = (article?.images ?? [])
|
|||||||
.map((im) => ({
|
.map((im) => ({
|
||||||
id: im.id,
|
id: im.id,
|
||||||
url: full(im.image || ""),
|
url: full(im.image || ""),
|
||||||
|
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||||
name: im.name || "",
|
name: im.name || "",
|
||||||
}))
|
}))
|
||||||
.filter((im) => im.url)
|
.filter((im) => im.url)
|
||||||
@ -53,6 +54,10 @@ if (article?.brand_id) {
|
|||||||
<div
|
<div
|
||||||
id="article-view"
|
id="article-view"
|
||||||
x-data="articleView()"
|
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"
|
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-index={i}
|
||||||
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-url={im.raw}
|
||||||
@click={`openLb(${i})`}
|
@click={`openLb(${i})`}
|
||||||
class="bg-[#f2f2f2] cursor-zoom-in group"
|
class="relative bg-[#f2f2f2] cursor-zoom-in group"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={im.url}
|
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"
|
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 && (
|
{im.name && (
|
||||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
|
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
|
||||||
{im.name}
|
{im.name}
|
||||||
@ -140,6 +162,18 @@ if (article?.brand_id) {
|
|||||||
x-text="`${lbIndex + 1} / ${imageCount}`"
|
x-text="`${lbIndex + 1} / ${imageCount}`"
|
||||||
></div>
|
></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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="closeLb()"
|
@click="closeLb()"
|
||||||
@ -282,6 +316,9 @@ if (article?.brand_id) {
|
|||||||
// handleTouchEnd(event: TouchEvent): void
|
// handleTouchEnd(event: TouchEvent): void
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
import { getUser } from "@/lib/api"
|
||||||
|
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("articleView", () => ({
|
Alpine.data("articleView", () => ({
|
||||||
lbOpen: false,
|
lbOpen: false,
|
||||||
@ -292,12 +329,50 @@ if (article?.brand_id) {
|
|||||||
imageCount: 0,
|
imageCount: 0,
|
||||||
currentImageName: "",
|
currentImageName: "",
|
||||||
|
|
||||||
|
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
|
||||||
|
favIds: [],
|
||||||
|
favType: "runway_image",
|
||||||
|
galleryUid: "",
|
||||||
|
galleryTitle: "",
|
||||||
|
galleryBrand: "",
|
||||||
|
|
||||||
init() {
|
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(
|
this.imageCount = document.querySelectorAll(
|
||||||
"#article-gallery [data-image-index]"
|
"#article-gallery [data-image-index]"
|
||||||
).length
|
).length
|
||||||
|
|
||||||
this.initThumbLazyLoading()
|
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) {
|
getImageElement(index: number) {
|
||||||
@ -314,6 +389,72 @@ if (article?.brand_id) {
|
|||||||
return this.getImageElement(index)?.dataset.imageName || ""
|
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) {
|
openLb(index = 0) {
|
||||||
if (!this.imageCount) return
|
if (!this.imageCount) return
|
||||||
|
|
||||||
@ -488,4 +629,25 @@ if (article?.brand_id) {
|
|||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: 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>
|
</style>
|
||||||
@ -23,6 +23,7 @@ const images = (snap?.images ?? [])
|
|||||||
.map((im) => ({
|
.map((im) => ({
|
||||||
id: im.id,
|
id: im.id,
|
||||||
url: full(im.image || ""),
|
url: full(im.image || ""),
|
||||||
|
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||||
name: im.name || "",
|
name: im.name || "",
|
||||||
}))
|
}))
|
||||||
.filter((im) => im.url)
|
.filter((im) => im.url)
|
||||||
@ -37,6 +38,10 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
<div
|
<div
|
||||||
id="street-snap-view"
|
id="street-snap-view"
|
||||||
x-data="streetSnapView()"
|
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"
|
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-index={i}
|
||||||
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-url={im.raw}
|
||||||
@click={`openLb(${i})`}
|
@click={`openLb(${i})`}
|
||||||
class="bg-[#f2f2f2] cursor-zoom-in group"
|
class="relative bg-[#f2f2f2] cursor-zoom-in group"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={im.url}
|
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"
|
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 && (
|
{im.name && (
|
||||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
|
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
|
||||||
{im.name}
|
{im.name}
|
||||||
@ -114,6 +136,18 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
x-text="`${lbIndex + 1} / ${imageCount}`"
|
x-text="`${lbIndex + 1} / ${imageCount}`"
|
||||||
></div>
|
></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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="closeLb()"
|
@click="closeLb()"
|
||||||
@ -221,6 +255,9 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { getUser } from "@/lib/api"
|
||||||
|
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("streetSnapView", () => ({
|
Alpine.data("streetSnapView", () => ({
|
||||||
lbOpen: false,
|
lbOpen: false,
|
||||||
@ -231,11 +268,49 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
imageCount: 0,
|
imageCount: 0,
|
||||||
currentImageName: "",
|
currentImageName: "",
|
||||||
|
|
||||||
|
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
|
||||||
|
favIds: [],
|
||||||
|
favType: "snap_image",
|
||||||
|
galleryUid: "",
|
||||||
|
galleryTitle: "",
|
||||||
|
galleryBrand: "",
|
||||||
|
|
||||||
init() {
|
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(
|
this.imageCount = document.querySelectorAll(
|
||||||
"#snap-gallery [data-image-index]"
|
"#snap-gallery [data-image-index]"
|
||||||
).length
|
).length
|
||||||
this.initThumbLazyLoading()
|
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) {
|
getImageElement(index: number) {
|
||||||
@ -250,6 +325,67 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
return this.getImageElement(index)?.dataset.imageName || ""
|
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) {
|
openLb(index = 0) {
|
||||||
if (!this.imageCount) return
|
if (!this.imageCount) return
|
||||||
this.lbIndex = this.normalizeIndex(index)
|
this.lbIndex = this.normalizeIndex(index)
|
||||||
@ -345,4 +481,19 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
[x-cloak] { display: none !important; }
|
[x-cloak] { display: none !important; }
|
||||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: 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>
|
</style>
|
||||||
|
|||||||
@ -19,12 +19,3 @@ export const LOCALE_NAMES: Record<string, { name: string; nativeName: string }>
|
|||||||
nativeName: 'English',
|
nativeName: 'English',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 浏览器 Accept-Language / navigator.language → 本项目支持的语言。 */
|
|
||||||
export const BROWSER_LOCALE_MAP: Record<string, Locale> = {
|
|
||||||
'zh': 'cn',
|
|
||||||
'zh-cn': 'cn',
|
|
||||||
'zh-tw': 'cn',
|
|
||||||
'zh-hk': 'cn',
|
|
||||||
'en': 'en',
|
|
||||||
};
|
|
||||||
|
|||||||
@ -90,6 +90,9 @@ export const dict: Record<string, TranslationEntry> = {
|
|||||||
'member profile': { cn: '会员资料' },
|
'member profile': { cn: '会员资料' },
|
||||||
'save': { cn: '收藏' },
|
'save': { cn: '收藏' },
|
||||||
'saved': { cn: '已收藏' },
|
'saved': { cn: '已收藏' },
|
||||||
|
'single image': { cn: '单图' },
|
||||||
|
'galleries': { cn: '图集' },
|
||||||
|
'images': { cn: '单图' },
|
||||||
'remove': { cn: '移除' },
|
'remove': { cn: '移除' },
|
||||||
'cancel': { cn: '取消' },
|
'cancel': { cn: '取消' },
|
||||||
'save changes': { cn: '保存修改' },
|
'save changes': { cn: '保存修改' },
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export type RawArticle = {
|
|||||||
brand_name: string
|
brand_name: string
|
||||||
brand_id?: string | number // hashid 编码串
|
brand_id?: string | number // hashid 编码串
|
||||||
image_count?: number // 列表卡片 "N+" 角标用(仅列表接口返回)
|
image_count?: number // 列表卡片 "N+" 角标用(仅列表接口返回)
|
||||||
images?: { id?: number; image: string; name?: string }[]
|
images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
@ -466,25 +466,9 @@ export async function logout(): Promise<void> {
|
|||||||
clearSession()
|
clearSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 踢下线:吊销当前用户所有 refresh_token(全设备退出)。
|
|
||||||
export async function logoutAll(): Promise<void> {
|
|
||||||
const refresh = getRefreshToken()
|
|
||||||
if (refresh) {
|
|
||||||
try {
|
|
||||||
await fetch(`${BASE_API_URL}/api/v1/auth/logout-all`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ refresh_token: refresh }),
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
/* 忽略网络错误,仍清本地态 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clearSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== 浏览历史:单篇详情回查(账户页 My History 渲染用) =====================
|
// ===================== 浏览历史:单篇详情回查(账户页 My History 渲染用) =====================
|
||||||
// 历史只存文章对外编码串(r= 走秀 / s= 街拍),展示时逐篇回查公开详情接口取 title/cover/brand。
|
// 历史只存文章对外编码串,展示时逐篇回查公开详情接口取 title/cover/brand。
|
||||||
|
// 编码串首字符即类型:s=… 实为 s 开头(如 s000zAwwQ)代表街拍,其余(r 开头等)为走秀。
|
||||||
// 走 request():自动带 locale 与前端签名,登录态附带 Bearer。
|
// 走 request():自动带 locale 与前端签名,登录态附带 Bearer。
|
||||||
|
|
||||||
export interface HistoryMeta {
|
export interface HistoryMeta {
|
||||||
@ -494,10 +478,11 @@ export interface HistoryMeta {
|
|||||||
type: "street" | "runway"
|
type: "street" | "runway"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单篇回查:编码串自带类型(s= 前缀街拍,其余走秀)。失败返回 null,由调用方容忍。
|
// 单篇回查:按编码串首字符判断类型(s 开头 = 街拍,其余 = 走秀)。失败返回 null,由调用方容忍。
|
||||||
export async function getHistoryMeta(id: string): Promise<HistoryMeta | null> {
|
export async function getHistoryMeta(id: string): Promise<HistoryMeta | null> {
|
||||||
const sid = String(id)
|
const sid = String(id)
|
||||||
const type = sid.startsWith("s=") ? ("street" as const) : ("runway" as const)
|
// 编码串类型前缀是单个字符(如 s000zAwwQ),与 /item/[id] 路由判断一致(r= 走秀 / s= 街拍)
|
||||||
|
const type = sid.charAt(0) === "s" ? ("street" as const) : ("runway" as const)
|
||||||
const path =
|
const path =
|
||||||
type === "street"
|
type === "street"
|
||||||
? `/public/street-snaps/${encodeURIComponent(sid)}`
|
? `/public/street-snaps/${encodeURIComponent(sid)}`
|
||||||
@ -583,7 +568,7 @@ export interface StreetSnapItem {
|
|||||||
title: string // 含字面 \t 分隔:地点 \t 场次
|
title: string // 含字面 \t 分隔:地点 \t 场次
|
||||||
cover: string
|
cover: string
|
||||||
image_count: number
|
image_count: number
|
||||||
images: { image: string; name: string }[] // 列表附带的每篇前 N 张缩略图
|
images: { id?: string; image: string; name?: string }[] // 列表附带的每篇前 N 张缩略图(id 为图片自身的编码串)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 列表响应(与走秀 ArticlePage 同构)
|
// 列表响应(与走秀 ArticlePage 同构)
|
||||||
@ -597,7 +582,7 @@ export interface RawStreetSnap {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
cover: string
|
cover: string
|
||||||
images?: { id?: number; image: string; name?: string }[]
|
images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用
|
||||||
}
|
}
|
||||||
|
|
||||||
// 接口:GET /api/public/street-snaps
|
// 接口:GET /api/public/street-snaps
|
||||||
@ -620,7 +605,10 @@ export async function getStreetSnaps(q: {
|
|||||||
title: it.title ?? "",
|
title: it.title ?? "",
|
||||||
cover: it.cover ?? "",
|
cover: it.cover ?? "",
|
||||||
image_count: Number(it.image_count ?? 0),
|
image_count: Number(it.image_count ?? 0),
|
||||||
images: Array.isArray(it.images) ? it.images.map((im: any) => ({ image: im.image || im, name: im.name || "" })) : [],
|
// 保留每张图的 id(图片编码串),单图收藏需要用它定位到具体某一张
|
||||||
|
images: Array.isArray(it.images)
|
||||||
|
? 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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,20 +10,30 @@
|
|||||||
// 即便网络失败也不回滚本地,保证前端不"卡死"。
|
// 即便网络失败也不回滚本地,保证前端不"卡死"。
|
||||||
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
|
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
|
||||||
//
|
//
|
||||||
// 对外函数:getFavorites / isFavorited / toggleFavorite / removeFavorite /
|
// 两种收藏粒度(共用一张 favorites 表,后端以 kind 区分):
|
||||||
|
// - gallery:整篇图集。id = 图集 uid(r/s 前缀),type = runway / street。
|
||||||
|
// - image :单张图片。id = 图片自身的编码 id(i/j 前缀),type = runway_image / snap_image,
|
||||||
|
// 额外带 imageUrl(这一张图)与 parentUid(所属图集,供从收藏回跳原图集)。
|
||||||
|
// 两种粒度的 id 前缀互不冲突,因此可放在同一张表、同一套接口里。
|
||||||
|
//
|
||||||
|
// 对外函数:getFavorites / isFavorited / toggleFavorite / toggleImageFavorite / removeFavorite /
|
||||||
// fetchFavoritesPage / checkFavorited(列表与个人中心按需调用)。
|
// fetchFavoritesPage / checkFavorited(列表与个人中心按需调用)。
|
||||||
|
|
||||||
import { getUser, getAccessToken, authJson } from "./api"
|
import { getUser, getAccessToken, authJson } from "./api"
|
||||||
|
|
||||||
export type FavoriteType = "runway" | "street"
|
export type FavoriteType = "runway" | "street" | "runway_image" | "snap_image"
|
||||||
|
export type FavoriteKind = "gallery" | "image"
|
||||||
|
|
||||||
export interface Favorite {
|
export interface Favorite {
|
||||||
id: string // 走秀/街拍的对外编码 id(带 r=/s= 前缀),与后端 target_uid 对齐
|
id: string // 图集 uid(r/s 前缀)或图片编码 id(i/j 前缀),与后端 target_uid 对齐
|
||||||
type: FavoriteType
|
type: FavoriteType
|
||||||
title: string
|
title: string
|
||||||
cover: string
|
cover: string
|
||||||
brand: string
|
brand: string
|
||||||
addedAt: number
|
addedAt: number
|
||||||
|
kind?: FavoriteKind // 缺省视为 gallery(兼容升级前的本地数据)
|
||||||
|
imageUrl?: string // kind=image:这一张图
|
||||||
|
parentUid?: string // kind=image:所属图集 uid
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后端 FavoriteItem 响应结构({data:{items:[...]}} 解包后)
|
// 后端 FavoriteItem 响应结构({data:{items:[...]}} 解包后)
|
||||||
@ -34,6 +44,9 @@ interface ServerFavorite {
|
|||||||
cover?: string
|
cover?: string
|
||||||
brand?: string
|
brand?: string
|
||||||
added_at?: number
|
added_at?: number
|
||||||
|
kind?: string
|
||||||
|
image_url?: string
|
||||||
|
parent_uid?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 本地存储(兜底 / 离线) ----------
|
// ---------- 本地存储(兜底 / 离线) ----------
|
||||||
@ -66,6 +79,15 @@ function serverMode(): boolean {
|
|||||||
return !!(getUser() && getAccessToken())
|
return !!(getUser() && getAccessToken())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 归一化(后端字符串 → 联合类型) ----------
|
||||||
|
function normalizeType(t?: string): FavoriteType {
|
||||||
|
if (t === "street" || t === "runway_image" || t === "snap_image") return t
|
||||||
|
return "runway"
|
||||||
|
}
|
||||||
|
function normalizeKind(k?: string): FavoriteKind {
|
||||||
|
return k === "image" ? "image" : "gallery"
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 对外 API ----------
|
// ---------- 对外 API ----------
|
||||||
|
|
||||||
// 本地收藏(兜底 / 离线态)。列表卡片打勾时用它做"即时填充"。
|
// 本地收藏(兜底 / 离线态)。列表卡片打勾时用它做"即时填充"。
|
||||||
@ -98,6 +120,31 @@ export function toggleFavorite(item: Omit<Favorite, "addedAt">): boolean {
|
|||||||
return newState
|
return newState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收藏 / 取消单张图片(比 toggleFavorite 更省事的语义化封装)。
|
||||||
|
* cover 直接设为该图 URL,个人中心网格即可原样渲染这张图;
|
||||||
|
* parentUid 记下所属图集,收藏卡片点击时回跳到原图集。
|
||||||
|
*/
|
||||||
|
export function toggleImageFavorite(args: {
|
||||||
|
imageId: string
|
||||||
|
type: "runway_image" | "snap_image"
|
||||||
|
imageUrl: string
|
||||||
|
parentUid: string
|
||||||
|
title?: string
|
||||||
|
brand?: string
|
||||||
|
}): boolean {
|
||||||
|
return toggleFavorite({
|
||||||
|
id: args.imageId,
|
||||||
|
type: args.type,
|
||||||
|
title: args.title || "",
|
||||||
|
cover: args.imageUrl,
|
||||||
|
brand: args.brand || "",
|
||||||
|
kind: "image",
|
||||||
|
imageUrl: args.imageUrl,
|
||||||
|
parentUid: args.parentUid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function removeFavorite(id: string): void {
|
export function removeFavorite(id: string): void {
|
||||||
setLocal(getLocal().filter((f) => f.id !== id))
|
setLocal(getLocal().filter((f) => f.id !== id))
|
||||||
if (serverMode()) {
|
if (serverMode()) {
|
||||||
@ -116,26 +163,36 @@ export interface FavPageResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 拉取收藏的某一页(默认 24 条/页)。
|
* 拉取收藏的某一页(默认 24 条/页)。
|
||||||
|
* kind 传 gallery / image 可只取对应粒度(服务端过滤),留空为全部。
|
||||||
* 返回 { items, total, lastPage },前端据此渲染当前页 + 翻页器。
|
* 返回 { items, total, lastPage },前端据此渲染当前页 + 翻页器。
|
||||||
* 未登录回落本地收藏(无分页)。
|
* 未登录回落本地收藏(无分页)。
|
||||||
*/
|
*/
|
||||||
export async function fetchFavoritesPage(page = 1, perPage = 24): Promise<FavPageResult> {
|
export async function fetchFavoritesPage(
|
||||||
|
page = 1,
|
||||||
|
perPage = 24,
|
||||||
|
kind?: FavoriteKind,
|
||||||
|
): Promise<FavPageResult> {
|
||||||
if (!serverMode()) {
|
if (!serverMode()) {
|
||||||
const local = getLocal()
|
const local = getLocal()
|
||||||
return { items: local, total: local.length, lastPage: 1 }
|
const items = kind ? local.filter((f) => normalizeKind(f.kind) === kind) : local
|
||||||
|
return { items, total: items.length, lastPage: 1 }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const qs = kind ? `&kind=${kind}` : ""
|
||||||
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
|
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
|
||||||
"GET",
|
"GET",
|
||||||
`/auth/favorites?page=${page}&per_page=${perPage}`,
|
`/auth/favorites?page=${page}&per_page=${perPage}${qs}`,
|
||||||
)
|
)
|
||||||
const items = (r?.items ?? []).map((it) => ({
|
const items = (r?.items ?? []).map((it) => ({
|
||||||
id: String(it.id),
|
id: String(it.id),
|
||||||
type: it.type === "street" ? "street" : "runway",
|
type: normalizeType(it.type),
|
||||||
title: it.title || "",
|
title: it.title || "",
|
||||||
cover: it.cover || "",
|
cover: it.cover || "",
|
||||||
brand: it.brand || "",
|
brand: it.brand || "",
|
||||||
addedAt: Number(it.added_at) || 0,
|
addedAt: Number(it.added_at) || 0,
|
||||||
|
kind: normalizeKind(it.kind),
|
||||||
|
imageUrl: it.image_url || "",
|
||||||
|
parentUid: it.parent_uid || "",
|
||||||
}))
|
}))
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
@ -144,14 +201,16 @@ export async function fetchFavoritesPage(page = 1, perPage = 24): Promise<FavPag
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const local = getLocal()
|
const local = getLocal()
|
||||||
return { items: local, total: local.length, lastPage: 1 }
|
const items = kind ? local.filter((f) => normalizeKind(f.kind) === kind) : local
|
||||||
|
return { items, total: items.length, lastPage: 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 列表卡片:批量校验当前页收藏态 ----------
|
// ---------- 列表卡片 / 详情页:批量校验当前页收藏态 ----------
|
||||||
/**
|
/**
|
||||||
* 传入当前可见的卡片 id 列表,返回「已收藏」的 id 集合。
|
* 传入当前可见的卡片 id 列表,返回「已收藏」的 id 集合。
|
||||||
* 后端只对这些 id 做 WHERE target_uid IN (...) 查询,复杂度与收藏总量无关。
|
* 后端只对这些 id 做 WHERE target_uid IN (...) 查询,复杂度与收藏总量无关。
|
||||||
|
* 图集 id 与图片 id 可混传(前缀不同,互不影响)。
|
||||||
*/
|
*/
|
||||||
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
|
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
|
||||||
if (!serverMode() || ids.length === 0) return new Set()
|
if (!serverMode() || ids.length === 0) return new Set()
|
||||||
@ -164,10 +223,27 @@ export async function checkFavorited(ids: string[]): Promise<Set<string>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 服务端校验命中时把该篇补进本地收藏(详情页跨设备校正用;不触发 POST——服务端已有该收藏)。 */
|
/** 服务端校验命中时把该篇补进本地收藏(详情页跨设备校正用;不触发 POST——服务端已有该收藏)。 */
|
||||||
export function hydrateFavorited(id: string, type: FavoriteType, title = "", cover = "", brand = ""): void {
|
export function hydrateFavorited(
|
||||||
|
id: string,
|
||||||
|
type: FavoriteType,
|
||||||
|
title = "",
|
||||||
|
cover = "",
|
||||||
|
brand = "",
|
||||||
|
extra?: { kind?: FavoriteKind; imageUrl?: string; parentUid?: string },
|
||||||
|
): void {
|
||||||
const list = getLocal()
|
const list = getLocal()
|
||||||
if (list.some((f) => f.id === id)) return
|
if (list.some((f) => f.id === id)) return
|
||||||
list.unshift({ id, type, title, cover, brand, addedAt: Date.now() })
|
list.unshift({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
cover,
|
||||||
|
brand,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
kind: normalizeKind(extra?.kind),
|
||||||
|
imageUrl: extra?.imageUrl || "",
|
||||||
|
parentUid: extra?.parentUid || "",
|
||||||
|
})
|
||||||
setLocal(list)
|
setLocal(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,6 +255,9 @@ function syncServer(item: Omit<Favorite, "addedAt">, newState: boolean): void {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
cover: item.cover,
|
cover: item.cover,
|
||||||
brand: item.brand,
|
brand: item.brand,
|
||||||
|
kind: normalizeKind(item.kind),
|
||||||
|
image_url: item.imageUrl || "",
|
||||||
|
parent_uid: item.parentUid || "",
|
||||||
}
|
}
|
||||||
if (newState) {
|
if (newState) {
|
||||||
authJson("POST", "/auth/favorites", payload).catch(() => {
|
authJson("POST", "/auth/favorites", payload).catch(() => {
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
// - 浏览历史 = 「点开过哪篇文章」,触发时机在 item 详情页(打开某篇走秀/街拍即记一条)。
|
// - 浏览历史 = 「点开过哪篇文章」,触发时机在 item 详情页(打开某篇走秀/街拍即记一条)。
|
||||||
// 列表页(Runway Looks / Street Snaps)进入不再记。
|
// 列表页(Runway Looks / Street Snaps)进入不再记。
|
||||||
// - 后端 histories 表只存 (user_id, target_uid, viewed_at):target_uid 为文章对外编码串
|
// - 后端 histories 表只存 (user_id, target_uid, viewed_at):target_uid 为文章对外编码串
|
||||||
// (r= 前缀走秀 / s= 前缀街拍,编码串自带类型,故不再有 kind/target_type 概念),
|
// (首字符区分类型:s 开头=街拍 / r 开头=走秀,编码串自带类型,故不再有 kind/target_type 概念),
|
||||||
// 表已去掉 title/cover/brand 快照列。
|
// 表已去掉 title/cover/brand 快照列。
|
||||||
// - 账户页 My History 展示:先取服务端 id 列表,再逐篇回查公开详情接口拿 title/cover/brand
|
// - 账户页 My History 展示:先取服务端 id 列表,再逐篇回查公开详情接口拿 title/cover/brand
|
||||||
// (用户选「只存 id、展示时回查」),历史卡片点击跳回文章详情。
|
// (用户选「只存 id、展示时回查」),历史卡片点击跳回文章详情。
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
// src/lib/profile.ts — 个人资料(昵称/简介,客户端本地存储版)
|
|
||||||
//
|
|
||||||
// 后端 users 表当前只有 username/email,没有展示用的 display_name / bio。
|
|
||||||
// 在后端补齐字段前,这里先把「编辑资料」做成真实可用的本地版(按账号隔离),
|
|
||||||
// 后续接 /api/v1/me/profile 时把 saveProfile 换成请求即可。
|
|
||||||
|
|
||||||
import { getUser } from "./api"
|
|
||||||
|
|
||||||
export interface Profile {
|
|
||||||
displayName?: string
|
|
||||||
bio?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function storageKey(): string | null {
|
|
||||||
const u = getUser()
|
|
||||||
return u ? `fa_profile_${u.username}` : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProfile(): Profile {
|
|
||||||
const k = storageKey()
|
|
||||||
if (!k) return {}
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(localStorage.getItem(k) || "{}")
|
|
||||||
return obj && typeof obj === "object" ? obj : {}
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveProfile(p: Profile): void {
|
|
||||||
const k = storageKey()
|
|
||||||
if (!k) return
|
|
||||||
localStorage.setItem(k, JSON.stringify(p))
|
|
||||||
}
|
|
||||||
@ -9,134 +9,174 @@ const titleLabel = t("my account")
|
|||||||
const pleaseLabel = t("please log in")
|
const pleaseLabel = t("please log in")
|
||||||
const loginLabel = t("login")
|
const loginLabel = t("login")
|
||||||
const historyLabel = t("my history")
|
const historyLabel = t("my history")
|
||||||
|
const savedLooksLabel = t("saved looks")
|
||||||
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
|
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
|
||||||
const archiveUrl = href(locale, ROUTES.runwayLooks)
|
const archiveUrl = href(locale, ROUTES.runwayLooks)
|
||||||
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
|
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
|
||||||
const removeLabel = t("remove")
|
const removeLabel = t("remove")
|
||||||
const itemsLabel = t("items")
|
const itemsLabel = t("items")
|
||||||
const clearHistoryLabel = t("clear history")
|
const clearHistoryLabel = t("clear history")
|
||||||
|
const noSavedLabel = t("no saved looks yet")
|
||||||
|
const noHistoryLabel = t("no history yet")
|
||||||
|
const browseLabel = t("browse the archive")
|
||||||
---
|
---
|
||||||
<Layout title={titleLabel}>
|
<Layout title={titleLabel}>
|
||||||
<main class="account-root mx-auto w-full max-w-7xl px-6 py-10 md:px-8 md:py-14" x-data="accountPage" data-locale={locale}>
|
<!-- 页面版心与 Layout 一致:黑白编辑风,衬线大标题 + 等宽小标 + 粗黑细线 -->
|
||||||
|
<main class="account-root mx-auto w-full max-w-[1400px] px-5 py-14 md:px-10 md:py-20 font-sans" x-data="accountPage" data-locale={locale}>
|
||||||
|
|
||||||
<!-- 未登录:引导登录 -->
|
<!-- ===== 未登录:整版引导 ===== -->
|
||||||
<div x-show="!user" x-cloak class="border border-black/15 bg-white px-8 py-20 text-center">
|
<div x-show="!user" x-cloak class="flex flex-col items-center justify-center border border-black/15 px-6 py-32 text-center">
|
||||||
<p class="mb-6 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
|
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{pleaseLabel}</p>
|
||||||
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-6 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
|
<p class="mb-12 font-serif text-5xl font-black uppercase leading-none tracking-tight md:text-7xl">{titleLabel}</p>
|
||||||
|
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-10 py-4 font-mono text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 已登录:个人中心 -->
|
<!-- ===== 已登录 ===== -->
|
||||||
<div x-show="user" x-cloak class="space-y-10">
|
<div x-show="user" x-cloak>
|
||||||
|
|
||||||
<!-- 报头:头像 + 用户名 -->
|
<!-- 报头:衬线大标题会员名 + 档案标签 -->
|
||||||
<section class="flex items-center gap-5 border-b border-black/15 pb-8">
|
<header class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8 border-b-2 border-black pb-10 pt-2">
|
||||||
<div class="flex h-16 w-16 shrink-0 items-center justify-center border border-black font-serif text-2xl tracking-[-0.04em]" x-text="(user?.username || '?').slice(0,2).toUpperCase()"></div>
|
<div class="min-w-0">
|
||||||
<div>
|
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
|
||||||
<p class="mb-2 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{t("member profile")}</p>
|
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
|
||||||
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.04em] md:text-5xl" x-text="user?.username"></h1>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.24em] text-zinc-400">
|
||||||
|
<p>Portfolio Studio</p>
|
||||||
|
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
||||||
|
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||||
|
</p>
|
||||||
|
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- 主体:左侧导航 + 右侧内容 -->
|
<!-- 主体:左侧分区索引 + 右侧内容 -->
|
||||||
<div class="grid gap-8 lg:grid-cols-[200px_1fr]">
|
<div class="grid gap-12 pt-12 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16 lg:pt-16">
|
||||||
|
|
||||||
<!-- 侧栏导航 -->
|
<!-- 左:编号目录式垂直导航(移动端折成横向条) -->
|
||||||
<aside class="lg:sticky lg:top-8 lg:self-start">
|
<nav class="lg:sticky lg:top-12 lg:self-start" aria-label="Account sections">
|
||||||
<nav class="flex gap-1 overflow-x-auto border-b border-black/15 lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0">
|
<p class="mb-3 hidden font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400 lg:block">Index</p>
|
||||||
<button @click="tab='saved'" :class="tab==='saved' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] font-mono uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{t("saved looks")}</button>
|
<div class="flex gap-10 overflow-x-auto border-b border-black/15 font-mono text-[12px] uppercase tracking-[0.2em] lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0 lg:border-t lg:border-black/10">
|
||||||
<button @click="tab='history'" :class="tab==='history' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] font-mono uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{historyLabel}</button>
|
<button type="button" @click="tab='saved'" :class="tab==='saved' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
|
||||||
|
<span class="mr-3 text-[10px] opacity-60">01</span>{savedLooksLabel}
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="tab='history'" :class="tab==='history' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
|
||||||
|
<span class="mr-3 text-[10px] opacity-60">02</span>{historyLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- 内容面板 -->
|
<div class="min-w-0">
|
||||||
<section class="js-surface js-panel border border-black/15 bg-white p-6 md:p-10">
|
<!-- ===== Saved looks ===== -->
|
||||||
|
|
||||||
<!-- Saved looks -->
|
|
||||||
<div x-show="tab==='saved'" x-cloak>
|
<div x-show="tab==='saved'" x-cloak>
|
||||||
<div class="mb-6 flex items-center justify-between">
|
|
||||||
<h2 class="font-serif text-2xl uppercase tracking-[-0.03em] md:text-3xl">{t("saved looks")}</h2>
|
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||||
<span class="text-[10px] font-mono uppercase tracking-[0.24em] text-black/40"><span x-text="favoritesCount"></span> {itemsLabel}</span>
|
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
|
||||||
|
<p class="pb-1 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1Z"/></svg>
|
<!-- 粒度切换:图集 / 单图(服务端 kind 过滤分页) -->
|
||||||
<p class="mb-6 text-sm text-black/45">{t("no saved looks yet")}</p>
|
<div class="mb-10 flex items-center gap-8 border-b border-black/15 font-mono text-[11px] uppercase tracking-[0.2em]">
|
||||||
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
|
<button type="button" @click="setFavKind('gallery')" :class="favKind==='gallery' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('galleries')}</button>
|
||||||
|
<button type="button" @click="setFavKind('image')" :class="favKind==='image' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('images')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
|
||||||
|
<!-- 空态 -->
|
||||||
|
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||||
|
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noSavedLabel}</p>
|
||||||
|
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 收藏网格 -->
|
||||||
|
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
|
||||||
<template x-for="f in favorites" :key="f.id">
|
<template x-for="f in favorites" :key="f.id">
|
||||||
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
|
<div class="group">
|
||||||
<a :href="itemHref(f)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
|
<a :href="itemHref(f)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
|
||||||
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
|
||||||
</a>
|
</a>
|
||||||
<button @click="removeFav(f.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] font-mono uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
|
<button @click="removeFav(f.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
|
||||||
<div class="p-3">
|
<div class="mt-3 min-w-0">
|
||||||
<a :href="itemHref(f)"><p class="truncate text-[12px] font-medium" x-text="f.title"></p></a>
|
<a :href="itemHref(f)" class="block">
|
||||||
<p class="mt-0.5 truncate text-[10px] font-mono uppercase tracking-[0.16em] text-black/45" x-text="f.brand || '—'"></p>
|
<p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p>
|
||||||
|
</a>
|
||||||
|
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 收藏分页(服务端分页,与收藏总量解耦,10万也常数级) -->
|
<!-- 收藏分页(服务端分页,与收藏总量解耦) -->
|
||||||
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="favLastPage > 1" aria-label="Saved pagination">
|
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="favLastPage > 1" aria-label="Saved pagination">
|
||||||
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
|
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
|
||||||
<template x-for="(p, i) in favPageList" :key="i">
|
<template x-for="(p, i) in favPageList" :key="i">
|
||||||
<span>
|
<span>
|
||||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
|
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
|
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- History -->
|
<!-- ===== History ===== -->
|
||||||
<div x-show="tab==='history'" x-cloak>
|
<div x-show="tab==='history'" x-cloak>
|
||||||
<div class="mb-6">
|
|
||||||
<h2 class="font-serif text-2xl uppercase tracking-[-0.03em] md:text-3xl">{historyLabel}</h2>
|
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||||
|
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{historyLabel}</h2>
|
||||||
|
<div class="flex items-baseline gap-8 pb-1">
|
||||||
|
<p class="font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="histTotal"></span> {itemsLabel}</p>
|
||||||
|
<button x-show="histTotal > 0" @click="clearAllHistory()" class="font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4 flex items-center justify-between">
|
|
||||||
<span class="text-[10px] font-mono uppercase tracking-[0.24em] text-black/40"><span x-text="histTotal"></span> {itemsLabel}</span>
|
|
||||||
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[10px] font-mono uppercase tracking-[0.22em] text-black/45 underline-offset-4 hover:text-black hover:underline">{clearHistoryLabel}</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
|
<!-- 空态 -->
|
||||||
<p class="mb-6 text-sm text-black/45">{t("no history yet")}</p>
|
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||||
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
|
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noHistoryLabel}</p>
|
||||||
|
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
|
||||||
|
<!-- 历史网格 -->
|
||||||
|
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
|
||||||
<template x-for="h in history" :key="h.id">
|
<template x-for="h in history" :key="h.id">
|
||||||
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
|
<div class="group">
|
||||||
<a :href="itemHref(h)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
|
<a :href="itemHref(h)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
|
||||||
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
|
||||||
</a>
|
</a>
|
||||||
<button @click="removeHist(h.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] font-mono uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
|
<button @click="removeHist(h.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
|
||||||
<div class="p-3">
|
<div class="mt-3 min-w-0">
|
||||||
<a :href="itemHref(h)"><p class="truncate text-[12px] font-medium" x-text="h.data?.title || '…'"></p></a>
|
<a :href="itemHref(h)" class="block">
|
||||||
<p class="mt-0.5 truncate text-[10px] font-mono uppercase tracking-[0.16em] text-black/45" x-text="h.data?.brand || '—'"></p>
|
<p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p>
|
||||||
|
</a>
|
||||||
|
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="histLastPage > 1" aria-label="History pagination">
|
<!-- 历史分页 -->
|
||||||
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
|
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="histLastPage > 1" aria-label="History pagination">
|
||||||
|
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
|
||||||
<template x-for="(p, i) in histPageList" :key="i">
|
<template x-for="(p, i) in histPageList" :key="i">
|
||||||
<span>
|
<span>
|
||||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
|
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
|
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 触屏没有 hover:卡片右上角操作常驻显示,否则点不到 */
|
||||||
|
@media (hover: none) {
|
||||||
|
.card-act { opacity: 1 !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getUser, toAbs } from "@/lib/api"
|
import { getUser, toAbs } from "@/lib/api"
|
||||||
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
|
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
|
||||||
@ -155,6 +195,7 @@ const clearHistoryLabel = t("clear history")
|
|||||||
user: null as any,
|
user: null as any,
|
||||||
locale: "en",
|
locale: "en",
|
||||||
tab: "saved",
|
tab: "saved",
|
||||||
|
favKind: "gallery", // 收藏子分类:gallery=整篇图集 / image=单张图片
|
||||||
favorites: [] as any[],
|
favorites: [] as any[],
|
||||||
favoritesCount: 0,
|
favoritesCount: 0,
|
||||||
favPage: 1,
|
favPage: 1,
|
||||||
@ -180,18 +221,19 @@ const clearHistoryLabel = t("clear history")
|
|||||||
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
|
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
|
||||||
this.loadHistPage(1)
|
this.loadHistPage(1)
|
||||||
},
|
},
|
||||||
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级)
|
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级);kind 决定取图集还是单图
|
||||||
async loadFavPage(page: number) {
|
async loadFavPage(page: number, kind?: string) {
|
||||||
const p = Math.max(1, page | 0)
|
const p = Math.max(1, page | 0)
|
||||||
|
const k = kind || this.favKind
|
||||||
try {
|
try {
|
||||||
const r = await fetchFavoritesPage(p, this.favPerPage)
|
const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery")
|
||||||
this.favorites = r.items
|
this.favorites = r.items
|
||||||
this.favTotal = r.total
|
this.favTotal = r.total
|
||||||
this.favLastPage = r.lastPage
|
this.favLastPage = r.lastPage
|
||||||
this.favPage = p
|
this.favPage = p
|
||||||
this.favoritesCount = r.total
|
this.favoritesCount = r.total
|
||||||
} catch {
|
} catch {
|
||||||
const local = getFavorites()
|
const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery"))
|
||||||
this.favorites = local
|
this.favorites = local
|
||||||
this.favTotal = local.length
|
this.favTotal = local.length
|
||||||
this.favLastPage = 1
|
this.favLastPage = 1
|
||||||
@ -199,6 +241,14 @@ const clearHistoryLabel = t("clear history")
|
|||||||
this.favoritesCount = local.length
|
this.favoritesCount = local.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setFavKind(k: string) {
|
||||||
|
if (this.favKind === k) return
|
||||||
|
this.favKind = k
|
||||||
|
this.favorites = []
|
||||||
|
this.favoritesCount = 0
|
||||||
|
this.favLastPage = 1
|
||||||
|
this.loadFavPage(1, k)
|
||||||
|
},
|
||||||
goFavPage(p: number) {
|
goFavPage(p: number) {
|
||||||
if (p < 1 || p > this.favLastPage || p === this.favPage) return
|
if (p < 1 || p > this.favLastPage || p === this.favPage) return
|
||||||
this.loadFavPage(p)
|
this.loadFavPage(p)
|
||||||
@ -223,7 +273,12 @@ const clearHistoryLabel = t("clear history")
|
|||||||
return c ? toAbs(c) : PLACEHOLDER
|
return c ? toAbs(c) : PLACEHOLDER
|
||||||
},
|
},
|
||||||
itemHref(f: any): string {
|
itemHref(f: any): string {
|
||||||
return clientHref(this.locale, ROUTES.item(f.id))
|
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
|
||||||
|
// 图集收藏与历史仍用自身 uid。
|
||||||
|
const isImg = f.kind === "image"
|
||||||
|
const uid = isImg && f.parentUid ? f.parentUid : f.id
|
||||||
|
const base = clientHref(this.locale, ROUTES.item(uid))
|
||||||
|
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
|
||||||
},
|
},
|
||||||
removeFav(id: string) {
|
removeFav(id: string) {
|
||||||
removeFavorite(id)
|
removeFavorite(id)
|
||||||
|
|||||||
@ -9,134 +9,174 @@ const titleLabel = t("my account")
|
|||||||
const pleaseLabel = t("please log in")
|
const pleaseLabel = t("please log in")
|
||||||
const loginLabel = t("login")
|
const loginLabel = t("login")
|
||||||
const historyLabel = t("my history")
|
const historyLabel = t("my history")
|
||||||
|
const savedLooksLabel = t("saved looks")
|
||||||
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
|
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
|
||||||
const archiveUrl = href(locale, ROUTES.runwayLooks)
|
const archiveUrl = href(locale, ROUTES.runwayLooks)
|
||||||
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
|
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
|
||||||
const removeLabel = t("remove")
|
const removeLabel = t("remove")
|
||||||
const itemsLabel = t("items")
|
const itemsLabel = t("items")
|
||||||
const clearHistoryLabel = t("clear history")
|
const clearHistoryLabel = t("clear history")
|
||||||
|
const noSavedLabel = t("no saved looks yet")
|
||||||
|
const noHistoryLabel = t("no history yet")
|
||||||
|
const browseLabel = t("browse the archive")
|
||||||
---
|
---
|
||||||
<Layout title={titleLabel}>
|
<Layout title={titleLabel}>
|
||||||
<main class="account-root mx-auto w-full max-w-7xl px-6 py-10 md:px-8 md:py-14" x-data="accountPage" data-locale={locale}>
|
<!-- 页面版心与 Layout 一致:黑白编辑风,衬线大标题 + 等宽小标 + 粗黑细线 -->
|
||||||
|
<main class="account-root mx-auto w-full max-w-[1400px] px-5 py-14 md:px-10 md:py-20 font-sans" x-data="accountPage" data-locale={locale}>
|
||||||
|
|
||||||
<!-- 未登录:引导登录 -->
|
<!-- ===== 未登录:整版引导 ===== -->
|
||||||
<div x-show="!user" x-cloak class="border border-black/15 bg-white px-8 py-20 text-center">
|
<div x-show="!user" x-cloak class="flex flex-col items-center justify-center border border-black/15 px-6 py-32 text-center">
|
||||||
<p class="mb-6 text-[10px] uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
|
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{pleaseLabel}</p>
|
||||||
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-6 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
|
<p class="mb-12 font-serif text-5xl font-black uppercase leading-none tracking-tight md:text-7xl">{titleLabel}</p>
|
||||||
|
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-10 py-4 font-mono text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 已登录:个人中心 -->
|
<!-- ===== 已登录 ===== -->
|
||||||
<div x-show="user" x-cloak class="space-y-10">
|
<div x-show="user" x-cloak>
|
||||||
|
|
||||||
<!-- 报头:头像 + 用户名 -->
|
<!-- 报头:衬线大标题会员名 + 档案标签 -->
|
||||||
<section class="flex items-center gap-5 border-b border-black/15 pb-8">
|
<header class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8 border-b-2 border-black pb-10 pt-2">
|
||||||
<div class="flex h-16 w-16 shrink-0 items-center justify-center border border-black text-2xl tracking-[-0.04em]" x-text="(user?.username || '?').slice(0,2).toUpperCase()"></div>
|
<div class="min-w-0">
|
||||||
<div>
|
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
|
||||||
<p class="mb-2 text-[10px] uppercase tracking-[0.32em] text-black/45">{t("member profile")}</p>
|
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
|
||||||
<h1 class="text-4xl uppercase leading-none tracking-[-0.04em] md:text-5xl" x-text="user?.username"></h1>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="shrink-0 pb-1 text-right font-mono text-[11px] uppercase leading-loose tracking-[0.24em] text-zinc-400">
|
||||||
|
<p>Portfolio Studio</p>
|
||||||
|
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
|
||||||
|
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
|
||||||
|
</p>
|
||||||
|
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- 主体:左侧导航 + 右侧内容 -->
|
<!-- 主体:左侧分区索引 + 右侧内容 -->
|
||||||
<div class="grid gap-8 lg:grid-cols-[200px_1fr]">
|
<div class="grid gap-12 pt-12 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16 lg:pt-16">
|
||||||
|
|
||||||
<!-- 侧栏导航 -->
|
<!-- 左:编号目录式垂直导航(移动端折成横向条) -->
|
||||||
<aside class="lg:sticky lg:top-8 lg:self-start">
|
<nav class="lg:sticky lg:top-12 lg:self-start" aria-label="Account sections">
|
||||||
<nav class="flex gap-1 overflow-x-auto border-b border-black/15 lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0">
|
<p class="mb-3 hidden font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400 lg:block">Index</p>
|
||||||
<button @click="tab='saved'" :class="tab==='saved' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{t("saved looks")}</button>
|
<div class="flex gap-10 overflow-x-auto border-b border-black/15 font-mono text-[12px] uppercase tracking-[0.2em] lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0 lg:border-t lg:border-black/10">
|
||||||
<button @click="tab='history'" :class="tab==='history' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{historyLabel}</button>
|
<button type="button" @click="tab='saved'" :class="tab==='saved' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
|
||||||
|
<span class="mr-3 text-[10px] opacity-60">01</span>{savedLooksLabel}
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="tab='history'" :class="tab==='history' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
|
||||||
|
<span class="mr-3 text-[10px] opacity-60">02</span>{historyLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- 内容面板 -->
|
<div class="min-w-0">
|
||||||
<section class="js-surface js-panel border border-black/15 bg-white p-6 md:p-10">
|
<!-- ===== Saved looks ===== -->
|
||||||
|
|
||||||
<!-- Saved looks -->
|
|
||||||
<div x-show="tab==='saved'" x-cloak>
|
<div x-show="tab==='saved'" x-cloak>
|
||||||
<div class="mb-6 flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl uppercase tracking-[-0.03em] md:text-3xl">{t("saved looks")}</h2>
|
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||||
<span class="text-[10px] uppercase tracking-[0.24em] text-black/40"><span x-text="favoritesCount"></span> {itemsLabel}</span>
|
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
|
||||||
|
<p class="pb-1 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1Z"/></svg>
|
<!-- 粒度切换:图集 / 单图(服务端 kind 过滤分页) -->
|
||||||
<p class="mb-6 text-sm text-black/45">{t("no saved looks yet")}</p>
|
<div class="mb-10 flex items-center gap-8 border-b border-black/15 font-mono text-[11px] uppercase tracking-[0.2em]">
|
||||||
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
|
<button type="button" @click="setFavKind('gallery')" :class="favKind==='gallery' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('galleries')}</button>
|
||||||
|
<button type="button" @click="setFavKind('image')" :class="favKind==='image' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('images')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
|
||||||
|
<!-- 空态 -->
|
||||||
|
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||||
|
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noSavedLabel}</p>
|
||||||
|
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 收藏网格 -->
|
||||||
|
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
|
||||||
<template x-for="f in favorites" :key="f.id">
|
<template x-for="f in favorites" :key="f.id">
|
||||||
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
|
<div class="group">
|
||||||
<a :href="itemHref(f)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
|
<a :href="itemHref(f)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
|
||||||
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
|
||||||
</a>
|
</a>
|
||||||
<button @click="removeFav(f.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
|
<button @click="removeFav(f.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
|
||||||
<div class="p-3">
|
<div class="mt-3 min-w-0">
|
||||||
<a :href="itemHref(f)"><p class="truncate text-[12px] font-medium" x-text="f.title"></p></a>
|
<a :href="itemHref(f)" class="block">
|
||||||
<p class="mt-0.5 truncate text-[10px] uppercase tracking-[0.16em] text-black/45" x-text="f.brand || '—'"></p>
|
<p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p>
|
||||||
|
</a>
|
||||||
|
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 收藏分页(服务端分页,与收藏总量解耦,10万也常数级) -->
|
<!-- 收藏分页(服务端分页,与收藏总量解耦) -->
|
||||||
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="favLastPage > 1" aria-label="Saved pagination">
|
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="favLastPage > 1" aria-label="Saved pagination">
|
||||||
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
|
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
|
||||||
<template x-for="(p, i) in favPageList" :key="i">
|
<template x-for="(p, i) in favPageList" :key="i">
|
||||||
<span>
|
<span>
|
||||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
|
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
|
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- History -->
|
<!-- ===== History ===== -->
|
||||||
<div x-show="tab==='history'" x-cloak>
|
<div x-show="tab==='history'" x-cloak>
|
||||||
<div class="mb-6">
|
|
||||||
<h2 class="text-2xl uppercase tracking-[-0.03em] md:text-3xl">{historyLabel}</h2>
|
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||||
|
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{historyLabel}</h2>
|
||||||
|
<div class="flex items-baseline gap-8 pb-1">
|
||||||
|
<p class="font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="histTotal"></span> {itemsLabel}</p>
|
||||||
|
<button x-show="histTotal > 0" @click="clearAllHistory()" class="font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4 flex items-center justify-between">
|
|
||||||
<span class="text-[10px] uppercase tracking-[0.24em] text-black/40"><span x-text="histTotal"></span> {itemsLabel}</span>
|
|
||||||
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[10px] uppercase tracking-[0.22em] text-black/45 underline-offset-4 hover:text-black hover:underline">{clearHistoryLabel}</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
|
<!-- 空态 -->
|
||||||
<p class="mb-6 text-sm text-black/45">{t("no history yet")}</p>
|
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||||
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
|
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noHistoryLabel}</p>
|
||||||
|
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
|
||||||
|
<!-- 历史网格 -->
|
||||||
|
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
|
||||||
<template x-for="h in history" :key="h.id">
|
<template x-for="h in history" :key="h.id">
|
||||||
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
|
<div class="group">
|
||||||
<a :href="itemHref(h)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
|
<a :href="itemHref(h)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
|
||||||
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
|
||||||
</a>
|
</a>
|
||||||
<button @click="removeHist(h.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
|
<button @click="removeHist(h.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 font-mono text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
|
||||||
<div class="p-3">
|
<div class="mt-3 min-w-0">
|
||||||
<a :href="itemHref(h)"><p class="truncate text-[12px] font-medium" x-text="h.data?.title || '…'"></p></a>
|
<a :href="itemHref(h)" class="block">
|
||||||
<p class="mt-0.5 truncate text-[10px] uppercase tracking-[0.16em] text-black/45" x-text="h.data?.brand || '—'"></p>
|
<p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p>
|
||||||
|
</a>
|
||||||
|
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="histLastPage > 1" aria-label="History pagination">
|
<!-- 历史分页 -->
|
||||||
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
|
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="histLastPage > 1" aria-label="History pagination">
|
||||||
|
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
|
||||||
<template x-for="(p, i) in histPageList" :key="i">
|
<template x-for="(p, i) in histPageList" :key="i">
|
||||||
<span>
|
<span>
|
||||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
|
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
|
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 触屏没有 hover:卡片右上角操作常驻显示,否则点不到 */
|
||||||
|
@media (hover: none) {
|
||||||
|
.card-act { opacity: 1 !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getUser, toAbs } from "@/lib/api"
|
import { getUser, toAbs } from "@/lib/api"
|
||||||
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
|
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
|
||||||
@ -155,6 +195,7 @@ const clearHistoryLabel = t("clear history")
|
|||||||
user: null as any,
|
user: null as any,
|
||||||
locale: "en",
|
locale: "en",
|
||||||
tab: "saved",
|
tab: "saved",
|
||||||
|
favKind: "gallery", // 收藏子分类:gallery=整篇图集 / image=单张图片
|
||||||
favorites: [] as any[],
|
favorites: [] as any[],
|
||||||
favoritesCount: 0,
|
favoritesCount: 0,
|
||||||
favPage: 1,
|
favPage: 1,
|
||||||
@ -180,18 +221,19 @@ const clearHistoryLabel = t("clear history")
|
|||||||
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
|
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
|
||||||
this.loadHistPage(1)
|
this.loadHistPage(1)
|
||||||
},
|
},
|
||||||
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级)
|
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级);kind 决定取图集还是单图
|
||||||
async loadFavPage(page: number) {
|
async loadFavPage(page: number, kind?: string) {
|
||||||
const p = Math.max(1, page | 0)
|
const p = Math.max(1, page | 0)
|
||||||
|
const k = kind || this.favKind
|
||||||
try {
|
try {
|
||||||
const r = await fetchFavoritesPage(p, this.favPerPage)
|
const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery")
|
||||||
this.favorites = r.items
|
this.favorites = r.items
|
||||||
this.favTotal = r.total
|
this.favTotal = r.total
|
||||||
this.favLastPage = r.lastPage
|
this.favLastPage = r.lastPage
|
||||||
this.favPage = p
|
this.favPage = p
|
||||||
this.favoritesCount = r.total
|
this.favoritesCount = r.total
|
||||||
} catch {
|
} catch {
|
||||||
const local = getFavorites()
|
const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery"))
|
||||||
this.favorites = local
|
this.favorites = local
|
||||||
this.favTotal = local.length
|
this.favTotal = local.length
|
||||||
this.favLastPage = 1
|
this.favLastPage = 1
|
||||||
@ -199,6 +241,14 @@ const clearHistoryLabel = t("clear history")
|
|||||||
this.favoritesCount = local.length
|
this.favoritesCount = local.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setFavKind(k: string) {
|
||||||
|
if (this.favKind === k) return
|
||||||
|
this.favKind = k
|
||||||
|
this.favorites = []
|
||||||
|
this.favoritesCount = 0
|
||||||
|
this.favLastPage = 1
|
||||||
|
this.loadFavPage(1, k)
|
||||||
|
},
|
||||||
goFavPage(p: number) {
|
goFavPage(p: number) {
|
||||||
if (p < 1 || p > this.favLastPage || p === this.favPage) return
|
if (p < 1 || p > this.favLastPage || p === this.favPage) return
|
||||||
this.loadFavPage(p)
|
this.loadFavPage(p)
|
||||||
@ -223,7 +273,12 @@ const clearHistoryLabel = t("clear history")
|
|||||||
return c ? toAbs(c) : PLACEHOLDER
|
return c ? toAbs(c) : PLACEHOLDER
|
||||||
},
|
},
|
||||||
itemHref(f: any): string {
|
itemHref(f: any): string {
|
||||||
return clientHref(this.locale, ROUTES.item(f.id))
|
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
|
||||||
|
// 图集收藏与历史仍用自身 uid。
|
||||||
|
const isImg = f.kind === "image"
|
||||||
|
const uid = isImg && f.parentUid ? f.parentUid : f.id
|
||||||
|
const base = clientHref(this.locale, ROUTES.item(uid))
|
||||||
|
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
|
||||||
},
|
},
|
||||||
removeFav(id: string) {
|
removeFav(id: string) {
|
||||||
removeFavorite(id)
|
removeFavorite(id)
|
||||||
|
|||||||
@ -82,9 +82,6 @@
|
|||||||
}
|
}
|
||||||
.showsg-chev.is-collapsed,
|
.showsg-chev.is-collapsed,
|
||||||
.snapsg-chev.is-collapsed { @apply -rotate-90; }
|
.snapsg-chev.is-collapsed { @apply -rotate-90; }
|
||||||
.showsg-count {
|
|
||||||
@apply text-[#999] font-normal text-[10px] tracking-[0.14em];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 复选 / 单选列表 */
|
/* 复选 / 单选列表 */
|
||||||
.showschecks,
|
.showschecks,
|
||||||
@ -95,9 +92,6 @@
|
|||||||
.snapschecks li {
|
.snapschecks li {
|
||||||
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
|
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
|
||||||
}
|
}
|
||||||
.showschecks li.letter-head {
|
|
||||||
@apply p-[8px_0_3px] text-[10px] tracking-[0.22em] text-[#8a8a8a] cursor-default pointer-events-none border-t border-dotted border-[#e6e6e6] first:border-t-0 first:pt-1;
|
|
||||||
}
|
|
||||||
.showschecks input[type="radio"],
|
.showschecks input[type="radio"],
|
||||||
.snapschecks input[type="radio"] {
|
.snapschecks input[type="radio"] {
|
||||||
@apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
|
@apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
|
||||||
@ -109,9 +103,6 @@
|
|||||||
@apply bg-white border-black;
|
@apply bg-white border-black;
|
||||||
background-image: radial-gradient(circle, #000 36%, transparent 40%);
|
background-image: radial-gradient(circle, #000 36%, transparent 40%);
|
||||||
}
|
}
|
||||||
.showschecks .ct {
|
|
||||||
@apply text-[#999] text-[10px] ml-auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showschecks--flat li,
|
.showschecks--flat li,
|
||||||
.snapschecks--flat li { @apply py-[4px]; }
|
.snapschecks--flat li { @apply py-[4px]; }
|
||||||
@ -173,14 +164,6 @@
|
|||||||
.showscard-thumb.is-loaded .showsspin,
|
.showscard-thumb.is-loaded .showsspin,
|
||||||
.snapscard-thumb.is-loaded .snapsspin { @apply opacity-0; }
|
.snapscard-thumb.is-loaded .snapsspin { @apply opacity-0; }
|
||||||
|
|
||||||
/* runway 专属徽标 */
|
|
||||||
.showsbadge {
|
|
||||||
@apply absolute top-[6px] right-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-white bg-black p-[3px_6px];
|
|
||||||
}
|
|
||||||
.showsflag-featured {
|
|
||||||
@apply absolute top-[6px] left-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-black bg-white p-[3px_6px] border border-black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showscard-right,
|
.showscard-right,
|
||||||
.snapscard-right {
|
.snapscard-right {
|
||||||
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
|
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
|
||||||
@ -197,13 +180,6 @@
|
|||||||
.snapscard-meta {
|
.snapscard-meta {
|
||||||
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
|
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
|
||||||
}
|
}
|
||||||
/* runway 专属:卡片底部多行 meta */
|
|
||||||
.showscard-meta-foot {
|
|
||||||
@apply flex flex-col gap-[6px] h-[38px] overflow-hidden;
|
|
||||||
}
|
|
||||||
.showscard-meta-line {
|
|
||||||
@apply inline-flex items-center gap-[6px] text-[10.5px] tracking-[0.06em] uppercase text-[#444] h-[16px] leading-[16px] overflow-hidden whitespace-nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showscard-strip,
|
.showscard-strip,
|
||||||
.snapscard-strip {
|
.snapscard-strip {
|
||||||
|
|||||||
Reference in New Issue
Block a user