This commit is contained in:
toom1996
2026-09-17 22:06:11 +08:00
parent 70a3304dc1
commit b04a511b26
6 changed files with 323 additions and 2 deletions

View File

@ -134,7 +134,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url">
<template x-for="(im, i) in extraImages" :key="im.url" >
<figure
:data-image-index="5 + i"
:data-image-url="im.url"

View File

@ -0,0 +1,73 @@
---
import Layout from "@/layouts/Layout.astro"
import RunwayLookCard from "@/components/RunwayLookCard.astro"
import { getSsrArticle } from "@/lib/ssr"
import { getI18n } from "@/lib/i18n"
import { config } from "@/lib/config"
// 走秀(runway)详情视图,en/cn 两页共用(语言由 Astro.currentLocale 运行时决定)。
// 注意:export const prerender = false 必须留在页面文件(Astro 只读页面模块的该导出),
// 故 id 由页面从 Astro.params 取出后作为 prop 透传进来。
interface Props {
id?: string
}
const { id } = Astro.props
const { locale, t } = getI18n(Astro)
let article = null
let articleErr = null
let preview = false
let imageTotal = 0
const data = await getSsrArticle(id, Astro.currentLocale)
article = "article" in data ? data.article : null
articleErr = data.error
preview = data.preview ?? false
imageTotal = data.imageTotal ?? 0
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
// id 优先取接口回传的规范 id(带类型前缀):用户若通过旧的无前缀链接进来,也保证写入规范形式。
const canonId = String(article?.id ?? id ?? "")
const meta = { id: canonId, type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
// 详情页动态 TDK:标题用「品牌 + 系列」,描述/关键词按语言生成(避免嵌套反引号,用拼接)。
const SITE_NAME = config.siteName ?? "Xisoa"
const runwayName = article ? (article.brand_name + " " + article.title).trim() : ""
const detailTitle = runwayName || SITE_NAME
const detailDesc = locale === "cn"
? `${detailTitle} 走秀造型共 ${imageTotal} 张,查看细节图与系列信息,收录于 ${SITE_NAME} 时尚档案。`
: `${detailTitle} — ${imageTotal} runway looks with detail images and collection info, in the ${SITE_NAME} fashion archive.`
const detailKeywords = `${article?.brand_name || ""},runway,runway looks,fashion week,${SITE_NAME}`.replace(/^,/, "")
const saveLabel = t("save")
const savedLabel = t("saved")
---
<Layout title={detailTitle} description={detailDesc} keywords={detailKeywords}>
<RunwayLookCard article={article} error={articleErr} preview={preview} imageTotal={imageTotal} />
</Layout>
<style>
#fav-fab.is-active .fav-heart { fill: currentColor; }
</style>
{article && (
<button login-required id="fav-fab" type="button"
data-id={meta.id}
data-type={meta.type}
data-title={meta.title}
data-cover={meta.cover}
data-brand={meta.brand}
data-save-label={saveLabel}
data-saved-label={savedLabel}
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" 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>
<span id="fav-label">{saveLabel}</span>
</button>
)}
<script>
import { initDetailFav } from "@/lib/detailFav"
initDetailFav()
</script>

View File

@ -0,0 +1,72 @@
---
import Layout from "@/layouts/Layout.astro"
import StreetSnapCard from "@/components/StreetSnapCard.astro"
import { getSsrStreetSnap } from "@/lib/ssr"
import { getI18n } from "@/lib/i18n"
import { config } from "@/lib/config"
// 街拍(street snap)详情视图,en/cn 两页共用(语言由 Astro.currentLocale 运行时决定)。
// 注意:export const prerender = false 必须留在页面文件(Astro 只读页面模块的该导出),
// 故 id 由页面从 Astro.params 取出后作为 prop 透传进来。
interface Props {
id?: string
}
const { id } = Astro.props
const { locale, t } = getI18n(Astro)
let snap = null
let snapErr = null
let preview = false
let imageTotal = 0
const data = await getSsrStreetSnap(id, Astro.currentLocale)
snap = "snap" in data ? data.snap : null
snapErr = data.error
preview = data.preview ?? false
imageTotal = data.imageTotal ?? 0
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
const canonId = String(snap?.id ?? id ?? "")
const meta = { id: canonId, type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
// 详情页动态 TDK:标题用街拍标题,描述/关键词按语言生成(避免嵌套反引号,用拼接)。
const SITE_NAME = config.siteName ?? "Xisoa"
const baseName = snap?.title || ""
const detailTitle = baseName || SITE_NAME
const detailDesc = locale === "cn"
? `${detailTitle} —— 来自全球街头的时尚抓拍,收录于 ${SITE_NAME} 时尚档案。`
: `${detailTitle} — street style snaps from cities around the world, in the ${SITE_NAME} fashion archive.`
const detailKeywords = `${snap?.title || ""},street style,street snaps,fashion,${SITE_NAME}`.replace(/^,/, "")
const saveLabel = t("save")
const savedLabel = t("saved")
---
<Layout title={detailTitle} description={detailDesc} keywords={detailKeywords}>
<StreetSnapCard snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />
</Layout>
<style>
#fav-fab.is-active .fav-heart { fill: currentColor; }
</style>
{snap && (
<button login-required id="fav-fab" type="button"
data-id={meta.id}
data-type={meta.type}
data-title={meta.title}
data-cover={meta.cover}
data-brand={meta.brand}
data-save-label={saveLabel}
data-saved-label={savedLabel}
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" 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>
<span id="fav-label">{saveLabel}</span>
</button>
)}
<script>
import { initDetailFav } from "@/lib/detailFav"
initDetailFav()
</script>

59
src/lib/detailFav.ts Normal file
View File

@ -0,0 +1,59 @@
import { getUser } from "@/lib/auth"
import { isFavorited, toggleFavorite, hydrateFavorited } from "@/lib/favorites"
import { recordHistory } from "@/lib/history"
/**
* 详情页「收藏 / 历史」按钮初始化。
* 按钮本体(#fav-fab)由详情视图渲染,元信息经 data-* 透传(不依赖 define:vars,避免打包期 import 进 IIFE 内报错)。
* runway / street 两类详情共用本逻辑,区别仅在于透传的 data-type 值。
* 注意:本函数只在详情页(含 #fav-fab 的页面)调用一次。
*/
export function initDetailFav() {
const btn = document.getElementById("fav-fab")
const label = document.getElementById("fav-label")
if (!btn) return
const meta = {
id: btn.dataset.id || "",
type: btn.dataset.type || "",
title: btn.dataset.title || "",
cover: btn.dataset.cover || "",
brand: btn.dataset.brand || "",
}
const saveLabel = btn.dataset.saveLabel || "save"
const savedLabel = btn.dataset.savedLabel || "saved"
if (!meta.id) return
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
if (getUser()) recordHistory(meta.id)
const sync = () => {
const fav = isFavorited(meta.id)
if (label) label.textContent = fav ? savedLabel : saveLabel
btn.classList.toggle("is-active", fav)
}
sync()
// 登录态收藏校正已合并进详情接口:articleView / streetSnapView 取到服务端收藏态后,
// 通过 fav:set 事件广播当前图集是否已收藏;此处监听并写回本地(换设备后与服务端一致)。
window.addEventListener("fav:set", (e: Event) => {
const detail = (e as CustomEvent<{ id: string; fav: boolean }>).detail
if (!detail || detail.id !== meta.id) return
if (detail.fav) {
hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
}
sync()
})
btn.addEventListener("click", () => {
toggleFavorite({
id: meta.id,
type: meta.type,
title: meta.title,
cover: meta.cover,
brand: meta.brand,
} as any)
sync()
})
}