36
src/components/LanguageDetector.astro
Normal file
36
src/components/LanguageDetector.astro
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
import { pathHasLocale, getRelativeLocaleUrl } from 'astro:i18n';
|
||||
|
||||
// 使用 pathHasLocale 安全判断当前路径是否已包含任何语言前缀(返回 boolean)
|
||||
const hasLocale = pathHasLocale(Astro.url.pathname);
|
||||
|
||||
// 自动生成中文根路径
|
||||
const cnRootUrl = getRelativeLocaleUrl('cn', '');
|
||||
---
|
||||
|
||||
<script is:inline define:vars={{ hasLocale, cnRootUrl }}>
|
||||
(() => {
|
||||
try {
|
||||
// 当前路径已带有语言前缀(如 /cn/ 或 /en/),直接跳过
|
||||
if (hasLocale) return;
|
||||
|
||||
// 避免重复跳转
|
||||
if (sessionStorage.getItem('lang-redirect')) return;
|
||||
|
||||
// 判定浏览器偏好语言
|
||||
const langs = navigator.languages || [navigator.language || 'en'];
|
||||
const isZh = langs.some((l) => l.toLowerCase().startsWith('zh'));
|
||||
|
||||
if (!isZh) return;
|
||||
|
||||
sessionStorage.setItem('lang-redirect', '1');
|
||||
|
||||
// 拼接真实跳转路径
|
||||
const { pathname, search, hash } = location;
|
||||
const cleanPath = pathname === '/' ? '' : pathname;
|
||||
const targetUrl = `${cnRootUrl}${cleanPath.replace(/^\//, '')}${search}${hash}`;
|
||||
|
||||
location.replace(targetUrl);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
@ -1,53 +1,45 @@
|
||||
---
|
||||
<!-- Alpine.js 防止闪烁样式 -->
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
/* 隐藏缩略图侧边栏滚动条 */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
|
||||
// 示例数据:字段严格对齐后端 BrandRunway 模型 + 图片集接口
|
||||
const article = {
|
||||
id: 1,
|
||||
title: "Maison Lumen — Paper Light",
|
||||
description:
|
||||
"巴黎,2026 秋冬。Maison Lumen 以“Paper Light(纸光)”为题,将薄纱、宣纸与压褶工艺叠合成一种近乎透明的体积感。\n\n系列延续了工作室对“轻”的执念:肩线被刻意放低,廓形在行走中随风摆动,像被光穿透的纸。色彩仅保留骨白、炭灰与一抹雾蓝。\n\n这不是关于廓形的宣言,而是关于 material 的克制——用最少的手段,制造最大的呼吸感。",
|
||||
created_at: 1711948800,
|
||||
updated_at: 1711948800,
|
||||
year: 2026,
|
||||
source_url: "https://example.com/runway/maison-lumen-paper-light",
|
||||
brand_id: 12,
|
||||
brand_name: "Maison Lumen",
|
||||
images: [
|
||||
{ id: 1, image: "https://picsum.photos/seed/art1/1200/1500", name: "Look 01", sort_order: 1 },
|
||||
{ id: 2, image: "https://picsum.photos/seed/art2/1200/1500", name: "Look 02", sort_order: 2 },
|
||||
{ id: 3, image: "https://picsum.photos/seed/art3/1200/1500", name: "Look 03", sort_order: 3 },
|
||||
{ id: 4, image: "https://picsum.photos/seed/art4/1200/1500", name: "Look 04", sort_order: 4 },
|
||||
],
|
||||
}
|
||||
<div x-data="articleDetail()" class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white">
|
||||
|
||||
const fmt = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getDate()).padStart(2, "0")
|
||||
return `${y}.${m}.${day}`
|
||||
}
|
||||
<!-- 1. 加载中状态 (Loading) -->
|
||||
<div x-show="loading" class="flex flex-col items-center justify-center min-h-[60vh] text-[12px] font-mono uppercase tracking-[0.2em] text-[#888]">
|
||||
<div class="w-7 h-7 border-2 border-black border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||
<span>Loading Article...</span>
|
||||
</div>
|
||||
|
||||
const body = article.description.split("\n\n")
|
||||
---
|
||||
<!-- 2. 错误提示状态 (Error) -->
|
||||
<div x-show="error && !loading" x-cloak class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
|
||||
<p class="font-mono text-sm text-red-500 uppercase tracking-widest mb-4" x-text="error"></p>
|
||||
<a href="/" class="text-[12px] font-mono uppercase tracking-widest border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors">
|
||||
← Back to Index
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<article class="mx-auto max-w-[1100px] px-5 md:px-10 py-12 md:py-16">
|
||||
<!-- 3. 文章主内容 -->
|
||||
<article x-show="!loading && article" x-cloak class="mx-auto max-w-[1100px] px-5 md:px-10 py-12 md:py-16">
|
||||
<!-- 面包屑 / 品牌 -->
|
||||
<div class="flex items-center justify-between text-[11px] font-mono uppercase tracking-[0.25em] text-black border-b border-black pb-4">
|
||||
<a href="/" class="hover:opacity-60 transition-opacity">← Index</a>
|
||||
<span id="art-brand">Runway · {article.brand_name}</span>
|
||||
<span>Runway · <span x-text="article?.brand_name || 'Brand'"></span></span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h1 class="font-serif font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
|
||||
{article.title}
|
||||
</h1>
|
||||
<h1 class="font-serif font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8" x-text="article?.title"></h1>
|
||||
|
||||
<!-- 元信息 -->
|
||||
<div class="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-[12px] font-mono uppercase tracking-widest text-[#666]">
|
||||
<span id="art-date">{fmt(article.created_at)}</span>
|
||||
<span id="art-year" class="text-black">{article.year}</span>
|
||||
<a href={article.source_url} target="_blank" rel="noopener" class="hover:text-black underline underline-offset-4">Source ↗</a>
|
||||
<span x-text="formatDate(article?.published_at || article?.created_at)"></span>
|
||||
<span class="text-black" x-text="article?.year"></span>
|
||||
<template x-if="article?.source_url">
|
||||
<a :href="article.source_url" target="_blank" rel="noopener" class="hover:text-black underline underline-offset-4">Source ↗</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 正文 -->
|
||||
@ -55,28 +47,36 @@ const body = article.description.split("\n\n")
|
||||
<div class="md:col-span-2 text-[11px] font-mono uppercase tracking-widest text-[#999] pt-1">
|
||||
Report
|
||||
</div>
|
||||
<div id="art-body" class="md:col-span-10 max-w-[68ch] space-y-5 text-[16px] leading-[1.9] text-[#222]">
|
||||
{body.map((p) => <p>{p}</p>)}
|
||||
<div class="md:col-span-10 max-w-[68ch] space-y-5 text-[16px] leading-[1.9] text-[#222]">
|
||||
<template x-for="(paragraph, idx) in bodyParagraphs" :key="idx">
|
||||
<p x-text="paragraph"></p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片集 -->
|
||||
<section class="mt-16">
|
||||
<section class="mt-16" x-show="article?.images && article.images.length > 0">
|
||||
<div class="flex items-end justify-between border-b border-[#eee] pb-3 mb-6">
|
||||
<div class="text-[11px] font-mono uppercase tracking-[0.25em] text-[#999]">
|
||||
Gallery <span id="art-gallery-count" class="ml-2 text-black"></span>
|
||||
Gallery <span class="ml-2 text-black" x-text="`· 共 ${article?.images?.length || 0} 张`"></span>
|
||||
</div>
|
||||
<button type="button" id="art-viewall" class="text-[11px] font-mono uppercase tracking-widest border border-black px-3 py-1.5 hover:bg-black hover:text-white transition-colors">⤢ 全屏查看</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openLb(0)"
|
||||
class="text-[11px] font-mono uppercase tracking-widest border border-black px-3 py-1.5 hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
⤢ 全屏查看
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 图片网格 -->
|
||||
<div id="art-gallery" class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{article.images.map((im) => (
|
||||
<figure class="g-item bg-[#f2f2f2] cursor-zoom-in">
|
||||
<img src={im.image} alt={im.name} loading="lazy" class="w-full aspect-[3/4] object-cover" />
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">{im.name}</figcaption>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<template x-for="(im, i) in article?.images" :key="im.id || i">
|
||||
<figure @click="openLb(i)" class="bg-[#f2f2f2] cursor-zoom-in group">
|
||||
<img :src="im.imageUrl" :alt="im.name" loading="lazy" class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity" />
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name || ''"></figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -85,376 +85,230 @@ const body = article.description.split("\n\n")
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 灯箱:左侧竖排缩略图条 + 中央大图(点击放大→宽度最大+纵向滚动) -->
|
||||
<div id="lb" class="lb" aria-hidden="true">
|
||||
<div class="lb-counter" id="lb-counter"></div>
|
||||
<button class="lb-btn lb-close" id="lb-close" aria-label="关闭">✕</button>
|
||||
<div class="lb-stage" id="lb-stage">
|
||||
<div class="lb-thumbs" id="lb-thumbs"></div>
|
||||
<button class="lb-tbtn lb-tup" id="lb-tup" type="button" aria-label="上一张">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 14l6-6 6 6"/></svg>
|
||||
<!-- 4. 全屏灯箱组件 (Lightbox) -->
|
||||
<div
|
||||
x-show="lbOpen"
|
||||
x-cloak
|
||||
@keydown.window.escape="closeLb()"
|
||||
@keydown.window.arrow-right="nextLb()"
|
||||
@keydown.window.arrow-left="prevLb()"
|
||||
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
|
||||
>
|
||||
<!-- 计数器 -->
|
||||
<div class="absolute top-5 left-1/2 -translate-x-1/2 z-30 font-mono text-[12px] font-semibold tracking-[0.14em] text-[#111] bg-white/75 border border-black/10 px-3 py-1.5 rounded-[6px] backdrop-blur-md" x-text="`${lbIndex + 1} / ${article?.images?.length || 0}`"></div>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<button @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" aria-label="关闭">
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- 主舞台 -->
|
||||
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
||||
<!-- 左侧缩略图纵向列表 -->
|
||||
<div x-ref="lbThumbs" class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar">
|
||||
<template x-for="(im, i) in article?.images" :key="im.id || i">
|
||||
<button
|
||||
type="button"
|
||||
@click="showLb(i)"
|
||||
class="relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200"
|
||||
:class="lbIndex === i ? 'opacity-100 scale-[1.03] ring-2 ring-[#111]' : 'opacity-40 hover:opacity-85'"
|
||||
>
|
||||
<img :src="im.imageUrl" alt="" class="w-full h-full object-cover block" />
|
||||
<span
|
||||
class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm"
|
||||
:class="lbIndex === i ? '!bg-[#111]' : ''"
|
||||
x-text="String(i + 1).padStart(2, '0')"
|
||||
></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 左侧上下翻页辅助按键 -->
|
||||
<button @click.stop="prevLb()" type="button" 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">
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 14l6-6 6 6"/></svg>
|
||||
</button>
|
||||
<button class="lb-tbtn lb-tdown" id="lb-tdown" type="button" aria-label="下一张">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 10l6 6 6-6"/></svg>
|
||||
<button @click.stop="nextLb()" type="button" 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">
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 10l6 6 6-6"/></svg>
|
||||
</button>
|
||||
<button class="lb-btn lb-prev" id="lb-prev" type="button" aria-label="上一张">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
|
||||
<!-- 左右主切换按钮 -->
|
||||
<button @click.stop="prevLb()" type="button" 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">
|
||||
<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"/></svg>
|
||||
</button>
|
||||
<button class="lb-btn lb-next" id="lb-next" type="button" aria-label="下一张">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
<button @click.stop="nextLb()" type="button" 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">
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</button>
|
||||
<div class="lb-main" id="lb-main">
|
||||
<img id="lb-img" class="lb-img" alt="" />
|
||||
|
||||
<!-- 大图显示区域(支持点击切换放大/缩小状态) -->
|
||||
<div
|
||||
x-ref="lbMain"
|
||||
@click.self="closeLb()"
|
||||
@touchstart="handleTouchStart($event)"
|
||||
@touchend="handleTouchEnd($event)"
|
||||
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
|
||||
:class="lbZoomed ? 'block overflow-y-auto overflow-x-hidden items-start' : 'items-center'"
|
||||
>
|
||||
<template x-if="article?.images && article.images[lbIndex]">
|
||||
<img
|
||||
:src="article.images[lbIndex].imageUrl"
|
||||
:alt="article.images[lbIndex].name || ''"
|
||||
@click="toggleZoom()"
|
||||
class="block select-none transition-opacity duration-200"
|
||||
:class="lbZoomed ? 'w-full max-w-full h-auto max-h-none cursor-zoom-out' : 'max-w-full max-h-full object-contain cursor-zoom-in'"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lb-caption" id="lb-caption"></div>
|
||||
<div class="lb-hint" id="lb-hint">点击图片放大 · 上下滚动查看 · Esc 关闭</div>
|
||||
|
||||
<!-- 图说标题 -->
|
||||
<div class="absolute left-0 right-0 bottom-11 z-20 text-center text-black/60 font-mono text-[11px] uppercase tracking-[0.18em] pointer-events-none px-6" x-text="article?.images[lbIndex]?.name || ''"></div>
|
||||
|
||||
<!-- 提示文本 -->
|
||||
<div 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">
|
||||
点击图片放大 · 上下滚动查看 · Esc 关闭
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style is:global>
|
||||
@reference "tailwindcss";
|
||||
|
||||
/* 灯箱:左侧竖排缩略图条 + 中央大图;点击放大→宽度最大+纵向滚动 */
|
||||
.lb {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
@apply bg-white;
|
||||
display: none; flex-direction: column;
|
||||
opacity: 0; transition: opacity .2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.lb.is-open { display: flex; opacity: 1; }
|
||||
|
||||
.lb-stage {
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
display: flex; align-items: stretch; justify-content: stretch;
|
||||
overflow: hidden; position: relative;
|
||||
animation: lbIn .2s ease both;
|
||||
}
|
||||
@keyframes lbIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.lb-thumbs {
|
||||
flex: 0 0 118px; overflow-y: auto; overflow-x: hidden; position: relative; scroll-behavior: smooth;
|
||||
padding: 38px 18px 38px 20px;
|
||||
border-right: 1px solid rgba(0,0,0,.08);
|
||||
background: linear-gradient(180deg, rgba(0,0,0,.02), rgba(0,0,0,0));
|
||||
scrollbar-width: none; -ms-overflow-style: none;
|
||||
}
|
||||
.lb-thumbs::-webkit-scrollbar { width: 0; height: 0; }
|
||||
.lb-thumb {
|
||||
position: relative; display: block; width: 100%; aspect-ratio: 3 / 4;
|
||||
padding: 0; margin: 0 0 8px; border: 0;
|
||||
@apply bg-[#ebebeb] rounded-[2px];
|
||||
overflow: hidden; cursor: pointer;
|
||||
opacity: .42; transition: opacity .2s ease, transform .2s ease;
|
||||
}
|
||||
.lb-thumb:last-child { margin-bottom: 0; }
|
||||
.lb-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.lb-thumb::after {
|
||||
content: ""; position: absolute; inset: 0; border-radius: 2px;
|
||||
box-shadow: inset 0 0 0 0 transparent; pointer-events: none;
|
||||
transition: box-shadow .2s ease;
|
||||
}
|
||||
.lb-thumb:hover { opacity: .85; }
|
||||
.lb-thumb.is-active { opacity: 1; transform: scale(1.03); }
|
||||
.lb-thumb.is-active::after { box-shadow: inset 0 0 0 2px #111; }
|
||||
.lb-thumb-idx {
|
||||
position: absolute; left: 6px; top: 6px; z-index: 1;
|
||||
font: 600 9px/1 ui-monospace, SFMono-Regular, monospace;
|
||||
@apply tracking-[.12em] text-white px-[5px] py-[3px] rounded-[3px];
|
||||
background: rgba(0,0,0,.55);
|
||||
backdrop-filter: blur(4px); pointer-events: none;
|
||||
}
|
||||
.lb-thumb.is-active .lb-thumb-idx { @apply bg-[#111] text-white; }
|
||||
|
||||
.lb-main {
|
||||
flex: 1 1 auto; min-width: 0; min-height: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: relative; padding: 0 72px;
|
||||
}
|
||||
.lb-img {
|
||||
max-width: 100%; max-height: 100%; display: block;
|
||||
object-fit: contain; user-select: none; -webkit-user-drag: none;
|
||||
transition: opacity .2s ease; cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* 放大态:宽度最大化 + 纵向滚动(无滚轮缩放、无拖拽、无特效) */
|
||||
.lb-main.is-zoomed { align-items: stretch; display: block; overflow-y: auto; overflow-x: hidden; }
|
||||
.lb-main.is-zoomed .lb-img { width: 100%; max-width: 100%; height: auto; max-height: none; cursor: zoom-out; }
|
||||
|
||||
.lb-btn {
|
||||
position: absolute; z-index: 3;
|
||||
width: 42px; height: 42px;
|
||||
@apply rounded-full;
|
||||
border: 1px solid rgba(0,0,0,.14); background: rgba(255,255,255,.72);
|
||||
@apply text-[#111];
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background .15s, transform .15s, border-color .15s; backdrop-filter: blur(10px);
|
||||
}
|
||||
.lb-btn svg, .lb-tbtn svg {
|
||||
display: block; fill: none; stroke: currentColor;
|
||||
stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
|
||||
}
|
||||
.lb-btn svg { width: 20px; height: 20px; }
|
||||
.lb-tbtn svg { width: 16px; height: 16px; }
|
||||
.lb-btn:hover { background: rgba(0,0,0,.06); border-color: rgba(0,0,0,.24); }
|
||||
.lb-close { top: 18px; right: 18px; }
|
||||
.lb-prev { left: 114px; top: 50%; transform: translateY(-50%); }
|
||||
.lb-next { right: 18px; top: 50%; transform: translateY(-50%); }
|
||||
.lb-prev:hover, .lb-next:hover { transform: translateY(-50%) scale(1.08); }
|
||||
|
||||
/* 左栏上下翻页按钮(浮在缩略图条上、禁手动滚动时的翻页入口) */
|
||||
.lb-tbtn {
|
||||
position: absolute; z-index: 4;
|
||||
left: 20px; width: 72px; height: 34px;
|
||||
border: 1px solid rgba(0,0,0,.12); background: rgba(255,255,255,.82);
|
||||
@apply rounded-[6px];
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
@apply text-[#111];
|
||||
backdrop-filter: blur(8px);
|
||||
transition: background .15s, transform .15s, border-color .15s;
|
||||
}
|
||||
.lb-tbtn svg { width: 16px; height: 16px; display: block; }
|
||||
.lb-tbtn:hover { background: rgba(0,0,0,.06); border-color: rgba(0,0,0,.22); }
|
||||
.lb-tup { top: 8px; }
|
||||
.lb-tdown { bottom: 8px; }
|
||||
|
||||
.lb-counter {
|
||||
position: absolute; top: 20px; left: 50%; transform: translateX(-50%); z-index: 3;
|
||||
font: 600 12px/1 ui-monospace, monospace;
|
||||
@apply tracking-[.14em] text-[#111];
|
||||
background: rgba(255,255,255,.75); border: 1px solid rgba(0,0,0,.1);
|
||||
@apply px-[11px] py-[7px] rounded-[6px];
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.lb-caption {
|
||||
position: absolute; left: 0; right: 0; bottom: 44px; z-index: 3;
|
||||
text-align: center; color: rgba(0,0,0,.62); pointer-events: none;
|
||||
font: 11px/1.4 ui-monospace, monospace;
|
||||
@apply tracking-[.18em] uppercase;
|
||||
padding: 0 24px; text-shadow: 0 1px 3px rgba(255,255,255,.7);
|
||||
}
|
||||
.lb-hint {
|
||||
position: absolute; left: 0; right: 0; bottom: 16px; z-index: 3;
|
||||
text-align: center; color: rgba(0,0,0,.45); pointer-events: none;
|
||||
font: 11px/1 ui-monospace, monospace;
|
||||
@apply tracking-[.14em];
|
||||
padding: 0 24px; transition: opacity .2s ease; text-shadow: 0 1px 3px rgba(255,255,255,.7);
|
||||
}
|
||||
.lb-hint.is-hidden { opacity: 0; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lb-thumbs { flex: 0 0 76px; padding: 32px 8px 32px 12px; }
|
||||
.lb-thumb { margin-bottom: 8px; }
|
||||
.lb-thumb-idx { font-size: 8px; left: 4px; top: 4px; padding: 2px 4px; }
|
||||
.lb-main { padding: 0 18px; }
|
||||
.lb-btn { width: 38px; height: 38px; }
|
||||
.lb-prev { left: 82px; } .lb-next { right: 8px; }
|
||||
.lb-tbtn { left: 12px; width: 52px; height: 30px; }
|
||||
.lb-caption { bottom: 40px; }
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js 业务逻辑代码 -->
|
||||
<script>
|
||||
// 真实数据接入:按 ?id= 拉取后端接口,失败时保留上方示例布局
|
||||
const API = import.meta.env.PUBLIC_API_BASE || ""
|
||||
const toAbs = (url: string) =>
|
||||
!url ? "" : /^https?:\/\//i.test(url) ? url : API + url
|
||||
const id = new URLSearchParams(location.search).get("id")
|
||||
if (id) {
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/public/articles/${id}`)
|
||||
if (!res.ok) return
|
||||
const d = (await res.json()).data
|
||||
if (!d) return
|
||||
const titleEl = document.querySelector("h1")
|
||||
if (titleEl && d.title) titleEl.textContent = d.title
|
||||
const bodyEl = document.getElementById("art-body")
|
||||
if (bodyEl && d.description) {
|
||||
bodyEl.innerHTML = d.description
|
||||
.split("\n\n")
|
||||
.map((p: string) => `<p>${p}</p>`)
|
||||
.join("")
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('articleDetail', () => ({
|
||||
loading: true,
|
||||
error: null,
|
||||
article: null,
|
||||
bodyParagraphs: [],
|
||||
|
||||
// 灯箱状态
|
||||
lbOpen: false,
|
||||
lbIndex: 0,
|
||||
lbZoomed: false,
|
||||
|
||||
// 触摸滑动记录
|
||||
touchStartX: 0,
|
||||
touchStartY: 0,
|
||||
|
||||
async init() {
|
||||
const api = import.meta.env.PUBLIC_API_BASE || "";
|
||||
const id = new URLSearchParams(window.location.search).get("id");
|
||||
|
||||
if (!id) {
|
||||
this.error = "未指定文章 ID (Missing Article ID)";
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
const brandEl = document.getElementById("art-brand")
|
||||
if (brandEl && d.brand_name) brandEl.textContent = `Runway · ${d.brand_name}`
|
||||
const yearEl = document.getElementById("art-year")
|
||||
if (yearEl && d.year) yearEl.textContent = String(d.year)
|
||||
const dateEl = document.getElementById("art-date")
|
||||
if (dateEl && d.published_at) {
|
||||
const dt = new Date(Number(d.published_at) * 1000)
|
||||
dateEl.textContent = `${dt.getFullYear()}.${String(dt.getMonth() + 1).padStart(2, "0")}.${String(dt.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
const imgs = d.images
|
||||
if (imgs && Array.isArray(imgs)) {
|
||||
const gal = document.getElementById("art-gallery")
|
||||
if (gal) {
|
||||
gal.innerHTML = imgs
|
||||
.map(
|
||||
(im: any) =>
|
||||
`<figure class="bg-[#f2f2f2]"><img src="${toAbs(im.image)}" alt="${im.name || ""}" loading="lazy" class="w-full aspect-[3/4] object-cover" /><figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">${im.name || ""}</figcaption></figure>`
|
||||
)
|
||||
.join("")
|
||||
|
||||
try {
|
||||
const res = await fetch(`${api}/api/public/articles/${id}`);
|
||||
if (!res.ok) throw new Error("网络请求失败");
|
||||
const resData = await res.json();
|
||||
if (!resData || !resData.data) throw new Error("未获取到有效文章数据");
|
||||
|
||||
const data = resData.data;
|
||||
|
||||
// 补全图片相对路径
|
||||
if (Array.isArray(data.images)) {
|
||||
data.images = data.images.map(im => ({
|
||||
...im,
|
||||
imageUrl: im.image ? (/^https?:\/\//i.test(im.image) ? im.image : api + im.image) : ""
|
||||
}));
|
||||
}
|
||||
|
||||
this.article = data;
|
||||
|
||||
// 处理正文段落
|
||||
if (data.description) {
|
||||
this.bodyParagraphs = data.description.split("\n\n").filter(Boolean);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fetch Article Error:", err);
|
||||
this.error = "加载文章失败,请刷新或稍后再试";
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
} catch {
|
||||
/* 后端不可达时,使用示例数据 */
|
||||
}
|
||||
})()
|
||||
}
|
||||
</script>
|
||||
},
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const gallery = document.getElementById("art-gallery");
|
||||
const lb = document.getElementById("lb");
|
||||
const lbStage = document.getElementById("lb-stage");
|
||||
const lbMain = document.getElementById("lb-main");
|
||||
const lbImg = document.getElementById("lb-img");
|
||||
const lbCounter = document.getElementById("lb-counter");
|
||||
const lbCaption = document.getElementById("lb-caption");
|
||||
const lbThumbs = document.getElementById("lb-thumbs");
|
||||
const lbHint = document.getElementById("lb-hint");
|
||||
const btnClose = document.getElementById("lb-close");
|
||||
const btnPrev = document.getElementById("lb-prev");
|
||||
const btnNext = document.getElementById("lb-next");
|
||||
const btnTup = document.getElementById("lb-tup");
|
||||
const btnTdown = document.getElementById("lb-tdown");
|
||||
const viewall = document.getElementById("art-viewall");
|
||||
// 时间格式化辅助函数
|
||||
formatDate(ts) {
|
||||
if (!ts) return "";
|
||||
const dt = new Date(Number(ts) * 1000);
|
||||
if (isNaN(dt.getTime())) return "";
|
||||
const y = dt.getFullYear();
|
||||
const m = String(dt.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(dt.getDate()).padStart(2, "0");
|
||||
return `${y}.${m}.${day}`;
|
||||
},
|
||||
|
||||
let items = [];
|
||||
let index = 0;
|
||||
let zoomed = false;
|
||||
// 打开灯箱
|
||||
openLb(index = 0) {
|
||||
if (!this.article?.images?.length) return;
|
||||
this.lbIndex = index;
|
||||
this.lbZoomed = false;
|
||||
this.lbOpen = true;
|
||||
document.body.style.overflow = "hidden";
|
||||
this.scrollThumbIntoView();
|
||||
},
|
||||
|
||||
/* 放大态:宽度最大化 + 纵向滚动(无滚轮缩放 / 无拖拽 / 无特效) */
|
||||
function zoomIn() {
|
||||
zoomed = true;
|
||||
if (lbMain) { lbMain.classList.add("is-zoomed"); lbMain.scrollTop = 0; }
|
||||
if (lbHint) lbHint.classList.add("is-hidden");
|
||||
}
|
||||
function resetZoom() {
|
||||
zoomed = false;
|
||||
if (lbMain) { lbMain.classList.remove("is-zoomed"); lbMain.scrollTop = 0; }
|
||||
if (lbHint) lbHint.classList.remove("is-hidden");
|
||||
}
|
||||
function toggleZoom() { zoomed ? resetZoom() : zoomIn(); }
|
||||
// 关闭灯箱
|
||||
closeLb() {
|
||||
this.lbOpen = false;
|
||||
this.lbZoomed = false;
|
||||
document.body.style.overflow = "";
|
||||
},
|
||||
|
||||
/* 收集画廊图片 */
|
||||
function collect() {
|
||||
const list = [];
|
||||
if (gallery) gallery.querySelectorAll("img").forEach((img) => {
|
||||
const fig = img.closest("figure");
|
||||
const cap = fig && fig.querySelector("figcaption");
|
||||
list.push({ src: img.currentSrc || img.src, name: (cap && cap.textContent || "").trim() });
|
||||
});
|
||||
return list;
|
||||
}
|
||||
// 切换视图
|
||||
showLb(index) {
|
||||
const total = this.article?.images?.length || 0;
|
||||
if (total === 0) return;
|
||||
this.lbIndex = (index + total) % total;
|
||||
this.resetZoom();
|
||||
this.scrollThumbIntoView();
|
||||
},
|
||||
|
||||
/* 渲染左侧竖排缩略图条 */
|
||||
function renderStrip() {
|
||||
if (!lbThumbs) return;
|
||||
lbThumbs.innerHTML = items
|
||||
.map((it, i) => {
|
||||
const n = String(i + 1).padStart(2, "0");
|
||||
return '<button type="button" class="lb-thumb' + (i === index ? ' is-active' : '') + '" data-i="' + i + '">' +
|
||||
'<img src="' + it.src + '" alt="" />' +
|
||||
'<span class="lb-thumb-idx">' + n + '</span>' +
|
||||
'</button>';
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
function show(i) {
|
||||
if (!items.length) return;
|
||||
index = (i + items.length) % items.length;
|
||||
const it = items[index];
|
||||
resetZoom();
|
||||
lbImg.style.opacity = "0";
|
||||
const loader = new Image();
|
||||
loader.onload = function () { lbImg.src = it.src; lbImg.style.opacity = "1"; };
|
||||
loader.src = it.src;
|
||||
if (lbCounter) lbCounter.textContent = (index + 1) + " / " + items.length;
|
||||
if (lbCaption) lbCaption.textContent = it.name || "";
|
||||
if (lbThumbs) lbThumbs.querySelectorAll(".lb-thumb").forEach((t, k) => {
|
||||
t.classList.toggle("is-active", k === index);
|
||||
if (k === index) {
|
||||
// 自动居中当前张(左栏禁用手动滚动,改用程序化 scrollTo)
|
||||
const target = t.offsetTop - (lbThumbs.clientHeight - t.clientHeight) / 2;
|
||||
lbThumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
|
||||
nextLb() { this.showLb(this.lbIndex + 1); },
|
||||
prevLb() { this.showLb(this.lbIndex - 1); },
|
||||
|
||||
toggleZoom() {
|
||||
this.lbZoomed = !this.lbZoomed;
|
||||
if (this.$refs.lbMain) this.$refs.lbMain.scrollTop = 0;
|
||||
},
|
||||
|
||||
resetZoom() {
|
||||
this.lbZoomed = false;
|
||||
if (this.$refs.lbMain) this.$refs.lbMain.scrollTop = 0;
|
||||
},
|
||||
|
||||
// 左侧缩略图自动居中滚到视野中央
|
||||
scrollThumbIntoView() {
|
||||
this.$nextTick(() => {
|
||||
const thumbsEl = this.$refs.lbThumbs;
|
||||
if (!thumbsEl) return;
|
||||
const activeThumb = thumbsEl.children[this.lbIndex];
|
||||
if (activeThumb) {
|
||||
const target = activeThumb.offsetTop - (thumbsEl.clientHeight - activeThumb.clientHeight) / 2;
|
||||
thumbsEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 手势处理
|
||||
handleTouchStart(e) {
|
||||
if (e.touches.length === 1) {
|
||||
this.touchStartX = e.touches[0].clientX;
|
||||
this.touchStartY = e.touches[0].clientY;
|
||||
}
|
||||
});
|
||||
}
|
||||
function open(i) {
|
||||
items = collect();
|
||||
if (!items.length) return;
|
||||
if (lb) { lb.classList.add("is-open"); lb.setAttribute("aria-hidden", "false"); }
|
||||
document.body.style.overflow = "hidden";
|
||||
renderStrip();
|
||||
show(i || 0);
|
||||
}
|
||||
function close() {
|
||||
if (lb) { lb.classList.remove("is-open"); lb.setAttribute("aria-hidden", "true"); }
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
function next() { show(index + 1); }
|
||||
function prev() { show(index - 1); }
|
||||
},
|
||||
|
||||
/* 事件绑定 */
|
||||
if (gallery) gallery.addEventListener("click", (e) => {
|
||||
const fig = e.target.closest("figure");
|
||||
if (!fig) return;
|
||||
const imgs = Array.prototype.slice.call(gallery.querySelectorAll("img"));
|
||||
open(Math.max(0, imgs.indexOf(fig.querySelector("img"))));
|
||||
});
|
||||
if (btnClose) btnClose.addEventListener("click", close);
|
||||
if (btnNext) btnNext.addEventListener("click", (e) => { e.stopPropagation(); next(); });
|
||||
if (btnPrev) btnPrev.addEventListener("click", (e) => { e.stopPropagation(); prev(); });
|
||||
if (btnTup) btnTup.addEventListener("click", (e) => { e.stopPropagation(); prev(); });
|
||||
if (btnTdown) btnTdown.addEventListener("click", (e) => { e.stopPropagation(); next(); });
|
||||
if (lb) lb.addEventListener("click", (e) => {
|
||||
const t = e.target;
|
||||
if (t === lb || (t.classList && t.classList.contains("lb-stage"))) close();
|
||||
});
|
||||
if (lbThumbs) lbThumbs.addEventListener("click", (e) => {
|
||||
const t = e.target.closest(".lb-thumb");
|
||||
if (t) show(Number(t.dataset.i));
|
||||
});
|
||||
if (lbImg) lbImg.addEventListener("click", toggleZoom);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!lb || !lb.classList.contains("is-open")) return;
|
||||
if (e.key === "Escape") close();
|
||||
else if (e.key === "ArrowRight") next();
|
||||
else if (e.key === "ArrowLeft") prev();
|
||||
});
|
||||
if (viewall) viewall.addEventListener("click", () => open(0));
|
||||
|
||||
/* 移动端单指滑动切换(仅在未放大时,放大态交给纵向滚动) */
|
||||
let tx0 = 0, ty0 = 0, two = false;
|
||||
if (lbStage) {
|
||||
lbStage.addEventListener("touchstart", (e) => {
|
||||
if (e.touches.length === 1) { tx0 = e.touches[0].clientX; ty0 = e.touches[0].clientY; }
|
||||
else if (e.touches.length === 2) two = true;
|
||||
}, { passive: true });
|
||||
lbStage.addEventListener("touchend", (e) => {
|
||||
if (two) { two = false; return; }
|
||||
if (zoomed) return;
|
||||
handleTouchEnd(e) {
|
||||
if (this.lbZoomed) return;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - tx0, dy = t.clientY - ty0;
|
||||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) (dx < 0 ? next() : prev());
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/* 计数 + 画廊重渲染后同步 */
|
||||
function syncCount() {
|
||||
const c = document.getElementById("art-gallery-count");
|
||||
const n = gallery ? gallery.querySelectorAll("img").length : 0;
|
||||
if (c) c.textContent = n ? "· 共 " + n + " 张" : "";
|
||||
}
|
||||
syncCount();
|
||||
if (gallery && "MutationObserver" in window) {
|
||||
new MutationObserver(syncCount).observe(gallery, { childList: true });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
const dx = t.clientX - this.touchStartX;
|
||||
const dy = t.clientY - this.touchStartY;
|
||||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
|
||||
dx < 0 ? this.nextLb() : this.prevLb();
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user