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前端不仅要功能完整,更要建立工程化流程确保质量:
// .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配置
// 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全栈开发的重要一环。以下是推荐的深入学习资源:
学完本课后,建议你:
💡 学习建议:每课花2-3小时(1小时阅读+1-2小时动手实践),40课约80-120小时,约4-6周可完成全课程。坚持每天1课,6周后你就是SaaS全栈开发者!
理论结合实践是掌握SaaS开发的关键。完成以下练习巩固本课内容:
# 将本课的核心代码在本地运行
# 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-3小时(1小时阅读+1-2小时实践)。遇到问题时,回顾前课内容或查阅官方文档。关键不是记住所有API,而是理解设计原理和决策逻辑。
完成本课后,你已解锁:
DataTable组件 排序分页 行选择批量操作 自定义渲染 空状态处理✅ 你现在能实现专业的数据表格了!