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