saas-product
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSaaS Product Design
SaaS产品设计
Methodology for building production SaaS features that drive user adoption and retention. Focused on patterns that reduce time-to-value and handle the complexity of multi-tenant, subscription-based applications.
For design system tokens and accessibility, see . For React component patterns, see . For API contracts behind features, see . For domain modeling, see .
/ux-design/react/api-design/domain-design用于构建能提升用户留存与转化的生产级SaaS功能的方法论。专注于缩短用户价值获取时间、处理多租户订阅型应用复杂度的设计模式。
关于设计系统令牌与无障碍设计,请查看。关于React组件模式,请查看。关于功能背后的API契约,请查看。关于领域建模,请查看。
/ux-design/react/api-design/domain-design1. Design Philosophy
1. 设计理念
Progressive complexity
渐进式复杂度
Start simple, reveal complexity as the user needs it. A new user should reach their first moment of value within 60 seconds. Advanced features unlock progressively.
| Principle | Application |
|---|---|
| Time-to-value | Minimize steps between signup and first meaningful action |
| Progressive disclosure | Hide advanced options behind "Advanced" toggles or secondary menus |
| Jobs-to-be-done | Design around what the user is trying to accomplish, not around data entities |
| Sensible defaults | Pre-fill settings with the most common choices; make the default path correct |
| Undo over confirm | Prefer reversible actions with undo over confirmation dialogs that interrupt flow |
从简入手,随用户需求逐步展示复杂功能。新用户应在60秒内获得首次价值体验。高级功能逐步解锁。
| 原则 | 应用场景 |
|---|---|
| 价值获取时间 | 最小化注册到首次有意义操作的步骤 |
| 渐进式披露 | 将高级选项隐藏在「高级」开关或二级菜单后 |
| 用户任务导向 | 围绕用户想要完成的任务设计,而非数据实体 |
| 合理默认值 | 用最常见的选择预填充设置;让默认路径即为正确路径 |
| 撤销优先于确认 | 优先选择可撤销操作,而非打断流程的确认对话框 |
Product hierarchy
产品层级
Feature → Page → Section → ComponentEach level has a clear responsibility:
- Feature: A complete capability (e.g., "Asset Tracking")
- Page: A view within a feature (e.g., "Asset List", "Asset Detail")
- Section: A logical grouping within a page (e.g., "Performance Chart", "Transaction History")
- Component: A reusable UI element (e.g., "Currency Badge", "Date Picker")
Feature → Page → Section → Component每个层级都有明确职责:
- Feature(功能):完整的能力模块(如「资产追踪」)
- Page(页面):功能内的视图(如「资产列表」「资产详情」)
- Section(区块):页面内的逻辑分组(如「性能图表」「交易历史」)
- Component(组件):可复用UI元素(如「货币徽章」「日期选择器」)
2. Onboarding & First-Run
2. 新手引导与首次使用
Activation metrics
激活指标
Define what "activated" means before building onboarding:
| Metric | Example |
|---|---|
| Setup complete | User has connected at least one data source |
| First value | User has viewed their first dashboard with real data |
| Habit formed | User returns 3 times in the first 7 days |
在构建引导前先定义「激活」的含义:
| 指标 | 示例 |
|---|---|
| 设置完成 | 用户已连接至少一个数据源 |
| 首次价值体验 | 用户已查看首个含真实数据的仪表盘 |
| 习惯养成 | 用户在首次7天内返回3次 |
Progressive onboarding patterns
渐进式引导模式
Setup wizard — for products requiring initial configuration:
tsx
// 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>
);
}Rules:
- Always allow skipping — never force completion of all steps
- Progress is saved automatically — each step is an action submission persisted server-side
- Max 3-5 steps — more than that and users abandon
- Show progress clearly (step 2 of 4)
Checklist pattern — for products where setup is gradual:
tsx
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>
);
}Contextual tooltips — for feature discovery after initial setup:
- Show once per user, track dismissal in user preferences
- Point to the specific UI element, not a general area
- Include a single clear CTA ("Try it now" / "Got it")
设置向导 —— 适用于需要初始配置的产品:
tsx
// Loader从会话/数据库返回当前步骤
export async function loader({ request }: LoaderFunctionArgs) {
const progress = await getOnboardingProgress(request);
return { step: progress.currentStep, steps: progress.steps };
}
// Action验证当前步骤,保存并推进到下一步
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}`);
}
// 组件将当前步骤渲染为表单
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步)
清单模式 —— 适用于设置过程渐进的产品:
tsx
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元素,而非笼统区域
- 包含单一清晰的行动号召(「立即试用」/「知道了」)
Anti-patterns
反模式
- Forced video tours (users skip them)
- Tooltips on every element simultaneously (overwhelming)
- Blocking the app until onboarding is complete (drives abandonment)
- Showing onboarding to returning users who already completed it
- 强制视频教程(用户会跳过)
- 同时在所有元素上显示提示框(信息过载)
- 完成引导前禁止使用应用(导致用户流失)
- 向已完成引导的回归用户再次展示引导
3. Empty States
3. 空状态
Every data-driven view must handle four empty conditions:
| Type | When | Content |
|---|---|---|
| First-use | User hasn't created any data yet | Illustration + explanation + primary CTA |
| No results | Search or filter returned nothing | "No results for X" + suggestion to broaden search |
| Error | Data failed to load | Error message + retry button |
| Filtered empty | Applied filters exclude all results | Show active filters + "Clear filters" button |
每个数据驱动的视图都必须处理四种空状态:
| 类型 | 触发场景 | 内容 |
|---|---|---|
| 首次使用 | 用户尚未创建任何数据 | 插图+说明+主要行动号召 |
| 无结果 | 搜索或筛选未返回任何内容 | 「未找到X相关结果」+ 放宽搜索范围的建议 |
| 错误 | 数据加载失败 | 错误信息+重试按钮 |
| 筛选后空状态 | 应用的筛选条件排除了所有结果 | 显示当前筛选条件+「清除筛选」按钮 |
First-use empty state pattern
首次使用空状态模式
tsx
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" }}
/>Rules:
- First-use empty states must have a CTA that leads to creating the first item
- Never show a blank page or a lonely "No data" message
- Use illustrations or icons to make the empty state feel intentional, not broken
- Reduce the CTA to a single clear action — don't offer multiple paths
tsx
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>
);
}
// 使用示例 —— 行动号召跳转到路由,而非onClick处理函数
<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" }}
/>规则:
- 首次使用空状态必须包含引导用户创建首个内容的行动号召
- 绝不要展示空白页面或孤零零的「无数据」提示
- 使用插图或图标让空状态看起来是有意设计的,而非系统故障
- 将行动号召简化为单一清晰的操作——不要提供多条路径
4. Dashboard Design
4. 仪表盘设计
KPI card anatomy
KPI卡片结构
A well-designed KPI card shows: current value, trend indicator, comparison period, and optional sparkline.
tsx
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>
);
}设计良好的KPI卡片应包含:当前值、趋势指示器、对比周期、可选迷你折线图。
tsx
interface KPICardProps {
label: string;
value: string;
change: number; // 百分比变化
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>
);
}Chart selection guide
图表选择指南
| Data relationship | Chart type | When to use |
|---|---|---|
| Part-to-whole | Donut (max 5 segments) | Budget allocation, portfolio mix |
| Change over time | Line / area | Revenue trends, growth metrics |
| Comparison | Horizontal bar | Category comparison, rankings |
| Distribution | Histogram | Value ranges, frequency |
| Composition over time | Stacked area | Revenue by segment over time |
Rules:
- Never use pie charts for more than 5 segments — switch to horizontal bar
- Never use 3D charts
- Line charts require a continuous x-axis (time, sequence)
- Always label axes and include units
| 数据关系 | 图表类型 | 使用场景 |
|---|---|---|
| 部分与整体 | 环形图(最多5个分段) | 预算分配、投资组合构成 |
| 随时间变化 | 折线图/面积图 | 收入趋势、增长指标 |
| 对比 | 横向条形图 | 类别对比、排名 |
| 分布 | 直方图 | 数值范围、频率 |
| 随时间的构成变化 | 堆叠面积图 | 各细分领域收入随时间变化 |
规则:
- 分段超过5个时绝不要使用饼图——切换为横向条形图
- 绝不要使用3D图表
- 折线图需要连续的X轴(时间、序列)
- 始终标注坐标轴并包含单位
Dashboard layout
仪表盘布局
- Top row: 3-4 KPI cards summarizing the most important metrics
- Middle: Primary chart (full width or 2/3 width)
- Bottom: Secondary data tables or detail views
- Use CSS Grid for the layout: for KPI row
grid-cols-1 md:grid-cols-2 lg:grid-cols-4
- 顶部行:3-4个KPI卡片,汇总最重要的指标
- 中部:主图表(全屏宽或2/3屏宽)
- 底部:二级数据表格或详情视图
- 使用CSS Grid布局:KPI行使用
grid-cols-1 md:grid-cols-2 lg:grid-cols-4
Data density
数据密度
- Dense displays for power users (tables with many columns, compact spacing)
- Summary views for casual users (KPI cards, sparklines, simplified charts)
- Let users toggle between views or remember their preference
- 为高级用户提供高密度显示(多列表格、紧凑间距)
- 为普通用户提供汇总视图(KPI卡片、迷你折线图、简化图表)
- 允许用户在视图间切换,或记住他们的偏好设置
5. Loading & Transition States
5. 加载与过渡状态
Skeleton screens
骨架屏
Match the skeleton shape to the actual content layout. Users perceive skeleton screens as faster than spinners:
tsx
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>
);
}骨架屏的形状要与实际内容布局匹配。用户会觉得骨架屏比加载动画更快:
tsx
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>
);
}Loading state decision framework
加载状态决策框架
| Duration | Pattern |
|---|---|
| < 100ms | No indicator needed |
| 100-300ms | Subtle inline indicator (button spinner) |
| 300ms-2s | Skeleton screen |
| 2-10s | Progress bar or skeleton with message |
| > 10s | Background task with notification on completion |
| 时长 | 模式 |
|---|---|
| < 100ms | 无需显示指示器 |
| 100-300ms | 微妙的内联指示器(按钮加载动画) |
| 300ms-2s | 骨架屏 |
| 2-10s | 进度条或带提示信息的骨架屏 |
| > 10s | 后台任务,完成时发送通知 |
Optimistic UI
乐观UI
For mutations where failure is rare, show the expected result immediately. In React Router v7, derive optimistic state from — the pending submission data. Render pending items separately from the data list — the optimistic item is transient UI state, not data. The server assigns the real ID via loader revalidation:
fetcher.formDatatsx
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>
</>
);
}- is non-null while the submission is in flight — derive the optimistic item directly from it
fetcher.formData - When the action completes, loaders revalidate, updates with the real data, and
itemsresets to nullfetcher.formData - On failure, loaders still revalidate with unchanged data — the optimistic item disappears because is null
fetcher.formData - No , no
useOptimistic, noonSubmit— React Router's data layer handles the lifecyclestartTransition
对于失败概率低的操作,立即展示预期结果。在React Router v7中,从派生乐观状态——即待提交的数据。将待处理项与数据列表分开渲染——乐观项是临时UI状态,而非真实数据。服务端会通过Loader重新验证分配真实ID:
fetcher.formDatatsx
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 - 操作完成后,Loader重新验证,更新为真实数据,
items重置为nullfetcher.formData - 失败时,Loader仍会用未更改的数据重新验证——乐观项会消失,因为为null
fetcher.formData - 无需、
useOptimistic或onSubmit——React Router的数据层会处理生命周期startTransition
Streaming SSR
流式SSR
For pages with mixed fast/slow data sources:
- Fast data (navigation, layout) renders immediately in the shell
- Slow data (analytics, external APIs) streams in via Suspense boundaries
- Each Suspense boundary shows its own skeleton while loading
对于包含快慢混合数据源的页面:
- 快数据(导航、布局)立即渲染到外壳中
- 慢数据(分析、外部API)通过Suspense边界流式加载
- 每个Suspense边界在加载时显示自己的骨架屏
Server-first loading principle
服务端优先加载原则
With route loaders, data is available when the page renders — no loading spinners for initial data. Reserve skeleton screens for:
- Streaming SSR (slow data sources behind Suspense boundaries)
- Fetcher-driven updates (non-navigation mutations in progress)
- Client-only components that need a mount guard
通过路由Loader,页面渲染时数据已就绪——初始数据加载无需显示加载动画。骨架屏仅用于:
- 流式SSR(Suspense边界后的慢数据源)
- Fetcher驱动的更新(进行中的非导航式操作)
- 需要挂载守卫的纯客户端组件
6. Notification Design
6. 通知设计
Notification channels
通知渠道
| Channel | Use for | Urgency |
|---|---|---|
| In-app toast | Action confirmation, minor errors | Low — auto-dismiss 5s |
| In-app bell | New activity, status changes | Medium — persists until read |
| Transactional (receipts, invites), digests | Low — batched where possible | |
| Browser push | Time-sensitive alerts only | High — interrupts |
| 渠道 | 使用场景 | 紧急程度 |
|---|---|---|
| 应用内提示框 | 操作确认、轻微错误 | 低——5秒后自动消失 |
| 应用内铃铛 | 新活动、状态变更 | 中——保留至已读 |
| 邮件 | 事务性通知(收据、邀请)、汇总报告 | 低——尽可能批量发送 |
| 浏览器推送 | 仅用于时间敏感的警报 | 高——会打断用户 |
Toast best practices
提示框最佳实践
tsx
// 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 },
});Rules:
- Auto-dismiss success toasts after 5 seconds
- Never auto-dismiss error toasts — the user may not notice them
- Provide an undo action for destructive operations
- Stack multiple toasts vertically, limit to 3 visible simultaneously
- Never use toasts as the sole error indicator for form validation
tsx
// 带撤销功能的成功提示框
showToast("Asset deleted", {
action: { label: "Undo", onClick: undoDelete },
duration: 5000,
});
// 错误提示框——保留至手动关闭
showToast("Failed to save changes. Please try again.", {
variant: "error",
duration: Infinity,
action: { label: "Retry", onClick: retryAction },
});规则:
- 成功提示框5秒后自动消失
- 错误提示框绝不要自动消失——用户可能没注意到
- 为破坏性操作提供撤销功能
- 多个提示框垂直堆叠,最多同时显示3个
- 绝不要将提示框作为表单验证的唯一错误指示器
Notification preferences
通知偏好设置
Implement as a channel-by-event matrix:
| Event | In-App | Push | |
|---|---|---|---|
| New team member | Default on | Default on | Default off |
| Weekly digest | N/A | Default on | N/A |
| Payment failed | Default on | Default on | Default on |
| Feature update | Default on | Default off | Default off |
Let users control each cell independently.
实现为按事件分渠道的矩阵:
| 事件 | 应用内 | 邮件 | 推送 |
|---|---|---|---|
| 新团队成员 | 默认开启 | 默认开启 | 默认关闭 |
| 每周汇总 | 不适用 | 默认开启 | 不适用 |
| 支付失败 | 默认开启 | 默认开启 | 默认开启 |
| 功能更新 | 默认开启 | 默认关闭 | 默认关闭 |
允许用户独立控制每个选项。
7. Billing & Subscription UX
7. 账单与订阅UX
Plan comparison
方案对比
tsx
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>
);
}Rules:
- Highlight the recommended plan visually (border, badge, "Most Popular")
- Show the current plan clearly so the user knows where they are
- List features as checkmarks per plan — show what's included AND what's not
- Annual pricing should show the monthly equivalent and savings percentage
tsx
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>
);
}规则:
- 视觉突出推荐方案(边框、徽章、「最受欢迎」标签)
- 清晰显示当前方案,让用户清楚自己所处的层级
- 每个方案的功能用勾选框列出——同时展示包含和不包含的功能
- 年度定价应显示每月等效价格和节省百分比
Upgrade prompts
升级提示
Contextual prompts are 3x more effective than generic upsell banners:
tsx
// 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>上下文提示比通用升级横幅的效果高3倍:
tsx
// 好示例 —— 上下文提示,在用户达到限制时显示
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>
);
}
// 坏示例 —— 通用横幅,在所有页面显示
<Banner>Upgrade to Pro for more features!</Banner>Trial and downgrade
试用与降级
- Show trial days remaining in a subtle, persistent indicator (not a popup)
- Before downgrade: show what the user will lose, not just the features list
- After downgrade: gracefully degrade features (read-only, not deleted)
- Never delete user data on downgrade — mark it as inaccessible and allow re-upgrade
- 在微妙的持久化指示器中显示剩余试用天数(不要用弹窗)
- 降级前:向用户展示他们将失去的内容,而非仅列出功能
- 降级后:优雅降级功能(只读,而非删除)
- 降级时绝不要删除用户数据——标记为不可访问,并允许重新升级
8. Feature Gating
8. 功能权限管控
Implementation patterns
实现模式
| Pattern | Use when |
|---|---|
| Feature flag | Rolling out new features gradually (% of users) |
| Plan gating | Feature is available only on certain subscription tiers |
| Role gating | Feature is restricted to certain user roles (admin, member) |
| Usage limit | Feature has a quota per billing period |
| 模式 | 使用场景 |
|---|---|
| 功能开关 | 逐步推出新功能(按用户比例) |
| 方案管控 | 功能仅对特定订阅层级开放 |
| 角色管控 | 功能仅对特定用户角色开放(管理员、成员) |
| 使用限制 | 功能在每个账单周期有配额 |
Graceful degradation
优雅降级
When a feature is gated, show the user what they're missing and how to get it:
tsx
// 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} />;
}Rules:
- Never show a blank space where a gated feature should be — show a teaser
- Don't hide gated features entirely — discovery drives upgrades
- Use blurred previews or locked icons, not error messages
- Role-gated features should show a "Contact your admin" message, not an upgrade prompt
当功能被管控时,向用户展示他们缺失的内容以及获取方式:
tsx
// 在Loader中检查权限——绝不要向客户端发送受管控的数据
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 };
}
// 组件根据Loader数据渲染
function AnalyticsPage() {
const loaderData = useLoaderData<typeof loader>();
if (loaderData.gated) {
return <UpgradeOverlay requiredPlan={loaderData.requiredPlan} />;
}
return <AnalyticsDashboard data={loaderData.data} />;
}规则:
- 绝不要在受管控功能应显示的位置留空白——展示预告
- 不要完全隐藏受管控功能——发现驱动升级
- 使用模糊预览或锁定图标,而非错误信息
- 角色管控的功能应显示「联系管理员」提示,而非升级号召
9. Settings & Admin UX
9. 设置与管理员UX
Settings organization
设置组织
| Section | Contents |
|---|---|
| Account | Profile, email, password, 2FA |
| Team | Members, invitations, roles |
| Billing | Plan, payment method, invoices |
| Preferences | Theme, language, notification settings |
| Integrations | Connected services, API keys |
| Danger zone | Delete account, export data |
| 板块 | 内容 |
|---|---|
| 账户 | 个人资料、邮箱、密码、双因素认证 |
| 团队 | 成员、邀请、角色 |
| 账单 | 方案、支付方式、发票 |
| 偏好设置 | 主题、语言、通知设置 |
| 集成 | 已连接服务、API密钥 |
| 危险区域 | 删除账户导出数据 |
Danger zone
危险区域
Destructive settings must be visually distinct and require confirmation:
tsx
// 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>
);
}Rules:
- Red border/background for the danger zone section
- Require typing a confirmation phrase for irreversible actions
- Server action validates the confirmation value — client-side is a UX hint, not a security boundary
pattern - Show a clear description of what will be deleted/lost
- Offer data export before account deletion
破坏性设置必须视觉上区分开,并需要确认:
tsx
// Action —— 服务端验证(客户端模式可被绕过)
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");
}
}
// 组件
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>
);
}规则:
- 危险区域板块使用红色边框/背景
- 不可逆操作需要输入确认短语
- 服务端Action验证确认值——客户端仅为UX提示,而非安全边界
pattern - 清晰展示将被删除/失去的内容
- 账户删除前提供数据导出选项
10. Audit Trails & Activity Feeds
10. 审计追踪与活动流
Feed structure
流结构
Every audit entry answers: who did what to which resource and when.
typescript
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;
}每条审计记录都应回答:谁对哪个资源执行了什么操作,以及何时执行的。
typescript
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;
}Display patterns
展示模式
- Group entries by day with date headers
- Show the most recent activity first
- Paginate with "Load more" (not page numbers) for chronological feeds
- Filter by: actor, action type, resource type, date range
- For field changes, show a diff view:
old value→ new value
- 按日期分组,带日期标题
- 最新活动优先显示
- 时间流使用「加载更多」分页(不要用页码)
- 可按:执行者、操作类型、资源类型、日期范围筛选
- 对于字段变更,显示差异视图:
旧值→ 新值
11. Multi-Tenancy Awareness
11. 多租户感知
Tenant context in UI
UI中的租户上下文
- Always show the current organization/workspace name in the sidebar or header
- Org switcher should be prominent and always accessible
- After switching orgs, redirect to the new org's dashboard (not the same page, which may not exist)
- 始终在侧边栏或页眉显示当前组织/工作区名称
- 组织切换器应显眼且随时可访问
- 切换组织后,重定向到新组织的仪表盘(而非同一页面,该页面可能不存在)
Data isolation
数据隔离
- Every API request must include tenant context (header, path param, or session)
- Never show data from other tenants — even in error messages
- Search results must be scoped to the current tenant
- URL paths should include the tenant identifier for shareable links:
/org/{org-id}/assets
- 每个API请求都必须包含租户上下文(请求头、路径参数或会话)
- 绝不要显示其他租户的数据——即使在错误信息中
- 搜索结果必须限定在当前租户范围内
- 可分享链接的URL路径应包含租户标识符:
/org/{org-id}/assets
Shared resources
共享资源
Some resources span tenants (billing admin, super admin views). Clearly distinguish:
- Tenant-scoped views: normal styling
- Cross-tenant views: distinct visual treatment (different background, admin badge)
部分资源跨租户(账单管理员、超级管理员视图)。需清晰区分:
- 租户限定视图:常规样式
- 跨租户视图:独特的视觉处理(不同背景、管理员徽章)
12. Anti-Patterns
12. 反模式
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Blocking modal on first visit | Users close it immediately, miss the content | Inline checklist or contextual hints |
| "No data" as empty state | Feels broken, gives no guidance | First-use empty state with CTA |
| Spinner for every load | Users perceive it as slow | Skeleton screens matching content shape |
| Generic upgrade banner | Banner blindness, users ignore it | Contextual prompts when hitting limits |
| Settings as a flat list | Overwhelming, hard to find things | Grouped sections with clear hierarchy |
| Hiding features behind menus | Low discoverability | Progressive disclosure with visual cues |
| Confirmation dialog for every action | Dialog fatigue, users click without reading | Undo pattern for reversible actions |
| Email-only notifications | Users miss them, no in-app awareness | In-app notification center + email fallback |
| All-or-nothing free plan | High barrier to conversion | Generous free tier with usage-based limits |
| Instant data deletion on downgrade | Users fear committing to plans | Grace period + read-only access |
| Activity feed without filters | Noise drowns signal for active orgs | Filter by actor, action, resource, date |
| No tenant indicator in UI | Users accidentally modify wrong org | Always show current org + easy switching |
| SPA-era: client-side wizard state | Progress lost on refresh, no deep links, no back button | Server-managed steps via loader/action |
SPA-era: | No progressive enhancement, no revalidation | |
| SPA-era: client-side feature gating | Gated data still sent to client, security risk | Check access in loader, never send gated data |
| 反模式 | 失败原因 | 更好的方案 |
|---|---|---|
| 首次访问时弹出阻塞式模态框 | 用户会立即关闭,错过内容 | 内联清单或上下文提示 |
| 空状态仅显示「无数据」 | 看起来像系统故障,无引导 | 带行动号召的首次使用空状态 |
| 每次加载都显示动画 | 用户会觉得加载慢 | 匹配内容形状的骨架屏 |
| 通用升级横幅 | 横幅盲区,用户会忽略 | 在达到限制时显示上下文提示 |
| 设置为扁平列表 | 信息过载,难以查找内容 | 分组板块,清晰层级 |
| 将功能隐藏在菜单后 | 发现率低 | 带视觉提示的渐进式披露 |
| 每个操作都显示确认对话框 | 对话框疲劳,用户会不假思索点击 | 可逆操作使用撤销模式 |
| 仅使用邮件通知 | 用户会错过,无应用内感知 | 应用内通知中心+邮件 fallback |
| 全有或全无的免费方案 | 转化门槛高 | 慷慨的免费层级,带基于使用量的限制 |
| 降级时立即删除数据 | 用户不敢订阅方案 | 宽限期+只读访问 |
| 活动流无筛选功能 | 对于活跃组织,噪声会掩盖重要信息 | 按执行者、操作、资源、日期筛选 |
| UI中无租户指示器 | 用户可能意外修改错误的组织 | 始终显示当前组织+便捷切换 |
| SPA时代:客户端向导状态 | 刷新会丢失进度,无深度链接,无返回按钮 | 通过Loader/Action在服务端管理步骤 |
| SPA时代:用onClick处理函数执行操作 | 无渐进增强,无重新验证 | 使用带意图模式的 |
| SPA时代:客户端功能权限管控 | 受管控的数据仍会发送到客户端,存在安全风险 | 在Loader中检查权限,绝不要发送受管控的数据 |
Cross-references
交叉引用
- — design tokens, accessibility, component API design, form UX
/ux-design - — React component patterns, hooks, state management
/react - — REST contracts, pagination, error formats behind features
/api-design - — aggregate boundaries, entity vs value object, domain events
/domain-design
- —— 设计令牌、无障碍设计、组件API设计、表单UX
/ux-design - —— React组件模式、Hooks、状态管理
/react - —— REST契约、分页、功能背后的错误格式
/api-design - —— 聚合边界、实体与值对象、领域事件
/domain-design