mcpField
S
saas-product

saas-product

github.com/tgautier设计与 UI237 次安装1 天前更新
代码仓库

关于 saas-product

面向 SaaS 产品设计者的产品设计方法指南,涵盖用户引导、空状态、仪表盘、通知、订阅和功能权限等。强调循序渐进地呈现复杂度,并围绕用户目标设计体验。

主要功能

  1. 01渐进式复杂度与用户引导
  2. 02空状态和仪表盘设计模式
  3. 03加载、过渡与乐观界面
  4. 04通知渠道与偏好设置
  5. 05订阅体验和功能权限设计

适用场景

  1. 01规划新用户首次使用流程
  2. 02设计数据为空或加载中的页面
  3. 03构建 SaaS 指标仪表盘
  4. 04设计订阅升级与功能限制体验

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/tgautier/dotfiles --skill saas-product

安装后在 Claude Code 中输入/saas-product确认已加载。

详细介绍

SaaS 产品设计

构建推动用户采用与留存的生产级 SaaS 功能的方法。重点关注缩短用户获得价值所需时间、并应对多租户订阅制应用复杂性的模式。

有关设计系统令牌和无障碍设计,请参阅 /ux-design。有关 React 组件模式,请参阅 /react。有关功能背后的 API 契约,请参阅 /api-design。有关领域建模,请参阅 /domain-design。


1. 设计理念

渐进式复杂度

从简单开始,在用户需要时逐步展现复杂功能。新用户应在 60 秒内首次获得价值。高级功能逐步解锁。

原则应用
获得价值所需时间尽量减少注册到首次有意义操作之间的步骤
渐进式披露将高级选项隐藏在“高级”切换项或二级菜单中
待完成任务围绕用户想要完成的任务进行设计,而不是围绕数据实体
合理默认值使用最常见的选项预填设置;确保默认流程正确
撤销优于确认优先采用可撤销并支持撤销的操作,而不是会打断流程的确认对话框
产品层级
Feature → Page → Section → Component

每个层级都有明确的职责:

  • 功能:完整的能力(例如,“资产跟踪”)
  • 页面:功能中的一个视图(例如,“资产列表”“资产详情”)
  • 分区:页面中的逻辑分组(例如,“性能图表”“交易历史”)
  • 组件:可复用的 UI 元素(例如,“货币徽标”“日期选择器”)

2. 引导与首次使用

激活指标

在构建引导流程之前,先定义“已激活”的含义:

指标示例
完成设置用户已连接至少一个数据源
获得首次价值用户已查看包含真实数据的首个仪表盘
养成习惯用户在前 7 天内返回 3 次
渐进式引导模式

设置向导 — 适用于需要初始配置的产品:

// Loader returns current step from session/DB
export async function loader({ request }: LoaderFunctionArgs) {
  const progress = await getOnboardingProgress(request);
  return { step: progress.currentStep, steps: progress.steps };
}

// Action validates current step, saves, and advances
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  if (formData.get("_intent") === "skip") return redirect("/app");
  const nextStep = await saveStepAndAdvance(formData);
  if (nextStep === "complete") return redirect("/app");
  return redirect(`/onboarding/step/${nextStep}`);
}

// Component renders the current step as a form
function SetupWizard() {
  const { step, steps } = useLoaderData<typeof loader>();
  return (
    <div>
      <StepIndicator steps={steps} current={step} />
      <Form method="post">
        <CurrentStepFields step={step} />
        <Button type="submit">Continue</Button>
      </Form>
      <Form method="post">
        <input type="hidden" name="_intent" value="skip" />
        <button type="submit" className="text-sm text-muted-foreground">
          Skip setup — I'll do this later
        </button>
      </Form>
    </div>
  );
}

规则:

  • 始终允许跳过 — 切勿强制用户完成所有步骤
  • 自动保存进度 — 每一步都是提交到服务器端持久化的操作
  • 最多 3-5 步 — 超过这个数量,用户就会放弃
  • 清楚显示进度(第 2 步,共 4 步)

清单模式 — 适用于设置过程较为渐进的产品:

function OnboardingChecklist({ tasks }: { tasks: OnboardingTask[] }) {
  const completed = tasks.filter(t => t.done).length;
  return (
    <Card>
      <Progress value={completed} max={tasks.length} />
      <p>{completed} of {tasks.length} complete</p>
      {tasks.map(task => (
        <ChecklistItem key={task.id} task={task} />
      ))}
      {completed === tasks.length && <DismissButton />}
    </Card>
  );
}

情境提示工具 — 用于初始设置后的功能发现:

  • 每位用户只显示一次,并在用户偏好设置中记录关闭状态
  • 指向具体的 UI 元素,而不是笼统的区域
  • 包含一个清晰的 CTA(“立即试用” / “知道了”)
反模式
  • 强制播放视频导览(用户会跳过)
  • 同时在每个元素上显示提示工具(让人不知所措)
  • 阻止用户使用应用,直到完成引导(导致用户放弃)
  • 向已完成引导的回访用户再次显示引导

3. 空状态

每个数据驱动视图都必须处理四种空状态:

类型触发时机内容
首次使用用户尚未创建任何数据插图 + 说明 + 主要 CTA
无结果搜索或筛选未返回任何结果“没有符合 X 的结果” + 扩大搜索范围的建议
错误数据加载失败错误消息 + 重试按钮
筛选后为空已应用的筛选条件排除了所有结果显示有效筛选条件 + “清除筛选条件”按钮
首次使用空状态模式
function EmptyState({ icon, title, description, action }: EmptyStateProps) {
  return (
    <div className="flex flex-col items-center justify-center py-16 text-center">
      <div className="mb-4 text-muted-foreground">{icon}</div>
      <h3 className="text-lg font-semibold">{title}</h3>
      <p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
      {action && (
        <Button className="mt-4" asChild>
          <Link to={action.href}>{action.label}</Link>
        </Button>
      )}
    </div>
  );
}

// Usage — CTA navigates to a route, not an onClick handler
<EmptyState
  icon={<WalletIcon size={48} />}
  title="No assets yet"
  description="Add your first asset to start tracking your portfolio performance."
  action={{ href: "/assets/new", label: "Add Asset" }}
/>

规则:

  • 首次使用空状态必须提供一个可创建首个条目的 CTA
  • 切勿显示空白页面或孤零零的“无数据”消息
  • 使用插图或图标,让空状态显得有意为之,而非发生故障
  • 将 CTA 精简为一个清晰的操作 — 不要提供多种路径

4. 仪表盘设计

KPI 卡片构成

设计良好的 KPI 卡片应显示:当前值、趋势指标、对比时段,以及可选的迷你趋势图。

interface KPICardProps {
  label: string;
  value: string;
  change: number;      // percentage change
  period: string;      // "vs last month"
  sparklineData?: number[];
}

function KPICard({ label, value, change, period, sparklineData }: KPICardProps) {
  const isPositive = change >= 0;
  return (
    <Card>
      <p className="text-sm text-muted-foreground">{label}</p>
      <p className="text-2xl font-bold">{value}</p>
      <div className="flex items-center gap-1 text-sm">
        <TrendIcon direction={isPositive ? "up" : "down"} />
        <span className={isPositive ? "text-green-600" : "text-red-600"}>
          {Math.abs(change)}%
        </span>
        <span className="text-muted-foreground">{period}</span>
      </div>
      {sparklineData && <Sparkline data={sparklineData} />}
    </Card>
  );
}
图表选择指南
数据关系图表类型适用场景
部分与整体环形图(最多 5 个区段)预算分配、投资组合构成
随时间变化折线图 / 面积图收入趋势、增长指标
比较横向条形图类别比较、排名
分布直方图数值范围、频率
随时间变化的构成堆叠面积图随时间变化的分区收入

规则:

  • 区段超过 5 个时,切勿使用饼图——改用横向条形图
  • 切勿使用 3D 图表
  • 折线图的 x 轴必须是连续的(时间、序列)
  • 始终标注坐标轴并注明单位
仪表板布局
  • 顶部一行:3-4 张 KPI 卡片,总结最重要的指标
  • 中部:主要图表(全宽或 2/3 宽)
  • 底部:辅助数据表或详细视图
  • 使用 CSS Grid 布局:KPI 行使用 grid-cols-1 md:grid-cols-2 lg:grid-cols-4
数据密度
  • 面向高级用户的密集展示(包含许多列、间距紧凑的表格)
  • 面向普通用户的摘要视图(KPI 卡片、迷你图、简化图表)
  • 允许用户在不同视图间切换,或记住用户的偏好

5. 加载与过渡状态

骨架屏

使骨架屏的形状与实际内容布局相匹配。用户会觉得骨架屏比加载指示器更快:

function AssetListSkeleton() {
  return (
    <div className="space-y-3">
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="flex items-center gap-4">
          <Skeleton className="h-10 w-10 rounded-full" />
          <div className="space-y-2">
            <Skeleton className="h-4 w-48" />
            <Skeleton className="h-3 w-32" />
          </div>
          <Skeleton className="ml-auto h-4 w-20" />
        </div>
      ))}
    </div>
  );
}
加载状态决策框架
持续时间模式
< 100ms无需指示器
100-300ms轻量的内联指示器(按钮加载动画)
300ms-2s骨架屏
2-10s进度条或带消息的骨架屏
> 10s后台任务,完成时发送通知
乐观 UI

对于失败概率较低的变更,立即显示预期结果。在 React Router v7 中,从 fetcher.formData(待处理的提交数据)派生乐观状态。将待处理项目与数据列表分开渲染——乐观项目属于临时 UI 状态,而非数据。服务器通过 loader 重新验证来分配真实 ID:

function TodoList({ items }: { items: Todo[] }) {
  const fetcher = useFetcher();

  return (
    <>
      <ul>
        {items.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
        {fetcher.formData && (
          <li className="opacity-50">
            {String(fetcher.formData.get("name") ?? "")}
          </li>
        )}
      </ul>
      <fetcher.Form method="post">
        <input type="hidden" name="_intent" value="create" />
        <input name="name" required />
        <Button type="submit">Add</Button>
      </fetcher.Form>
    </>
  );
}
  • 提交正在进行时,fetcher.formData 不为 null——直接从中派生乐观项目
  • 操作完成后,loader 会重新验证,items 会更新为真实数据,而 fetcher.formData 会重置为 null
  • 失败时,loader 仍会使用未更改的数据重新验证——由于 fetcher.formData 为 null,乐观项目会消失
  • 无需 useOptimistic、onSubmit 或 startTransition——React Router 的数据层会处理整个生命周期
流式 SSR

对于包含快慢数据源的页面:

  • 快速数据(导航、布局)会立即在外壳中渲染
  • 慢速数据(分析、外部 API)会通过 Suspense 边界流式传输
  • 每个 Suspense 边界在加载时都会显示自己的骨架屏
服务端优先加载原则

使用路由 loader 时,页面渲染时数据就已可用——初始数据无需显示加载动画。仅在以下情况使用骨架屏:

  • 流式 SSR(Suspense 边界后的慢速数据源)
  • 由 Fetcher 驱动的更新(进行中的非导航变更)
  • 需要挂载保护的纯客户端组件

6. 通知设计

通知渠道
渠道用途紧急程度
应用内 Toast操作确认、轻微错误低——5 秒后自动关闭
应用内铃铛新活动、状态变更中——持续显示,直到阅读
电子邮件事务性通知(收据、邀请)、摘要低——尽可能批量发送
浏览器推送仅用于有时效性的提醒高——会打断用户
Toast 最佳实践
// Success toast with undo
showToast("Asset deleted", {
  action: { label: "Undo", onClick: undoDelete },
  duration: 5000,
});

// Error toast — persists until dismissed
showToast("Failed to save changes. Please try again.", {
  variant: "error",
  duration: Infinity,
  action: { label: "Retry", onClick: retryAction },
});

规则:

  • 成功 Toast 在 5 秒后自动关闭
  • 错误 Toast 切勿自动关闭——用户可能注意不到
  • 为破坏性操作提供撤销操作
  • 将多个 Toast 纵向堆叠,同时最多显示 3 条
  • 切勿将 Toast 作为表单验证错误的唯一提示方式
通知偏好设置

以渠道与事件矩阵的形式实现:

事件应用内电子邮件推送
新团队成员默认开启默认开启默认关闭
每周摘要不适用默认开启不适用
付款失败默认开启默认开启默认开启
功能更新默认开启默认关闭默认关闭

允许用户独立控制每个单元格。


7. 账单与订阅体验

方案比较
function PlanComparison({ plans }: { plans: Plan[] }) {
  return (
    <div className="grid gap-6 md:grid-cols-3">
      {plans.map(plan => (
        <PlanCard
          key={plan.id}
          name={plan.name}
          price={plan.price}
          period={plan.period}
          features={plan.features}
          recommended={plan.recommended}
          current={plan.current}
        />
      ))}
    </div>
  );
}

规则:

  • 通过视觉样式突出显示推荐方案(边框、徽章、“最受欢迎”)
  • 清晰显示当前方案,让用户知道自己当前使用的是哪种方案
  • 逐项列出各方案包含的功能,并标明已包含和未包含的功能
  • 年付价格应显示折算后的月均价格和节省比例
升级提示

情境化提示的效果比通用升级横幅高 3 倍:

// GOOD — contextual, shown when the user hits a limit
function FeatureLimitPrompt({ feature, limit, current }: LimitPromptProps) {
  return (
    <Alert>
      <p>You've used {current} of {limit} {feature}.</p>
      <Button variant="link" asChild>
        <Link to="/settings/billing">Upgrade for unlimited {feature}</Link>
      </Button>
    </Alert>
  );
}

// BAD — generic banner shown on every page
<Banner>Upgrade to Pro for more features!</Banner>
试用与降级
  • 以微妙且持续显示的指示器展示剩余试用天数(不要使用弹窗)
  • 降级前:展示用户将失去什么,而不只是功能列表
  • 降级后:平稳地降低功能可用性(设为只读,而非删除)
  • 降级时绝不删除用户数据——将其标记为不可访问,并允许用户重新升级

8. 功能门控

实现模式
模式适用场景
功能标志逐步向一定比例的用户推出新功能
方案门控仅特定订阅等级可使用该功能
角色门控仅特定用户角色(管理员、成员)可使用该功能
用量限制功能在每个计费周期内有配额
平稳降级

当某项功能受限时,向用户展示他们缺少什么以及如何获得:

// Check access in the loader — never send gated data to the client
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireAuth(request);
  const access = await checkFeatureAccess(user, "advanced-analytics");
  if (!access.granted) {
    return { gated: true, requiredPlan: access.requiredPlan };
  }
  const data = await loadAnalytics();
  return { gated: false, data };
}

// Component renders based on loader data
function AnalyticsPage() {
  const loaderData = useLoaderData<typeof loader>();
  if (loaderData.gated) {
    return <UpgradeOverlay requiredPlan={loaderData.requiredPlan} />;
  }
  return <AnalyticsDashboard data={loaderData.data} />;
}

规则:

  • 受限功能本应出现的位置绝不能留白——应显示预览
  • 不要完全隐藏受限功能——让用户发现功能有助于促成升级
  • 使用模糊预览或锁定图标,而不是错误消息
  • 角色受限的功能应显示“联系你的管理员”,而不是升级提示

9. 设置与管理体验

设置分类
区域内容
账户个人资料、电子邮件、密码、双重身份验证
团队成员、邀请、角色
账单方案、付款方式、发票
偏好设置主题、语言、通知设置
集成已连接的服务、API 密钥
危险区域删除账户、导出数据
危险区域

破坏性设置必须在视觉上区别显示,并要求确认:

// Action — server-side validation (client-side pattern is bypassable)
export async function action({ request }: ActionFunctionArgs) {
  const user = await requireAuth(request);
  const formData = await request.formData();
  if (formData.get("_intent") === "delete-account") {
    const confirmation = String(formData.get("confirmation") ?? "");
    if (confirmation !== "delete my account") {
      return Response.json(
        { error: "Confirmation phrase does not match", intent: "delete-account" },
        { status: 400 },
      );
    }
    await api.deleteAccount(user.id);
    return redirect("/goodbye");
  }
}

// Component
function DangerZone() {
  return (
    <Card className="border-red-200 bg-red-50">
      <h3 className="text-red-900">Danger Zone</h3>
      <div className="space-y-4">
        <Form method="post">
          <input type="hidden" name="_intent" value="delete-account" />
          <p className="text-sm">Permanently delete your account and all data. This cannot be undone.</p>
          <label htmlFor="delete-confirmation" className="text-sm font-medium">
            Type "delete my account" to confirm
          </label>
          <input
            id="delete-confirmation"
            name="confirmation"
            required
            pattern="delete my account"
          />
          <Button type="submit" variant="destructive">Delete account</Button>
        </Form>
      </div>
    </Card>
  );
}

规则:

  • 为危险区域使用红色边框或背景
  • 对不可逆操作,要求用户输入确认短语
  • 服务端操作应验证确认值——客户端的 pattern 只是 UX 提示,不是安全边界
  • 清楚说明将删除或丢失的内容
  • 删除账户前提供数据导出选项

10. 审计跟踪与活动动态

动态结构

每条审计记录都应回答:谁在何时对哪个资源做了什么。

interface AuditEntry {
  id: string;
  actor: { id: string; name: string; avatar?: string };
  action: string;         // "created" | "updated" | "deleted" | "exported"
  resource: { type: string; id: string; name: string };
  changes?: FieldChange[];
  timestamp: string;      // ISO 8601
}

interface FieldChange {
  field: string;
  from: string | null;
  to: string | null;
}
展示模式
  • 按日期对记录分组,并显示日期标题
  • 优先显示最近的活动
  • 对按时间排序的动态,使用“加载更多”(而不是页码)进行分页
  • 按以下条件筛选:操作者、操作类型、资源类型、日期范围
  • 对字段变更,显示差异视图:旧值 → 新值

11. 多租户意识

UI 中的租户上下文
  • 始终在侧边栏或页眉中显示当前组织或工作区名称
  • 组织切换器应醒目且始终可访问
  • 切换组织后,跳转到新组织的控制面板(而不是可能不存在的同一页面)
数据隔离
  • 每个 API 请求都必须包含租户上下文(请求头、路径参数或会话)
  • 绝不显示其他租户的数据——即使是在错误消息中
  • 搜索结果必须限定在当前租户范围内
  • URL 路径应包含租户标识符,以便分享链接:/org/{org-id}/assets
共享资源

部分资源跨租户共用(账单管理员、超级管理员视图)。请明确区分:

  • 租户范围内的视图:使用常规样式
  • 跨租户视图:采用明显不同的视觉样式(不同的背景、管理员徽章)

12. 反模式

反模式失败原因更好的做法
首次访问时显示阻塞式模态框用户会立即关闭,错过内容使用内嵌清单或情境提示
将“无数据”作为空状态让人觉得功能出了问题,也没有提供指引首次使用时显示带有 CTA 的空状态
每次加载都显示微调器用户会觉得加载很慢使用与内容形状相匹配的骨架屏
通用升级横幅用户会对横幅视而不见达到限制时显示情境化提示
设置采用扁平列表内容过多,难以查找按组划分,并建立清晰的层级
将功能隐藏在菜单中功能不易被发现通过视觉提示渐进式展示
每项操作都显示确认对话框用户会对对话框感到厌烦,不看内容就点击对可撤销的操作采用撤销模式
仅通过电子邮件发送通知用户可能会错过,且无法在应用内获知使用应用内通知中心,并以电子邮件作为备用
免费方案非全有即全无提高了转化门槛提供慷慨的免费层级,并根据使用量设置限制
降级时立即删除数据用户会担心提交方案变更提供宽限期,并开放只读访问
没有筛选器的活动动态活跃组织中的噪声会淹没重要信息按操作者、操作、资源和日期筛选
UI 中没有租户标识用户可能会误修改错误的组织始终显示当前组织,并提供便捷的切换方式
SPA 时代:客户端向导状态刷新后进度丢失、无法深度链接,也没有后退按钮通过 loader/action 管理服务器端步骤
SPA 时代:使用 onClick 处理变更操作不支持渐进增强,也不会重新验证使用 <Form method="post"> 和意图模式
SPA 时代:客户端功能门控受限数据仍会发送到客户端,存在安全风险在 loader 中检查访问权限,绝不发送受限数据

交叉引用

  • /ux-design — 设计令牌、无障碍性、组件 API 设计、表单 UX
  • /react — React 组件模式、hooks、状态管理
  • /api-design — 功能背后的 REST 契约、分页、错误格式
  • /domain-design — 聚合边界、实体与值对象、领域事件

评论

支持 Markdown