关于 saas-product
面向 SaaS 产品设计者的产品设计方法指南,涵盖用户引导、空状态、仪表盘、通知、订阅和功能权限等。强调循序渐进地呈现复杂度,并围绕用户目标设计体验。
主要功能
- 01渐进式复杂度与用户引导
- 02空状态和仪表盘设计模式
- 03加载、过渡与乐观界面
- 04通知渠道与偏好设置
- 05订阅体验和功能权限设计
适用场景
- 01规划新用户首次使用流程
- 02设计数据为空或加载中的页面
- 03构建 SaaS 指标仪表盘
- 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— 聚合边界、实体与值对象、领域事件