弹窗、表单、加载、空状态、报错反馈、页面跳转
- Standard:弹窗分为三类,按使用场景选择:
- Dialog(对话框):需要用户确认的操作,如删除确认、二次验证
- Drawer(抽屉):表单填写、详情查看等内容较多的场景
- Modal(模态弹窗):展示信息、引导等轻量交互
- Requirement:所有弹窗必须支持点击遮罩关闭和 ESC 键关闭(含未保存变更的弹窗除外,需二次确认)。
- Requirement:弹窗标题必须明确,操作按钮必须有明确语义(“确认删除”而非”确定”)。
- Standard:确认类危险操作(删除、重置)使用红色主按钮,普通操作使用蓝色主按钮。
- Standard:弹窗内容高度超过视口 70% 时,内容区域必须可滚动,按钮固定在底部。
- Standard:弹窗内部提交按钮在请求中显示 loading 状态,且禁用关闭,避免重复提交。
- Caution:弹窗中含未保存的表单数据时,关闭前必须弹出二次确认。
import { Modal, Button, Form, Input } from 'antd';
interface DeleteConfirmProps {
open: boolean;
title: string;
onConfirm: () => Promise<void>;
onCancel: () => void;
}
export function DeleteConfirm({ open, title, onConfirm, onCancel }: DeleteConfirmProps) {
const [loading, setLoading] = useState(false);
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm();
onCancel();
} finally {
setLoading(false);
}
};
return (
<Modal
title="确认删除"
open={open}
onCancel={onCancel}
maskClosable={!loading}
keyboard={!loading}
footer={[
<Button key="cancel" onClick={onCancel} disabled={loading}>
取消
</Button>,
<Button key="confirm" danger loading={loading} onClick={handleConfirm}>
确认删除
</Button>,
]}
>
<p>确定要删除「{title}」吗?此操作不可恢复。</p>
</Modal>
);
}
| 反模式 | 问题 | 建议 |
|---|
| 弹窗标题只写”提示” | 用户无法判断操作内容 | 标题说明具体操作 |
| 按钮文案”确定/取消” | 语义模糊 | 使用”保存/取消”或”确认删除/取消” |
| 弹窗中嵌套弹窗 | 层级混乱、体验差 | 改用步骤切换或 Drawer |
| 危险操作无二次确认 | 误操作风险 | 必须二次确认 |
- Requirement:所有输入项必须有明确的校验规则,前端校验与后端校验保持一致。
- Standard:必填项在 label 前加红色
* 标记。
- Standard:校验失败时,输入框标红并在下方展示错误信息,错误信息必须具体可操作(“请输入 6-20 位密码”而非”格式错误”)。
- Default:失焦(onBlur)时触发校验,提交时全量校验。
- Standard:表单包含三种状态:编辑中、提交中(loading)、提交成功/失败。
- Requirement:提交中必须禁用提交按钮,防止重复提交。
- Standard:提交成功后展示成功提示并关闭弹窗或跳转,提交失败展示错误信息且保留用户输入。
- Standard:数字输入框限制最大值、最小值和小数位数。
- Standard:文本输入框设置
maxLength,并在右侧显示已输入/最大字数(10/100)。
- Caution:手机号、身份证等敏感输入在前端做格式校验,同时后端必须二次校验。
import { Form, Input, InputNumber, Button, message } from 'antd';
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (values: any) => {
setSubmitting(true);
try {
await api.createUser(values);
message.success('创建成功');
onSuccess?.();
} catch (e) {
message.error('创建失败,请重试');
} finally {
setSubmitting(false);
}
};
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
label="用户名"
name="name"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 2, max: 20, message: '用户名长度为 2-20 个字符' },
]}
>
<Input maxLength={20} showCount placeholder="请输入用户名" />
</Form.Item>
<Form.Item
label="年龄"
name="age"
rules={[{ required: true, message: '请输入年龄' }]}
>
<InputNumber min={0} max={150} style={{ width: '100%' }} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={submitting}>
保存
</Button>
</Form.Item>
</Form>
| 反模式 | 问题 | 建议 |
|---|
| 无前端校验直接提交 | 用户体验差、增加服务端压力 | 前端做完整校验 |
| 错误信息只写”格式错误” | 用户不知如何修正 | 给出具体可操作的提示 |
| 提交中不禁用按钮 | 重复提交 | loading 时禁用按钮 |
| 提交失败清空表单 | 用户需重新输入 | 保留输入内容 |
- Standard:加载分为三类,按场景选择:
- 页面级加载:首屏数据加载,使用全屏 Spin 或骨架屏
- 区域级加载:列表、卡片等局部数据加载,在区域内显示 Spin
- 按钮级加载:提交操作时按钮显示 loading
- Standard:加载时间超过 300ms 才展示 loading 动画,避免闪烁。
- Caution:加载时间超过 10s 仍未完成时,应提示用户”加载较慢,请稍后重试”并提供重试按钮。
- Suggestion:列表、卡片等结构化内容优先使用骨架屏而非纯 Spin,减少用户等待焦虑。
- Standard:骨架屏的布局结构必须与实际内容一致,高度相同,避免内容跳动。
import { Spin, Skeleton } from 'antd';
// 区域级加载
<div style={{ position: 'relative', minHeight: 200 }}>
{loading ? <Skeleton active paragraph={{ rows: 4 }} /> : <UserList data={data} />}
</div>
// 按钮级加载
<Button type="primary" loading={submitting} onClick={handleSubmit}>
提交
</Button>
| 反模式 | 问题 | 建议 |
|---|
| 所有加载都用全屏 Spin | 遮挡内容、体验差 | 局部区域用局部 Spin |
| 无 loading 状态 | 用户不知道是否在请求 | 所有异步操作必须有 loading |
| 骨架屏与实际内容高度不一致 | 内容跳动 | 保持布局一致 |
- Standard:空状态分为三类,必须分别设计:
- 首次空状态:用户从未使用过该功能,引导用户开始使用
- 搜索无结果:用户搜索/筛选后无匹配数据,提供清空筛选入口
- 数据为空:功能已使用但暂无数据,提供创建入口
- Requirement:每个列表页面必须处理空状态,禁止出现空白页面。
- Standard:空状态包含图标 + 文案 + 操作按钮(如”新建”、“清空筛选”)。
- Standard:空状态文案使用”暂无数据”而非”没有数据”或”null”。
import { Empty, Button } from 'antd';
// 首次空状态
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="还没有用户,点击下方按钮创建"
>
<Button type="primary" onClick={() => navigate('/user/create')}>
新建用户
</Button>
</Empty>
// 搜索无结果
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="没有匹配的用户"
>
<Button onClick={resetFilters}>清空筛选条件</Button>
</Empty>
| 反模式 | 问题 | 建议 |
|---|
| 列表为空时页面空白 | 用户困惑 | 展示空状态组件 |
| 空状态无操作入口 | 用户不知下一步 | 提供创建或清空入口 |
| 空状态文案”没有数据” | 表达生硬 | 使用”暂无数据” |
| 错误类型 | 处理方式 | 示例 |
|---|
| 表单校验错误 | 输入框下方红字提示 | “请输入正确的手机号” |
| 操作失败 | Message 提示,保留当前状态 | “保存失败,请重试” |
| 页面加载失败 | 页面级错误提示 + 重试按钮 | “加载失败,请重试” |
| 网络异常 | 全局提示 + 引导检查网络 | “网络异常,请检查网络连接” |
- Standard:瞬时操作结果用
message(3 秒自动消失);需要用户关注的警告用 notification(右上角);错误详情用 Modal 展示。
- Requirement:错误提示必须包含具体原因,禁止只展示”操作失败”。
- Requirement:必须配置全局错误边界(Error Boundary),捕获未处理的渲染异常,展示统一的错误页面。
- Standard:接口请求统一在 axios 拦截器中处理网络错误和业务错误码,避免每个请求单独处理。
// 全局 Error Boundary
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: any) {
console.error('页面渲染异常:', error, info);
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: 48, textAlign: 'center' }}>
<h2>页面出错了</h2>
<p>{this.state.error?.message}</p>
<Button onClick={() => window.location.reload()}>刷新页面</Button>
</div>
);
}
return this.props.children;
}
}
// axios 统一错误处理
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
message.error('登录已过期,请重新登录');
// 跳转登录页
} else if (error.response?.status >= 500) {
message.error('服务器异常,请稍后重试');
} else if (error.code === 'ECONNABORTED') {
message.error('请求超时,请重试');
} else {
message.error(error.response?.data?.message || '请求失败');
}
return Promise.reject(error);
}
);
| 反模式 | 问题 | 建议 |
|---|
| 错误提示”操作失败” | 用户不知原因 | 展示具体错误原因 |
| 接口错误在每个组件单独处理 | 重复代码 | 统一在拦截器处理 |
| 渲染崩溃白屏 | 用户体验极差 | 配置 Error Boundary |
| 401 未跳转登录 | 用户卡在无权限页面 | 自动跳转登录页 |
- Standard:站内跳转使用路由跳转(
router.push / navigate),禁止使用 window.location.href。
- Standard:站外链接使用
<a> 标签并设置 target="_blank",同时添加 rel="noopener noreferrer"。
- Caution:未保存的表单页面跳转前必须二次确认。
- Standard:页面跳转时顶部展示进度条(如 NProgress),告知用户正在加载。
- Standard:新页面加载完成后滚动条回到顶部。
import { useNavigate } from 'react-router-dom';
function UserList() {
const navigate = useNavigate();
const handleEdit = (id: string) => {
navigate(`/user/edit/${id}`);
};
// 站外链接
return (
<a href="https://docs.example.com" target="_blank" rel="noopener noreferrer">
查看文档
</a>
);
}
| 反模式 | 问题 | 建议 |
|---|
站内跳转用 window.location.href | 全页刷新、丢失状态 | 使用路由跳转 |
新窗口链接无 rel="noopener" | 安全风险 | 添加 rel="noopener noreferrer" |
| 未保存表单直接跳转 | 数据丢失 | 跳转前二次确认 |