5 changed files with 489 additions and 367 deletions
@ -0,0 +1,368 @@ |
|||
<script setup lang="ts"> |
|||
interface CardItem { |
|||
id: number |
|||
type: 'text' | 'image' | 'image-text' | 'portfolio' | 'project' |
|||
image?: string |
|||
images?: string[] |
|||
title: string |
|||
description?: string |
|||
tags?: string[] |
|||
aspectRatio: number |
|||
} |
|||
|
|||
const { capture, play } = useFlipAnimation() |
|||
const masonryEl = ref<HTMLElement | null>(null) |
|||
|
|||
const allItems = ref<CardItem[]>([]) |
|||
const page = ref(1) |
|||
const hasMore = ref(true) |
|||
const loading = ref(false) |
|||
const initLoading = ref(true) |
|||
const sentinel = ref<HTMLElement | null>(null) |
|||
|
|||
const columnCount = ref(4) |
|||
const columns = ref<CardItem[][]>([[], [], [], []]) |
|||
const columnHeights = ref<number[]>([0, 0, 0, 0]) |
|||
|
|||
function getColumnWidth() { |
|||
const padding = 48 |
|||
const maxWidth = 1400 |
|||
const containerWidth = Math.min(window.innerWidth - padding, maxWidth) |
|||
const gap = 20 |
|||
return (containerWidth - gap * (columnCount.value - 1)) / columnCount.value |
|||
} |
|||
|
|||
function estimateCardHeight(item: CardItem) { |
|||
const colWidth = getColumnWidth() |
|||
if (item.type === 'text') { |
|||
const textWidth = colWidth - 48 |
|||
const charsPerLine = Math.max(1, Math.floor(textWidth / 13.5)) |
|||
const charCount = (item.description || '').length |
|||
const lines = Math.ceil(charCount / charsPerLine) |
|||
return 109 + lines * 24 |
|||
} |
|||
return colWidth / item.aspectRatio |
|||
} |
|||
|
|||
function addToShortestColumn(item: CardItem) { |
|||
let minIdx = 0 |
|||
for (let i = 1; i < columnCount.value; i++) { |
|||
if (columnHeights.value[i] < columnHeights.value[minIdx]) minIdx = i |
|||
} |
|||
columns.value[minIdx].push(item) |
|||
columnHeights.value[minIdx] += estimateCardHeight(item) |
|||
} |
|||
|
|||
function distributeAll() { |
|||
const n = columnCount.value |
|||
columns.value = Array.from({ length: n }, () => []) |
|||
columnHeights.value = new Array(n).fill(0) |
|||
for (const item of allItems.value) { |
|||
addToShortestColumn(item) |
|||
} |
|||
} |
|||
|
|||
let resizeTimer: ReturnType<typeof setTimeout> | null = null |
|||
function onResize() { |
|||
if (resizeTimer) clearTimeout(resizeTimer) |
|||
resizeTimer = setTimeout(() => { |
|||
updateColumns() |
|||
}, 150) |
|||
} |
|||
|
|||
function updateColumns() { |
|||
const w = window.innerWidth |
|||
let n: number |
|||
if (w < 640) n = 1 |
|||
else if (w < 768) n = 2 |
|||
else if (w < 1024) n = 3 |
|||
else if (w < 1280) n = 4 |
|||
else n = 5 |
|||
|
|||
if (n !== columnCount.value) { |
|||
if (masonryEl.value) capture(masonryEl.value, '.card-reveal') |
|||
columnCount.value = n |
|||
distributeAll() |
|||
nextTick(() => { |
|||
if (masonryEl.value) { |
|||
play(masonryEl.value, '.card-reveal', { duration: 420 }) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
async function loadMore() { |
|||
if (loading.value || !hasMore.value) return |
|||
loading.value = true |
|||
try { |
|||
const data = await $fetch<{ items: CardItem[]; hasMore: boolean }>('/api/cards', { |
|||
query: { page: page.value, pageSize: 12 }, |
|||
}) |
|||
for (const item of data.items) { |
|||
allItems.value.push(item) |
|||
addToShortestColumn(item) |
|||
} |
|||
hasMore.value = data.hasMore |
|||
page.value++ |
|||
} finally { |
|||
loading.value = false |
|||
initLoading.value = false |
|||
} |
|||
|
|||
await nextTick() |
|||
tryLoadMoreIfSentinelVisible() |
|||
} |
|||
|
|||
function tryLoadMoreIfSentinelVisible() { |
|||
if (!sentinel.value || loading.value || !hasMore.value) return |
|||
const rect = sentinel.value.getBoundingClientRect() |
|||
if (rect.top < window.innerHeight + 300) { |
|||
loadMore() |
|||
} |
|||
} |
|||
|
|||
onMounted(() => { |
|||
updateColumns() |
|||
window.addEventListener('resize', onResize) |
|||
loadMore() |
|||
}) |
|||
|
|||
onUnmounted(() => { |
|||
window.removeEventListener('resize', onResize) |
|||
if (resizeTimer) clearTimeout(resizeTimer) |
|||
}) |
|||
|
|||
let observer: IntersectionObserver | null = null |
|||
watch(sentinel, (el) => { |
|||
observer?.disconnect() |
|||
if (!el) return |
|||
observer = new IntersectionObserver( |
|||
([entry]) => { |
|||
if (entry.isIntersecting) loadMore() |
|||
}, |
|||
{ rootMargin: '300px' }, |
|||
) |
|||
observer.observe(el) |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="home"> |
|||
<header class="page-header"> |
|||
<h1>发现</h1> |
|||
<p class="subtitle">灵感与美学的无声对话</p> |
|||
</header> |
|||
|
|||
<div ref="masonryEl" class="masonry"> |
|||
<div |
|||
v-for="(col, ci) in columns" |
|||
:key="ci" |
|||
class="masonry-col" |
|||
> |
|||
<template v-for="(item, ri) in col" :key="item.id"> |
|||
<div |
|||
class="card-reveal" |
|||
:style="{ '--enter-delay': `${ci * 80 + ri * 60}ms` }" |
|||
> |
|||
<IndexWaterfallCard |
|||
v-if="item.type === 'image-text'" |
|||
:image="item.image!" |
|||
:title="item.title" |
|||
:description="item.description!" |
|||
:aspect-ratio="item.aspectRatio" |
|||
/> |
|||
<IndexWaterfallTextCard |
|||
v-else-if="item.type === 'text'" |
|||
:title="item.title" |
|||
:description="item.description!" |
|||
:aspect-ratio="item.aspectRatio" |
|||
/> |
|||
<IndexWaterfallImageCard |
|||
v-else-if="item.type === 'image'" |
|||
:image="item.image!" |
|||
:title="item.title" |
|||
:aspect-ratio="item.aspectRatio" |
|||
/> |
|||
<IndexWaterfallPortfolioCard |
|||
v-else-if="item.type === 'portfolio'" |
|||
:images="item.images!" |
|||
:title="item.title" |
|||
:description="item.description!" |
|||
:aspect-ratio="item.aspectRatio" |
|||
/> |
|||
<IndexWaterfallProjectCard |
|||
v-else |
|||
:image="item.image!" |
|||
:title="item.title" |
|||
:description="item.description!" |
|||
:tags="item.tags!" |
|||
:aspect-ratio="item.aspectRatio" |
|||
/> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</div> |
|||
|
|||
<div ref="sentinel" class="sentinel"> |
|||
<template v-if="initLoading"> |
|||
<div class="loading-bars"> |
|||
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" /> |
|||
</div> |
|||
</template> |
|||
<template v-else-if="loading"> |
|||
<div class="loading-bars"> |
|||
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" /> |
|||
</div> |
|||
</template> |
|||
<span v-else-if="hasMore" class="sentinel-text">继续滚动查看更多</span> |
|||
<span v-else class="sentinel-text end">—— 已经到底了 ——</span> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.home { |
|||
max-width: 1400px; |
|||
margin: 0 auto; |
|||
padding: 0 24px; |
|||
} |
|||
|
|||
/* ── Header: cream canvas, coral type ── */ |
|||
|
|||
.page-header { |
|||
padding: clamp(64px, 10vw, 108px) 0 clamp(20px, 3vw, 32px); |
|||
animation: header-reveal 0.8s var(--ease-out-expo) both; |
|||
} |
|||
|
|||
@keyframes header-reveal { |
|||
from { |
|||
opacity: 0; |
|||
transform: translateY(8px); |
|||
} |
|||
to { |
|||
opacity: 1; |
|||
transform: translateY(0); |
|||
} |
|||
} |
|||
|
|||
.page-header h1 { |
|||
font-family: var(--font-display); |
|||
font-size: clamp(64px, 10vw, 132px); |
|||
font-weight: 400; |
|||
color: var(--color-primary); |
|||
margin: 0; |
|||
line-height: 0.88; |
|||
letter-spacing: -0.02em; |
|||
} |
|||
|
|||
.subtitle { |
|||
color: var(--color-muted); |
|||
font-size: clamp(16px, 2vw, 20px); |
|||
font-weight: 400; |
|||
margin: clamp(14px, 2.5vw, 24px) 0 0; |
|||
letter-spacing: 0.06em; |
|||
animation: fade-slide-up 0.7s var(--ease-out-expo) both; |
|||
animation-delay: 200ms; |
|||
} |
|||
|
|||
@keyframes fade-slide-up { |
|||
from { |
|||
opacity: 0; |
|||
transform: translateY(16px); |
|||
} |
|||
to { |
|||
opacity: 1; |
|||
transform: translateY(0); |
|||
} |
|||
} |
|||
|
|||
/* ── Masonry ── */ |
|||
|
|||
.masonry { |
|||
display: flex; |
|||
gap: 20px; |
|||
align-items: flex-start; |
|||
} |
|||
|
|||
.masonry-col { |
|||
flex: 1; |
|||
min-width: 0; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 20px; |
|||
} |
|||
|
|||
/* ── Card reveal ── */ |
|||
|
|||
.card-reveal { |
|||
animation: card-enter 0.65s var(--ease-out-expo) both; |
|||
animation-delay: var(--enter-delay, 0ms); |
|||
} |
|||
|
|||
@keyframes card-enter { |
|||
from { |
|||
opacity: 0; |
|||
transform: translateY(24px); |
|||
} |
|||
to { |
|||
opacity: 1; |
|||
transform: translateY(0); |
|||
} |
|||
} |
|||
|
|||
/* ── Sentinel ── */ |
|||
|
|||
.sentinel { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
padding: 56px 0 96px; |
|||
min-height: 60px; |
|||
} |
|||
|
|||
.sentinel-text { |
|||
font-size: 14px; |
|||
color: var(--color-muted-soft); |
|||
} |
|||
|
|||
.sentinel-text.end { |
|||
color: var(--color-hairline); |
|||
} |
|||
|
|||
.loading-bars { |
|||
display: flex; |
|||
gap: 6px; |
|||
align-items: center; |
|||
} |
|||
|
|||
.bar { |
|||
width: 5px; |
|||
height: 20px; |
|||
background: var(--color-primary); |
|||
border-radius: 3px; |
|||
opacity: 0.5; |
|||
animation: pulse 0.9s ease-in-out infinite; |
|||
} |
|||
|
|||
@keyframes pulse { |
|||
0%, 100% { opacity: 0.3; transform: scaleY(0.6); } |
|||
50% { opacity: 0.8; transform: scaleY(1); } |
|||
} |
|||
|
|||
/* ── Responsive ── */ |
|||
|
|||
@media (max-width: 768px) { |
|||
.home { |
|||
padding: 0 16px; |
|||
} |
|||
|
|||
.page-header { |
|||
margin: 16px 0 40px; |
|||
padding: 40px 24px; |
|||
} |
|||
|
|||
.page-header h1 { |
|||
font-size: clamp(40px, 12vw, 64px); |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,109 @@ |
|||
# 首页流体画布设计 |
|||
|
|||
## 概述 |
|||
|
|||
首页定位为纯粹的视觉体验,不承载信息。全屏 WebGL 流体画布,鼠标/触摸交互产生扰动,中央展示 "Dash" 品牌文字。首屏即全部,无下方内容区。 |
|||
|
|||
## 布局 |
|||
|
|||
``` |
|||
┌──────────────────────────────────────────┐ |
|||
│ │ |
|||
│ Nav (透明浮层,固定顶部) │ |
|||
│ │ |
|||
│ ┌─────────────┐ │ |
|||
│ │ 流体画布 │ │ |
|||
│ │ (全屏 Canvas) │ │ |
|||
│ │ │ │ |
|||
│ │ Dash │ │ |
|||
│ │ (中央文字) │ │ |
|||
│ │ │ │ |
|||
│ └─────────────┘ │ |
|||
│ │ |
|||
└──────────────────────────────────────────┘ |
|||
``` |
|||
|
|||
- Canvas 铺满整个视口 (100vw × 100vh),位于所有内容之下 (z-index: 0) |
|||
- 中央文字固定居中,z-index 高于 Canvas |
|||
- 无滚动内容,无 footer(首页专属) |
|||
|
|||
## 流体画布 |
|||
|
|||
### 色彩 |
|||
|
|||
- 底色:cream `#faf9f5`,占画布大部分面积 |
|||
- 主流动层:coral `#cc785c`,混入少量 amber `#e8a55a` |
|||
- 点缀暗部:dark navy `#181715`,偶尔出现如墨滴 |
|||
- 所有颜色 smooth blending,无硬边界 |
|||
|
|||
### 交互 |
|||
|
|||
- 鼠标移动 → 光标周围流体扰动,模拟手指划过水面 |
|||
- 扰动强度正比于鼠标速度:快速 = 大波纹,缓慢 = 细微涟漪 |
|||
- 鼠标静止 → 流体缓慢自主流动,不完全静止 |
|||
- 触摸设备 → 触摸点产生扰动 |
|||
|
|||
### 中央文字 |
|||
|
|||
- 内容:"Dash" |
|||
- 字体:Liu Jian Mao Cao(草书),字号约 120-160px |
|||
- 颜色:cream `#faf9f5` |
|||
- 位置:居中固定,始终在 Canvas 之上 |
|||
|
|||
### 技术方案 |
|||
|
|||
- WebGL + Three.js + custom fragment shader 实现流体模拟 |
|||
- Canvas 分辨率适配 devicePixelRatio |
|||
- 移动端降级为简化粒子系统(~200 粒子),触摸滑动产生扰动 |
|||
- 加载态:纯 cream 背景 + coral 色呼吸动画点,Canvas 就绪后淡入 |
|||
|
|||
## 导航栏 (TopNav) |
|||
|
|||
### 首页行为 |
|||
|
|||
- 背景透明,去掉 border-bottom |
|||
- 文字颜色使用 `on-dark` 风格(cream 偏白),确保在流体背景上可读 |
|||
- Logo 保持 Liu Jian Mao Cao 字体 |
|||
- 离开首页(导航到其他页面)恢复正常 cream 底 + hairline border |
|||
|
|||
### 实现方式 |
|||
|
|||
- 通过 route 判断是否在首页 (`route.path === '/'`) |
|||
- 首页:添加 `transparent` modifier class |
|||
- 非首页:默认样式 |
|||
|
|||
## 响应式 |
|||
|
|||
| 断点 | 行为 | |
|||
|------|------| |
|||
| 桌面 (>1024px) | 全 WebGL 流体,120-160px 字号 | |
|||
| 平板 (768-1024px) | WebGL 流体,100-120px 字号 | |
|||
| 移动 (<768px) | 粒子系统降级,80-100px 字号 | |
|||
|
|||
## 组件分解 |
|||
|
|||
### 1. FluidCanvas.vue |
|||
|
|||
WebGL Canvas 组件,负责: |
|||
- Three.js 场景初始化与销毁 |
|||
- Shader uniform 更新(鼠标位置、时间) |
|||
- 窗口 resize 适配 |
|||
- 移动端检测与降级切换 |
|||
|
|||
### 2. HomeHero.vue |
|||
|
|||
首页内容层,负责: |
|||
- 居中 "Dash" 文字 |
|||
- 文字淡入动画(Canvas 就绪后触发) |
|||
|
|||
### 3. TopNav 改动 |
|||
|
|||
- 新增 `transparent` prop 或 composable 判断首页 |
|||
- 首页样式覆盖:透明背景、无边框、文字颜色调整为 `on-dark` |
|||
|
|||
## 不在范围内 |
|||
|
|||
- 首页下方内容区域(明确不需要) |
|||
- 向下滚动提示(明确不需要) |
|||
- 音效 |
|||
- 流体录制/截图功能 |
|||
Loading…
Reference in new issue