update
This commit is contained in:
@ -30,9 +30,9 @@ const { groups = [] } = Astro.props as { groups: Group[] }
|
|||||||
{
|
{
|
||||||
groups.map((g) => (
|
groups.map((g) => (
|
||||||
<section class="showsg">
|
<section class="showsg">
|
||||||
<header class="showsg-h" @click={`toggleGroup('${g.key}')`}>
|
<header class="showsg-h" @click={`toggleGroup('${g.key}')`} >
|
||||||
<span>{g.title}</span>
|
<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>
|
<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>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -4,9 +4,6 @@ import CollectionCard from "@/components/CollectionCard.astro"
|
|||||||
import RefineSidebar from "@/components/RefineSidebar.astro"
|
import RefineSidebar from "@/components/RefineSidebar.astro"
|
||||||
import { getSsgHotBrands } from "@/lib/ssg"
|
import { getSsgHotBrands } from "@/lib/ssg"
|
||||||
import { getI18n } from '@/lib/i18n'
|
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"
|
import "@/styles/look-grid.css"
|
||||||
|
|
||||||
const { locale, t } = getI18n(Astro)
|
const { locale, t } = getI18n(Astro)
|
||||||
@ -95,7 +92,7 @@ const runwayGroups = [
|
|||||||
<!-- LEFT SIDEBAR -->
|
<!-- LEFT SIDEBAR -->
|
||||||
<aside class="showsaside">
|
<aside class="showsaside">
|
||||||
<div class="showsaside-head">
|
<div class="showsaside-head">
|
||||||
<h2 class="showsaside-h">REFINE</h2>
|
<!-- <h2 class="showsaside-h">REFINE</h2> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="showsaside-body">
|
<div class="showsaside-body">
|
||||||
@ -272,7 +269,7 @@ const runwayGroups = [
|
|||||||
this.modalLetter = "HOT"
|
this.modalLetter = "HOT"
|
||||||
this.modalLoading = true
|
this.modalLoading = true
|
||||||
try {
|
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 {
|
} finally {
|
||||||
this.modalLoading = false
|
this.modalLoading = false
|
||||||
}
|
}
|
||||||
@ -335,7 +332,7 @@ const runwayGroups = [
|
|||||||
get pills() {
|
get pills() {
|
||||||
const list: { label: string; clear: () => void }[] = []
|
const list: { label: string; clear: () => void }[] = []
|
||||||
if (this.selectedBrand) {
|
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)
|
const name = findName(this.selectedBrand)
|
||||||
list.push({
|
list.push({
|
||||||
label: "DESIGNER · " + name,
|
label: "DESIGNER · " + name,
|
||||||
@ -372,7 +369,7 @@ const runwayGroups = [
|
|||||||
return b ? `${b} / ${ti}` : ti
|
return b ? `${b} / ${ti}` : ti
|
||||||
},
|
},
|
||||||
// 走秀单行标题,无副标题
|
// 走秀单行标题,无副标题
|
||||||
cardMeta(it: any) { return "" },
|
cardMeta() { return "" },
|
||||||
|
|
||||||
// 渲染 thumb 图片列表(复用共享工具):固定 5 个槽位 + 末位叠加「共 N 张」遮罩。
|
// 渲染 thumb 图片列表(复用共享工具):固定 5 个槽位 + 末位叠加「共 N 张」遮罩。
|
||||||
//
|
//
|
||||||
|
|||||||
0
src/components/views/RunwayLooksDetail.astro
Normal file
0
src/components/views/RunwayLooksDetail.astro
Normal file
@ -2,9 +2,6 @@
|
|||||||
import { getI18n } from '@/lib/i18n'
|
import { getI18n } from '@/lib/i18n'
|
||||||
import CollectionCard from "@/components/CollectionCard.astro"
|
import CollectionCard from "@/components/CollectionCard.astro"
|
||||||
import RefineSidebar from "@/components/RefineSidebar.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"
|
import "@/styles/look-grid.css"
|
||||||
|
|
||||||
const { locale, t } = getI18n(Astro)
|
const { locale, t } = getI18n(Astro)
|
||||||
@ -79,7 +76,7 @@ const streetGroups = [
|
|||||||
<!-- LEFT SIDEBAR -->
|
<!-- LEFT SIDEBAR -->
|
||||||
<aside class="snapsaside">
|
<aside class="snapsaside">
|
||||||
<div class="snapsaside-head">
|
<div class="snapsaside-head">
|
||||||
<h2 class="snapsaside-h">REFINE</h2>
|
<!-- <h2 class="snapsaside-h">REFINE</h2> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="snapsaside-body">
|
<div class="snapsaside-body">
|
||||||
<RefineSidebar groups={streetGroups} />
|
<RefineSidebar groups={streetGroups} />
|
||||||
|
|||||||
0
src/components/views/StreetSnapsDetail.astro
Normal file
0
src/components/views/StreetSnapsDetail.astro
Normal file
@ -17,6 +17,10 @@ const { title, description, keywords } = Astro.props
|
|||||||
|
|
||||||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||||
|
|
||||||
|
// 语言偏好跳转用的 localStorage key 与默认语言:注入到 <html> 的 data-*,供内联脚本读取
|
||||||
|
const storedLocaleKey = STORED_LOCALE_KEY
|
||||||
|
const defaultLocaleVal = DEFAULT_LOCALE
|
||||||
|
|
||||||
const SITE_NAME = config.siteName ?? "Xisoa"
|
const SITE_NAME = config.siteName ?? "Xisoa"
|
||||||
const siteTitle = title ? `${title} — ${SITE_NAME}` : SITE_NAME
|
const siteTitle = title ? `${title} — ${SITE_NAME}` : SITE_NAME
|
||||||
|
|
||||||
@ -72,14 +76,14 @@ const headers = {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
|
<html lang={locale === 'cn' ? 'zh-CN' : 'en'} data-stored-key={storedLocaleKey} data-default-locale={defaultLocaleVal}>
|
||||||
<head>
|
<head>
|
||||||
<script is:inline define:vars={{ defaultLocale: DEFAULT_LOCALE, storedKey: STORED_LOCALE_KEY }}>
|
<script is:inline>
|
||||||
// 记住语言偏好:若用户曾选过非默认语言,进首页(/ 或 /<default>)时自动跳到该语言首页
|
// 记住语言偏好:若用户曾选过非默认语言,进首页(/ 或 /<default>)时自动跳到该语言首页
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var stored = localStorage.getItem(storedKey);
|
var stored = localStorage.getItem(document.documentElement.dataset.storedKey);
|
||||||
var def = defaultLocale;
|
var def = document.documentElement.dataset.defaultLocale;
|
||||||
var p = location.pathname;
|
var p = location.pathname;
|
||||||
if (stored && stored !== def && (p === '/' || p === '/' + def)) {
|
if (stored && stored !== def && (p === '/' || p === '/' + def)) {
|
||||||
location.replace('/' + stored);
|
location.replace('/' + stored);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 展示层共享工具(从 RunwayLooks.astro / StreetSnaps.astro / AccountPage.astro 抽出)。
|
* 展示层共享工具(从 RunwayLooks / StreetSnaps / Account 视图抽出)。
|
||||||
* 仅承载同构、且与各自筛选项无关的逻辑:加载转圈 SVG、卡片缩略图槽位、分页页码计算。
|
* 仅承载同构、且与各自筛选项无关的逻辑:加载转圈 SVG、卡片缩略图槽位、分页页码计算。
|
||||||
* 页面特有逻辑(品牌弹窗、各筛选维度、取数 API)仍保留在各组件内。
|
* 页面特有逻辑(品牌弹窗、各筛选维度、取数 API)仍保留在各组件内。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/views/AccountPage.astro(en/cn 共用)。
|
// 语言由 Astro.currentLocale 运行时决定,实现见 components/views/Account.astro(en/cn 共用)。
|
||||||
import AccountPage from "@/components/views/AccountPage.astro"
|
import Account from "@/components/views/Account.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
<AccountPage />
|
<Account />
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
export const prerender = false
|
|
||||||
|
|
||||||
// 旧 /item/[id] 保留为 301 重定向,按 id 类型前缀回退到对应类型化路由,避免外链 / SEO 权重失效。
|
|
||||||
// r=runway → /runway-looks,s=street → /street-snaps,其余(含图像 i/j 经 parentUid 已回退)回落 runway。
|
|
||||||
import { ROUTES, clientHref } from "@/lib/routes"
|
|
||||||
|
|
||||||
const id = Astro.params.id || ""
|
|
||||||
const isSnap = id[0] === "s"
|
|
||||||
const target = isSnap ? ROUTES.streetSnap(id) : ROUTES.runwayLook(id)
|
|
||||||
return Astro.redirect(clientHref(Astro.currentLocale || "cn", target), 301)
|
|
||||||
---
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/views/LoginPage.astro(en/cn 共用)。
|
// 语言由 Astro.currentLocale 运行时决定,实现见 components/views/Login.astro(en/cn 共用)。
|
||||||
import LoginPage from "@/components/views/LoginPage.astro"
|
import Login from "@/components/views/Login.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
<LoginPage />
|
<Login />
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
export const prerender = false
|
export const prerender = false
|
||||||
|
|
||||||
// 走秀详情:类型化路由 /runway-looks/[id]。
|
// 走秀详情:类型化路由 /runway-looks/[id]。
|
||||||
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/ItemPage.astro(en/cn 共用)。
|
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/RunwayLooksDetail.astro(en/cn 共用)。
|
||||||
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
||||||
import ItemPage from "@/components/views/ItemPage.astro"
|
import RunwayLooksDetail from "@/components/views/RunwayLooksDetail.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
<ItemPage id={Astro.params.id} type="runway" />
|
<RunwayLooksDetail id={Astro.params.id} />
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
export const prerender = false
|
export const prerender = false
|
||||||
|
|
||||||
// 街拍详情:类型化路由 /street-snaps/[id]。
|
// 街拍详情:类型化路由 /street-snaps/[id]。
|
||||||
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/ItemPage.astro(en/cn 共用)。
|
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/StreetSnapsDetail.astro(en/cn 共用)。
|
||||||
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
||||||
import ItemPage from "@/components/views/ItemPage.astro"
|
import StreetSnapsDetail from "@/components/views/StreetSnapsDetail.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
<ItemPage id={Astro.params.id} type="street" />
|
<StreetSnapsDetail id={Astro.params.id} />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/views/AccountPage.astro(en/cn 共用)。
|
// 语言由 Astro.currentLocale 运行时决定,实现见 components/views/Account.astro(en/cn 共用)。
|
||||||
import AccountPage from "@/components/views/AccountPage.astro"
|
import Account from "@/components/views/Account.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
<AccountPage />
|
<Account />
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
export const prerender = false
|
|
||||||
|
|
||||||
// 旧 /item/[id] 保留为 301 重定向,按 id 类型前缀回退到对应类型化路由,避免外链 / SEO 权重失效。
|
|
||||||
// r=runway → /runway-looks,s=street → /street-snaps,其余(含图像 i/j 经 parentUid 已回退)回落 runway。
|
|
||||||
import { ROUTES, clientHref } from "@/lib/routes"
|
|
||||||
|
|
||||||
const id = Astro.params.id || ""
|
|
||||||
const isSnap = id[0] === "s"
|
|
||||||
const target = isSnap ? ROUTES.streetSnap(id) : ROUTES.runwayLook(id)
|
|
||||||
return Astro.redirect(clientHref(Astro.currentLocale || "en", target), 301)
|
|
||||||
---
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/views/LoginPage.astro(en/cn 共用)。
|
// 语言由 Astro.currentLocale 运行时决定,实现见 components/views/Login.astro(en/cn 共用)。
|
||||||
import LoginPage from "@/components/views/LoginPage.astro"
|
import Login from "@/components/views/Login.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
<LoginPage />
|
<Login />
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
export const prerender = false
|
export const prerender = false
|
||||||
|
|
||||||
// 走秀详情:类型化路由 /runway-looks/[id]。
|
// 走秀详情:类型化路由 /runway-looks/[id]。
|
||||||
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/ItemPage.astro(en/cn 共用)。
|
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/RunwayLooksDetail.astro(en/cn 共用)。
|
||||||
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
||||||
import ItemPage from "@/components/views/ItemPage.astro"
|
import RunwayLooksDetail from "@/components/views/RunwayLooksDetail.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
<ItemPage id={Astro.params.id} type="runway" />
|
<RunwayLooksDetail id={Astro.params.id} />
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
export const prerender = false
|
export const prerender = false
|
||||||
|
|
||||||
// 街拍详情:类型化路由 /street-snaps/[id]。
|
// 街拍详情:类型化路由 /street-snaps/[id]。
|
||||||
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/ItemPage.astro(en/cn 共用)。
|
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/StreetSnapsDetail.astro(en/cn 共用)。
|
||||||
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
||||||
import ItemPage from "@/components/views/ItemPage.astro"
|
import StreetSnapsDetail from "@/components/views/StreetSnapsDetail.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
<ItemPage id={Astro.params.id} type="street" />
|
<StreetSnapsDetail id={Astro.params.id} />
|
||||||
|
|||||||
12
src/pages/robots.txt.ts
Normal file
12
src/pages/robots.txt.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// src/pages/robots.txt.ts — 动态 robots,自动指向当前域名的 sitemap。
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
export const prerender = false
|
||||||
|
|
||||||
|
export const GET: APIRoute = () => {
|
||||||
|
const site = ((import.meta.env as any).PUBLIC_SITE_URL ?? 'https://xisoa.com').replace(/\/$/, '')
|
||||||
|
const body = `User-agent: *\nAllow: /\n\nSitemap: ${site}/sitemap.xml\n`
|
||||||
|
return new Response(body, {
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
})
|
||||||
|
}
|
||||||
141
src/pages/sitemap.xml.ts
Normal file
141
src/pages/sitemap.xml.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
// src/pages/sitemap.xml.ts — 动态 sitemap 端点(SSR 运行期由 Node 生成)
|
||||||
|
//
|
||||||
|
// 为什么用「端点」而不是构建期静态文件:本站详情页是按需 SSR、文章 ID 在数据库里动态增长,
|
||||||
|
// 构建期拿不到全量,故在每次 Google 抓取时由服务端实时拉列表生成,永远最新。
|
||||||
|
//
|
||||||
|
// 注意:
|
||||||
|
// 1. 在 .env 里配置 PUBLIC_SITE_URL=https://你的域名(含协议、不含结尾斜杠),否则回退占位符。
|
||||||
|
// 2. 列表接口的分页字段名若与下方假设不同(data.list / page+limit),按后端实际返回微调 fetchIds。
|
||||||
|
// 3. 每条详情页同时输出 /en 与 /cn 两个 <loc> 并互标 hreflang,顺带补齐双语站点的 hreflang 信号。
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
import { config } from '@/lib/config'
|
||||||
|
|
||||||
|
// SSR 模式下端点按需执行;显式声明避免被当作静态预渲染。
|
||||||
|
export const prerender = false
|
||||||
|
|
||||||
|
// 站点绝对域名(公开环境变量,server/client 均可用;as any 规避未声明的 PUBLIC_* 类型报错)
|
||||||
|
const SITE_URL = ((import.meta.env as any).PUBLIC_SITE_URL ?? 'https://xisoa.com').replace(/\/$/, '')
|
||||||
|
const LOCALES = ['en', 'cn'] as const
|
||||||
|
const hreflangOf = (l: (typeof LOCALES)[number]) => (l === 'cn' ? 'zh-CN' : 'en')
|
||||||
|
|
||||||
|
// 简单内存缓存:Google 不会高频抓,但避免每次请求都打一遍后端列表接口。
|
||||||
|
let cache: { xml: string; ts: number } | null = null
|
||||||
|
const TTL = 10 * 60 * 1000
|
||||||
|
|
||||||
|
interface SitemapEntry {
|
||||||
|
loc: string
|
||||||
|
alts: { lang: string; href: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function abs(path: string): string {
|
||||||
|
return SITE_URL + path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 翻页拉取某列表接口下全部文章 ID(容错多种返回结构)。 */
|
||||||
|
async function fetchIds(path: string): Promise<string[]> {
|
||||||
|
const base = (config.baseApiSsr ?? '').replace(/\/$/, '')
|
||||||
|
if (!base) return []
|
||||||
|
const ids: string[] = []
|
||||||
|
const limit = 200
|
||||||
|
for (let page = 1; page <= 1000; page++) {
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(`${base}${path}?page=${page}&limit=${limit}`)
|
||||||
|
} catch {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!res.ok) break
|
||||||
|
let json: any
|
||||||
|
try {
|
||||||
|
json = await res.json()
|
||||||
|
} catch {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const items: any[] =
|
||||||
|
json?.data?.list ??
|
||||||
|
json?.data?.items ??
|
||||||
|
json?.data ??
|
||||||
|
json?.list ??
|
||||||
|
json?.items ??
|
||||||
|
[]
|
||||||
|
if (!Array.isArray(items) || items.length === 0) break
|
||||||
|
for (const it of items) if (it?.id) ids.push(String(it.id))
|
||||||
|
if (items.length < limit) break
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(entries: SitemapEntry[]): string {
|
||||||
|
const urlEls = entries
|
||||||
|
.map((e) => {
|
||||||
|
const alts = e.alts
|
||||||
|
.map((a) => ` <xhtml:link rel="alternate" hreflang="${a.lang}" href="${esc(a.href)}"/>`)
|
||||||
|
.join('\n')
|
||||||
|
return ` <url>\n <loc>${esc(e.loc)}</loc>\n${alts}\n </url>`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
|
${urlEls}
|
||||||
|
</urlset>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
if (cache && Date.now() - cache.ts < TTL) {
|
||||||
|
return new Response(cache.xml, {
|
||||||
|
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const runwayIds = await fetchIds('/api/v1/public/runway-looks')
|
||||||
|
const snapIds = await fetchIds('/api/v1/public/street-snaps')
|
||||||
|
|
||||||
|
const entries: SitemapEntry[] = []
|
||||||
|
|
||||||
|
// —— 静态页(首页 + 两个列表页),双语各一份 + 互相 hreflang ——
|
||||||
|
for (const l of LOCALES) {
|
||||||
|
const home = abs(`/${l}`)
|
||||||
|
entries.push({
|
||||||
|
loc: home,
|
||||||
|
alts: LOCALES.map((l2) => ({ lang: hreflangOf(l2), href: abs(`/${l2}`) })),
|
||||||
|
})
|
||||||
|
for (const sec of ['runway-looks', 'street-snaps'] as const) {
|
||||||
|
const p = `/${l}/${sec}`
|
||||||
|
entries.push({
|
||||||
|
loc: abs(p),
|
||||||
|
alts: LOCALES.map((l2) => ({ lang: hreflangOf(l2), href: abs(`/${l2}/${sec}`) })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 详情页:双语 + hreflang(x-default 指向 en)——
|
||||||
|
const pushDetail = (sec: 'runway-looks' | 'street-snaps', id: string) => {
|
||||||
|
const en = `/en/${sec}/${id}`
|
||||||
|
const cn = `/cn/${sec}/${id}`
|
||||||
|
entries.push({
|
||||||
|
loc: abs(en),
|
||||||
|
alts: [
|
||||||
|
{ lang: 'en', href: abs(en) },
|
||||||
|
{ lang: 'zh-CN', href: abs(cn) },
|
||||||
|
{ lang: 'x-default', href: abs(en) },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const id of runwayIds) pushDetail('runway-looks', id)
|
||||||
|
for (const id of snapIds) pushDetail('street-snaps', id)
|
||||||
|
|
||||||
|
const xml = render(entries)
|
||||||
|
cache = { xml, ts: Date.now() }
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user