文章目录
1 API设计
1.1 用户功能
1.1.1 获取用户信息
- method:
get
- path:
/api/user/info
- response:
- 成功:
{errno: 0, data{...}}
- 失败:
{errno: 10001, mes: 'xxx'}
- 成功:
1.1.2 注册
method:
post
path:
/api/user/register
request body:
{username, password, nickname}
response:
{errno: 0}
1.1.3 登录
- method:
post
- path:
/api/user/login
- request body:
{username, password}
- response:
{errno: 0, token}
- Token令牌:使用jwt
1.2 问卷功能
1.2.1 获取单个问卷
- method:
get
- path:
/api/question/:id
- response:
{errno: 0, data: {id, title,...}}
1.2.2 获取问卷列表
- method:
get
- path:
/api/question/
- response:
{errno: 0, data:{list:[{...}}
1.2.3 创建问卷
- method:
post
- path:
/api/question
- request body: 暂无
- response:
{errno: 0, data: {id}}
1.2.4 更新问卷
- method:
patch
- path:
/api/question/:id
- response:
{errno: 0}
tips:删除是“假删除”,实际 是更新isDeleted
属性
1.2.5 批量彻底删除问卷
- method:
delete
- path:
/api/question
- request body:
{ids: [...]}
- response:
{errno: 0}
1.2.6 复制问卷
- method:
post
- path: `/api/question/duplicate/:id``
- response:
{errno: 0, data: {id}}
1.3 小结
- 使用Restful API
- 用户验证使用JWT
- 统一返回格式:
{errno, data, msg}
2 实战
2.1配置axios
src/services/request.ts基础代码如下:
import axios from "axios";
const request = axios.create({
timeout: 5000,
});
export default request;
添加返回类型和统一错误出来,request.ts代码如下:
import axios from "axios";
import { message } from "antd";
const request = axios.create({
timeout: 5000,
});
// response 拦截:统一处理errno和msg
request.interceptors.response.use(
res => {
const resData = (res.data || {}) as ResType
const {errno, data, msg} = resData
if (errno !== 0) {
// 错误提示
if (msg) {
message.error(msg)
}
throw new Error(msg);
}
return data as any
}
)
export default request;
export type ResDataType = {
[key: string]: any;
};
export type ResType = {
errno: number;
data?: ResDataType;
msg?: string;
};
2.2 封装API和测试
获取问卷信息API,src/api/question.ts代码如下:
import request, { ResDataType } from "../services/request";
export async function getQuestionApi(id: string): Promise<ResDataType> {
const url = `/api/question/${id}`;
const data = (await request.get(url)) as ResDataType;
return data;
}
src/pages/quesiton/Edit/index.tsx代码如下:
import { FC, useEffect} from "react";
import { useParams } from "react-router-dom";
import { getQuestionApi } from "@/api/question";
const Edit: FC = () => {
const { id = "" } = useParams();
// 获取问卷信息
useEffect(()=> {
async function fn() {
const resData = await getQuestionApi(id)
console.log(resData);
}
fn()
}, [])
return <div>Edit {id}</div>;
};
export default Edit;
测试结果如下图所示:
2.3 新建问卷
封装新建问卷API,question.ts代码如下:
import request, { ResDataType } from "../services/request";
...
/**
* 新建问卷
* @returns 问卷id
*/
export async function createQuestionApi() {
const url = `/api/question`;
const data = (await request.post(url)) as ResDataType;
return data;
}
ManageLayout.tsx中调用新建问卷,src/layouts/MangeLayout.tsx代码如下:
import { FC, useState } from "react";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import {
PlusOutlined,
BarsOutlined,
StarOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { Button, Space, Divider, message } from "antd";
import { createQuestionApi } from "@/api/question";
import styles from "./ManageLayout.module.scss";
const ManageLayout: FC = () => {
const nav = useNavigate();
const { pathname } = useLocation();
const [loading, setLoading] = useState(false)
// 点击创建问卷
async function handleCreateClick() {
setLoading(true)
const data = await createQuestionApi()
const {id} = data || {}
if (id) {
nav(`/question/edit/${id}`)
message.success("创建成功")
}
setLoading(false)
}
return (
<div className={styles.container}>
<div className={styles.left}>
<Space direction="vertical">
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={handleCreateClick}
disabled={loading}
>
新建问卷
</Button>
...
);
};
export default ManageLayout;
tips:接口请求有延迟,防止用户连续点击按钮,触发重复操作,这里添加loading控制按钮,后续可以用封装好的“防抖”操作替换。
2.4 自定义hooks封装获取问卷信息
在编辑页和统计页都需要获取带加载状态的问卷信息,这里我们通过自定义hooks抽取公共部分,src/hooks/useLoadQuestionData.ts代码如下:
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { getQuestionApi } from "@/api/question";
/**
* 获取带加载状态的问卷信息
* @returns loading状态,问卷信息
*/
function useLoadQuestionData() {
const { id = "" } = useParams();
const [loading, setLoading] = useState(true);
const [questionData, setQuestionData] = useState({});
useEffect(() => {
async function fn() {
const data = await getQuestionApi(id);
setQuestionData(data);
setLoading(false);
}
fn();
}, []);
return { loading, questionData };
}
export default useLoadQuestionData;
编辑页src/pages/question/Edit/index.tsx代码改造如下:
import { FC } from "react";
import useLoadQuestionData from "@/hooks/useLoadQuestionData";
const Edit: FC = () => {
// 获取问卷信息
const { loading, questionData } = useLoadQuestionData();
return (
<div>
<p>Edit page</p>
<div>
{loading ? <p>loading</p> : <p>{JSON.stringify(questionData)}</p>}
</div>
</div>
);
};
export default Edit;
2.5 使用useRequest重构Ajax请求-统一处理
第三方ahooks-useRequst 当前功能有:
- 自动/手动请求
- 轮询
- 防抖
- 节流
- 窗口聚焦刷新
- 错误重试
- 加载延迟
- SWR(stale-while-revalidate)
- 缓存
由于接口请求,每次都需要定义loading,data,使用useEffect执行函数,很繁琐,这里我们使用第三方ahooks提供的useRequest简化。
改造自定义hook-useLoadQuestionData,useLoadQuestionData.ts代码如下:
import { useParams } from "react-router-dom";
import { useRequest } from "ahooks";
import { getQuestionApi } from "@/api/question";
/**
* 获取带加载状态的问卷信息
* @returns loading状态,问卷信息
*/
function useLoadQuestionData() {
const { id = "" } = useParams();
async function load() {
const data = await getQuestionApi(id);
return data;
}
const { loading, data, error } = useRequest(load);
return { loading, data, error };
}
export default useLoadQuestionData;
改造创建问卷,ManageLayout.tsx代码如下:
import { FC, useState } from "react";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import {
PlusOutlined,
BarsOutlined,
StarOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { Button, Space, Divider, message } from "antd";
import { createQuestionApi } from "@/api/question";
import styles from "./ManageLayout.module.scss";
import { useRequest } from "ahooks";
const ManageLayout: FC = () => {
const nav = useNavigate();
const { pathname } = useLocation();
// 点击创建问卷
const {
loading,
// error,
run: handleCreateClick,
} = useRequest(createQuestionApi, {
manual: true,
onSuccess: (res) => {
nav(`/question/edit/${res.id}`);
message.success("创建成功");
},
});
return (
<div className={styles.container}>
<div className={styles.left}>
<Space direction="vertical">
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={handleCreateClick}
disabled={loading}
>
新建问卷
</Button>
...
};
export default ManageLayout;
2.6 开发问卷列表
获取问卷列表mock接口 getQuestionList.js代码如下:
const Mock = require('mockjs');
const Random = Mock.Random;
function getQuestionList(len = 10, isDeleted = false) {
const list = [];
for (let i = 0; i < len; i++) {
list.push({
_id: Random.id(),
title: Random.ctitle(),
isPublished: Random.boolean(),
isStar: Random.boolean(),
answerCount: Random.natural(50, 100),
createdAt: Random.datetime(),
isDeleted
})
}
return list;
}
module.exports = getQuestionList;
question.js代码如下:
const Mock = require('mockjs')
const getQuestionList = require("./data/getQuestionList")
const Random = Mock.Random
module.exports = [
...
{
// 获取问卷列表
url: '/api/question',
method: 'get',
response() {
return {
errno: 0,
data: {
list: getQuestionList(),
total: 100
}
}
}
},
]
question.ts api代码如下所示:
/**
* 获取问卷列表
* @returns 问卷列表数据
*/
export async function getQuestionListApi() {
const url = `/api/question`;
const data = (await request.get(url)) as ResDataType;
return data;
}
List.tsx调用接口代码如下所示:
import { FC } from "react";
// import { useSearchParams } from "react-router-dom";
import { useRequest, useTitle } from "ahooks";
import { Typography, Spin, Divider } from "antd";
import QuestionCard from "@/components/QuestionCard";
import ListSearch from "@/components/ListSearch";
import { getQuestionListApi } from "@/api/question";
import styles from "./common.module.scss";
const { Title } = Typography;
const List: FC = () => {
useTitle("调查问卷-我的问卷");
// const [searchParams] = useSearchParams();
// console.log("keyword", searchParams.get("keyword"));
//问卷列表数据
const { data = {}, loading } = useRequest(getQuestionListApi);
const { list = [], total = 0 } = data;
return (
<>
<div className={styles.header}>
<div className={styles.left}>
<Title level={3}>我的问卷</Title>
</div>
<div className={styles.right}>
<ListSearch />
</div>
</div>
<div className={styles.content}>
{loading && (
<div style={{ textAlign: "center" }}>
<Spin />
</div>
)}
{!loading &&
list.length > 0 &&
list.map((q: any) => {
const { _id } = q;
return <QuestionCard key={_id} {...q} />;
})}
</div>
<div className={styles.footer}>loadingMore 上划加载更多</div>
</>
);
};
export default List;
2.7 第三方hook抽离搜索功能
在我的问卷、星标问卷和回收站都有搜索功能,有相同的逻辑,这里我们通过自定义hook抽离公共逻辑。自定义搜索hook-useLoadQuestionListData.ts代码如下所示:
import { useSearchParams } from "react-router-dom";
import { useRequest } from "ahooks";
import { getQuestionListApi } from "@/api/question";
import { LIST_SEARCH_PARAM_KEY } from "@/constant";
/**
* 获取问卷列表
* @returns 问卷列表
*/
function useLoadQuestionListData() {
const [searchParams] = useSearchParams();
const keyword = searchParams.get(LIST_SEARCH_PARAM_KEY) || "";
async function load() {
const data = await getQuestionListApi({ keyword });
return data;
}
const { loading, data, error } = useRequest(load, {
refreshDeps: [searchParams],
});
return { loading, data, error };
}
export default useLoadQuestionListData;
getQuestionListApi.ts如下所示:
import request, { ResDataType } from "../services/request";
type SearchOption = {
keyword: string;
};
...
/**
* 获取问卷列表
* @param opt 请求参数
* @returns 问卷列表数据
*/
export async function getQuestionListApi(
opt: Partial<SearchOption>
): Promise<ResDataType> {
const url = `/api/question`;
const data = (await request.get(url, {params: opt})) as ResDataType;
return data;
}
说明:
- refreshDeps: useRequest 刷新依赖项,根据数组里面的变量重新执行useRequest
- request.get(url, {params: opt})),params axios get请求传递参数,形式:url?a=b&b=1…
2.8 开发星标和回收站
星标问卷查询的数据 isStar=true
;回收站查询的问卷列表isDeleted=true
,我们需要扩展api接口参数。
useLoadQuestionListData.ts代码改造如下:
import { useSearchParams } from "react-router-dom";
import { useRequest } from "ahooks";
import { getQuestionListApi } from "@/api/question";
import { LIST_SEARCH_PARAM_KEY } from "@/constant";
type OptionType = {
isStar: boolean;
isDeleted: boolean;
};
/**
* 获取问卷列表
* @returns 问卷列表
*/
function useLoadQuestionListData(opt: Partial<OptionType>) {
const { isStar, isDeleted } = opt;
const [searchParams] = useSearchParams();
const keyword = searchParams.get(LIST_SEARCH_PARAM_KEY) || "";
async function load() {
const data = await getQuestionListApi({ keyword, isStar, isDeleted });
return data;
}
const { loading, data, error } = useRequest(load, {
refreshDeps: [searchParams],
});
return { loading, data, error };
}
export default useLoadQuestionListData;
星标问卷-Star.tsx代码如下所示:
import { FC } from "react";
import { useTitle } from "ahooks";
import { Typography, Empty, Spin } from "antd";
import QuestionCard from "@/components/QuestionCard";
import ListSearch from "@/components/ListSearch";
import styles from "./common.module.scss";
import useLoadQuestionListData from "@/hooks/useLoadQuestionListData";
const { Title } = Typography;
const List: FC = () => {
useTitle("调查问卷-星标问卷");
//问卷列表数据
const { data = {}, loading } = useLoadQuestionListData({ isStar: true });
const { list = [], total = 0 } = data;
return (
<>
<div className={styles.header}>
<div className={styles.left}>
<Title level={3}>星标问卷</Title>
</div>
<div className={styles.right}>
<ListSearch />
</div>
</div>
<div className={styles.content}>
{loading && (
<div style={{ textAlign: "center" }}>
<Spin />
</div>
)}
{!loading && list.length === 0 && <Empty description="暂无数据" />}
{!loading &&
list.length > 0 &&
list.map((q: any) => {
const { _id } = q;
return <QuestionCard key={_id} {...q} />;
})}
</div>
<div className={styles.footer}>分页</div>
</>
);
};
export default List;
回收站-Trash.tsx代码如下所示;
import { FC, useState } from "react";
import { useTitle } from "ahooks";
import {
Typography,
Empty,
Table,
Tag,
Space,
Button,
Modal,
message,
Spin,
} from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import ListSearch from "../../components/ListSearch";
import styles from "./common.module.scss";
import useLoadQuestionListData from "@/hooks/useLoadQuestionListData";
const { Title } = Typography;
const { confirm } = Modal;
const List: FC = () => {
useTitle("调查问卷-回收站");
//问卷列表数据
const { data = {}, loading } = useLoadQuestionListData({ isDeleted: true });
const { list = [], total = 0 } = data;
const columns = [
{
title: "标题",
dataIndex: "title",
},
{
title: "是否发布",
dataIndex: "isPublished",
render: (isPublished: boolean) =>
isPublished ? <Tag color="processing">已发布</Tag> : <Tag>未发布</Tag>,
},
{
title: "答卷",
dataIndex: "answerCount",
},
{
title: "创建时间",
dataIndex: "createdAt",
},
];
// 选择ids集合
const [selectedIds, setSelectedIds] = useState<string[]>([]);
function del() {
confirm({
title: "您确定要删除吗?",
okText: "确定",
cancelText: "取消",
content: "删除以后不可找回!",
icon: <ExclamationCircleOutlined />,
onOk: () => message.success("删除成功"),
});
}
const TableEle = (
<>
<div style={{ marginBottom: "10px" }}>
<Space>
<Button type="primary" disabled={selectedIds.length === 0}>
恢复
</Button>
<Button danger disabled={selectedIds.length === 0} onClick={del}>
彻底删除
</Button>
</Space>
</div>
<Table
dataSource={list}
columns={columns}
pagination={false}
rowKey={(q: any) => q._id}
rowSelection={{
type: "checkbox",
onChange: (selectRowKeys) => {
setSelectedIds(selectRowKeys as string[]);
},
}}
></Table>
</>
);
return (
<>
<div className={styles.header}>
<div className={styles.left}>
<Title level={3}>回收站</Title>
</div>
<div className={styles.right}>
<ListSearch />
</div>
</div>
<div className={styles.content}>
{loading && (
<div style={{ textAlign: "center" }}>
<Spin />
</div>
)}
{!loading && list.length === 0 && <Empty description="暂无数据" />}
{TableEle}
</div>
<div className={styles.footer}>分页</div>
</>
);
};
export default List;
服务端星标问卷查询返回的isStar=true
;回收站返回的isDeleted=true
;
index.js代码如下所示:
...
async function getRes(fn, ctx) {
return new Promise(resolve => {
setTimeout(() => {
const res = fn(ctx)
resolve(res)
}, 500);
})
}
// 注册mock路由
mockList.forEach(item => {
const {url, method, response} = item
router[method](url, async ctx => {
const res = await getRes(response, ctx)
ctx.body = res
})
})
...
question.js代码如下:
...
{
// 获取问卷列表
url: '/api/question',
method: 'get',
response(ctx) {
const { url = '' } = ctx
const isStar = url.indexOf('isStar=true') >= 0
const isDeleted = url.indexOf('isDeleted=true') >= 0
return {
errno: 0,
data: {
list: getQuestionList({isStar, isDeleted}),
total: 100
}
}
}
},
]
getQuestionList.js如下所示:
const Mock = require('mockjs');
const Random = Mock.Random;
function getQuestionList(opt = {}) {
const { len = 10, isStar, isDeleted } = opt
const list = [];
for (let i = 0; i < len; i++) {
list.push({
_id: Random.id(),
title: Random.ctitle(),
isPublished: Random.boolean(),
isStar: isStar || Random.boolean(),
answerCount: Random.natural(50, 100),
createdAt: Random.datetime(),
isDeleted: isDeleted || Random.boolean()
})
}
return list;
}
module.exports = getQuestionList;
说明
- 数据展示有3种状态:根据不同的状态有不同展示
- 加载中:
- 加载完成,数据为空
- 加载完成,有数据
- rowKey={(q: any) => q._id}:ts不设置any类型,程序不报错,但是ts检查错误
结语
❓QQ:806797785
⭐️仓库地址:https://gitee.com/gaogzhen
⭐️仓库地址:https://github.com/gaogzhen
[1]ahook官网[CP/OL].
[2]mock文档[CP/OL].
[3]Ant Design官网[CP/OL].