saas-product

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SaaS 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
/ux-design
. For React component patterns, see
/react
. For API contracts behind features, see
/api-design
. For domain modeling, see
/domain-design
.

用于构建能提升用户留存与转化的生产级SaaS功能的方法论。专注于缩短用户价值获取时间、处理多租户订阅型应用复杂度的设计模式。
关于设计系统令牌与无障碍设计,请查看
/ux-design
。关于React组件模式,请查看
/react
。关于功能背后的API契约,请查看
/api-design
。关于领域建模,请查看
/domain-design

1. 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.
PrincipleApplication
Time-to-valueMinimize steps between signup and first meaningful action
Progressive disclosureHide advanced options behind "Advanced" toggles or secondary menus
Jobs-to-be-doneDesign around what the user is trying to accomplish, not around data entities
Sensible defaultsPre-fill settings with the most common choices; make the default path correct
Undo over confirmPrefer reversible actions with undo over confirmation dialogs that interrupt flow
从简入手,随用户需求逐步展示复杂功能。新用户应在60秒内获得首次价值体验。高级功能逐步解锁。
原则应用场景
价值获取时间最小化注册到首次有意义操作的步骤
渐进式披露将高级选项隐藏在「高级」开关或二级菜单后
用户任务导向围绕用户想要完成的任务设计,而非数据实体
合理默认值用最常见的选择预填充设置;让默认路径即为正确路径
撤销优先于确认优先选择可撤销操作,而非打断流程的确认对话框

Product hierarchy

产品层级

Feature → Page → Section → Component
Each 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:
MetricExample
Setup completeUser has connected at least one data source
First valueUser has viewed their first dashboard with real data
Habit formedUser 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:
TypeWhenContent
First-useUser hasn't created any data yetIllustration + explanation + primary CTA
No resultsSearch or filter returned nothing"No results for X" + suggestion to broaden search
ErrorData failed to loadError message + retry button
Filtered emptyApplied filters exclude all resultsShow 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 relationshipChart typeWhen to use
Part-to-wholeDonut (max 5 segments)Budget allocation, portfolio mix
Change over timeLine / areaRevenue trends, growth metrics
ComparisonHorizontal barCategory comparison, rankings
DistributionHistogramValue ranges, frequency
Composition over timeStacked areaRevenue 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:
    grid-cols-1 md:grid-cols-2 lg:grid-cols-4
    for KPI row
  • 顶部行: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

加载状态决策框架

DurationPattern
< 100msNo indicator needed
100-300msSubtle inline indicator (button spinner)
300ms-2sSkeleton screen
2-10sProgress bar or skeleton with message
> 10sBackground 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
fetcher.formData
— 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:
tsx
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
    is non-null while the submission is in flight — derive the optimistic item directly from it
  • When the action completes, loaders revalidate,
    items
    updates with the real data, and
    fetcher.formData
    resets to null
  • On failure, loaders still revalidate with unchanged data — the optimistic item disappears because
    fetcher.formData
    is null
  • No
    useOptimistic
    , no
    onSubmit
    , no
    startTransition
    — React Router's data layer handles the lifecycle
对于失败概率低的操作,立即展示预期结果。在React Router v7中,从
fetcher.formData
派生乐观状态——即待提交的数据。将待处理项与数据列表分开渲染——乐观项是临时UI状态,而非真实数据。服务端会通过Loader重新验证分配真实ID:
tsx
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
    更新为真实数据,
    fetcher.formData
    重置为null
  • 失败时,Loader仍会用未更改的数据重新验证——乐观项会消失,因为
    fetcher.formData
    为null
  • 无需
    useOptimistic
    onSubmit
    startTransition
    ——React Router的数据层会处理生命周期

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

通知渠道

ChannelUse forUrgency
In-app toastAction confirmation, minor errorsLow — auto-dismiss 5s
In-app bellNew activity, status changesMedium — persists until read
EmailTransactional (receipts, invites), digestsLow — batched where possible
Browser pushTime-sensitive alerts onlyHigh — 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:
EventIn-AppEmailPush
New team memberDefault onDefault onDefault off
Weekly digestN/ADefault onN/A
Payment failedDefault onDefault onDefault on
Feature updateDefault onDefault offDefault 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

实现模式

PatternUse when
Feature flagRolling out new features gradually (% of users)
Plan gatingFeature is available only on certain subscription tiers
Role gatingFeature is restricted to certain user roles (admin, member)
Usage limitFeature 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

设置组织

SectionContents
AccountProfile, email, password, 2FA
TeamMembers, invitations, roles
BillingPlan, payment method, invoices
PreferencesTheme, language, notification settings
IntegrationsConnected services, API keys
Danger zoneDelete 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
    pattern
    is a UX hint, not a security boundary
  • 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验证确认值——客户端
    pattern
    仅为UX提示,而非安全边界
  • 清晰展示将被删除/失去的内容
  • 账户删除前提供数据导出选项

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-patternWhy it failsBetter approach
Blocking modal on first visitUsers close it immediately, miss the contentInline checklist or contextual hints
"No data" as empty stateFeels broken, gives no guidanceFirst-use empty state with CTA
Spinner for every loadUsers perceive it as slowSkeleton screens matching content shape
Generic upgrade bannerBanner blindness, users ignore itContextual prompts when hitting limits
Settings as a flat listOverwhelming, hard to find thingsGrouped sections with clear hierarchy
Hiding features behind menusLow discoverabilityProgressive disclosure with visual cues
Confirmation dialog for every actionDialog fatigue, users click without readingUndo pattern for reversible actions
Email-only notificationsUsers miss them, no in-app awarenessIn-app notification center + email fallback
All-or-nothing free planHigh barrier to conversionGenerous free tier with usage-based limits
Instant data deletion on downgradeUsers fear committing to plansGrace period + read-only access
Activity feed without filtersNoise drowns signal for active orgsFilter by actor, action, resource, date
No tenant indicator in UIUsers accidentally modify wrong orgAlways show current org + easy switching
SPA-era: client-side wizard stateProgress lost on refresh, no deep links, no back buttonServer-managed steps via loader/action
SPA-era:
onClick
handlers for mutations
No progressive enhancement, no revalidation
<Form method="post">
with intent pattern
SPA-era: client-side feature gatingGated data still sent to client, security riskCheck access in loader, never send gated data

反模式失败原因更好的方案
首次访问时弹出阻塞式模态框用户会立即关闭,错过内容内联清单或上下文提示
空状态仅显示「无数据」看起来像系统故障,无引导带行动号召的首次使用空状态
每次加载都显示动画用户会觉得加载慢匹配内容形状的骨架屏
通用升级横幅横幅盲区,用户会忽略在达到限制时显示上下文提示
设置为扁平列表信息过载,难以查找内容分组板块,清晰层级
将功能隐藏在菜单后发现率低带视觉提示的渐进式披露
每个操作都显示确认对话框对话框疲劳,用户会不假思索点击可逆操作使用撤销模式
仅使用邮件通知用户会错过,无应用内感知应用内通知中心+邮件 fallback
全有或全无的免费方案转化门槛高慷慨的免费层级,带基于使用量的限制
降级时立即删除数据用户不敢订阅方案宽限期+只读访问
活动流无筛选功能对于活跃组织,噪声会掩盖重要信息按执行者、操作、资源、日期筛选
UI中无租户指示器用户可能意外修改错误的组织始终显示当前组织+便捷切换
SPA时代:客户端向导状态刷新会丢失进度,无深度链接,无返回按钮通过Loader/Action在服务端管理步骤
SPA时代:用onClick处理函数执行操作无渐进增强,无重新验证使用带意图模式的
<Form method="post">
SPA时代:客户端功能权限管控受管控的数据仍会发送到客户端,存在安全风险在Loader中检查权限,绝不要发送受管控的数据

Cross-references

交叉引用

  • /ux-design
    — design tokens, accessibility, component API design, form UX
  • /react
    — React component patterns, hooks, state management
  • /api-design
    — REST contracts, pagination, error formats behind features
  • /domain-design
    — aggregate boundaries, entity vs value object, domain events
  • /ux-design
    —— 设计令牌、无障碍设计、组件API设计、表单UX
  • /react
    —— React组件模式、Hooks、状态管理
  • /api-design
    —— REST契约、分页、功能背后的错误格式
  • /domain-design
    —— 聚合边界、实体与值对象、领域事件