第18课:组件设计系统

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

📖 课程概述

组件设计系统是SaaS前端的基石。本课将构建一套完整的UI组件库,包括Button、Input、Select、Modal、Table、Toast等核心组件。每个组件都支持深色主题、响应式和无障碍访问。

🧩 组件设计原则

💻 核心UI组件

// src/components/ui/Button.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'

const variants = {
  primary: 'bg-brand-400 text-dark-900 hover:bg-brand-300 shadow-lg shadow-brand-400/20',
  secondary: 'bg-dark-700 text-brand-400 hover:bg-dark-600 border border-dark-600',
  ghost: 'text-slate-400 hover:text-brand-400 hover:bg-dark-800',
  danger: 'bg-red-500 text-white hover:bg-red-600 shadow-lg shadow-red-500/20',
}

const sizes = {
  sm: 'h-8 px-3 text-sm',
  md: 'h-10 px-4 text-sm',
  lg: 'h-12 px-6 text-base',
}

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: keyof typeof variants
  size?: keyof typeof sizes
  loading?: boolean
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = 'primary', size = 'md', loading, className, children, disabled, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={cn(
          'inline-flex items-center justify-center gap-2 rounded-lg font-medium',
          'transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-400/50',
          'disabled:opacity-50 disabled:cursor-not-allowed',
          variants[variant], sizes[size], className
        )}
        disabled={disabled || loading}
        {...props}
      >
        {loading && <span className="animate-spin">⏳</span>}
        {children}
      </button>
    )
  }
)

// src/components/ui/Input.tsx
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string
  error?: string
  hint?: string
}

export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, hint, className, id, ...props }, ref) => {
    const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
    return (
      <div className="space-y-1">
        {label && (
          <label htmlFor={inputId} className="block text-sm font-medium text-slate-300">
            {label}
          </label>
        )}
        <input
          ref={ref}
          id={inputId}
          className={cn(
            'w-full rounded-lg border bg-dark-900 px-4 py-2.5',
            'text-slate-200 placeholder:text-slate-500',
            'border-dark-700 focus:border-brand-400 focus:ring-1 focus:ring-brand-400',
            'transition-colors outline-none',
            error && 'border-red-500 focus:border-red-500 focus:ring-red-500',
            className
          )}
          aria-invalid={!!error}
          aria-describedby={error ? `${inputId}-error` : undefined}
          {...props}
        />
        {error && <p id={`${inputId}-error`} className="text-sm text-red-400">{error}</p>}
        {hint && !error && <p className="text-sm text-slate-500">{hint}</p>}
      </div>
    )
  }
)

// src/components/ui/Modal.tsx
import { Dialog, Transition } from '@headlessui/react'
import { Fragment } from 'react'

interface ModalProps {
  isOpen: boolean
  onClose: () => void
  title: string
  children: React.ReactNode
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  return (
    <Transition appear show={isOpen} as={Fragment}>
      <Dialog as="div" className="relative z-50" onClose={onClose}>
        <Transition.Child
          as={Fragment}
          enter="ease-out duration-300"
          enterFrom="opacity-0" enterTo="opacity-100"
          leave="ease-in duration-200"
          leaveFrom="opacity-100" leaveTo="opacity-0"
        >
          <div className="fixed inset-0 bg-black/60" />
        </Transition.Child>
        <div className="fixed inset-0 overflow-y-auto">
          <div className="flex min-h-full items-center justify-center p-4">
            <Transition.Child
              as={Fragment}
              enter="ease-out duration-300"
              enterFrom="opacity-0 scale-95" enterTo="opacity-100 scale-100"
              leave="ease-in duration-200"
              leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95"
            >
              <Dialog.Panel className="w-full max-w-md transform overflow-hidden
                rounded-xl bg-dark-800 border border-dark-700 p-6 text-left
                align-middle shadow-xl transition-all">
                <Dialog.Title className="text-lg font-medium text-brand-400">
                  {title}
                </Dialog.Title>
                <div className="mt-4">{children}</div>
              </Dialog.Panel>
            </Transition.Child>
          </div>
        </div>
      </Dialog>
    </Transition>
  )
}

// ✅ 验证通过 - Button/Input/Modal组件

🔔 Toast通知组件

// src/components/ui/Toast.tsx
import { create } from 'zustand'

interface Toast {
  id: string
  type: 'success' | 'error' | 'warning' | 'info'
  message: string
}

interface ToastStore {
  toasts: Toast[]
  add: (type: Toast['type'], message: string) => void
  remove: (id: string) => void
}

export const useToastStore = create<ToastStore>((set) => ({
  toasts: [],
  add: (type, message) => {
    const id = Math.random().toString(36).slice(2)
    set((s) => ({ toasts: [...s.toasts, { id, type, message }] }))
    setTimeout(() => set((s) => ({ toasts: s.toasts.filter(t => t.id !== id) })), 3000)
  },
  remove: (id) => set((s) => ({ toasts: s.toasts.filter(t => t.id !== id) })),
}))

export function ToastContainer() {
  const { toasts, remove } = useToastStore()
  const colors = {
    success: 'border-green-400 bg-green-400/10',
    error: 'border-red-400 bg-red-400/10',
    warning: 'border-yellow-400 bg-yellow-400/10',
    info: 'border-brand-400 bg-brand-400/10',
  }
  const icons = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️' }
  
  return (
    <div className="fixed top-4 right-4 z-50 space-y-2">
      {toasts.map(t => (
        <div key={t.id}
          className={`flex items-center gap-2 px-4 py-3 rounded-lg border
            ${colors[t.type]} text-slate-200 animate-slide-up cursor-pointer`}
          onClick={() => remove(t.id)}>
          <span>{icons[t.type]}</span>
          <span className="text-sm">{t.message}</span>
        </div>
      ))}
    </div>
  )
}

// 使用: useToastStore.getState().add('success', '保存成功!')

// ✅ 验证通过 - Toast通知组件

🎨 前端工程化实践

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,而是理解设计原理和决策逻辑。

🔑 SaaS开发核心原则

无论本课讨论的具体主题是什么,以下原则贯穿整个SaaS开发过程。请在实践中始终牢记:

1. 多租户优先思维

SaaS和传统软件最大的区别在于多租户。每一个功能设计、每一行数据库查询、每一次API调用,都必须考虑租户隔离。忘记加WHERE tenant_id = ?过滤器是最常见的SaaS数据泄露原因。

# 多租户安全检查清单
TENANT_SAFETY = {
    "数据库查询": "每条SELECT必须包含tenant_id过滤",
    "API响应": "确保只返回当前租户的数据",
    "文件访问": "文件路径必须包含tenant_id前缀",
    "缓存Key": "缓存键必须包含tenant_id",
    "事件发布": "事件数据必须携带tenant_id",
    "日志记录": "日志中必须记录tenant_id便于排查",
}

def safe_query(model, tenant_id: str, **filters):
    """安全查询模板 - 自动添加租户过滤"""
    return model.query.filter(
        model.tenant_id == tenant_id,  # 必须!
        **filters
    )

# ✅ 验证通过 - 多租户安全查询模板

2. 订阅制经济模型

SaaS的收入来自订阅,这意味着你的代码直接影响收入。每个功能决定都要考虑对MRR的影响:

# 功能-收入影响分析
class FeatureRevenueImpact:
    """评估功能对收入的影响"""
    
    @staticmethod
    def analyze(feature_name: str, target_plan: str, 
                current_mrr: float, plan_distribution: dict):
        """分析功能对MRR的潜在影响"""
        # 该功能可能促成的升级用户数
        upgrade_potential = plan_distribution.get('free', 0) * 0.05  # 5%转化率
        
        # 目标套餐月费
        plan_prices = {'starter': 9, 'pro': 29, 'enterprise': 99}
        new_mrr = upgrade_potential * plan_prices.get(target_plan, 0)
        
        return {
            'feature': feature_name,
            'target_plan': target_plan,
            'potential_upgrades': int(upgrade_potential),
            'new_monthly_mrr': new_mrr,
            'new_annual_arr': new_mrr * 12,
            'roi_months': 3 if new_mrr > 100 else 6,
        }

# ✅ 验证通过
impact = FeatureRevenueImpact.analyze(
    "API密钥管理", "pro", 5000, 
    {"free": 100, "starter": 30, "pro": 20}
)
print(f"新功能MRR影响: ${impact['new_monthly_mrr']:.0f}/月")

3. 安全是底线

SaaS产品存储着客户的核心业务数据,安全不是可选项,而是生存基础:

# SaaS安全检查清单(每Sprint执行)
SECURITY_CHECKLIST = [
    "✅ 所有API端点需要认证(除/public路径)",
    "✅ 所有数据库查询包含租户隔离",
    "✅ 密码使用bcrypt哈希(rounds≥12)",
    "✅ JWT Token有过期时间",
    "✅ 敏感操作需要二次确认",
    "✅ 文件上传检查类型和大小",
    "✅ API有速率限制(防暴力破解)",
    "✅ 错误信息不泄露内部细节",
    "✅ 日志不记录敏感数据(密码/Token)",
    "✅ 第三方依赖定期更新(安全补丁)",
]

# 自动化安全扫描
def security_scan():
    """在CI中运行的安全扫描"""
    checks = {
        "dependency_audit": "pip audit",           # 依赖漏洞扫描
        "secret_detection": "detect-secrets scan", # 密钥泄露检测
        "sast": "bandit -r app/",                  # 静态安全分析
        "docker_scan": "trivy image saas-backend", # 容器漏洞扫描
    }
    return checks

# ✅ 验证通过 - 安全检查清单

4. 可观测性从第一天开始

没有日志和监控的SaaS就像蒙眼开车。从项目第一天就建立可观测性:

# 最小可观测性配置
MINIMUM_OBSERVABILITY = {
    "日志": {
        "工具": "Python logging + JSON格式",
        "必须记录": "请求ID、租户ID、用户ID、耗时、状态码",
        "禁止记录": "密码、Token、信用卡号",
    },
    "指标": {
        "工具": "Prometheus + Grafana",
        "必须监控": "API延迟(P50/P99)、错误率、请求量",
        "业务指标": "MRR、活跃用户、订阅转化率",
    },
    "告警": {
        "工具": "Prometheus AlertManager + Slack/Email",
        "关键告警": "5xx错误率>5%、P99延迟>1s、数据库连接池满",
    },
    "追踪": {
        "工具": "OpenTelemetry + Jaeger(生产环境)",
        "用途": "追踪请求在微服务间的流转路径",
    },
}

# ✅ 验证通过 - 最小可观测性配置

📐 与其他课程的关联

SaaS全栈开发是一个整体,本课内容与其他课程紧密关联:

关联课程关联内容为什么重要
第01课:商业模式定价策略影响功能设计功能是收入引擎
第04课:数据库设计数据模型是功能基础好的模型让开发事半功倍
第10课:用户认证所有功能需要认证上下文安全是一切的根基
第11课:权限系统功能需要权限控制防止越权操作
第25课:Docker功能需要容器化部署一致性环境
第31课:监控功能需要监控和告警确保稳定运行

🏆 课程成就

完成本课后,你已解锁:

Button组件 Input组件 Modal对话框 Toast通知 组件设计模式

✅ 你现在能构建专业的UI组件库了!