This commit is contained in:
toom1996
2026-09-16 21:30:49 +08:00
parent ad028c9ebd
commit 70a3304dc1
22 changed files with 193 additions and 223 deletions

View File

@ -30,9 +30,9 @@ const { groups = [] } = Astro.props as { groups: Group[] }
{
groups.map((g) => (
<section class="showsg">
<header class="showsg-h" @click={`toggleGroup('${g.key}')`}>
<header class="showsg-h" @click={`toggleGroup('${g.key}')`} >
<span>{g.title}</span>
<span class="showsg-chev" :class={`{'is-collapsed': collapsedGroups.${g.key}}`}>
<span class="showsg-chev" :class={`{'is-collapsed': collapsedGroups.${g.key}}`} >
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>

View File

@ -1,157 +0,0 @@
---
import Layout from "@/layouts/Layout.astro"
import RunwayLookCard from "@/components/RunwayLookCard.astro"
import StreetSnapCard from "@/components/StreetSnapCard.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/ssr"
import { getI18n } from "@/lib/i18n"
import { config } from '@/lib/config'
// 按需 SSR:走秀(runway)与街拍(street snap)详情共用本视图,由页面按类型化路由透传 type。
// 对外 id 已是「类型进密码、无前缀」编码(见后端 hashid.EncodeWithType);新类型化路由直接显式传 type。
// 旧 /item/[id] 链接无 type 时回落到按 id 首字符嗅探(s=street),后端 Detail handler 对带 r=/s= 前缀的
// 旧编码串有向后兼容回退(DecodeTyped),故历史外链仍能正确分流。
//
// 本组件是 en/cn 两页的共享实现(语言由 Astro.currentLocale 运行时决定)。
// 注意:`export const prerender = false` 必须留在**页面**文件里(Astro 只读页面模块的该导出),
// 故 id 由页面从 Astro.params 取出后作为 prop 透传进来。
interface Props {
id?: string
type?: "runway" | "street"
}
const { id, type } = Astro.props
// 优先用页面显式传入的 type(类型化路由);旧 /item/[id] 链接无 type 时按 id 前缀嗅探回退。
const isSnap = type ? type === "street" : !!id && id[0] === "s"
const { locale, t } = getI18n(Astro)
let article = null
let articleErr = null
let snap = null
let snapErr = null
let preview = false
let imageTotal = 0
// await 必须位于 frontmatter 顶层(Astro 不支持块内顶层 await)
const data = isSnap
? await getSsrStreetSnap(id, Astro.currentLocale)
: await getSsrArticle(id, Astro.currentLocale)
// 用 in 收窄联合类型:isSnap 是运行时判断,TS 无法据此关联 data 的具体分支。
if (isSnap) {
snap = "snap" in data ? data.snap : null
snapErr = data.error
} else {
article = "article" in data ? data.article : null
articleErr = data.error
}
preview = data.preview ?? false
imageTotal = data.imageTotal ?? 0
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
// id 优先取**接口回传的规范 id**(带类型前缀):用户若通过旧的无前缀链接进来(如 /item/0008CZYI),
// 也保证收藏 / 历史写入的是规范形式,不会与既有记录形成「同一篇文章两行」。
const canonId = String((isSnap ? snap?.id : article?.id) ?? id ?? "")
const meta = isSnap
? { id: canonId, type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
: { 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 baseName = isSnap ? (snap?.title || "") : runwayName
const detailTitle = baseName || SITE_NAME
const detailDesc = isSnap
? (locale === 'cn'
? `${baseName || SITE_NAME} —— 来自全球街头的时尚抓拍,收录于 ${SITE_NAME} 时尚档案。`
: `${baseName || SITE_NAME} — street style snaps from cities around the world, in the ${SITE_NAME} fashion archive.`)
: (locale === 'cn'
? `${baseName || SITE_NAME} 走秀造型共 ${imageTotal} 张,查看细节图与系列信息,收录于 ${SITE_NAME} 时尚档案。`
: `${baseName || SITE_NAME} — ${imageTotal} runway looks with detail images and collection info, in the ${SITE_NAME} fashion archive.`)
const detailKeywords = isSnap
? `${snap?.title || ''},street style,street snaps,fashion,${SITE_NAME}`.replace(/^,/, '')
: `${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}>
{article ? <RunwayLookCard article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnapCard snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
</Layout>
<style>
#fav-fab.is-active .fav-heart { fill: currentColor; }
</style>
{(article || 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>
)}
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
<script>
import { getUser } from "@/lib/auth"
import { isFavorited, toggleFavorite, hydrateFavorited } from "@/lib/favorites"
import { recordHistory } from "@/lib/history"
const btn = document.getElementById("fav-fab")
const label = document.getElementById("fav-label")
if (btn) {
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) {
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 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 事件广播当前图集是否已收藏;此处监听并写回本地(换设备后与服务端一致)。
// 不再单独请求 /me/favorites/checks。
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()
})
}
}
</script>

View File

@ -4,9 +4,6 @@ import CollectionCard from "@/components/CollectionCard.astro"
import RefineSidebar from "@/components/RefineSidebar.astro"
import { getSsgHotBrands } from "@/lib/ssg"
import { getI18n } from '@/lib/i18n'
import { getArticles, getBrands, type ArticleItem, type BrandEntry } from "@/lib/api"
import { makeCardSlots } from "@/lib/ui"
import { createCollectionPage } from "@/lib/collection-page"
import "@/styles/look-grid.css"
const { locale, t } = getI18n(Astro)
@ -95,7 +92,7 @@ const runwayGroups = [
<!-- LEFT SIDEBAR -->
<aside class="showsaside">
<div class="showsaside-head">
<h2 class="showsaside-h">REFINE</h2>
<!-- <h2 class="showsaside-h">REFINE</h2> -->
</div>
<div class="showsaside-body">
@ -272,7 +269,7 @@ const runwayGroups = [
this.modalLetter = "HOT"
this.modalLoading = true
try {
this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name }))
this.modalResults = this.hotBrands.map((b: { id: string | number; name?: string }) => ({ id: b.id, name: b.name }))
} finally {
this.modalLoading = false
}
@ -335,7 +332,7 @@ const runwayGroups = [
get pills() {
const list: { label: string; clear: () => void }[] = []
if (this.selectedBrand) {
const findName = (id: any) => this.hotBrands.find((b) => b.id === id)?.name ?? this.modalResults.find((b) => b.id === id)?.name ?? `#${id}`
const findName = (id: any) => this.hotBrands.find((b: { id: string | number; name?: string }) => b.id === id)?.name ?? this.modalResults.find((b: { id: string | number; name?: string }) => b.id === id)?.name ?? `#${id}`
const name = findName(this.selectedBrand)
list.push({
label: "DESIGNER · " + name,
@ -372,7 +369,7 @@ const runwayGroups = [
return b ? `${b} / ${ti}` : ti
},
// 走秀单行标题,无副标题
cardMeta(it: any) { return "" },
cardMeta() { return "" },
// 渲染 thumb 图片列表(复用共享工具):固定 5 个槽位 + 末位叠加「共 N 张」遮罩。
//

View File

@ -2,9 +2,6 @@
import { getI18n } from '@/lib/i18n'
import CollectionCard from "@/components/CollectionCard.astro"
import RefineSidebar from "@/components/RefineSidebar.astro"
import { getStreetSnaps, type StreetSnapItem } from "@/lib/api"
import { makeCardSlots } from "@/lib/ui"
import { createCollectionPage } from "@/lib/collection-page"
import "@/styles/look-grid.css"
const { locale, t } = getI18n(Astro)
@ -79,7 +76,7 @@ const streetGroups = [
<!-- LEFT SIDEBAR -->
<aside class="snapsaside">
<div class="snapsaside-head">
<h2 class="snapsaside-h">REFINE</h2>
<!-- <h2 class="snapsaside-h">REFINE</h2> -->
</div>
<div class="snapsaside-body">
<RefineSidebar groups={streetGroups} />