You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

808 lines
20 KiB

<script setup lang="ts">
import type { CategoryNode } from '~/components/index/CategoryTreeNode.vue'
definePageMeta({
layout: 'home',
})
const { $toast } = useNuxtApp()
const isMobile = useMediaQuery('(max-width: 767.98px)')
const drawerOpen = ref(false)
const sidebarMode = computed<'rail' | 'drawer'>(() => isMobile.value ? 'drawer' : 'rail')
interface CardItem {
id: number
type: 'text' | 'image' | 'image-text' | 'portfolio' | 'project'
image?: string
images?: string[]
title: string
description?: string
tags?: string[]
aspectRatio: number
}
// ── Sidebar state ──
const activeToolKey = ref<string>('home')
const activeCategoryId = ref<string>('all')
const categories = ref<CategoryNode[]>([
{
id: 'all',
name: '全部灵感',
image: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=80&h=80&fit=crop',
count: 156,
},
{
id: 'design',
name: '设计',
image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=80&h=80&fit=crop',
count: 42,
children: [
{
id: 'design-graphic',
name: '平面 / 海报',
image: 'https://images.unsplash.com/photo-1611532736597-de2d4265fba3?w=80&h=80&fit=crop',
count: 18,
},
{
id: 'design-product',
name: '产品 / UI',
image: 'https://images.unsplash.com/photo-1545235617-9465d2a55698?w=80&h=80&fit=crop',
count: 16,
},
{
id: 'design-typo',
name: '字体',
image: 'https://images.unsplash.com/photo-1561070791-2526d30994b8?w=80&h=80&fit=crop',
count: 8,
},
],
},
{
id: 'photo',
name: '摄影',
image: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=80&h=80&fit=crop',
count: 38,
children: [
{
id: 'photo-street',
name: '街头',
image: 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=80&h=80&fit=crop',
count: 14,
},
{
id: 'photo-portrait',
name: '人像',
image: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&fit=crop',
count: 12,
},
{
id: 'photo-landscape',
name: '风光',
image: 'https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=80&h=80&fit=crop',
count: 12,
},
],
},
{
id: 'words',
name: '文字摘录',
image: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?w=80&h=80&fit=crop',
count: 24,
},
{
id: 'projects',
name: '项目档案',
image: 'https://images.unsplash.com/photo-1467232004584-a241de8bcf5d?w=80&h=80&fit=crop',
count: 17,
children: [
{
id: 'projects-web',
name: 'Web',
image: 'https://images.unsplash.com/photo-1517180102446-f3ece451e9d8?w=80&h=80&fit=crop',
count: 9,
},
{
id: 'projects-mobile',
name: 'Mobile',
image: 'https://images.unsplash.com/photo-1512941937669-90a1b58e7e9c?w=80&h=80&fit=crop',
count: 8,
},
],
},
{
id: 'inspiration',
name: '随手收藏',
image: 'https://images.unsplash.com/photo-1490730141103-6cac27aaab94?w=80&h=80&fit=crop',
count: 35,
},
])
const activeCategoryName = computed(() => {
function find(nodes: CategoryNode[]): CategoryNode | null {
for (const n of nodes) {
if (n.id === activeCategoryId.value) return n
if (n.children) {
const r = find(n.children)
if (r) return r
}
}
return null
}
return find(categories.value)?.name ?? '全部灵感'
})
function onSelectTool(key: string) {
activeToolKey.value = key
const labelMap: Record<string, string> = {
home: '主页',
search: '搜索',
tags: '标签',
highlights: '高亮',
archive: '归档',
}
$toast.info(`切换到 · ${labelMap[key] ?? key}`)
}
function onSelectCategory(id: string) {
activeCategoryId.value = id
if (drawerOpen.value) drawerOpen.value = false
}
function onAddCategory(parentId: string | null) {
const newName = window.prompt(parentId ? '新建子分类名称' : '新建分类名称')
if (!newName?.trim()) return
const node: CategoryNode = {
id: `cat-${Date.now()}`,
name: newName.trim(),
count: 0,
}
if (!parentId) {
categories.value.push(node)
$toast.success(`已创建分类「${node.name}`)
return
}
function attach(nodes: CategoryNode[]): boolean {
for (const n of nodes) {
if (n.id === parentId) {
n.children = n.children ?? []
n.children.push(node)
return true
}
if (n.children && attach(n.children)) return true
}
return false
}
if (attach(categories.value)) {
$toast.success(`已添加子分类「${node.name}`)
}
}
function findNode(nodes: CategoryNode[], id: string): CategoryNode | null {
for (const n of nodes) {
if (n.id === id) return n
if (n.children) {
const r = findNode(n.children, id)
if (r) return r
}
}
return null
}
function onRenameCategory(id: string) {
const node = findNode(categories.value, id)
if (!node) return
const next = window.prompt('重命名分类', node.name)
if (!next?.trim() || next.trim() === node.name) return
node.name = next.trim()
$toast.success('已重命名')
}
function onDeleteCategory(id: string) {
const node = findNode(categories.value, id)
if (!node) return
if (!window.confirm(`确定删除「${node.name}」吗?`)) return
function remove(nodes: CategoryNode[]): CategoryNode[] {
return nodes
.filter(n => n.id !== id)
.map(n => ({ ...n, children: n.children ? remove(n.children) : undefined }))
}
categories.value = remove(categories.value)
if (activeCategoryId.value === id) activeCategoryId.value = 'all'
$toast.success('已删除分类')
}
function onMoveCategory(_id: string) {
$toast.info('移动功能即将上线')
}
function onChangeCover(_id: string) {
$toast.info('封面更换即将上线')
}
// ── Right panel waterfall ──
const sentinel = ref<HTMLElement | null>(null)
const mainRef = 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 columnCount = ref(1)
const columns = ref<CardItem[][]>([[]])
const columnHeights = ref<number[]>([0])
function containerWidth(): number {
return mainRef.value?.clientWidth ?? 800
}
function getColumnWidth(): number {
const padding = 48
const w = containerWidth() - padding
const gap = 18
return (w - gap * (columnCount.value - 1)) / columnCount.value
}
function estimateCardHeight(item: CardItem): number {
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)
}
function decideColumnCount(): number {
if (isMobile.value) return 1
const w = containerWidth()
if (w < 900) return 2
if (w < 1200) return 3
return 4
}
let resizeTimer: ReturnType<typeof setTimeout> | null = null
function onContainerResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
const n = decideColumnCount()
if (n !== columnCount.value) {
columnCount.value = n
distributeAll()
}
}, 120)
}
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++
} catch {
$toast.error('加载失败,请稍后重试')
} finally {
loading.value = false
initLoading.value = false
}
}
// Reset when category switches (mock: just reshuffle existing items)
watch(activeCategoryId, () => {
page.value = 1
hasMore.value = true
allItems.value = []
columns.value = Array.from({ length: columnCount.value }, () => [])
columnHeights.value = new Array(columnCount.value).fill(0)
initLoading.value = true
loadMore()
})
watch(isMobile, () => {
const n = decideColumnCount()
if (n !== columnCount.value) {
columnCount.value = n
distributeAll()
}
})
let observer: IntersectionObserver | null = null
let resizeObserver: ResizeObserver | null = null
watch(sentinel, (el) => {
observer?.disconnect()
if (!el) return
observer = new IntersectionObserver(
([entry]) => {
if (entry!.isIntersecting) loadMore()
},
{ rootMargin: '320px' },
)
observer.observe(el)
})
onMounted(() => {
if (mainRef.value) {
columnCount.value = decideColumnCount()
distributeAll()
resizeObserver = new ResizeObserver(onContainerResize)
resizeObserver.observe(mainRef.value)
}
loadMore()
})
onUnmounted(() => {
observer?.disconnect()
resizeObserver?.disconnect()
if (resizeTimer) clearTimeout(resizeTimer)
})
</script>
<template>
<div class="home-layout">
<IndexLeftSidebar
v-if="sidebarMode === 'rail'"
class="home-sidebar"
:mode="sidebarMode"
:categories="categories"
:active-tool-key="activeToolKey"
:active-category-id="activeCategoryId"
@select-tool="onSelectTool"
@select-category="onSelectCategory"
@add-category="onAddCategory"
@rename-category="onRenameCategory"
@delete-category="onDeleteCategory"
@move-category="onMoveCategory"
@change-cover="onChangeCover"
/>
<IndexLeftSidebar
v-else
:mode="sidebarMode"
:drawer-open="drawerOpen"
:categories="categories"
:active-tool-key="activeToolKey"
:active-category-id="activeCategoryId"
@update:drawer-open="(v: boolean) => drawerOpen = v"
@select-tool="onSelectTool"
@select-category="onSelectCategory"
@add-category="onAddCategory"
@rename-category="onRenameCategory"
@delete-category="onDeleteCategory"
@move-category="onMoveCategory"
@change-cover="onChangeCover"
/>
<main ref="mainRef" class="home-main">
<header class="main-header">
<!-- Desktop / tablet layout (>= 768px) -->
<template v-if="!isMobile">
<div class="header-left">
<span class="header-eyebrow">分类</span>
<h1 class="header-title">{{ activeCategoryName }}</h1>
</div>
<div class="header-actions">
<button type="button" class="action-btn" @click="$toast.info('视图切换即将上线')">
<Icon name="lucide:layout-grid" />
</button>
<button type="button" class="action-btn" @click="$toast.info('筛选即将上线')">
<Icon name="lucide:sliders-horizontal" />
</button>
<button type="button" class="action-btn primary" @click="$toast.info('上传即将上线')">
<Icon name="lucide:plus" />
<span>新增</span>
</button>
</div>
</template>
<!-- Mobile layout (< 768px) -->
<template v-else>
<div class="header-row header-row--top">
<span class="header-eyebrow">分类</span>
<div class="header-actions">
<button type="button" class="action-btn action-btn--icon" aria-label="视图" @click="$toast.info('视图切换即将上线')">
<Icon name="lucide:layout-grid" />
</button>
<button type="button" class="action-btn action-btn--icon" aria-label="筛选" @click="$toast.info('筛选即将上线')">
<Icon name="lucide:sliders-horizontal" />
</button>
<button type="button" class="action-btn action-btn--icon action-btn--primary" aria-label="新增" @click="$toast.info('上传即将上线')">
<Icon name="lucide:plus" />
</button>
</div>
</div>
<h1 class="header-title">{{ activeCategoryName }}</h1>
<div class="header-row header-row--bottom">
<button
type="button"
class="drawer-trigger"
aria-label="打开分类抽屉"
@click="drawerOpen = true"
>
<Icon name="lucide:menu" />
<span>分类</span>
</button>
<span class="category-chip">{{ activeCategoryName }}</span>
</div>
</template>
</header>
<div 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 || 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>
</main>
</div>
</template>
<style scoped>
.home-layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 280px 1fr;
align-items: start;
gap: 0;
}
.home-sidebar {
position: sticky;
top: 64px;
height: calc(100vh - 64px);
align-self: start;
}
.home-main {
min-width: 0;
padding: 28px 24px 48px;
display: flex;
flex-direction: column;
}
/* ── Header ── */
.main-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding-bottom: 24px;
margin-bottom: 24px;
border-bottom: 1px solid var(--color-hairline);
}
.header-left {
display: flex;
flex-direction: column;
gap: 6px;
}
.header-eyebrow {
font-family: var(--font-body);
font-size: 11px;
font-weight: 500;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--color-primary);
}
.header-title {
font-family: var(--font-display);
font-size: clamp(28px, 4vw, 44px);
font-weight: 400;
color: var(--color-ink);
letter-spacing: -0.8px;
line-height: 1.05;
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 12px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--color-hairline);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
color: var(--color-body);
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn :deep(svg) {
width: 16px;
height: 16px;
}
.action-btn:hover {
border-color: var(--color-ink);
color: var(--color-ink);
}
.action-btn.primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-on-primary);
padding: 0 16px;
}
.action-btn.primary:hover {
background: var(--color-primary-active);
border-color: var(--color-primary-active);
}
/* ── Masonry ── */
.masonry {
display: flex;
gap: 18px;
align-items: flex-start;
}
.masonry-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
}
.card-reveal {
animation: card-enter 0.6s var(--ease-out-expo) both;
animation-delay: var(--enter-delay, 0ms);
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Sentinel ── */
.sentinel {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0 24px;
min-height: 56px;
}
.sentinel-text {
font-size: 13px;
color: var(--color-muted-soft);
}
.sentinel-text.end {
color: var(--color-hairline);
}
.loading-bars {
display: flex;
gap: 5px;
align-items: center;
}
.bar {
width: 4px;
height: 18px;
background: var(--color-primary);
border-radius: 2px;
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 (DESIGN.md breakpoints) ── */
@media (max-width: 767.98px) {
.home-layout {
grid-template-columns: 1fr;
}
.home-sidebar {
display: none; /* rail 节点在移动端被 v-if 隐藏;drawer 由 BoDrawer 渲染到 body */
}
.home-main {
padding: 16px 16px 32px;
}
.main-header {
flex-direction: column;
align-items: stretch;
gap: 12px;
padding-bottom: 16px;
margin-bottom: 16px;
}
.header-title {
font-size: clamp(22px, 7vw, 30px);
}
}
@media (min-width: 768px) and (max-width: 1023.98px) {
.home-layout {
grid-template-columns: 240px 1fr;
}
.home-main {
padding: 20px 16px 32px;
}
}
/* ── Mobile header rows ── */
.header-row {
display: flex;
align-items: center;
gap: 12px;
}
.header-row--top {
justify-content: space-between;
}
.header-row--bottom {
flex-wrap: wrap;
gap: 8px;
}
.action-btn--icon {
width: 36px;
height: 36px;
padding: 0;
justify-content: center;
}
.action-btn--icon.action-btn--primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-on-primary);
}
.drawer-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 16px;
border-radius: var(--rounded-md, 8px);
background: transparent;
border: 1px solid var(--color-hairline);
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
color: var(--color-ink);
cursor: pointer;
min-width: 44px;
transition: border-color 0.15s ease, color 0.15s ease;
}
.drawer-trigger:hover {
border-color: var(--color-ink);
}
.drawer-trigger :deep(svg) {
width: 18px;
height: 18px;
}
.category-chip {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 14px;
border-radius: 9999px;
background: var(--color-surface-card);
color: var(--color-ink);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
}
</style>