13
.env.example
13
.env.example
@ -9,7 +9,16 @@ DB_NAME=fashionadmin
|
|||||||
# ---- 后端 JWT 密钥(生产务必改为随机长字符串)----
|
# ---- 后端 JWT 密钥(生产务必改为随机长字符串)----
|
||||||
JWT_SECRET=change-me-in-prod
|
JWT_SECRET=change-me-in-prod
|
||||||
|
|
||||||
|
# ---- 公开 ID 混淆盐值(生产务必改为随机串)----
|
||||||
|
# 决定对外 id/brand_id 编码结果;变更会使线上旧链接全部失效,一旦上生产请固定。
|
||||||
|
HASHID_SECRET=change-me-in-prod-hashid
|
||||||
|
|
||||||
|
# ---- SSG 内部接口令牌(可选)----
|
||||||
|
# SSG_TOKEN=some-long-random-token
|
||||||
|
|
||||||
# 说明:
|
# 说明:
|
||||||
# - 前端构建期通过 compose 的 build.args.API_SSR=http://localhost:8090 拉数据
|
|
||||||
# - 前端运行期走同源 /api(nginx 反代到 backend:8090),PUBLIC_API_BASE 在 Dockerfile 中置空
|
# - 前端运行期走同源 /api(nginx 反代到 backend:8090),PUBLIC_API_BASE 在 Dockerfile 中置空
|
||||||
# - backend 监听 8090;生产建议去掉 ports 暴露,仅由前端 nginx 反代访问
|
# - 前端构建期(SSG)通过 compose 的 build.args.SSG_API=http://localhost:8091 拉数据,
|
||||||
|
# 指向后端独立的 SSG 内部端口(只绑宿主回环,nginx 不反代)
|
||||||
|
# - 若后端 SSG_TOKEN 非空,前端构建需传相同 SSG_TOKEN(compose 已对齐)
|
||||||
|
# - backend 监听 8090 对外 + 8091 对内;生产建议去掉 8090 的 ports 暴露
|
||||||
|
|||||||
19
Dockerfile
19
Dockerfile
@ -2,12 +2,23 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 构建期 SSR 拉数据用的「后端内部地址」。
|
# 构建期 SSG 拉数据用的「后端 SSG 内部地址」。
|
||||||
# 在 docker-compose 里通过 build.args.API_SSR 传入(= http://localhost:8090,
|
# 在 docker-compose 里通过 build.args.SSG_API 传入(= http://localhost:8091,
|
||||||
# 配合 build.network: host,让前端构建容器能访问已启动的 backend 容器)。
|
# 配合 build.network: host,让前端构建容器能访问已启动的 backend 容器的 SSG 内部端口)。
|
||||||
ARG API_SSR=http://localhost:8090
|
# 该端口只绑宿主回环(127.0.0.1:8091),nginx 绝不反代,因此 SSG 全量数据不会外泄。
|
||||||
|
# SSG_API 与运行期 PUBLIC_API_BASE(8090)完全分离,二者指向不同端口。
|
||||||
|
ARG SSG_API=http://localhost:8091
|
||||||
|
ENV SSG_API=$SSG_API
|
||||||
|
|
||||||
|
# 兼容旧命名:API_SSR 仍可作为 SSG 基址(lib/ssg.ts 优先读 SSG_API)。
|
||||||
|
ARG API_SSR=http://localhost:8091
|
||||||
ENV API_SSR=$API_SSR
|
ENV API_SSR=$API_SSR
|
||||||
|
|
||||||
|
# SSG 内部端口的可选访问令牌(防御纵深)。仅构建期 node fetch 使用,且不带 PUBLIC_ 前缀,
|
||||||
|
# 故绝不会进入前端 bundle。置空时后端不校验(依赖回环绑定即可)。
|
||||||
|
ARG SSG_TOKEN=""
|
||||||
|
ENV SSG_TOKEN=$SSG_TOKEN
|
||||||
|
|
||||||
# 运行期浏览器调用地址:置空 = 同源相对路径 /api(由下方 nginx 反代到 backend:8090)
|
# 运行期浏览器调用地址:置空 = 同源相对路径 /api(由下方 nginx 反代到 backend:8090)
|
||||||
ENV PUBLIC_API_BASE=""
|
ENV PUBLIC_API_BASE=""
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# 生产部署 compose(放在前端仓库根,服务器结构见 DEPLOY.md)
|
# 生产部署 compose(放在前端仓库根,服务器结构见 DEPLOY.md)
|
||||||
# 前端仓: /opt/fashion/test (本文件所在)
|
# 前端仓: /opt/fashion/test (本文件所在)
|
||||||
# 后端仓: /opt/fashion/admin/backend
|
# 后端仓: /opt/fashion/backend (注意:已对齐到当前活跃开发的后端,原 admin/backend 为旧扁平版)
|
||||||
services:
|
services:
|
||||||
# ---- 数据库 ----
|
# ---- 数据库 ----
|
||||||
db:
|
db:
|
||||||
@ -20,10 +20,13 @@ services:
|
|||||||
# ---- 后端 (Go) ----
|
# ---- 后端 (Go) ----
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ../admin/backend
|
context: ../backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SERVER_PORT: 8090
|
SERVER_PORT: 8090
|
||||||
|
SSG_PORT: 8091 # SSG 内部端口(见下 ports 绑定)
|
||||||
|
SSG_TOKEN: ${SSG_TOKEN:-} # 可选:非空则 SSG 接口需带 token(防御纵深)
|
||||||
|
HASHID_SECRET: ${HASHID_SECRET:-} # 公开 ID 混淆盐值:生产务必注入随机串(改值会使旧链接失效,谨慎变更)
|
||||||
GIN_MODE: release
|
GIN_MODE: release
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-prod}
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-prod}
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
@ -32,7 +35,8 @@ services:
|
|||||||
DB_PASSWORD: ${DB_PASSWORD:-root}
|
DB_PASSWORD: ${DB_PASSWORD:-root}
|
||||||
DB_NAME: ${DB_NAME:-fashionadmin}
|
DB_NAME: ${DB_NAME:-fashionadmin}
|
||||||
ports:
|
ports:
|
||||||
- "8090:8090" # 仅构建期前端拉数据用;生产可去掉,改由前端 nginx 反代
|
- "8090:8090" # 对外公开 API(运行期由 nginx 反代);生产可去掉此发布,改由前端 nginx 直接反代 backend:8090
|
||||||
|
- "127.0.0.1:8091:8091" # ⚠️ SSG 内部端口:只绑宿主回环,外部网络不可达;nginx 也绝不反代它
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -47,7 +51,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
API_SSR: http://localhost:8090 # 构建期 SSR 拉数据:配合 network: host 访问已起的 backend
|
SSG_API: http://localhost:8091 # 构建期 SSG 拉数据:指向 backend 的 SSG 内部端口(配合 network: host 访问回环绑定的 8091)
|
||||||
|
API_SSR: http://localhost:8091 # 兼容旧命名(效果同 SSG_API)
|
||||||
|
SSG_TOKEN: ${SSG_TOKEN:-} # 与 backend 的 SSG_TOKEN 保持一致;为空则构建期无需携带 token
|
||||||
network: host
|
network: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -10,7 +10,13 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 反向代理后端 API:浏览器走同源 /api,由 nginx 转发到 backend 容器
|
# 网关隔离:SSG 内部接口只存在于 backend 的 8091 端口(回环绑定,nginx 不反代)。
|
||||||
|
# 若有人从公网域名探测 /api/ssg/*,此处直接 404,确保构建期全量数据绝不从公网出口。
|
||||||
|
location /api/ssg/ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 反向代理后端 API:浏览器走同源 /api,由 nginx 转发到 backend 容器(仅 8090 公开端口)
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8090/;
|
proxy_pass http://backend:8090/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@ -1,37 +1,34 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
import ComingSoon from "@/components/ComingSoon.astro"
|
import ComingSoon from "@/components/ComingSoon.astro"
|
||||||
import { getShows, seriesLabel } from "@/lib/data"
|
import { toAbs } from "@/lib/api"
|
||||||
import { getI18n } from "@/i18n/utils"
|
import { getI18n } from "@/i18n/utils"
|
||||||
import { getRelativeLocaleUrl } from 'astro:i18n'
|
import { getRelativeLocaleUrl } from 'astro:i18n'
|
||||||
|
import { getSsgPopularBrands } from "@/lib/ssg"
|
||||||
|
|
||||||
const shows = await getShows(20)
|
|
||||||
|
|
||||||
// 国际化:一行取当前语言 + 翻译函数
|
// 国际化:一行取当前语言 + 翻译函数
|
||||||
const { locale, t } = getI18n(Astro)
|
const { locale, t } = getI18n(Astro)
|
||||||
|
|
||||||
|
// 首页「热门品牌」区块:构建期(SSG 内部接口,server-only)拉取 20 个热门品牌,
|
||||||
|
// 封面/标题取自每个品牌代表走秀,直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
|
||||||
|
// 具体 base 地址 + token 拼接已在 @/lib/ssg 内封装。
|
||||||
|
const popular = await getSsgPopularBrands(20)
|
||||||
|
|
||||||
type GalleryItem = {
|
type GalleryItem = {
|
||||||
src: string;
|
src: string
|
||||||
brand: string;
|
brand: string
|
||||||
articleId: number;
|
articleId: string // 代表走秀 hashid 编码串(后端返回 string)
|
||||||
year: number;
|
title: string
|
||||||
title: string;
|
|
||||||
collection_type?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const galleryItems: GalleryItem[] = []
|
const galleryItems: GalleryItem[] = popular
|
||||||
for (const s of shows) {
|
.filter((b) => b.cover && b.article_id)
|
||||||
if (!s.img) continue
|
.map((b) => ({
|
||||||
galleryItems.push({
|
src: toAbs(b.cover || ""),
|
||||||
src: s.img,
|
brand: b.brand,
|
||||||
brand: s.brand,
|
articleId: b.article_id,
|
||||||
articleId: s.id,
|
title: b.title,
|
||||||
year: s.year,
|
}))
|
||||||
title: s.title,
|
|
||||||
collection_type: s.collection_type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
|
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
|
||||||
<!-- 二级分类标签页 -->
|
<!-- 二级分类标签页 -->
|
||||||
@ -89,7 +86,7 @@ for (const s of shows) {
|
|||||||
{
|
{
|
||||||
galleryItems.map((g, i) => (
|
galleryItems.map((g, i) => (
|
||||||
<div class="swiper-slide hs-slide">
|
<div class="swiper-slide hs-slide">
|
||||||
<a class="hs-item" href={getRelativeLocaleUrl(locale, `/runway-looks/${g.articleId}`)} data-idx={i}>
|
<a class="hs-item" href={getRelativeLocaleUrl(locale, `/items/${g.articleId}`)} data-idx={i}>
|
||||||
<!-- Alpine 管理单张图片的加载/失败状态 -->
|
<!-- Alpine 管理单张图片的加载/失败状态 -->
|
||||||
<div
|
<div
|
||||||
class="hs-img-box"
|
class="hs-img-box"
|
||||||
@ -111,12 +108,9 @@ for (const s of shows) {
|
|||||||
@load="loaded = true; $dispatch('img-ready')"
|
@load="loaded = true; $dispatch('img-ready')"
|
||||||
@error="error = true; $dispatch('img-ready')"
|
@error="error = true; $dispatch('img-ready')"
|
||||||
/>
|
/>
|
||||||
<span class="hs-caption">
|
<span class="hs-caption font-sans">
|
||||||
<span class="hs-cap-brand">{g.brand}</span>
|
<span class="hs-cap-brand">{g.brand}</span>
|
||||||
<span class="hs-cap-meta">
|
<span class="hs-cap-meta">{g.title}</span>
|
||||||
{g.year ? g.year : ""}
|
|
||||||
{g.collection_type ? " · " + seriesLabel(g.collection_type) : ""}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@ -269,36 +263,51 @@ for (const s of shows) {
|
|||||||
to { background-position: -200% 0; }
|
to { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 悬停品牌遮罩 */
|
/* 底部品牌/标题信息——左下角大标题+小副标题,始终可见 */
|
||||||
.hs-caption {
|
.hs-caption {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
padding: 28px 16px 12px;
|
padding: 48px 24px 22px;
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.78) 0%, rgba(0, 0, 0, 0.28) 55%, transparent 100%);
|
||||||
color: white;
|
color: white;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
justify-content: flex-end;
|
||||||
opacity: 0;
|
gap: 6px;
|
||||||
transition: opacity 0.25s ease;
|
opacity: 1;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
font-family: monospace;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.hs-item:hover .hs-caption { opacity: 1; }
|
|
||||||
.hs-cap-brand {
|
.hs-cap-brand {
|
||||||
font-size: 13px;
|
@apply font-sans;
|
||||||
|
font-size: 21px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.1;
|
||||||
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
.hs-cap-meta {
|
.hs-cap-meta {
|
||||||
font-size: 11px;
|
@apply font-sans;
|
||||||
opacity: 0.85;
|
font-size: 13px;
|
||||||
letter-spacing: 0.12em;
|
font-weight: 500;
|
||||||
|
opacity: 0.9;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
|
max-width: 90%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.hs-caption { padding: 60px 28px 26px; }
|
||||||
|
.hs-cap-brand { font-size: 26px; }
|
||||||
|
.hs-cap-meta { font-size: 15px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
---
|
---
|
||||||
import Layout from "@/layouts/Layout.astro"
|
|
||||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||||
import { getBrands, getYears, type BrandEntry } from "@/lib/api"
|
import { getBrands, getYears, type BrandEntry } from "@/lib/api"
|
||||||
import { getI18n } from '@/i18n/utils'
|
import { getI18n } from '@/i18n/utils'
|
||||||
@ -71,26 +70,24 @@ if (!yearChips.length) yearChips = yearSeed
|
|||||||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
||||||
<input class="showssearch" placeholder="Search designer…" autocomplete="off" x-model.debounce.150ms="keyword" />
|
<input class="showssearch" placeholder="Search designer…" autocomplete="off" x-model.debounce.150ms="keyword" />
|
||||||
<ul class="showschecks">
|
<ul class="showschecks">
|
||||||
<li>
|
<!-- <li>
|
||||||
<!-- Letter Header -->
|
<template x-if="item.type === 'letter'">
|
||||||
<template x-if="item.type === 'letter'">
|
<div class="letter-head" x-text="item.letter"></div>
|
||||||
<div class="letter-head" x-text="item.letter"></div>
|
</template>
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Brand Item -->
|
<template x-if="item.type === 'brand'">
|
||||||
<template x-if="item.type === 'brand'">
|
<label class="flex items-center gap-2 w-full cursor-pointer">
|
||||||
<label class="flex items-center gap-2 w-full cursor-pointer">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
:value="item.id"
|
||||||
:value="item.id"
|
:checked="selectedBrands.includes(item.id)"
|
||||||
:checked="selectedBrands.includes(item.id)"
|
@change="toggleBrand(item.id)"
|
||||||
@change="toggleBrand(item.id)"
|
>
|
||||||
>
|
<span x-text="item.name"></span>
|
||||||
<span x-text="item.name"></span>
|
<span class="ct" x-text="item.count ? item.count : ''"></span>
|
||||||
<span class="ct" x-text="item.count ? item.count : ''"></span>
|
</label>
|
||||||
</label>
|
</template>
|
||||||
</template>
|
</li> -->
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<a class="showsmore" @click="openBrandModal()" x-text="'浏览全部品牌 +'"></a>
|
<a class="showsmore" @click="openBrandModal()" x-text="'浏览全部品牌 +'"></a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,20 +1,16 @@
|
|||||||
---
|
---
|
||||||
import type { RawArticle } from "@/lib/data"
|
import type { RawArticle } from "@/lib/data"
|
||||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||||
|
import { getI18n } from "@/i18n/utils"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
article: RawArticle | null
|
article: RawArticle | null
|
||||||
locale?: "en" | "cn"
|
|
||||||
apiBase?: string
|
apiBase?: string
|
||||||
error?: "missing" | "failed" | null
|
error?: "missing" | "failed" | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { article, apiBase = "", error = null } = Astro.props
|
||||||
article,
|
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||||
locale = "en",
|
|
||||||
apiBase = "",
|
|
||||||
error = null,
|
|
||||||
} = Astro.props
|
|
||||||
|
|
||||||
const full = (u: string): string => {
|
const full = (u: string): string => {
|
||||||
if (!u) return ""
|
if (!u) return ""
|
||||||
@ -35,36 +31,12 @@ const descParas = (article?.description || "")
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
const L = {
|
const homeHref = getRelativeLocaleUrl(locale, "/")
|
||||||
en: {
|
const archiveHref = getRelativeLocaleUrl(locale, "/shows")
|
||||||
back: "← Back to Index",
|
|
||||||
missing: "No article ID specified",
|
|
||||||
failed: "Failed to load article. Please refresh or try later.",
|
|
||||||
close: "Close",
|
|
||||||
previous: "Previous image",
|
|
||||||
next: "Next image",
|
|
||||||
zoomTip: "Click image to zoom · Scroll to view · Esc to close",
|
|
||||||
},
|
|
||||||
cn: {
|
|
||||||
back: "← 返回首页",
|
|
||||||
missing: "未指定文章 ID",
|
|
||||||
failed: "加载文章失败,请刷新或稍后再试",
|
|
||||||
close: "关闭",
|
|
||||||
previous: "上一张",
|
|
||||||
next: "下一张",
|
|
||||||
zoomTip: "点击图片放大 · 上下滚动查看 · Esc 关闭",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const lbl = L[locale === "cn" ? "cn" : "en"]
|
|
||||||
const backLabel = lbl.back.replace("← ", "")
|
|
||||||
|
|
||||||
const homeHref = locale === "cn" ? "/cn" : "/en/"
|
|
||||||
const archiveHref = locale === "cn" ? "/cn/shows" : "/en/shows"
|
|
||||||
|
|
||||||
const crumbs: { label: string; href: string }[] = [
|
const crumbs: { label: string; href: string }[] = [
|
||||||
{
|
{
|
||||||
label: "Runway Looks",
|
label: t("Runway looks"),
|
||||||
href: archiveHref,
|
href: archiveHref,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -139,14 +111,14 @@ if (article?.brand_id) {
|
|||||||
) : (
|
) : (
|
||||||
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
|
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
|
||||||
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">
|
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">
|
||||||
{error === "missing" ? lbl.missing : lbl.failed}
|
{error === "missing" ? t('No article ID specified') : t('Failed to load article. Please refresh or try later.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={homeHref}
|
href={homeHref}
|
||||||
class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
|
class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
← {backLabel}
|
← {t('Back to Index')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -171,7 +143,7 @@ if (article?.brand_id) {
|
|||||||
type="button"
|
type="button"
|
||||||
@click="closeLb()"
|
@click="closeLb()"
|
||||||
class="absolute top-[18px] right-[18px] 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"
|
class="absolute top-[18px] right-[18px] 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={lbl.close}
|
aria-label={t('Close')}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@ -204,7 +176,7 @@ if (article?.brand_id) {
|
|||||||
type="button"
|
type="button"
|
||||||
@click.stop="prevLb()"
|
@click.stop="prevLb()"
|
||||||
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
||||||
aria-label={lbl.previous}
|
aria-label={t('Previous image')}
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||||
<path d="M6 14l6-6 6 6" />
|
<path d="M6 14l6-6 6 6" />
|
||||||
@ -215,7 +187,7 @@ if (article?.brand_id) {
|
|||||||
type="button"
|
type="button"
|
||||||
@click.stop="nextLb()"
|
@click.stop="nextLb()"
|
||||||
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
||||||
aria-label={lbl.next}
|
aria-label={t('Next image')}
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||||
<path d="M6 10l6 6 6-6" />
|
<path d="M6 10l6 6 6-6" />
|
||||||
@ -226,7 +198,7 @@ if (article?.brand_id) {
|
|||||||
type="button"
|
type="button"
|
||||||
@click.stop="prevLb()"
|
@click.stop="prevLb()"
|
||||||
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
||||||
aria-label={lbl.previous}
|
aria-label={t('Previous image')}
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||||
<path d="M15 6l-6 6 6 6" />
|
<path d="M15 6l-6 6 6 6" />
|
||||||
@ -237,7 +209,7 @@ if (article?.brand_id) {
|
|||||||
type="button"
|
type="button"
|
||||||
@click.stop="nextLb()"
|
@click.stop="nextLb()"
|
||||||
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
||||||
aria-label={lbl.next}
|
aria-label={t('Next image')}
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||||
<path d="M9 6l6 6 6-6" />
|
<path d="M9 6l6 6 6-6" />
|
||||||
@ -275,7 +247,7 @@ if (article?.brand_id) {
|
|||||||
x-show="!lbZoomed"
|
x-show="!lbZoomed"
|
||||||
class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200"
|
class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200"
|
||||||
>
|
>
|
||||||
{lbl.zoomTip}
|
{t('Click image to zoom · Scroll to view · Esc to close')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -459,12 +431,12 @@ if (article?.brand_id) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// TODO 这是干啥的
|
||||||
initThumbLazyLoading() {
|
initThumbLazyLoading() {
|
||||||
const thumbs = document.querySelectorAll<HTMLElement>(
|
const thumbs = document.querySelectorAll<HTMLElement>(
|
||||||
"#lb-thumbs [data-thumb-url]"
|
"#lb-thumbs [data-thumb-url]"
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(thumbs)
|
|
||||||
if (!thumbs.length) return
|
if (!thumbs.length) return
|
||||||
|
|
||||||
if (!("IntersectionObserver" in window)) {
|
if (!("IntersectionObserver" in window)) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { getReports, fmtDate } from "@/lib/data"
|
import { getReports, fmtDate } from "@/lib/data"
|
||||||
|
import { getI18n } from "@/i18n/utils"
|
||||||
|
|
||||||
// 文章展示创意布局页:复用你现有的 Layout 外壳。
|
// 文章展示创意布局页:复用你现有的 Layout 外壳。
|
||||||
// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts);
|
// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts);
|
||||||
@ -7,9 +8,11 @@ import { getReports, fmtDate } from "@/lib/data"
|
|||||||
|
|
||||||
const reports = await getReports(30)
|
const reports = await getReports(30)
|
||||||
const fmt = fmtDate
|
const fmt = fmtDate
|
||||||
|
const { locale, getRelativeLocaleUrl } = getI18n(Astro)
|
||||||
|
const itemBase = getRelativeLocaleUrl(locale, "/items/").replace(/\/$/, "")
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="archive">
|
<div class="archive" data-item-base={itemBase}>
|
||||||
<!-- 页头(极简黑白编辑风,与首页 Hero 一致的排版语言) -->
|
<!-- 页头(极简黑白编辑风,与首页 Hero 一致的排版语言) -->
|
||||||
<header class="archive-head">
|
<header class="archive-head">
|
||||||
<div class="archive-kicker">
|
<div class="archive-kicker">
|
||||||
@ -65,7 +68,7 @@ const fmt = fmtDate
|
|||||||
style={`--ratio:${s.ratio}`}
|
style={`--ratio:${s.ratio}`}
|
||||||
>
|
>
|
||||||
<div class="card-datecol"><span class="card-date">{fmt(s.created_at)}</span></div>
|
<div class="card-datecol"><span class="card-date">{fmt(s.created_at)}</span></div>
|
||||||
<a class="card-media" href={`/article?id=${s.id}`} aria-label={s.title}>
|
<a class="card-media" href={`${itemBase}/${s.id}`} aria-label={s.title}>
|
||||||
<img src={s.cover} alt={s.title} loading="lazy" />
|
<img src={s.cover} alt={s.title} loading="lazy" />
|
||||||
{s.featured && <span class="card-flag">Featured</span>}
|
{s.featured && <span class="card-flag">Featured</span>}
|
||||||
</a>
|
</a>
|
||||||
@ -269,6 +272,7 @@ const fmt = fmtDate
|
|||||||
const filterbar = document.getElementById("filterbar")
|
const filterbar = document.getElementById("filterbar")
|
||||||
const viewBtns = Array.from(document.querySelectorAll<HTMLButtonElement>(".viewswitch button"))
|
const viewBtns = Array.from(document.querySelectorAll<HTMLButtonElement>(".viewswitch button"))
|
||||||
const API_BASE = (import.meta.env.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
|
const API_BASE = (import.meta.env.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
|
||||||
|
const itemBase = archive?.dataset.itemBase || "/items"
|
||||||
// 封面可能是完整外链(https://...),也可能是相对路径(/uploads/...),统一处理
|
// 封面可能是完整外链(https://...),也可能是相对路径(/uploads/...),统一处理
|
||||||
const toAbs = (url: string) => {
|
const toAbs = (url: string) => {
|
||||||
if (!url) return ""
|
if (!url) return ""
|
||||||
@ -310,7 +314,7 @@ const fmt = fmtDate
|
|||||||
<article class="card" data-brand="${it.brand}" data-featured="${it.featured ? "true" : "false"}"
|
<article class="card" data-brand="${it.brand}" data-featured="${it.featured ? "true" : "false"}"
|
||||||
data-year="${it.year}" data-archive="${archiveFlag}" style="--ratio:${ratio}">
|
data-year="${it.year}" data-archive="${archiveFlag}" style="--ratio:${ratio}">
|
||||||
<div class="card-datecol"><span class="card-date">${fmt(it.created_at)}</span></div>
|
<div class="card-datecol"><span class="card-date">${fmt(it.created_at)}</span></div>
|
||||||
<a class="card-media" href="/article?id=${it.id}" aria-label="${it.title}">
|
<a class="card-media" href="${itemBase}/${it.id}" aria-label="${it.title}">
|
||||||
<img src="${it.cover}" alt="${it.title}" loading="lazy" />
|
<img src="${it.cover}" alt="${it.title}" loading="lazy" />
|
||||||
${it.featured ? '<span class="card-flag">Featured</span>' : ""}
|
${it.featured ? '<span class="card-flag">Featured</span>' : ""}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -1,37 +1,19 @@
|
|||||||
---
|
---
|
||||||
|
import { getSsgBrands, type SsgBrand } from "@/lib/ssg"
|
||||||
|
|
||||||
|
type Brand = SsgBrand
|
||||||
|
|
||||||
// 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开)
|
// 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开)
|
||||||
// - 数据:一次 SSR 拉完全部精选集合(?size=300&only_with_articles=1&featured=1),
|
// - 数据:一次 SSR 拉完全部精选集合,前端按 name 首字母分桶渲染;数据量小,无需懒加载。
|
||||||
// 前端按 name 首字母分桶渲染;198 个品牌数据量小,无需懒加载。
|
|
||||||
// - 风格:极简编辑风(黑白 + monospace + 细线)。
|
// - 风格:极简编辑风(黑白 + monospace + 细线)。
|
||||||
// · 顶部单行 BRANDS 标题 + 总数
|
// · 顶部单行 BRANDS 标题 + 总数
|
||||||
// · sticky A-Z 字母条(点击平滑滚动)
|
// · sticky A-Z 字母条(点击平滑滚动)
|
||||||
// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
|
// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
|
||||||
// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
|
// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
|
||||||
// 构建期 SSR 拉数据用 API_SSR(容器内部后端地址,如 http://backend:8090);
|
// 构建期 SSR 拉数据走 SSG 内部接口 /api/ssg/brands(不对外暴露,位于独立内部端口 8091);
|
||||||
// 运行期浏览器走同源 /api(由 nginx 反代),PUBLIC_API_BASE 置空即可。
|
// base 地址 + token 拼接已在 @/lib/ssg 内封装。后端不可达则回落空数组(页面显示空索引)。
|
||||||
const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
|
const brands = await getSsgBrands({ size: 300, only_with_articles: 1, featured: 1 })
|
||||||
|
const loadError = brands.length === 0
|
||||||
interface Brand {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
show_name: string
|
|
||||||
article_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
let brands: Brand[] = []
|
|
||||||
let loadError = false
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API}/api/public/brands?size=300&only_with_articles=1&featured=1`)
|
|
||||||
if (res.ok) {
|
|
||||||
const j = await res.json()
|
|
||||||
brands = (j?.data ?? []) as Brand[]
|
|
||||||
} else {
|
|
||||||
loadError = true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
loadError = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const LETTERS = Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
const LETTERS = Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
|
|
||||||
|
|||||||
@ -31,9 +31,18 @@ export const dict: Record<string, TranslationEntry> = {
|
|||||||
'Signup': { cn: '注册' },
|
'Signup': { cn: '注册' },
|
||||||
|
|
||||||
// ── 控件 / Controls ──
|
// ── 控件 / Controls ──
|
||||||
|
'Back to Index': { cn: '返回首页' },
|
||||||
'Back to top': { cn: '回到顶部' },
|
'Back to top': { cn: '回到顶部' },
|
||||||
'Next': { cn: '下一组' },
|
'Close': { cn: '关闭' },
|
||||||
|
'Previous image': { cn: '上一张' },
|
||||||
|
'Next image': { cn: '下一张' },
|
||||||
'Previous': { cn: '上一组' },
|
'Previous': { cn: '上一组' },
|
||||||
|
'Next': { cn: '下一组' },
|
||||||
|
|
||||||
|
// ── 错误 / Errors ──
|
||||||
|
'No article ID specified': { cn: '未指定文章 ID' },
|
||||||
|
'Failed to load article. Please refresh or try later.': { cn: '加载文章失败,请刷新或稍后再试' },
|
||||||
|
'Click image to zoom · Scroll to view · Esc to close': { cn: '点击图片放大 · 上下滚动查看 · Esc 关闭' },
|
||||||
|
|
||||||
// ── 页脚 / Footer ──
|
// ── 页脚 / Footer ──
|
||||||
'© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
|
'© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
|
||||||
|
|||||||
@ -62,7 +62,7 @@ const queryString = Astro.url.search;
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="open = !open"
|
@click="open = !open"
|
||||||
class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[12px] tracking-[0.5px]"
|
class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]"
|
||||||
aria-label="Language"
|
aria-label="Language"
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
:aria-expanded="open"
|
:aria-expanded="open"
|
||||||
@ -91,7 +91,7 @@ const queryString = Astro.url.search;
|
|||||||
href={getRelativeLocaleUrl(l, currentPath) + queryString}
|
href={getRelativeLocaleUrl(l, currentPath) + queryString}
|
||||||
@click="open = false"
|
@click="open = false"
|
||||||
class:list={[
|
class:list={[
|
||||||
'block px-4 py-1.5 text-[12px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors',
|
'block px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors',
|
||||||
l === locale ? 'font-bold' : 'opacity-60',
|
l === locale ? 'font-bold' : 'opacity-60',
|
||||||
]}
|
]}
|
||||||
>{LOCALE_NAMES[l]}</a>
|
>{LOCALE_NAMES[l]}</a>
|
||||||
|
|||||||
@ -7,14 +7,14 @@
|
|||||||
// - 客户端(浏览器运行期):走 PUBLIC_API_BASE,置空时同源相对 /api(由 nginx 反代到 backend)。
|
// - 客户端(浏览器运行期):走 PUBLIC_API_BASE,置空时同源相对 /api(由 nginx 反代到 backend)。
|
||||||
|
|
||||||
export interface BrandEntry {
|
export interface BrandEntry {
|
||||||
id: number
|
id: string // 后端返回 hashid 编码串(非自增主键),防爬虫顺序枚举
|
||||||
name: string
|
name: string
|
||||||
article_count: number
|
article_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleItem {
|
export interface ArticleItem {
|
||||||
id: number
|
id: string // hashid 编码串
|
||||||
brand_id: number
|
brand_id: string // hashid 编码串
|
||||||
title: string
|
title: string
|
||||||
cover: string
|
cover: string
|
||||||
brand_name: string
|
brand_name: string
|
||||||
@ -31,7 +31,7 @@ export interface ArticleQuery {
|
|||||||
page?: number
|
page?: number
|
||||||
size?: number
|
size?: number
|
||||||
sort?: string
|
sort?: string
|
||||||
brand_ids?: number[]
|
brand_ids?: string[] // 编码串数组,透传给后端解码
|
||||||
collection_type?: string[]
|
collection_type?: string[]
|
||||||
season?: string[]
|
season?: string[]
|
||||||
year?: string[]
|
year?: string[]
|
||||||
@ -83,7 +83,7 @@ export async function getBrands(opts?: {
|
|||||||
if (opts?.letter) qs.set("letter", opts.letter)
|
if (opts?.letter) qs.set("letter", opts.letter)
|
||||||
const j = await request<{ data?: any[] }>("/public/brands", qs)
|
const j = await request<{ data?: any[] }>("/public/brands", qs)
|
||||||
return (j.data ?? []).map((b: any) => ({
|
return (j.data ?? []).map((b: any) => ({
|
||||||
id: Number(b.id),
|
id: String(b.id),
|
||||||
name: (b.name || b.show_name || "").toString(),
|
name: (b.name || b.show_name || "").toString(),
|
||||||
article_count: Number(b.article_count ?? 0),
|
article_count: Number(b.article_count ?? 0),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -11,13 +11,13 @@
|
|||||||
const API_BASE = ((import.meta as any).env?.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
|
const API_BASE = ((import.meta as any).env?.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
|
||||||
|
|
||||||
export type RawArticle = {
|
export type RawArticle = {
|
||||||
id: number
|
id: string | number // 后端为 hashid 编码串(string);示例数据回落为数字
|
||||||
title: string
|
title: string
|
||||||
summary?: string
|
summary?: string
|
||||||
description?: string
|
description?: string
|
||||||
cover: string
|
cover: string
|
||||||
brand_name: string
|
brand_name: string
|
||||||
brand_id?: number
|
brand_id?: string | number // hashid 编码串
|
||||||
year: number
|
year: number
|
||||||
image_count: number
|
image_count: number
|
||||||
published_at: number
|
published_at: number
|
||||||
@ -26,7 +26,7 @@ export type RawArticle = {
|
|||||||
collection_type?: string
|
collection_type?: string
|
||||||
season?: string
|
season?: string
|
||||||
season_code?: string
|
season_code?: string
|
||||||
images?: { id: number; image: string; name?: string }[]
|
images?: { id?: number; image: string; name?: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 通用辅助 ----------
|
// ---------- 通用辅助 ----------
|
||||||
@ -62,7 +62,7 @@ export function fmtDate(t: number | string): string {
|
|||||||
// ---------- 内置示例(后端不可达时回落) ----------
|
// ---------- 内置示例(后端不可达时回落) ----------
|
||||||
const BRANDS = ["MAISON LUMEN", "STUDIO VANTA", "ATELIER NOIR", "KÔ", "CÉLESTE", "RIVE GAUCHE"]
|
const BRANDS = ["MAISON LUMEN", "STUDIO VANTA", "ATELIER NOIR", "KÔ", "CÉLESTE", "RIVE GAUCHE"]
|
||||||
const SAMPLE: RawArticle[] = Array.from({ length: 24 }, (_, i) => ({
|
const SAMPLE: RawArticle[] = Array.from({ length: 24 }, (_, i) => ({
|
||||||
id: i + 1,
|
id: String(i + 1),
|
||||||
title: `Collection ${String(i + 1).padStart(2, "0")}`,
|
title: `Collection ${String(i + 1).padStart(2, "0")}`,
|
||||||
brand_name: BRANDS[i % BRANDS.length],
|
brand_name: BRANDS[i % BRANDS.length],
|
||||||
year: 2023 + (i % 4),
|
year: 2023 + (i % 4),
|
||||||
@ -120,6 +120,7 @@ export async function fetchArticleById(id: number | string, apiBase: string = AP
|
|||||||
if (!base) return null
|
if (!base) return null
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/public/articles/${id}`)
|
const res = await fetch(`${base}/api/public/articles/${id}`)
|
||||||
|
console.log(`${base}/api/public/articles/${id}`)
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
const json: any = await res.json()
|
const json: any = await res.json()
|
||||||
return json?.data ?? null
|
return json?.data ?? null
|
||||||
@ -133,7 +134,7 @@ export async function fetchArticleById(id: number | string, apiBase: string = AP
|
|||||||
// (接口仅暴露 runway 文章一类数据,四块面板取真实列表的不同区段,避免重复同一批内容)
|
// (接口仅暴露 runway 文章一类数据,四块面板取真实列表的不同区段,避免重复同一批内容)
|
||||||
|
|
||||||
export type ArticleCard = {
|
export type ArticleCard = {
|
||||||
id: number
|
id: string | number
|
||||||
brand: string
|
brand: string
|
||||||
title: string
|
title: string
|
||||||
season: string // 季节码,如 AW16
|
season: string // 季节码,如 AW16
|
||||||
@ -209,7 +210,7 @@ export function loadArticlesWithImages(size = 40): Promise<ArticleCard[]> {
|
|||||||
|
|
||||||
// attachGallery 在构建期(SSG)为每个展示文章预取其真实多图(前 3 张),
|
// attachGallery 在构建期(SSG)为每个展示文章预取其真实多图(前 3 张),
|
||||||
// 供首页卡片直接渲染"图片堆",无需等到悬停才拉取。后端不可达时 gallery 为空。
|
// 供首页卡片直接渲染"图片堆",无需等到悬停才拉取。后端不可达时 gallery 为空。
|
||||||
async function attachGallery<T extends { id: number; gallery: string[] }>(items: T[]): Promise<T[]> {
|
async function attachGallery<T extends { id: string | number; gallery: string[] }>(items: T[]): Promise<T[]> {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
items.map(async (it) => {
|
items.map(async (it) => {
|
||||||
let gallery: string[] = []
|
let gallery: string[] = []
|
||||||
@ -226,7 +227,7 @@ async function attachGallery<T extends { id: number; gallery: string[] }>(items:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ColorItem = {
|
export type ColorItem = {
|
||||||
id: number
|
id: string | number
|
||||||
name: string
|
name: string
|
||||||
brand: string
|
brand: string
|
||||||
season: string
|
season: string
|
||||||
@ -306,7 +307,7 @@ export async function getSite(): Promise<SiteInfo> {
|
|||||||
|
|
||||||
// ---------- 文章画廊(/articles)所需结构 ----------
|
// ---------- 文章画廊(/articles)所需结构 ----------
|
||||||
export type ReportItem = {
|
export type ReportItem = {
|
||||||
id: number
|
id: string | number
|
||||||
brand: string
|
brand: string
|
||||||
title: string
|
title: string
|
||||||
year: number
|
year: number
|
||||||
|
|||||||
97
src/lib/ssg.ts
Normal file
97
src/lib/ssg.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
// src/lib/ssg.ts — SSG 构建专用接口客户端(集中封装 /api/ssg/*)
|
||||||
|
//
|
||||||
|
// SSG 接口只在「构建 / 预渲染期」由 Astro 后端(Node)fetch 调用,
|
||||||
|
// 永远不进入浏览器 bundle。它们位于独立的内部端口(默认 8091),不对外暴露、
|
||||||
|
// nginx 绝不反代。本文件与运行期公共 API 客户端(api.ts)完全分离:
|
||||||
|
// - api.ts → 运行期浏览器调用 /api/public/*,base = PUBLIC_API_BASE(同源 /api)
|
||||||
|
// - ssg.ts → 构建期 Node 调用 /api/ssg/*,base = SSG_API(独立内部端口 8091)
|
||||||
|
//
|
||||||
|
// base 解析优先级:
|
||||||
|
// 1) SSG_API —— 构建专用内部地址(部署由 build arg 注入,如 http://localhost:8091)
|
||||||
|
// 2) API_SSR —— 兼容旧命名
|
||||||
|
// 3) http://localhost:8091 —— 本地回落(SSG 引擎默认端口,绝不是 8090)
|
||||||
|
//
|
||||||
|
// token:SSG_TOKEN(server-only,绝不加 PUBLIC_ 前缀,不会进入 bundle);
|
||||||
|
// 为空则后端不校验(依赖回环绑定即可)。
|
||||||
|
|
||||||
|
// 同时读 import.meta.env 与 process.env:
|
||||||
|
// 构建/预渲染在 Node 中运行,.env 里的 server-only 变量两者皆可能承载,合并最稳妥。
|
||||||
|
function env(name: string): string | undefined {
|
||||||
|
const e = import.meta.env as any
|
||||||
|
if (e[name] != null && e[name] !== "") return e[name] as string
|
||||||
|
if (typeof process !== "undefined" && process.env[name]) return process.env[name] as string
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ssgBase(): string {
|
||||||
|
return (env("SSG_API") || env("API_SSR") || "http://localhost:8091").replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ssgToken(): string {
|
||||||
|
return (env("SSG_TOKEN") || "").toString().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若后端 SSG 端口启用了 SSG_TOKEN,则给每个请求附上 ?token=。
|
||||||
|
function withToken(path: string): string {
|
||||||
|
const t = ssgToken()
|
||||||
|
if (!t) return path
|
||||||
|
return path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ssgRequest<T>(path: string): Promise<T | null> {
|
||||||
|
const url = ssgBase() + withToken(path)
|
||||||
|
try {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return (await res.json()) as T
|
||||||
|
} catch {
|
||||||
|
// 后端不可达 / 超时:返回 null,由各 getter 回落到空数组(构建期不会崩)。
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 类型 ----------
|
||||||
|
// 注意:id / brand_id 是后端 hashid 编码串(string),不是自增数字主键。
|
||||||
|
export interface SsgPopularBrand {
|
||||||
|
id: string
|
||||||
|
article_id: string
|
||||||
|
brand: string
|
||||||
|
cover: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SsgBrand {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
show_name: string
|
||||||
|
article_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- getter ----------
|
||||||
|
|
||||||
|
// 首页「热门品牌」区块:按图片数排名前 limit 个品牌,每个附带代表走秀的封面与标题。
|
||||||
|
export async function getSsgPopularBrands(limit = 20): Promise<SsgPopularBrand[]> {
|
||||||
|
const j = await ssgRequest<{ data?: SsgPopularBrand[] }>(`/api/ssg/brands/popular?limit=${limit}`)
|
||||||
|
console.log(j)
|
||||||
|
return j?.data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 品牌索引页全量:一次拉齐精选集合,前端按首字母分桶渲染。
|
||||||
|
export async function getSsgBrands(opts?: {
|
||||||
|
size?: number
|
||||||
|
only_with_articles?: number
|
||||||
|
featured?: number
|
||||||
|
}): Promise<SsgBrand[]> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts?.size) qs.set("size", String(opts.size))
|
||||||
|
if (opts?.only_with_articles != null) qs.set("only_with_articles", String(opts.only_with_articles))
|
||||||
|
if (opts?.featured != null) qs.set("featured", String(opts.featured))
|
||||||
|
const j = await ssgRequest<{ data?: SsgBrand[] }>(`/api/ssg/brands?${qs.toString()}`)
|
||||||
|
return j?.data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStaticPaths 用的轻量枚举:只回文章编码 uid,不带回 cover/description,省带宽。
|
||||||
|
export async function getSsgArticleIds(): Promise<string[]> {
|
||||||
|
const j = await ssgRequest<{ data?: string[] }>(`/api/ssg/articles/ids`)
|
||||||
|
return j?.data ?? []
|
||||||
|
}
|
||||||
@ -6,15 +6,17 @@ import { fetchArticleById } from "@/lib/data"
|
|||||||
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
|
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
|
||||||
export const prerender = false
|
export const prerender = false
|
||||||
|
|
||||||
const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
|
const API = ("http://localhost:8090").replace(/\/$/, "")
|
||||||
const {id} = Astro.params
|
const {id} = Astro.params
|
||||||
let article: import("@/lib/data").RawArticle | null = null
|
let article: import("@/lib/data").RawArticle | null = null
|
||||||
let error: "missing" | "failed" | null = null
|
let error: "missing" | "failed" | null = null
|
||||||
|
console.log(id)
|
||||||
if (!id) {
|
if (!id) {
|
||||||
error = "missing"
|
error = "missing"
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
article = await fetchArticleById(id, API)
|
article = await fetchArticleById(id, API)
|
||||||
|
console.log(article)
|
||||||
} catch {
|
} catch {
|
||||||
article = null
|
article = null
|
||||||
}
|
}
|
||||||
@ -23,5 +25,5 @@ if (!id) {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<RunwayLookDetail article={article} locale="en" apiBase={API} error={error} />
|
<RunwayLookDetail article={article} apiBase={API} error={error} />
|
||||||
</Layout>
|
</Layout>
|
||||||
@ -1,858 +0,0 @@
|
|||||||
---
|
|
||||||
import Layout from "@/layouts/Layout.astro"
|
|
||||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
|
||||||
import { getBrands, getYears, type BrandEntry } from "@/lib/api"
|
|
||||||
import { getI18n } from '@/i18n/utils'
|
|
||||||
const { locale, t } = getI18n(Astro)
|
|
||||||
|
|
||||||
|
|
||||||
let sidebarBrands: BrandEntry[] = []
|
|
||||||
let yearChips: number[] = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
sidebarBrands = (await getBrands({ size: 300, only_with_articles: 1, featured: 1 }))
|
|
||||||
.filter((b) => b.id && b.name)
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
let yearSeed = [2026, 2025, 2024, 2023, 2022, 2021]
|
|
||||||
try {
|
|
||||||
const ys = await getYears(30)
|
|
||||||
if (ys.length) yearChips = ys
|
|
||||||
} catch {}
|
|
||||||
if (!yearChips.length) yearChips = yearSeed
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout title="All Shows — Portfolio Studio">
|
|
||||||
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
|
|
||||||
<!-- 顶部:极简眉头 -->
|
|
||||||
<header class="showshead">
|
|
||||||
<div>
|
|
||||||
<Breadcrumb
|
|
||||||
items={[
|
|
||||||
{ label: t('Home'), href: '/' },
|
|
||||||
{ label: 'Runway Archive', href: '/shows' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<span slot="current" x-text="crumbYear">All shows</span>
|
|
||||||
</Breadcrumb>
|
|
||||||
<h1 class="showsh1">All Shows</h1>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 已激活筛选 pills -->
|
|
||||||
<div class="showspills" x-show="pills.length > 0" x-cloak>
|
|
||||||
<template x-for="(p, i) in pills" :key="i">
|
|
||||||
<span class="showspill">
|
|
||||||
<span x-text="p.label"></span>
|
|
||||||
<button class="x" type="button" @click="p.clear()">×</button>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 主体:左 sidebar + 右 4 列商品 grid -->
|
|
||||||
<div class="showsbody">
|
|
||||||
<!-- LEFT SIDEBAR -->
|
|
||||||
<aside class="showsaside">
|
|
||||||
<div class="showsaside-head">
|
|
||||||
<h2 class="showsaside-h">REFINE</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="showsaside-body">
|
|
||||||
<!-- Designer 多选 -->
|
|
||||||
<section class="showsg">
|
|
||||||
<header class="showsg-h" @click="toggleGroup('designers')">
|
|
||||||
<span>DESIGNERS</span>
|
|
||||||
<span class="showsg-right">
|
|
||||||
<span class="showsg-count" x-text="selectedBrands.length">0</span>
|
|
||||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
|
|
||||||
<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>
|
|
||||||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
|
||||||
<input class="showssearch" placeholder="Search designer…" autocomplete="off" x-model.debounce.150ms="keyword" />
|
|
||||||
<ul class="showschecks">
|
|
||||||
<li>
|
|
||||||
<!-- Letter Header -->
|
|
||||||
<template x-if="item.type === 'letter'">
|
|
||||||
<div class="letter-head" x-text="item.letter"></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Brand Item -->
|
|
||||||
<template x-if="item.type === 'brand'">
|
|
||||||
<label class="flex items-center gap-2 w-full cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:value="item.id"
|
|
||||||
:checked="selectedBrands.includes(item.id)"
|
|
||||||
@change="toggleBrand(item.id)"
|
|
||||||
>
|
|
||||||
<span x-text="item.name"></span>
|
|
||||||
<span class="ct" x-text="item.count ? item.count : ''"></span>
|
|
||||||
</label>
|
|
||||||
</template>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<a class="showsmore" @click="openBrandModal()" x-text="'浏览全部品牌 +'"></a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Collection type -->
|
|
||||||
<section class="showsg">
|
|
||||||
<header class="showsg-h" @click="toggleGroup('collection')">
|
|
||||||
<span>COLLECTION</span>
|
|
||||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
|
|
||||||
<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>
|
|
||||||
<div class="showsg-b" x-show="!collapsedGroups.collection">
|
|
||||||
<ul class="showschecks showschecks--flat">
|
|
||||||
<li><label><input type="checkbox" value="rtw" x-model="selectedCt" @change="refreshFilter()" /> <span>Ready-to-Wear</span></label></li>
|
|
||||||
<li><label><input type="checkbox" value="menswear" x-model="selectedCt" @change="refreshFilter()" /> <span>Menswear</span></label></li>
|
|
||||||
<li><label><input type="checkbox" value="couture" x-model="selectedCt" @change="refreshFilter()" /> <span>Couture</span></label></li>
|
|
||||||
<li><label><input type="checkbox" value="resort" x-model="selectedCt" @change="refreshFilter()" /> <span>Resort</span></label></li>
|
|
||||||
<li><label><input type="checkbox" value="pre_fall" x-model="selectedCt" @change="refreshFilter()" /> <span>Pre-Fall</span></label></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Season -->
|
|
||||||
<section class="showsg">
|
|
||||||
<header class="showsg-h" @click="toggleGroup('season')">
|
|
||||||
<span>SEASON</span>
|
|
||||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }">
|
|
||||||
<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>
|
|
||||||
<div class="showsg-b" x-show="!collapsedGroups.season">
|
|
||||||
<ul class="showschecks showschecks--flat">
|
|
||||||
<li><label><input type="checkbox" value="spring" x-model="selectedSeason" @change="refreshFilter()" /> <span>Spring / Summer</span></label></li>
|
|
||||||
<li><label><input type="checkbox" value="fall" x-model="selectedSeason" @change="refreshFilter()" /> <span>Fall / Winter</span></label></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Year -->
|
|
||||||
<section class="showsg">
|
|
||||||
<header class="showsg-h" @click="toggleGroup('year')">
|
|
||||||
<span>YEAR</span>
|
|
||||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
|
|
||||||
<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>
|
|
||||||
<div class="showsg-b" x-show="!collapsedGroups.year">
|
|
||||||
<ul class="showschecks showschecks--flat">
|
|
||||||
{yearChips.map((y) => (
|
|
||||||
<li><label><input type="checkbox" value={String(y)} x-model="selectedYear" @change="refreshFilter()" /> <span>{y}</span></label></li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<button class="showsclear" type="button" @click="clearAll()">Clear all ×</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- MAIN GRID -->
|
|
||||||
<main class="showsmain">
|
|
||||||
<div class="showsgrid" :class="{ 'is-loading': loading }">
|
|
||||||
|
|
||||||
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
|
|
||||||
<template x-if="loading && items.length === 0">
|
|
||||||
<template x-for="i in skeletonCount" :key="'skel-'+i">
|
|
||||||
<div class="showscard showscard--skel" aria-hidden="true">
|
|
||||||
<div class="showscard-left">
|
|
||||||
<div class="showscard-img"></div>
|
|
||||||
</div>
|
|
||||||
<div class="showscard-right">
|
|
||||||
<div class="showscard-info">
|
|
||||||
<div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div>
|
|
||||||
<div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div>
|
|
||||||
<div class="showsline h-[38px] rounded-[2px]"></div>
|
|
||||||
</div>
|
|
||||||
<div class="showscard-strip">
|
|
||||||
<div class="showscard-thumb"></div>
|
|
||||||
<div class="showscard-thumb"></div>
|
|
||||||
<div class="showscard-thumb"></div>
|
|
||||||
<div class="showscard-thumb"></div>
|
|
||||||
<div class="showscard-thumb"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<template x-if="!loading && items.length === 0">
|
|
||||||
<div class="showsempty">No shows match the current filters</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Card List -->
|
|
||||||
<template x-for="it in items" :key="it.id">
|
|
||||||
<a class="showscard" :href="`/${localeUrl}?id=${it.id}`" :aria-label="`${it.brand_name} ${it.title}`">
|
|
||||||
<div class="showscard-left">
|
|
||||||
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
|
||||||
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
|
|
||||||
<img :src="toAbsUrl(it.cover)" :alt="it.title" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
|
|
||||||
<!-- <span class="showsbadge" x-show="isNewAdded(it.published_at)">New</span> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="showscard-right">
|
|
||||||
<div class="showscard-info">
|
|
||||||
<h3 class="showscard-title" x-text="it.title"></h3>
|
|
||||||
<p class="showscard-meta" x-text="`${it.season_code || it.year || ''} · ${ctLabel(it.collection_type)} · ${it.brand_name}`"></p>
|
|
||||||
<div class="showscard-meta-foot">
|
|
||||||
<span class="showscard-meta-line"><span x-html="ICON_USER"></span><span x-text="it.brand_name"></span></span>
|
|
||||||
<span class="showscard-meta-line"><span x-html="ICON_DATE"></span><span x-text="fmtDate(it.published_at)"></span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="showscard-strip">
|
|
||||||
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
|
|
||||||
<div class="showscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
|
|
||||||
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG" x-show="slot.src"></span>
|
|
||||||
<img x-show="slot.src" :src="toAbsUrl(slot.src)" alt="" loading="lazy" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
|
|
||||||
<span class="showscard-thumb-more" x-show="slot.isLast && slot.moreLabel" x-text="slot.moreLabel"></span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="showsloadmore-wrap" x-show="items.length > 0">
|
|
||||||
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 品牌筛选弹窗:搜索 + A-Z 跳转 + 热门推荐,按需加载,避免一次拉全量 -->
|
|
||||||
<div class="showsmodal" x-show="modalOpen" x-cloak>
|
|
||||||
<div class="showsmodal-mask" @click="closeBrandModal()"></div>
|
|
||||||
<div class="showsmodal-panel">
|
|
||||||
<header class="showsmodal-head">
|
|
||||||
<h3>全部品牌</h3>
|
|
||||||
<button class="showsmodal-x" type="button" @click="closeBrandModal()" aria-label="关闭">✕</button>
|
|
||||||
</header>
|
|
||||||
<div class="showsmodal-search">
|
|
||||||
<input
|
|
||||||
class="showsmodal-input"
|
|
||||||
type="text"
|
|
||||||
x-model="modalKeyword"
|
|
||||||
@input.debounce.300ms="modalSearch()"
|
|
||||||
placeholder="搜索品牌名…"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="showsmodal-az">
|
|
||||||
<template x-for="l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split('')" :key="l">
|
|
||||||
<button class="az" type="button" :class="{ active: modalLetter === l }" @click="modalPickLetter(l)" x-text="l"></button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div class="showsmodal-body">
|
|
||||||
<div class="showsmodal-hint" x-show="!modalKeyword && !modalLetter">热门推荐</div>
|
|
||||||
<div class="showsmodal-loading" x-show="modalLoading" x-html="SPIN_SVG"></div>
|
|
||||||
<ul class="showsmodal-list">
|
|
||||||
<template x-for="b in modalResults" :key="b.id">
|
|
||||||
<li>
|
|
||||||
<label class="showsmodal-row">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:value="b.id"
|
|
||||||
:checked="modalSelected.includes(b.id)"
|
|
||||||
:disabled="!modalSelected.includes(b.id) && modalMaxReached"
|
|
||||||
@change="toggleModalBrand(b.id)"
|
|
||||||
/>
|
|
||||||
<span class="showsmodal-name" x-text="b.name"></span>
|
|
||||||
<span class="showsmodal-ct" x-text="b.article_count ? b.article_count : ''"></span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
<li class="showsmodal-empty" x-show="!modalLoading && modalResults.length === 0">未找到匹配的品牌</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<footer class="showsmodal-foot">
|
|
||||||
<span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 最多 5 个`"></span>
|
|
||||||
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
|
|
||||||
<script id="showsbrands-data" type="application/json" set:html={JSON.stringify(sidebarBrands)}></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@reference "tailwindcss";
|
|
||||||
[x-cloak] { display: none !important; }
|
|
||||||
|
|
||||||
|
|
||||||
/* 使用 @apply 精简并重构样式 */
|
|
||||||
.showsroot {
|
|
||||||
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
|
|
||||||
}
|
|
||||||
|
|
||||||
.showshead {
|
|
||||||
@apply flex justify-between items-end gap-6 py-5 pb-[26px] ;
|
|
||||||
}
|
|
||||||
.showsh1 {
|
|
||||||
@apply text-[clamp(40px,6vw,72px)] leading-[0.9] font-black tracking-[-0.02em] my-0 mb-[10px];
|
|
||||||
}
|
|
||||||
|
|
||||||
.showspills {
|
|
||||||
@apply flex flex-wrap gap-2 pt-3.5;
|
|
||||||
}
|
|
||||||
.showspill {
|
|
||||||
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
|
|
||||||
}
|
|
||||||
.showspill .x {
|
|
||||||
@apply inline-flex w-3.5 h-3.5 items-center justify-center border-none bg-black text-white cursor-pointer text-xs leading-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showsbody {
|
|
||||||
@apply flex gap-[36px] items-start max-md:flex-col max-md:gap-[18px];
|
|
||||||
}
|
|
||||||
|
|
||||||
.showsaside {
|
|
||||||
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] rounded-none transition-[flex-basis] duration-250 ease-in-out flex flex-col max-md:static max-md:max-h-none max-md:w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showsaside-head {
|
|
||||||
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
|
|
||||||
}
|
|
||||||
.showsaside-h {
|
|
||||||
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none transition-opacity duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showsaside-body {
|
|
||||||
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
|
|
||||||
}
|
|
||||||
|
|
||||||
.showsg {
|
|
||||||
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
|
|
||||||
}
|
|
||||||
.showsg-h {
|
|
||||||
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
|
|
||||||
}
|
|
||||||
.showsg-right {
|
|
||||||
@apply inline-flex items-center gap-2.5;
|
|
||||||
}
|
|
||||||
.showsg-chev {
|
|
||||||
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
|
|
||||||
}
|
|
||||||
.showsg-chev.is-collapsed {
|
|
||||||
@apply -rotate-90;
|
|
||||||
}
|
|
||||||
.showsg-count {
|
|
||||||
@apply text-[#999] font-normal text-[10px] tracking-[0.14em];
|
|
||||||
}
|
|
||||||
|
|
||||||
.showssearch {
|
|
||||||
@apply w-full border border-[#d4d4d4] p-[7px_10px] text-[11px] uppercase tracking-[0.08em] text-black rounded-none outline-none bg-[#fafafa] focus:border-black focus:bg-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showschecks {
|
|
||||||
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin];
|
|
||||||
}
|
|
||||||
.showschecks 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];
|
|
||||||
}
|
|
||||||
.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="checkbox"] {
|
|
||||||
@apply appearance-none w-3 h-3 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-none inline-grid place-content-center checked:bg-black;
|
|
||||||
}
|
|
||||||
.showschecks input[type="checkbox"]:checked::after {
|
|
||||||
content: "";
|
|
||||||
@apply w-[6px] h-[6px] bg-white;
|
|
||||||
}
|
|
||||||
.showschecks .ct {
|
|
||||||
@apply text-[#999] text-[10px] ml-auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showschecks--flat li {
|
|
||||||
@apply py-[4px];
|
|
||||||
}
|
|
||||||
.showschecks--flat li label {
|
|
||||||
@apply inline-flex items-center gap-2 cursor-pointer w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showsmore {
|
|
||||||
@apply inline-block mt-[10px] text-[10.5px] uppercase tracking-[0.16em] text-[#555] cursor-pointer hover:text-black;
|
|
||||||
}
|
|
||||||
.showsclear {
|
|
||||||
@apply mt-[18px] w-full border-[1.5px] border-black bg-white text-black p-[11px_12px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-150 hover:bg-black hover:text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showsmain {
|
|
||||||
@apply flex-1 min-w-0;
|
|
||||||
}
|
|
||||||
.showsgrid {
|
|
||||||
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
|
|
||||||
}
|
|
||||||
.showsgrid.is-loading::before {
|
|
||||||
content: "";
|
|
||||||
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
|
|
||||||
animation: hbxProg 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showscard {
|
|
||||||
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px];
|
|
||||||
}
|
|
||||||
.showscard:hover .showscard-img img {
|
|
||||||
@apply scale-104;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showscard-left {
|
|
||||||
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
|
|
||||||
}
|
|
||||||
.showscard-img {
|
|
||||||
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
|
|
||||||
}
|
|
||||||
.showscard-img img {
|
|
||||||
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
|
|
||||||
}
|
|
||||||
.showscard-img img.is-loaded {
|
|
||||||
@apply opacity-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showsspin {
|
|
||||||
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
|
|
||||||
}
|
|
||||||
.showscard-img.is-loaded .showsspin,
|
|
||||||
.showscard-thumb.is-loaded .showsspin {
|
|
||||||
@apply opacity-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
|
|
||||||
}
|
|
||||||
.showscard-info {
|
|
||||||
@apply flex-none min-w-0 flex flex-col p-[2px_2px_0];
|
|
||||||
}
|
|
||||||
.showscard-title {
|
|
||||||
@apply text-[15.5px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] tracking-[-0.01em] line-clamp-2 h-[41px] overflow-hidden;
|
|
||||||
}
|
|
||||||
.showscard-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;
|
|
||||||
}
|
|
||||||
.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 {
|
|
||||||
@apply grid grid-cols-5 gap-1 mt-[10px];
|
|
||||||
}
|
|
||||||
.showscard-thumb {
|
|
||||||
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
|
|
||||||
}
|
|
||||||
.showscard-thumb img {
|
|
||||||
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
|
|
||||||
}
|
|
||||||
.showscard-thumb img.is-loaded {
|
|
||||||
@apply opacity-100;
|
|
||||||
}
|
|
||||||
.showscard-thumb.placeholder {
|
|
||||||
@apply bg-[#f6f6f6];
|
|
||||||
}
|
|
||||||
.showscard-thumb-more {
|
|
||||||
@apply absolute inset-0 bg-black/55 text-white text-[11px] font-bold tracking-[0.04em] flex items-center justify-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showscard--skel .showscard-img,
|
|
||||||
.showscard--skel .showscard-thumb,
|
|
||||||
.showscard--skel .showsline {
|
|
||||||
@apply bg-[#efefef];
|
|
||||||
animation: hbxShim 1.6s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showsloadmore-wrap {
|
|
||||||
@apply flex justify-center mt-[38px];
|
|
||||||
}
|
|
||||||
.showsloadmore {
|
|
||||||
@apply border-[1.5px] border-black bg-white text-black p-[13px_36px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-200 hover:bg-black hover:text-white disabled:opacity-40 disabled:cursor-default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showsempty {
|
|
||||||
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hbxProg { 0% { left: -38%; } 100% { left: 100%; } }
|
|
||||||
@keyframes hbxShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }
|
|
||||||
/* ===== 品牌筛选弹窗 ===== */
|
|
||||||
.showsmodal {
|
|
||||||
@apply fixed inset-0 z-[60] flex items-center justify-center p-[16px];
|
|
||||||
}
|
|
||||||
.showsmodal-mask {
|
|
||||||
@apply absolute inset-0 bg-black/45;
|
|
||||||
}
|
|
||||||
.showsmodal-panel {
|
|
||||||
@apply relative w-full max-w-[680px] max-h-[86vh] bg-white flex flex-col border border-black;
|
|
||||||
}
|
|
||||||
.showsmodal-head {
|
|
||||||
@apply flex items-center justify-between px-[16px] py-[13px] border-b border-black shrink-0;
|
|
||||||
}
|
|
||||||
.showsmodal-head h3 {
|
|
||||||
@apply m-0 text-[13px] tracking-[0.2em] uppercase font-bold;
|
|
||||||
}
|
|
||||||
.showsmodal-x {
|
|
||||||
@apply appearance-none bg-white border border-black w-[24px] h-[24px] inline-flex items-center justify-center cursor-pointer text-[12px] leading-none;
|
|
||||||
}
|
|
||||||
.showsmodal-x:hover { @apply bg-black text-white; }
|
|
||||||
.showsmodal-search { @apply px-[16px] pt-[12px] pb-[8px] shrink-0; }
|
|
||||||
.showsmodal-input {
|
|
||||||
@apply w-full border border-black px-[10px] py-[8px] text-[13px] outline-none;
|
|
||||||
}
|
|
||||||
.showsmodal-input:focus { @apply ring-1 ring-black; }
|
|
||||||
.showsmodal-az {
|
|
||||||
@apply flex flex-wrap gap-[4px] px-[16px] pb-[10px] border-b border-[#e6e6e6] shrink-0;
|
|
||||||
}
|
|
||||||
.showsmodal-az .az {
|
|
||||||
@apply appearance-none bg-white border border-[#ccc] w-[22px] h-[22px] text-[11px] cursor-pointer leading-none;
|
|
||||||
}
|
|
||||||
.showsmodal-az .az.active { @apply bg-black text-white border-black; }
|
|
||||||
.showsmodal-body { @apply flex-1 min-h-0 overflow-y-auto px-[16px] py-[8px]; }
|
|
||||||
.showsmodal-hint {
|
|
||||||
@apply text-[11px] tracking-[0.2em] uppercase text-[#888] py-[6px] pb-[8px];
|
|
||||||
}
|
|
||||||
.showsmodal-loading { @apply flex justify-center py-[28px] text-[#bdbdbd]; }
|
|
||||||
.showsmodal-list { @apply list-none m-0 p-0; }
|
|
||||||
.showsmodal-row {
|
|
||||||
@apply flex items-center gap-[8px] w-full cursor-pointer py-[7px] border-b border-[#f0f0f0] text-[13px];
|
|
||||||
}
|
|
||||||
.showsmodal-name { @apply flex-1 truncate; }
|
|
||||||
.showsmodal-ct { @apply text-[#999] text-[11px] ml-auto; }
|
|
||||||
.showsmodal-empty { @apply py-[28px] text-center text-[#999] text-[13px]; }
|
|
||||||
.showsmodal-foot {
|
|
||||||
@apply flex items-center justify-between px-[16px] py-[12px] border-t border-black shrink-0 text-[12px];
|
|
||||||
}
|
|
||||||
.showsmodal-ok {
|
|
||||||
@apply appearance-none bg-black text-white border border-black px-[22px] py-[8px] cursor-pointer text-[12px] tracking-[0.1em];
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import Alpine from "alpinejs"
|
|
||||||
import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
|
|
||||||
|
|
||||||
const ICON_USER = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><circle cx="6" cy="4" r="2" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 10c0-2 1.8-3 4-3s4 1 4 3" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
|
|
||||||
const ICON_DATE = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><rect x="2" y="3" width="8" height="7" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 6h8M4 2v2M8 2v2" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
|
|
||||||
const SPIN_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block"><g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g><g opacity="0.55" fill="none" stroke="currentColor" stroke-width="1"><rect x="8" y="9.6" width="8" height="5.4"></rect><circle cx="12" cy="12.3" r="1.6"></circle></g></svg>`
|
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
|
||||||
Alpine.data("showsPage", () => {
|
|
||||||
const node = document.getElementById("showsbrands-data")
|
|
||||||
const allBrands: BrandEntry[] = node?.textContent ? JSON.parse(node.textContent) : []
|
|
||||||
|
|
||||||
return {
|
|
||||||
localeUrl: this.dataset.locale,
|
|
||||||
allBrands,
|
|
||||||
ICON_USER,
|
|
||||||
ICON_DATE,
|
|
||||||
SPIN_SVG,
|
|
||||||
collapsedGroups: {
|
|
||||||
designers: localStorage.getItem("showsg-designers") === "1",
|
|
||||||
collection: localStorage.getItem("showsg-collection") === "1",
|
|
||||||
season: localStorage.getItem("showsg-season") === "1",
|
|
||||||
year: localStorage.getItem("showsg-year") === "1",
|
|
||||||
},
|
|
||||||
selectedBrands: [] as number[],
|
|
||||||
selectedCt: [] as string[],
|
|
||||||
selectedSeason: [] as string[],
|
|
||||||
selectedYear: [] as string[],
|
|
||||||
keyword: "",
|
|
||||||
brandsExpanded: false,
|
|
||||||
|
|
||||||
// 品牌筛选弹窗状态
|
|
||||||
modalOpen: false,
|
|
||||||
modalKeyword: "",
|
|
||||||
modalLetter: "",
|
|
||||||
modalResults: [] as BrandEntry[],
|
|
||||||
modalLoading: false,
|
|
||||||
modalSelected: [] as number[],
|
|
||||||
get modalMaxReached() {
|
|
||||||
return this.modalSelected.length >= 5
|
|
||||||
},
|
|
||||||
|
|
||||||
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
|
|
||||||
loading: false,
|
|
||||||
loadingMore: false,
|
|
||||||
page: 1,
|
|
||||||
size: 24,
|
|
||||||
total: 0,
|
|
||||||
isEnd: false,
|
|
||||||
reqToken: 0,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.loadPage(1, false)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 计算属性:侧边栏设计师列表的分组渲染
|
|
||||||
get visibleBrandItems() {
|
|
||||||
const kw = this.keyword.trim().toLowerCase()
|
|
||||||
const result: any[] = []
|
|
||||||
|
|
||||||
if (kw) {
|
|
||||||
this.allBrands.forEach((b) => {
|
|
||||||
if (b.name.toLowerCase().includes(kw)) {
|
|
||||||
result.push({ type: "brand", id: b.id, name: b.name, count: b.article_count, key: b.id })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const map = new Map<string, BrandEntry[]>()
|
|
||||||
for (const b of this.allBrands) {
|
|
||||||
const letter = (b.name[0] || "?").toUpperCase()
|
|
||||||
if (!map.has(letter)) map.set(letter, [])
|
|
||||||
map.get(letter)!.push(b)
|
|
||||||
}
|
|
||||||
const grouped = [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
||||||
|
|
||||||
let shown = 0
|
|
||||||
const MAX_INITIAL = 60
|
|
||||||
for (const [letter, list] of grouped) {
|
|
||||||
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
|
|
||||||
result.push({ type: "letter", letter, key: `letter-${letter}` })
|
|
||||||
for (const b of list) {
|
|
||||||
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
|
|
||||||
result.push({ type: "brand", id: b.id, name: b.name, count: b.article_count, key: b.id })
|
|
||||||
shown++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
|
|
||||||
get hasMoreBrands() {
|
|
||||||
return this.allBrands.length > 60
|
|
||||||
},
|
|
||||||
|
|
||||||
// 面包屑末级:未选年份时显示默认的「2026 Edition」,选中后显示对应年份
|
|
||||||
get crumbYear() {
|
|
||||||
if (this.selectedYear.length) {
|
|
||||||
const ys = [...this.selectedYear].sort()
|
|
||||||
return ys.length === 1 ? `${ys[0]} Edition` : `${ys.join(" / ")}`
|
|
||||||
}
|
|
||||||
return "2026 Edition"
|
|
||||||
},
|
|
||||||
|
|
||||||
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
|
|
||||||
get skeletonCount() {
|
|
||||||
return this.size
|
|
||||||
},
|
|
||||||
|
|
||||||
// 已筛选选项 Pills 视图数据
|
|
||||||
get pills() {
|
|
||||||
const list: { label: string; clear: () => void }[] = []
|
|
||||||
if (this.selectedBrands.length) {
|
|
||||||
const labels = this.selectedBrands
|
|
||||||
.map((id) => this.allBrands.find((b) => b.id === id)?.name ?? `#${id}`)
|
|
||||||
.slice(0, 4)
|
|
||||||
const more = this.selectedBrands.length > 4 ? ` +${this.selectedBrands.length - 4}` : ""
|
|
||||||
list.push({
|
|
||||||
label: "DESIGNER · " + labels.join(", ") + more,
|
|
||||||
clear: () => { this.selectedBrands = []; this.refreshFilter(); }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.selectedCt.length) {
|
|
||||||
list.push({
|
|
||||||
label: "COLLECTION · " + this.selectedCt.join("+").toUpperCase(),
|
|
||||||
clear: () => { this.selectedCt = []; this.refreshFilter(); }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.selectedSeason.length) {
|
|
||||||
list.push({
|
|
||||||
label: "SEASON · " + this.selectedSeason.join("+").toUpperCase(),
|
|
||||||
clear: () => { this.selectedSeason = []; this.refreshFilter(); }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.selectedYear.length) {
|
|
||||||
list.push({
|
|
||||||
label: "YEAR · " + [...this.selectedYear].sort().join("+"),
|
|
||||||
clear: () => { this.selectedYear = []; this.refreshFilter(); }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
},
|
|
||||||
|
|
||||||
get loadBtnText() {
|
|
||||||
if (this.loadingMore) return "Loading…"
|
|
||||||
if (this.isEnd) return "— End of Archive —"
|
|
||||||
return "Load More ↓"
|
|
||||||
},
|
|
||||||
|
|
||||||
// 事件处理方法
|
|
||||||
toggleGroup(group: "designers" | "collection" | "season" | "year") {
|
|
||||||
this.collapsedGroups[group] = !this.collapsedGroups[group]
|
|
||||||
localStorage.setItem(`showsg-${group}`, this.collapsedGroups[group] ? "1" : "")
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleBrand(id: number) {
|
|
||||||
const idx = this.selectedBrands.indexOf(id)
|
|
||||||
if (idx >= 0) this.selectedBrands.splice(idx, 1)
|
|
||||||
else this.selectedBrands.push(id)
|
|
||||||
this.refreshFilter()
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshFilter() {
|
|
||||||
this.loadPage(1, false)
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 品牌筛选弹窗方法 =====
|
|
||||||
async openBrandModal() {
|
|
||||||
this.modalSelected = [...this.selectedBrands]
|
|
||||||
this.modalKeyword = ""
|
|
||||||
this.modalLetter = ""
|
|
||||||
this.modalOpen = true
|
|
||||||
await this.modalLoadHot()
|
|
||||||
},
|
|
||||||
closeBrandModal() {
|
|
||||||
this.modalOpen = false
|
|
||||||
},
|
|
||||||
async modalLoadHot() {
|
|
||||||
this.modalLoading = true
|
|
||||||
try {
|
|
||||||
const list = await getBrands({ featured: 1, size: 50 })
|
|
||||||
this.modalResults = list.length ? list : this.allBrands.slice(0, 50)
|
|
||||||
} catch {
|
|
||||||
this.modalResults = this.allBrands.slice(0, 50)
|
|
||||||
} finally {
|
|
||||||
this.modalLoading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async modalSearch() {
|
|
||||||
const kw = this.modalKeyword.trim()
|
|
||||||
if (kw) {
|
|
||||||
this.modalLetter = ""
|
|
||||||
this.modalLoading = true
|
|
||||||
try {
|
|
||||||
this.modalResults = await getBrands({ keyword: kw, size: 60 })
|
|
||||||
} catch {
|
|
||||||
this.modalResults = []
|
|
||||||
} finally {
|
|
||||||
this.modalLoading = false
|
|
||||||
}
|
|
||||||
} else if (this.modalLetter) {
|
|
||||||
await this.modalPickLetter(this.modalLetter)
|
|
||||||
} else {
|
|
||||||
await this.modalLoadHot()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async modalPickLetter(l: string) {
|
|
||||||
this.modalLetter = l
|
|
||||||
this.modalKeyword = ""
|
|
||||||
this.modalLoading = true
|
|
||||||
try {
|
|
||||||
const letter = l === "#" ? "OTHER" : l
|
|
||||||
this.modalResults = await getBrands({ letter, size: 200 })
|
|
||||||
} catch {
|
|
||||||
this.modalResults = []
|
|
||||||
} finally {
|
|
||||||
this.modalLoading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toggleModalBrand(id: number) {
|
|
||||||
const idx = this.modalSelected.indexOf(id)
|
|
||||||
if (idx >= 0) this.modalSelected.splice(idx, 1)
|
|
||||||
else {
|
|
||||||
if (this.modalSelected.length >= 5) return
|
|
||||||
this.modalSelected.push(id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmBrandModal() {
|
|
||||||
this.selectedBrands = [...this.modalSelected]
|
|
||||||
this.modalOpen = false
|
|
||||||
this.refreshFilter()
|
|
||||||
},
|
|
||||||
|
|
||||||
clearAll() {
|
|
||||||
this.selectedBrands = []
|
|
||||||
this.selectedCt = []
|
|
||||||
this.selectedSeason = []
|
|
||||||
this.selectedYear = []
|
|
||||||
this.keyword = ""
|
|
||||||
this.refreshFilter()
|
|
||||||
},
|
|
||||||
|
|
||||||
// API 加载
|
|
||||||
async loadPage(page: number, append = false) {
|
|
||||||
const myToken = ++this.reqToken
|
|
||||||
if (append) {
|
|
||||||
this.loadingMore = true
|
|
||||||
} else {
|
|
||||||
this.loading = true
|
|
||||||
this.items = []
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const json = await getArticles({
|
|
||||||
page,
|
|
||||||
size: this.size,
|
|
||||||
sort: "newest",
|
|
||||||
brand_ids: this.selectedBrands,
|
|
||||||
collection_type: this.selectedCt,
|
|
||||||
season: this.selectedSeason,
|
|
||||||
year: this.selectedYear,
|
|
||||||
with_images: 6,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (myToken !== this.reqToken) return
|
|
||||||
|
|
||||||
const rows = json.data.map((item: ArticleItem) => ({ ...item, _imgLoaded: false }))
|
|
||||||
this.total = json.total
|
|
||||||
this.page = page
|
|
||||||
this.items = append ? [...this.items, ...rows] : rows
|
|
||||||
|
|
||||||
this.isEnd = page >= json.last_page || rows.length < this.size
|
|
||||||
} catch {
|
|
||||||
if (myToken !== this.reqToken) return
|
|
||||||
if (!append) this.items = []
|
|
||||||
} finally {
|
|
||||||
if (myToken === this.reqToken) {
|
|
||||||
this.loading = false
|
|
||||||
this.loadingMore = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 辅助辅助工具函数
|
|
||||||
toAbsUrl(url: string) { return toAbs(url) },
|
|
||||||
isNewAdded(pubAt?: number) { return Date.now() / 1000 - Number(pubAt || 0) < 60 * 60 * 24 * 14 },
|
|
||||||
fmtDate(ts: number) {
|
|
||||||
if (!ts) return ""
|
|
||||||
const d = new Date(ts * 1000)
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
|
||||||
},
|
|
||||||
ctLabel(ct?: string) {
|
|
||||||
const map: Record<string, string> = { rtw: "Ready-to-Wear", menswear: "Menswear", couture: "Couture", resort: "Resort", pre_fall: "Pre-Fall" }
|
|
||||||
return ct && map[ct] ? map[ct] : (ct || "")
|
|
||||||
},
|
|
||||||
getCardSlots(it: ArticleItem) {
|
|
||||||
const gallery = it.gallery || []
|
|
||||||
const totalCount = it.image_count || 0
|
|
||||||
const moreLabel = totalCount > 30 ? "30+" : `${totalCount || 0}+`
|
|
||||||
const slots = []
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const src = gallery[i]
|
|
||||||
const isLast = i === 4
|
|
||||||
slots.push({ src, loaded: false, isLast, moreLabel: (isLast && totalCount > 0) ? moreLabel : "" })
|
|
||||||
}
|
|
||||||
return slots
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
Reference in New Issue
Block a user