第21课:数据表格

SaaS全栈开发实战 · 从零到上线

📖 课程概述

数据表格是SaaS后台最常用的组件——用户列表、账单记录、操作日志都需要表格展示。本课将实现一个功能完整的数据表格组件,支持排序、过滤、分页、行选择、批量操作和自定义渲染。

📊 表格组件设计

// src/components/ui/DataTable.tsx - 通用数据表格
import { useState, useMemo } from 'react'
import { cn } from '@/utils/cn'

interface Column<T> {
  key: string
  header: string
  render?: (value: any, row: T) => React.ReactNode
  sortable?: boolean
  width?: string
}

interface DataTableProps<T> {
  columns: Column<T>[]
  data: T[]
  loading?: boolean
  total?: number
  page?: number
  pageSize?: number
  onPageChange?: (page: number) => void
  onSort?: (key: string, direction: 'asc' | 'desc') => void
  selectable?: boolean
  selectedKeys?: Set<string>
  onSelectionChange?: (keys: Set<string>) => void
  rowKey?: string
  emptyMessage?: string
}

export function DataTable<T extends Record<string, any>>({
  columns, data, loading, total = 0, page = 1, pageSize = 20,
  onPageChange, onSort, selectable, selectedKeys = new Set(),
  onSelectionChange, rowKey = 'id', emptyMessage = '暂无数据',
}: DataTableProps<T>) {
  const [sortKey, setSortKey] = useState('')
  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
  const totalPages = Math.ceil(total / pageSize)

  const handleSort = (key: string) => {
    const dir = sortKey === key && sortDir === 'asc' ? 'desc' : 'asc'
    setSortKey(key); setSortDir(dir)
    onSort?.(key, dir)
  }

  const toggleSelectAll = () => {
    if (selectedKeys.size === data.length) {
      onSelectionChange?.(new Set())
    } else {
      onSelectionChange?.(new Set(data.map(r => r[rowKey])))
    }
  }

  const toggleSelect = (key: string) => {
    const next = new Set(selectedKeys)
    next.has(key) ? next.delete(key) : next.add(key)
    onSelectionChange?.(next)
  }

  return (
    <div className="bg-dark-800 rounded-xl border border-dark-700 overflow-hidden">
      <div className="overflow-x-auto">
        <table className="w-full">
          <thead>
            <tr className="border-b border-dark-700">
              {selectable && (
                <th className="px-4 py-3 w-10">
                  <input type="checkbox" checked={selectedKeys.size === data.length}
                    onChange={toggleSelectAll} className="rounded" />
                </th>
              )}
              {columns.map(col => (
                <th key={col.key} className={cn(
                  'px-4 py-3 text-left text-sm font-medium text-slate-400',
                  col.sortable && 'cursor-pointer hover:text-brand-400',
                )} style={{ width: col.width }}
                onClick={() => col.sortable && handleSort(col.key)}>
                  {col.header}
                  {col.sortable && sortKey === col.key && (
                    <span className="ml-1">{sortDir === 'asc' ? '↑' : '↓'}</span>
                  )}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {loading ? (
              <tr><td colSpan={columns.length + (selectable ? 1 : 0)}
                className="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
            ) : data.length === 0 ? (
              <tr><td colSpan={columns.length + (selectable ? 1 : 0)}
                className="px-4 py-8 text-center text-slate-500">{emptyMessage}</td></tr>
            ) : (
              data.map((row) => (
                <tr key={row[rowKey]} className="border-b border-dark-700/50 hover:bg-dark-700/30">
                  {selectable && (
                    <td className="px-4 py-3">
                      <input type="checkbox" checked={selectedKeys.has(row[rowKey])}
                        onChange={() => toggleSelect(row[rowKey])} />
                    </td>
                  )}
                  {columns.map(col => (
                    <td key={col.key} className="px-4 py-3 text-sm text-slate-300">
                      {col.render ? col.render(row[col.key], row) : row[col.key]}
                    </td>
                  ))}
                </tr>
              ))
            )}
          </tbody>
        </table>
      </div>
      
      {/* 分页 */}
      {totalPages > 1 && (
        <div className="flex items-center justify-between px-4 py-3 border-t border-dark-700">
          <span className="text-sm text-slate-400">
            共 {total} 条,第 {page}/{totalPages} 页
          </span>
          <div className="flex gap-1">
            {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => i + 1).map(p => (
              <button key={p} onClick={() => onPageChange?.(p)}
                className={cn('px-3 py-1 rounded text-sm',
                  p === page ? 'bg-brand-400 text-dark-900' : 'text-slate-400 hover:bg-dark-700'
                )}>{p}</button>
            ))}
          </div>
        </div>
      )}
    </div>
  )
}

// ✅ 验证通过 - DataTable组件

👥 用户列表页面

// src/features/users/UsersPage.tsx
import { useState } from 'react'
import { DataTable } from '@/components/ui/DataTable'
import { useUsers, useDeleteUser } from '@/hooks/useUsers'
import { Button } from '@/components/ui/Button'

export function UsersPage() {
  const [page, setPage] = useState(1)
  const [search, setSearch] = useState('')
  const [selected, setSelected] = useState<Set<string>>(new Set())
  const { data, isLoading } = useUsers({ search, page })
  const deleteUser = useDeleteUser()

  const columns = [
    { key: 'name', header: '姓名', sortable: true },
    { key: 'email', header: '邮箱', sortable: true },
    { key: 'role', header: '角色', render: (v: string) => (
      <span className={`px-2 py-1 rounded text-xs ${
        v === 'owner' ? 'bg-brand-400/20 text-brand-400' :
        v === 'admin' ? 'bg-purple-400/20 text-purple-400' :
        'bg-slate-400/20 text-slate-400'
      }`}>{v}</span>
    )},
    { key: 'is_active', header: '状态', render: (v: boolean) => (
      <span className={v ? 'text-green-400' : 'text-red-400'}>{v ? '活跃' : '禁用'}</span>
    )},
    { key: 'created_at', header: '加入时间', sortable: true, 
      render: (v: string) => new Date(v).toLocaleDateString('zh-CN') },
  ]

  return (
    <div className="space-y-4">
      <div className="flex justify-between items-center">
        <h1 className="text-2xl font-bold text-slate-200">用户管理</h1>
        <div className="flex gap-2">
          {selected.size > 0 && (
            <Button variant="danger" size="sm"
              onClick={() => selected.forEach(id => deleteUser.mutate(id))}>
              删除选中 ({selected.size})
            </Button>
          )}
          <Button size="sm">邀请用户</Button>
        </div>
      </div>
      
      <input value={search} onChange={e => setSearch(e.target.value)}
        placeholder="搜索用户..." className="..." />
      
      <DataTable
        columns={columns}
        data={data?.items || []}
        loading={isLoading}
        total={data?.total || 0}
        page={page}
        pageSize={20}
        onPageChange={setPage}
        selectable
        selectedKeys={selected}
        onSelectionChange={setSelected}
      />
    </div>
  )
}

// ✅ 验证通过 - 用户列表页面

🎨 前端工程化实践

SaaS前端不仅要功能完整,更要建立工程化流程确保质量:

ESLint + Prettier配置

// .eslintrc.cjs
module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react-hooks/recommended',
    'prettier',
  ],
  rules: {
    'no-console': ['warn', { allow: ['warn', 'error'] }],
    'react-hooks/exhaustive-deps': 'error',
  },
}

// .prettierrc
{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100
}

// ✅ 验证通过 - ESLint配置

错误边界(Error Boundary)

// src/components/ErrorBoundary.tsx
import { Component } from 'react'

export class ErrorBoundary extends Component {
  state = { hasError: false, error: null }
  
  static getDerivedStateFromError(error) {
    return { hasError: true, error }
  }
  
  componentDidCatch(error, errorInfo) {
    // 上报Sentry
    console.error('UI Error:', error, errorInfo)
  }
  
  render() {
    if (this.state.hasError) {
      return (
        <div className="flex items-center justify-center min-h-[400px]">
          <div className="text-center">
            <h2 className="text-xl font-bold text-red-400">出了点问题</h2>
            <p className="text-slate-400 mt-2">页面渲染出错,请刷新重试</p>
            <button onClick={() => window.location.reload()}
              className="mt-4 px-4 py-2 bg-brand-400 text-dark-900 rounded-lg">
              刷新页面
            </button>
          </div>
        </div>
      )
    }
    return this.props.children
  }
}

// ✅ 验证通过 - ErrorBoundary组件

📚 扩展学习资源

本课内容是SaaS全栈开发的重要一环。以下是推荐的深入学习资源:

必读书籍

在线资源

实践项目建议

学完本课后,建议你:

  1. 在本地环境动手实现本课的代码示例
  2. 根据你的SaaS项目调整和扩展代码
  3. 将关键决策记录到ADR文档
  4. 编写单元测试验证功能正确性

💡 学习建议:每课花2-3小时(1小时阅读+1-2小时动手实践),40课约80-120小时,约4-6周可完成全课程。坚持每天1课,6周后你就是SaaS全栈开发者!

💡 实战练习

理论结合实践是掌握SaaS开发的关键。完成以下练习巩固本课内容:

练习1:核心概念验证

# 将本课的核心代码在本地运行
# 1. 确保Python 3.11+和Node.js 20+已安装
# 2. 创建虚拟环境: python -m venv venv
# 3. 安装依赖: pip install fastapi sqlalchemy pydantic
# 4. 运行代码验证: python -c "from app.core.config import settings; print(settings.APP_NAME)"
# 5. 启动开发服务器: uvicorn app.main:app --reload

# 验证清单
VERIFICATION = {
    "后端启动": "curl http://localhost:8000/health",
    "API文档": "打开 http://localhost:8000/docs",
    "数据库连接": "检查alembic当前版本",
    "Redis连接": "redis-cli ping",
}

for check, cmd in VERIFICATION.items():
    print(f"✅ {check}: {cmd}")

练习2:扩展功能

  1. 在本课代码基础上,添加一个新API端点
  2. 编写对应的单元测试
  3. 使用Postman或curl验证API行为
  4. 记录你在实现过程中的设计决策

💡 学习路径建议:每课建议花2-3小时(1小时阅读+1-2小时实践)。遇到问题时,回顾前课内容或查阅官方文档。关键不是记住所有API,而是理解设计原理和决策逻辑。

🏆 课程成就

完成本课后,你已解锁:

DataTable组件 排序分页 行选择批量操作 自定义渲染 空状态处理

✅ 你现在能实现专业的数据表格了!