⚠️ Alpha内测版本警告:此为早期内部构建版本,尚不完整且可能存在错误,欢迎大家提Issue反馈问题或建议
Skip to content

3.7.2 空态设计

一句话破题

空白不是终点,而是引导用户开始的机会。好的空态设计让用户知道该做什么。

核心价值

当列表为空、搜索无结果时,不要只显示"暂无数据"。空态页面应该解释为什么是空的,并引导用户进行下一步操作。

空态类型

场景用户心理设计要点
首次使用好奇期待强调价值,引导开始
搜索无结果轻微失望提供替代建议
筛选无结果困惑显示重置按钮
数据已清空可能误操作提供恢复途径

基础空态组件

tsx
// components/Empty.tsx
import { ReactNode } from 'react'

interface EmptyProps {
  icon?: ReactNode
  title: string
  description?: string
  action?: ReactNode
}

export function Empty({ icon, title, description, action }: EmptyProps) {
  return (
    <div className="flex flex-col items-center justify-center py-12">
      {icon && (
        <div className="text-gray-400 mb-4">
          {icon}
        </div>
      )}
      <h3 className="text-lg font-medium text-gray-900 mb-2">
        {title}
      </h3>
      {description && (
        <p className="text-sm text-gray-500 mb-4 text-center max-w-sm">
          {description}
        </p>
      )}
      {action && (
        <div className="mt-4">
          {action}
        </div>
      )}
    </div>
  )
}

场景化空态

首次使用

tsx
<Empty
  icon={<FileIcon className="w-12 h-12" />}
  title="还没有任何文档"
  description="创建你的第一个文档,开始记录你的想法"
  action={
    <Button onClick={openCreateDialog}>
      <PlusIcon className="w-4 h-4 mr-2" />
      创建文档
    </Button>
  }
/>

搜索无结果

tsx
function SearchEmpty({ query }: { query: string }) {
  return (
    <Empty
      icon={<SearchIcon className="w-12 h-12" />}
      title={`找不到 "${query}" 相关内容`}
      description="试试其他关键词,或检查是否有拼写错误"
      action={
        <div className="space-y-2 text-center">
          <p className="text-sm text-gray-500">你可以试试:</p>
          <div className="flex gap-2 justify-center">
            <Button variant="outline" size="sm">React</Button>
            <Button variant="outline" size="sm">Next.js</Button>
            <Button variant="outline" size="sm">TypeScript</Button>
          </div>
        </div>
      }
    />
  )
}

筛选无结果

tsx
function FilterEmpty({ onReset }: { onReset: () => void }) {
  return (
    <Empty
      icon={<FilterIcon className="w-12 h-12" />}
      title="没有符合条件的结果"
      description="当前筛选条件下没有匹配的数据"
      action={
        <Button variant="outline" onClick={onReset}>
          重置筛选条件
        </Button>
      }
    />
  )
}

列表组件封装

tsx
// components/List.tsx
interface ListProps<T> {
  data: T[]
  loading?: boolean
  empty?: ReactNode
  renderItem: (item: T, index: number) => ReactNode
}

export function List<T>({ data, loading, empty, renderItem }: ListProps<T>) {
  if (loading) {
    return <ListSkeleton />
  }
  
  if (data.length === 0) {
    return empty || <Empty title="暂无数据" />
  }
  
  return (
    <ul className="divide-y">
      {data.map((item, index) => (
        <li key={index}>{renderItem(item, index)}</li>
      ))}
    </ul>
  )
}

// 使用
<List
  data={posts}
  loading={isLoading}
  empty={
    <Empty
      title="还没有文章"
      action={<Button>写第一篇</Button>}
    />
  }
  renderItem={(post) => <PostCard post={post} />}
/>

带插图的空态

tsx
// components/EmptyWithIllustration.tsx
interface EmptyWithIllustrationProps {
  illustration: 'inbox' | 'search' | 'error' | 'success'
  title: string
  description?: string
  action?: ReactNode
}

const illustrations = {
  inbox: '/illustrations/empty-inbox.svg',
  search: '/illustrations/empty-search.svg',
  error: '/illustrations/error.svg',
  success: '/illustrations/success.svg',
}

export function EmptyWithIllustration({
  illustration,
  title,
  description,
  action,
}: EmptyWithIllustrationProps) {
  return (
    <div className="flex flex-col items-center py-16">
      <img 
        src={illustrations[illustration]} 
        alt=""
        className="w-48 h-48 mb-6"
      />
      <h3 className="text-xl font-medium mb-2">{title}</h3>
      {description && (
        <p className="text-gray-500 mb-6 text-center max-w-md">
          {description}
        </p>
      )}
      {action}
    </div>
  )
}

动态空态

根据上下文显示不同的空态:

tsx
function PostList({ category }: { category?: string }) {
  const { data: posts, isLoading } = usePosts({ category })
  
  const emptyState = useMemo(() => {
    if (category) {
      return (
        <Empty
          title={`"${category}" 分类下暂无文章`}
          action={
            <Link href="/posts">查看全部文章</Link>
          }
        />
      )
    }
    
    return (
      <Empty
        title="还没有任何文章"
        description="开始写你的第一篇文章吧"
        action={
          <Button asChild>
            <Link href="/posts/new">写文章</Link>
          </Button>
        }
      />
    )
  }, [category])
  
  return (
    <List
      data={posts}
      loading={isLoading}
      empty={emptyState}
      renderItem={(post) => <PostCard post={post} />}
    />
  )
}

空态设计原则

1. 解释原因:告诉用户为什么是空的

tsx
// 差
<p>暂无数据</p>

// 好
<p>你还没有创建任何项目</p>

2. 提供出路:给用户一个明确的行动

tsx
// 差
<Empty title="没有收藏" />

// 好
<Empty 
  title="没有收藏" 
  action={<Link href="/explore">发现内容</Link>}
/>

3. 保持品牌一致性:使用统一的插图风格和语气

AI 协作指南

核心意图:让 AI 帮你设计各种场景的空态页面。

需求定义公式

  • 场景描述:[页面/功能] 的空态设计
  • 空态原因:[首次使用/无搜索结果/筛选为空]
  • 引导目标:希望用户 [执行某操作]

示例 Prompt

请设计电商收藏夹的空态页面:
1. 首次使用:引导用户去逛商品
2. 显示友好的插图和文案
3. 提供"去逛逛"按钮
4. 可以推荐热门商品

验收清单

  • [ ] 所有列表都有空态处理
  • [ ] 空态有明确的下一步引导
  • [ ] 搜索/筛选有对应的空态
  • [ ] 空态风格与品牌一致