主题
编辑医院设置与医院省市区列表(Day13 1/3)
本页对应 React + TypeScript + 医通项目,共 7 段视频,约 50 分钟。
学习目标
- 能根据接口的请求方式、URL、参数和返回值,定义 TypeScript 返回类型并封装 API 函数,正确区分 path 参数与 query 参数的写法。
- 能使用表格组件渲染医院设置分页数据,并在请求前开启 loading、请求结束后关闭 loading。
- 能通过列配置实现列宽、居中对齐、右侧固定列,并通过表格滚动配置实现横向滚动条。
- 能实现医院列表表单搜索查询:绑定表单提交事件,获取带 name 的表单字段值,更新查询状态并触发重新请求,查询时将页码重置为第一页。
- 能实现清空表单功能:按查询条件控制清空按钮禁用状态,重置表单字段、查询状态和页码,并验证状态未变化时不重复发起请求。
- 能创建添加/编辑医院设置路由组件,配置隐藏菜单路由,使用编程式导航跳转,并基于表单与卡片组件完成静态布局及字段绑定。
知识点
编辑与新增共用一个页面
编辑医院设置总共分两个大的步骤:第一,点击编辑按钮,跳转到编辑页面,并获取当前条医院设置数据渲染到表单中;第二,点击表单的保存按钮,将用户最新修改的医院设置数据修改原数据。这里最关键的设计是:新增医院设置和编辑医院设置共用同一个页面 AddOrUpdate.tsx,通过路由参数 id 来区分是编辑还是新增操作。
为什么需要共用?因为新增和编辑的表单字段、校验规则、布局几乎一致,如果拆成两个页面,后续字段一改就要改两处,容易漏。怎么想?把“有没有 id”作为判断条件:没有 id 就是新增,有 id 就是编辑。怎么做?在路由中配置同一个组件,编辑按钮跳转时把当前行 id 带在路径上;进入组件后用路由参数拿到 id,再决定是否请求当前条数据。易错点是:id 从路由里取出来通常是字符串,传给接口前要按项目类型转换;编辑路由不要出现在侧边菜单里,转写稿里新增路由就用了 hidden 隐藏菜单,编辑路由同理。
tsx
// AddOrUpdate.tsx 结构示意:不新增具体接口名
const { id } = useParams();
const isEdit = Boolean(id);
useEffect(() => {
if (id) {
// 按项目现有详情接口获取当前条医院设置数据
// 拿到数据后通过 form.setFieldsValue 回显
}
}, [id]);进入编辑页并回显当前条数据
为什么需要回显?编辑不是让用户从空表单重新填,而是把当前这条医院设置的原数据展示出来,用户只改需要改的字段。怎么想?编辑按钮一般在表格的“操作”列,点击后既要跳转,也要让目标页面知道“编辑哪一条”。转写稿里新增按钮是通过 navigate 做编程式导航跳转的,编辑按钮同样可以复用这个思路,只是路径后要带上 id。怎么做?在列表操作列给编辑按钮绑定点击事件,调用 navigate 跳到 AddOrUpdate 对应路径并携带当前行 id;编辑页里用 useParams 取 id,有 id 时请求当前条数据,再用表单实例的 setFieldsValue 回显。
易错点很集中:第一,表单字段必须要有 name,转写稿里查询时打印出空对象,最后发现就是表单项没有 name,编辑回显和保存取值都依赖它;第二,回显要在数据回来之后做,不能在请求发出前就 setFieldsValue;第三,useEffect 的依赖要包含 id,否则从一条编辑切到另一条时可能不重新取数;第四,请求期间可以配合 loading,转写稿里表格 loading 的做法是请求前开启、数据回来关闭,编辑页取详情也可以按同样思路处理。
保存修改并回到列表
为什么保存要区分?新增是“创建一条”,编辑是“修改原数据”,两者请求方法、是否携带 id 往往不同。怎么想?保存时先判断当前是编辑还是新增:有 id 走修改逻辑,没有 id 走新增逻辑。怎么做?点击保存按钮后,通过表单实例拿到最新值,编辑时把 id 和表单数据一起提交给修改接口;成功后提示并返回列表页,让列表重新请求分页数据。转写稿里查询功能的核心经验是“把请求参数做成状态,状态一变就重新发请求”,编辑保存后返回列表也应让列表刷新,否则用户看到的还是旧数据。
易错点:保存按钮在表单里时,button 的 type 要设成 submit,表单用 onFinish 接收提交;如果直接绑 onClick,要注意不要触发表单默认提交导致页面刷新。转写稿里专门讲过:表单提交按钮更习惯给 Form 绑 onFinish,按钮 htmlType 指定为 submit。另外,编辑保存不要漏传 id;保存成功后的跳转路径要回到医院设置列表;如果列表页有查询条件和分页状态,返回后要保证列表能按当前条件重新取数。
调试与验证过程
按转写稿的调试习惯,先看地址栏:点击编辑后路径上是否带上了当前行 id;再看 Network:进入编辑页是否按 id 请求了当前条数据,保存时请求方法、参数里是否带 id 和修改后的字段;最后看页面:表单是否回显、保存后列表是否刷新。转写稿里多次出现同音字,理解时要纠正:碰装是封装,放是 Form,非那是是 onFinish,diable是 disabled,楼顶是 loading,四个肉是 scroll,扣的是 code,氨基范是 undefined。编辑功能不要另起一套页面,沿着“列表带 id 跳转 → 编辑页按 id 取数回显 → 保存按 id 修改 → 返回列表刷新”这条链路验证即可。
先看接口,确定要封装什么
为什么需要:省市区列表不是前端写死的,而是从后端接口获取。写代码前先看接口,才能确定请求方式、URL、参数和返回值。转写稿里明确说,查看接口就是看四个方面:请求方式、URL、参数、返回值;其中返回值会决定后面怎么定义类型。
怎么想:省市区列表接口的返回值是一个数组,数组每一项包含 id、name、value、hasChildren 等信息。参数是父级 id,省列表传 86,市列表传省 id,区列表传市 id。
怎么做:先根据接口返回值封装 interface,再封装 API 函数。易错点:不要只看 URL 忽略参数;返回类型不要图省事写成 any,否则后面访问 provinceList 里的数据时 TS 检查会失效。
封装 interface:让返回值有类型
为什么需要:转写稿里说,封装 interface 主要是针对返回值,因为只有把返回值类型定义出来,请求回来的结果才能通过 TS 语法检查,才能正常使用结果里的数据。
怎么想:一个省市区对象用一个 interface 描述,省市区列表是对象数组,所以再定义 IDistrictList。笔记中的代码如下:
ts
/**
* 省市区 返回数据 对象类型
*/
export interface IDistrictItem {
id: number;
createTime: string;
updateTime: string;
name: string;
value: string;
hasChildren: boolean;
}
/**
* 省市区列表类型,也是获取省市区列表接口的返回值类型
*/
export type IDistrictList = IDistrictItem[]怎么做:把接口返回字段逐个落到类型里,列表类型写成 IDistrictItem[]。易错点:id 是 number,hasChildren 是 boolean,不要写成字符串;字段名要和接口一致,不能漏 name、value 等。
封装 api 函数:把请求方式、URL、参数落到函数
为什么需要:组件里不应该到处写请求细节,封装成 API 函数后,调用处只关心“传什么 id、拿什么列表”。
怎么想:转写稿里说,封装 API 函数其实就是查看接口前三个内容的应用:请求方式、URL、参数。这里用 request.get,URL 后直接拼接 id,返回值类型用 IDistrictList。
怎么做:笔记中的 API 函数如下:
ts
export const getDistrictList = (id: number) => {
return request.get<any, IDistrictList>('/admin/cmn/dict/findByParentId/' + id)
}易错点:id 要拼在 URL 后面;泛型第二参数才是返回值类型;调用时传的必须是父级 id,省列表传 86,市、区列表传选中项的 id。
调用 api:省列表在挂载后获取
为什么需要:页面一进入就要有省列表,所以省列表适合放在组件挂载完成后的生命周期钩子里调用。转写稿也提到,调用 API 时先判断有没有用户操作:没有用户操作就用钩子函数,有用户操作就用事件回调。
怎么想:省列表初始化没有用户点击,所以在 useEffect 空依赖中调用;请求是异步的,用 async/await;数据回来后通过 setProvinceList 设置状态,驱动渲染。
怎么做:笔记中的代码如下:
ts
// 获取省列表
const getProvinceList = async () => {
const provinceList = await getDistrictList(86);
// 设置省状态数据
setProvinceList(provinceList);
}
useEffect(() => {
getProvinceList();
}, [])易错点:useEffect 依赖空数组表示只执行一次;不要忘记 await;请求成功后一定要设置状态,否则数据回来了页面也不渲染。
市和区:选中后按父 id 请求
为什么需要:市列表依赖省,区列表依赖市,不能在页面初始化时全部写死。笔记明确说:市列表是在省选中后根据省 id 发请求获取;区列表是在市选中后根据市 id 发请求获取。
怎么想:把“选中项的 id”作为参数传给请求函数,请求回来后设置对应状态。转写稿里的通用思路是:先定义状态,再在合适回调里 async/await 调用 API,最后设置状态。
怎么做:笔记中的代码如下:
ts
// 根据省id 获取是列表并渲染
const getCityList = async (id: number) => {
console.log('id: ', id);
const cityList = await getDistrictList(id);
setCityList(cityList);
}ts
// 根据市id 获取区列表并渲染
const getDictList = async (id: number) => {
console.log('city id: ', id);
const dictList = await getDistrictList(id);
setDictList(dictList);
}易错点:获取市列表不要继续传 86,要传省选中项的 id;获取区列表要传市选中项的 id;切换省或市后要重新请求下级列表,否则级联数据会不一致。调试时先看请求有没有发出、参数 id 对不对,再看请求回来后有没有调用 setCityList、setDictList 设置状态。
先明确 bug 的表现与修复目标
为什么需要:省、市、区是三级联动。切换省之后,原来的市、区表单如果还保留旧值,就会出现“省已经变了,市和区还显示上一个省的数据”的脏数据;同时区列表如果还保留旧数组,下拉框里也会出现不属于当前市的区。
怎么想:切换省时,要同时做两件事:一是把市、区表单项恢复成未选择状态;二是把区列表状态清空为空数组。切换市时,只清空区表单项,并重新请求区列表。这里不要只改表单显示,也不要只改列表状态,两者要配套。
怎么做:在切换省的函数里,用 form.setFieldsValue 把 cityCode、districtCode 设为 undefined,再用 setDictList([]) 清空区列表;在切换市的函数里,只把 districtCode 设为 undefined。
易错点:undefined 表示未选择,不要随手写成空字符串;清空表单值不等于清空下拉数据源,区列表状态必须单独清空;清空动作要放在请求新列表之前,避免旧数据短暂残留。
用 form 对象操作表单:四步不能少
为什么需要:如果表单值都靠一个个 useState 手动维护,切换省时就要同时改多个状态,容易漏改、顺序也乱。form 对象可以统一读写表单字段,setFieldsValue 一次设置多个字段,更适合联动清空。
怎么想:转写稿里调试表单值时出现过一个典型现象:调用 form.getFieldsValue() 打印出来是空对象。不是方法没执行,而是 Form.Item 没有写 name。form 是按 name 建立字段映射的,没有 name 就取不到值。所以使用 form 的前提是字段要有 name。
怎么做:按笔记中的四步走:
- 给
Form.Item设置name属性; - 创建 form:
const [form] = Form.useForm(); - 把 form 绑定到表单组件;
- 使用 form 的 API 操作表单。
常用 API 如下:
ts
form.getFieldsValue() // 获取表单数据的全部的值
form.setFieldsValue({key1:value1,key2:value2}) // 设置表单数据的值易错点:Form.Item 的 name 必须和 setFieldsValue 里的 key 一致;form 实例创建后要绑定到表单;不要把 setFieldsValue 当成“清空整个表单”的万能方法,它只设置你传入的字段。
切换省:清空市、区表单并重置区列表
为什么需要:省变化后,原来的市、区都不再可信。此时如果只请求新的市列表,市、区表单还留着旧值,就会出现省市区不匹配。
怎么想:切换省的回调里,先清空表单显示,再清空区列表状态,最后请求新的市列表。顺序上先清空,用户不会看到旧值残留。
怎么做:保留笔记中的实现:
ts
const getCityList = async (id: number) => {
console.log('id: ', id);
// 将市、区的表单项赋值为undefined
form.setFieldsValue({
cityCode:undefined,
districtCode:undefined
})
// 将区的状态数据设置为空数组
setDictList([]);
const cityList = await getDistrictList(id);
setCityList(cityList);
}易错点:这里清空的是 cityCode 和 districtCode 两个字段;setDictList([]) 清的是区列表数据源,不是表单值;请求市列表的函数名按原代码保留,不要因为命名相似就误改成区列表请求。
切换市:只清空区表单
为什么需要:市变化后,区列表必须重新请求;原来的区选中值也不再属于当前市,所以要清空区表单。但市本身是刚选中的,不能把市也清掉。
怎么想:切换市只影响区,因此只设置 districtCode: undefined,然后请求新的区列表并写入状态。
怎么做:
ts
const getDictList = async (id: number) => {
console.log('city id: ', id);
// 将区的表单值设置为undefined
form.setFieldsValue({
districtCode:undefined
})
const dictList = await getDistrictList(id);
setDictList(dictList);
}易错点:不要在切换市时清空 cityCode;setFieldsValue 只改表单显示,区下拉的选项仍要靠 setDictList(dictList) 更新;如果请求失败或返回空数组,页面应保持区列表为空,而不是继续显示旧数据。
调试与检查清单
为什么需要:联动 bug 往往不是请求错,而是“表单值”和“列表状态”没有同步清空。
怎么想:先看切换省时 form.setFieldsValue 是否把市、区置为 undefined,再看 setDictList([]) 是否执行,最后看请求回来的市列表是否写入 setCityList。如果表单值取不到,优先检查 Form.Item 的 name。
怎么做:保留关键 console.log,切换省时观察 id、表单值和区列表状态;切换市时观察 city id 和区列表返回。
易错点:只清表单不清列表,下拉仍会出现旧区;只清列表不清表单,表单仍显示旧区;name 写错或漏写,form 取不到对应字段;把 undefined 写成 null 或空字符串,可能导致“未选择”判断不一致。
先看分页数据链路:接口、类型、API、调用、渲染
为什么需要:医院列表不是写死数据,而是从分页接口取回;没有接口类型,TS 无法约束返回值,页面拿不到列表字段。怎么想:先看接口的请求方式、URL、参数和返回值,再封装返回值的 interface,最后封装 API 函数。怎么做:转写稿里的“碰装”应理解为“封装”,核心是 request.请求方式(URL, 参数);params 参数通常直接拼在 URL 上,query 参数放在配置对象里。调用 API 时看触发场景:有用户操作就放事件回调,没有就放钩子函数;拿到数据后定义状态并 setState 渲染。易错点:不要只封装 API 不定义返回类型;不要混淆 params 与 query 的传参位置。代码按项目已有接口保留结构:
ts
// 按接口返回值定义类型,字段以实际接口为准
const res = await getHospitalList({ current, pageSize })
setList(res.records)表格 loading:请求前开启,数据回来后关闭
为什么需要:网络请求有等待时间,不显示 loading 用户会以为页面卡住。怎么想:Table 本身支持 loading 属性,只要把状态传给它即可。怎么做:用 useState 定义 loading,发请求前 setLoading(true),请求结束后 setLoading(false);建议放 finally,避免失败时一直转圈。易错点:只写 loading 属性但不切换状态,表格会一直 loading。转写稿里的“老ning”即 loading:
tsx
const [loading, setLoading] = useState(false)
const getList = async () => {
setLoading(true)
try {
const res = await getHospitalList({ current, pageSize })
// 设置列表数据
} finally {
setLoading(false)
}
}
<Table loading={loading} columns={columns} dataSource={list} />列宽、列固定与横向滚动
为什么需要:默认列宽较随意,操作列在右侧,数据多时希望横向滚动且操作列固定。怎么想:列相关配置放 columns,整体滚动放 Table 的 scroll。怎么做:在列配置里加 width、align,操作列加 fixed: 'right';表格加 scroll={{ x: 1300 }},超过宽度才出现横向滚动条。易错点:只写 fixed 不配 scroll.x,通常看不到固定效果;宽度不要乱设,按内容调整。转写稿里的“4cr”即 scroll:
tsx
const columns = [
{ title: '序号', width: 60, align: 'center' },
{ title: '操作', width: 120, fixed: 'right' },
]
<Table columns={columns} scroll={{ x: 1300 }} />搜索查询:复用分页接口,用状态驱动请求
为什么需要:输入医院名称、编号后点查询,要重新获取分页列表。怎么想:这个功能不跳新页面,所以不创建路由组件、不配路由;接口仍是医院分页接口,类型和 API 已封装过,只需调用。怎么做:给表单绑 onFinish,查询按钮设 htmlType="submit";用 Form.useForm() 获取表单值,把 houseName、houseCode 设为状态,并在 useEffect 中监听 current、pageSize、houseName、houseCode 重新发请求。易错点:Form.Item 必须写 name,否则 getFieldsValue() 得到空对象;转写稿里的“放”即 form:
tsx
const [form] = Form.useForm()
const onSearch = () => {
const { houseName, houseCode } = form.getFieldsValue()
setHouseName(houseName)
setHouseCode(houseCode)
setCurrent(1)
}
useEffect(() => {
getList()
}, [current, pageSize, houseName, houseCode])调试:搜索后页码要回到第一页
为什么需要:转写稿现场出现一个 bug:先在第四页,再搜索“北京”,请求仍带 current=4,结果可能没有数据。怎么想:检索条件变化后,用户期望从第一页看结果。怎么做:在查询回调里重置 current 为 1。易错点:只改检索条件不改页码,分页和搜索会互相干扰。清空表单同理,也要把页码设回第一页。
tsx
setCurrent(1)清空表单:重置表单和状态,并禁用空表单按钮
为什么需要:清空后界面输入框要空,列表也要按无检索条件重新请求;空表单时“清空”按钮不应可点。怎么想:表单显示归 form.resetFields() 管,请求归 houseName、houseCode 状态管,两者都要重置。怎么做:清空回调里重置表单、把两个检索状态设为 undefined、current 设 1;按钮用 disabled 判断两个状态都为空时禁用。易错点:只重置表单不重置状态,列表不会重新请求;转写稿里的“diable”即 disabled:
tsx
const clear = () => {
form.resetFields()
setHouseName(undefined)
setHouseCode(undefined)
setCurrent(1)
}
<Button
disabled={houseName === undefined && houseCode === undefined}
onClick={clear}
>
清空
</Button>添加医院设置:组件、路由与静态布局
为什么需要:点“添加”进入新页面,需要路由组件和路由配置。怎么想:按基础流程创建路由组件、配置路由、搭静态结构;按钮跳转用编程式导航。怎么做:在 hospitalSet/components 下建添加/更新组件,路由配置里设置隐藏菜单属性,避免“添加医院”出现在侧边栏;页面静态结构可复用 Ant Design 表单,外层包 Card,表单项 name 与接口字段对应。易错点:路由路径最好复制已有配置,不要手写;表单 name 写错会导致提交数据字段错误。转写稿里的“unfinish”应理解为 onFinish:
tsx
<Form form={form} onFinish={onSubmit}>
<Form.Item name="houseName" label="医院名称" rules={[{ required: true }]}>
<Input placeholder="请输入医院名称" />
</Form.Item>
</Form>本节要解决的问题:先看接口,再写类型
这一节的核心不是“多写几个 interface”,而是把接口返回的数据结构先用 TypeScript 描述清楚。转写稿里反复强调:查看接口要看四个方面——请求方式、URL、参数、返回值;其中封装 interface 主要是针对返回值,因为只有把返回值的类型定义出来,请求回来的结果才能通过 TS 语法检查,页面里访问结果数据时才不会报类型错误。
为什么需要:如果只写 const res = await request(...),res 里的字段对 TS 来说是不明确的,后续访问 res.content、res.totalElements 或列表项里的 hosname 时容易出错。
怎么想:先看接口返回值长什么样,再拆成“查询参数类型”“列表项类型”“列表类型”“响应对象类型”。
怎么做:保留本节笔记里的完整类型代码,并让请求函数的泛型、页面状态都复用这些类型。
易错点:不要把所有查询参数都写成必填;不要漏掉嵌套对象;响应对象字段名必须和后端一致。
本节完整类型代码(保留笔记原样)
ts
/**
* 医院列表参数类型
*/
export interface IHospitalListParams {
page: number;
limit: number;
hoscode?: string;
hosname?: string;
hostype?: string;
provinceCode?: string;
cityCode?: string;
districtCode?: string;
status?: number;
}
/**
* 医院列表每一项 类型
*/
export interface IHospitalItem {
id: string;
createTime: string; // 创建时间
param: {
hostypeString: string; // 医院等级
fullAddress: string; // 医院地址
},
hoscode: string; // 医院编号
hosname: string; // 医院名
hostype: string; // 医院类型
provinceCode: string; // 省
cityCode: string; // 市
districtCode: string; // 区
address: string; // 地址
logoData: string; // 医院logo base64URL
route: string; //乘车路线
status: number; // 医院状态
bookingRule: {
cycle: number; // 预约周期
releaseTime: string; // 放号时间
stopTime: string;// 停止挂号时间
quitDay: number; // 就诊结束日期
quitTime: string; // 结束时间
rule: string[]; //取号规则
}
}
/**
* 医院列表类型
*/
export type IHospitalList = IHospitalItem[]
/**
* 请求医院列表响应对象类型
*/
export interface IHospitalListResponse {
content:IHospitalList;
totalElements:number;
}查询参数类型:page、limit 必填,筛选条件可选
为什么需要:医院列表分页请求一定需要页码和每页条数;而医院编号、医院名称、类型、省市区、状态是筛选条件,用户不一定每次都传。
怎么想:从转写稿的接口分析出发,参数里固定要带的是 page 和 limit,其余查询条件用 ? 标成可选。
怎么做:定义 IHospitalListParams,必填字段不加问号,可选字段加问号。
易错点:如果把 hosname、hoscode 也写成必填,后续搜索功能只传其中一个条件时就会类型报错;反过来,page、limit 不能漏问号写成可选。
列表项类型:按返回值逐层描述嵌套结构
为什么需要:表格渲染时要用到每一项的 hosname、hoscode、param.fullAddress、bookingRule.rule 等字段,必须让 TS 知道这些字段存在。
怎么想:转写稿说“根据返回值定义返回结果类型”,所以不是凭感觉写字段,而是照着接口返回结构拆。顶层有 id、createTime、hoscode、hosname 等;param 是嵌套对象,里面有 hostypeString、fullAddress;bookingRule 也是嵌套对象,rule 是字符串数组。
怎么做:用 IHospitalItem 描述一项,嵌套对象直接写在类型里,数组写成 string[]。
易错点:param、bookingRule 不是字符串,不能写成 param: string;rule 是数组,不能写成 string;字段名要和接口一致,比如 hostypeString 不要写成 hostypeString 以外的拼写。
列表类型与响应类型:让请求结果可直接被页面使用
为什么需要:接口返回的 content 是医院列表数组,totalElements 是总条数;分页表格既需要数组渲染,也需要总数做分页。
怎么想:先用 export type IHospitalList = IHospitalItem[] 表示“医院列表就是医院项组成的数组”,再用 IHospitalListResponse 表示整个响应对象。
怎么做:请求函数泛型传 IHospitalListResponse,页面状态可定义为 IHospitalList,总条数用 number。
易错点:不要写成 content: IHospitalItem,少写 [] 会导致表格不能遍历;totalElements 是数字,不要写成字符串。转写稿也提到,封装 API 函数时请求方式、URL、参数和泛型要对应起来;查询参数放在配置对象的 params 里,返回类型通过泛型传入,这样后续调用才能获得类型提示。
调试思路与易错点汇总
转写稿里有一个很典型的调试思路:先确认方法有没有执行、对象是否存在,再检查数据为什么是空。放到类型封装上也是一样:如果页面访问 res.content 报错,先检查响应类型是否写对;如果请求参数没带上,先检查查询参数是否放在 params 配置对象里;如果表格项字段报错,先对照 IHospitalItem 是否漏了嵌套层级。
易错点:可选参数滥用、嵌套对象漏写、数组类型漏写 []、响应字段名与后端不一致。把这几类类型定义好,后面的列表渲染、搜索、分页和清空功能才有稳定的类型基础。
本节要解决的问题:把“看接口”变成可复用的 API 函数
为什么需要:医院设置分页列表要在页面里请求数据。如果每个组件都自己写一遍 request.get,URL、参数名、返回类型就会散落在各处,改接口时容易漏改,TS 也拿不到返回数据的类型提示。
怎么想:实际中把“查看接口”拆成四件事:请求方式、URL、参数、返回值。封装 API 函数,本质上就是把前三项落到一个函数里;封装 interface,主要是针对返回值,让请求回来的结果能通过 TS 检查,后续才能安全使用结果里的数据。
怎么做:先确认接口是 GET,URL 是 /admin/hosp/hospital/${page}/${limit},分页参数 page、limit 在路径里,其余查询条件放在 query 参数里。然后再写类型和函数。
易错点:不要只看 URL 就动手。返回值类型没定义,后面用返回数据时 TS 会报错;参数名和接口文档不一致,请求能发出去但筛选条件不生效。
先明确参数类型和返回类型
为什么需要:API 函数既要接收调用方传来的查询条件,又要告诉调用方返回什么。参数类型约束入参,返回类型约束出参,二者缺一不可。
怎么想:笔记代码里入参用了 IHospitalListParams,返回用了 IHospitalListResponse。这说明类型已经提前定义好。IHospitalListParams 至少包含 page、limit、hoscode、hosname、hostype、provinceCode、cityCode、districtCode、status 这些字段;IHospitalListResponse 的具体字段以接口返回为准,本节不重复展开。
怎么做:在类型文件里维护这两个 interface,API 文件只负责引用。不要把类型写成 any,否则封装就失去了 TS 的意义。
易错点:interface 不是“可有可无的注释”。转写稿里讲师特别强调,封装 interface 主要针对返回值,因为只有把返回值类型定义出来,请求回来的结果才能通过 TS 语法检查。另一个易错点是入参解构时字段名要一致,比如 provinceCode 不要写成 provincecode。
封装 getHospitalList:路径参数拼 URL,query 参数放 params
为什么需要:分页接口同时有路径参数和查询参数。路径参数必须拼进 URL,查询参数要交给请求配置对象,混在一起就会请求错地址或丢条件。
怎么想:转写稿里把参数分成两种情况:path 参数直接连在 URL 里,没有第二个配置对象;query 参数则在 URL 后面再传一个配置对象,里面用 params 放键值对。当前接口的 page、limit 属于路径参数,其余筛选条件属于 query 参数。
怎么做:保留笔记中的函数写法:
ts
/**
* 获取医院列表分页数据api方法
* @param param0
* @returns
*/
export const getHospitalList = ({page,limit,hoscode,hosname,hostype,provinceCode,cityCode,districtCode,status}:IHospitalListParams)=>{
return request.get<any, IHospitalListResponse>(`/admin/hosp/hospital/${page}/${limit}`, {
params:{
hoscode,
hosname,
hostype,
provinceCode,
cityCode,
districtCode,
status
}
})
}这里 request.get 的第一个泛型参数按笔记保留为 any,第二个泛型传 IHospitalListResponse,用来约束返回结果类型。URL 模板字符串里只放 page 和 limit;params 对象里放其余查询条件。
易错点:第一,不要把 page、limit 又放进 params,否则路径里是 undefined;第二,不要漏写 params 这个键,直接把查询对象作为第二个参数传进去;第三,解构参数名要与类型和接口文档一致;第四,返回类型泛型位置不要写反。转写稿里说“这个位置要传 interface,就是响应对象的类型”,说的就是第二个泛型。
调用 API 时怎么配合:事件回调或钩子函数里 await
为什么需要:API 函数只是“发请求的工具”,真正触发请求还要看页面交互。没有用户操作时,通常在钩子函数里调用;有查询、分页等用户操作时,就在事件回调里调用。
怎么想:转写稿里把调用总结为:看路由组件里有没有用户操作。有用户操作就是事件回调,没有就是钩子函数。调用时用 async/await,拿到结果后设置状态,再渲染表格。
怎么做:例如在获取列表的方法里写 const res = await getHospitalList({ page, limit, ...查询条件 }),然后把 res 中需要的数据设置给状态。分页场景下,当前页、每页条数、查询条件通常都作为状态数据参与请求;状态变化后重新调用 API。转写稿还提到,发送请求前可以开启 loading,数据回来后再关闭 loading,这属于调用侧的体验优化,不是 API 函数本身的职责。
易错点:调用时参数对象字段要齐全;await 不要漏;请求成功后要设置状态,否则数据回来了页面不更新;如果查询条件变化,页码要回到第一页,避免带着旧页码查新条件。
调试与验证:看 Network 和类型提示
为什么需要:封装完不能只看代码“像对的”,要确认请求真的带上了参数,返回类型也真的能用。
怎么想:先看浏览器 Network 里的请求 URL 是否形如 /admin/hosp/hospital/1/10,再看 query 里是否带上 hosname、hoscode 等条件。转写稿里演示查询时也强调看请求参数,汉字会被编码属于正常现象。
怎么做:在调用处打印返回结果,或把结果设置给状态后看表格是否渲染;在 API 函数里检查 params 对象是否把 undefined 字段也传了。若接口允许空条件,空字符串或 undefined 的处理要按后端要求来。
易错点:URL 里出现 undefined,通常是 page、limit 没传;筛选不生效,通常是字段名写错或放错位置;TS 提示返回结果没有某属性,通常是 IHospitalListResponse 没定义完整或泛型没传对。把这几处核对完,getHospitalList 这个 API 封装就可以被分页列表、查询、重置等场景复用了。
本节目标与整体思路
这一节要把“医院列表”从静态表格变成真实接口数据,并让分页、页码、每页条数联动。转写稿里反复强调:先看接口四要素——请求方式、URL、参数、返回值;再封装 interface 和 API 函数;最后在组件里调用、设置状态、渲染 Table。分页的核心不是“点一下页码就完事”,而是把 current、pageSize 做成状态,让 useEffect 监听它们,状态一变就重新请求。
1. 定义分页相关状态
为什么需要: Table 的页码和每页条数不能写死,否则用户翻页时请求参数不会变。
怎么想: 分页至少需要当前页 current 和每页条数 pageSize;列表数据、加载状态也应作为状态保存。
怎么做: 按转写稿思路定义状态,请求前开启 loading,数据回来关闭 loading。
易错点: 只改 Table 的 pagination 显示,不更新状态,页码会“看起来变了但请求没变”。
tsx
const [current, setCurrent] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [list, setList] = useState([])
const [loading, setLoading] = useState(false)2. 定义获取医院分页列表的 async 函数
为什么需要: 页面初始化、翻页、改每页条数都要复用同一个请求逻辑。
怎么想: 转写稿说调用 API 时用 async/await,请求前 setLoading(true),回来 setLoading(false),再把返回值里的列表数据设置给状态。
怎么做: 在函数内部读取 current、pageSize,调用已封装好的医院分页 API,把结果设置到 list。
易错点: 忘记关闭 loading;把整个返回值直接当数组传给 Table;请求参数仍写固定值。
tsx
async function getHospitalList() {
setLoading(true)
const res = await 医院分页API({ current, pageSize })
setList(res.rows) // 具体字段以接口返回为准
setLoading(false)
}3. 定义 useEffect 并监听 current、pageSize
为什么需要: 分页状态变化后要自动重新请求,不能每次手动调用。
怎么想: 转写稿明确:监听 current 和 pageSize,它俩一变就重新发请求。
怎么做: 在 useEffect 中调用请求函数,依赖数组写 [current, pageSize]。
易错点: 依赖数组漏写,翻页不请求;请求函数每次渲染重建导致重复请求时,要结合项目实际处理,不能盲目加依赖。
tsx
useEffect(() => {
getHospitalList()
}, [current, pageSize])4. 给 Table 添加 rowKey、dataSource
为什么需要: dataSource 决定表格渲染哪批数据,rowKey 给每行唯一标识,避免 React 复用行时出错。
怎么想: 接口返回的列表数据给 dataSource;rowKey 选接口中的唯一字段。
怎么做: 在 Table 上写 rowKey 和 dataSource={list},同时把 loading 挂上。
易错点: rowKey 重复或不唯一;dataSource 不是数组;loading 写成固定 true 导致一直转圈。
tsx
<Table
rowKey="id"
loading={loading}
dataSource={list}
columns={columns}
/>5. 给 columns 配置 dataIndex 或 render
为什么需要: 表格列要告诉 Table 取哪条数据、怎么显示。
怎么想: 普通字段用 dataIndex;需要自定义内容时用 render。转写稿还提到列宽、对齐、固定列。
怎么做: 在对应列配置对象里写 dataIndex 或 render,需要时加 width、align: 'center'、fixed: 'right'。
易错点: dataIndex 和接口字段不一致;只加 fixed: 'right' 但没出横向滚动条,固定列看不到效果,需要配合 Table 的 scroll。
tsx
const columns = [
{ title: '序号', width: 60, align: 'center' },
{ title: '医院名称', dataIndex: 'houseName' },
{ title: '操作', width: 120, fixed: 'right', render: () => '操作' }
]
<Table scroll={{ x: 1300 }} />6. 给 Table 定义 pagination 实现分页
为什么需要: 让用户能切页、改每页条数,并把变化同步回状态。
怎么想: pagination 的 current、pageSize 绑定状态,onChange 里更新状态。
怎么做: 配置 pagination={{ current, pageSize, onChange: (page, size) => { setCurrent(page); setPageSize(size) } }}。
易错点: 只写 pagination 不写 onChange;页码变化后没回到第一页。转写稿调试过:在第四页搜索“北京”可能没数据,因为请求仍带第 4 页;检索条件变化时应把 current 重置为 1。
7. 把请求方法里的固定值替换成状态数据
为什么需要: 固定值只能请求第一页或固定条数,分页永远不生效。
怎么想: 请求函数内部所有分页参数都应来自状态,而不是写死数字。
怎么做: 把 current、pageSize 作为请求参数传入;检索条件也按转写稿思路做成状态,条件变化时重置页码。
易错点: 请求参数和 Table 显示状态不同步;只改了一处固定值,另一处仍写死。
tsx
const res = await 医院分页API({ current, pageSize })整体调试顺序按转写稿:先确认接口返回值,再定义状态和请求函数,接着在 useEffect 中调用,最后给 Table 配 rowKey、dataSource、columns、pagination。列宽、固定列、横向滚动条和 loading 都是 Table 体验配置,属于同一轮调试内容。
查询功能的整体思路
本节笔记给出的查询实现原理是:
- 点击查询按钮,获取表单中的数据,用来重置状态数据;
- 在
useEffect中监听表单中对应状态数据的变化,触发重新发送请求; - 请求的方法参数,都换成状态数据。
为什么需要:查询不跳转新页面,只是让列表按条件重新取数,所以不需要新建路由组件、配置路由或重做静态布局;真正要做的是复用医院分页接口,把查询条件变成可监听的状态。
怎么想:先判断“点击查询后发生了什么”——获取表单值,重置状态,状态变化触发 useEffect,请求函数携带最新状态再发一次。
怎么做:保留已有接口和 API 封装,在页面中维护 houseName、houseCode 等状态,并让请求参数使用这些状态。
易错点:不要以为查询一定要新接口;转写稿明确说仍是医院分页接口,interface 和 API 函数此前已封装过,本节重点是调用和状态联动。
表单提交与获取查询条件
为什么需要:查询按钮通常是表单提交按钮,直接用 click 也能写,但更符合表单习惯的是绑定 onFinish,由表单校验通过后触发查询。
怎么想:要拿表单值,可以用回调参数 values,也可以用 Form.useForm() 创建表单对象,再通过 getFieldsValue() 获取。转写稿选择后者,因为后面清空还要用 resetFields(),表单对象操作更灵活。
怎么做:
tsx
const [form] = Form.useForm()
const search = () => {
const { houseName, houseCode } = form.getFieldsValue()
setHouseName(houseName)
setHouseCode(houseCode)
setCurrent(1)
}
<Form form={form} onFinish={search}>
<Form.Item name="houseName">...</Form.Item>
<Form.Item name="houseCode">...</Form.Item>
<Button type="submit">查询</Button>
</Form>易错点:转写稿调试时 getFieldsValue() 返回空对象,原因不是方法没执行,而是 Form.Item 缺少 name 属性。表单值本质是键值对,没有 name 就取不到对应字段;另外提交按钮要设置 type="submit",才能触发 onFinish。
用 useEffect 监听状态重新请求
为什么需要:查询条件、当前页、每页条数都可能变化,如果每处手写请求,逻辑会散且容易漏。把请求参数状态化,由 useEffect 统一监听,可以让分页和查询共用一套取数逻辑。
怎么想:原来监听 current、pageSize,现在把 houseName、houseCode 也加入依赖;查询时只负责重置状态,不直接重复写请求代码。
怎么做:
tsx
useEffect(() => {
// 调用已有的分页请求函数
// 参数使用 current、pageSize、houseName、houseCode
}, [current, pageSize, houseName, houseCode])易错点:状态没变时不会重复发请求,这是正常现象;但查询后如果当前页仍是第 4 页,就可能出现“搜北京却没有数据”。转写稿中的调试过程就是:先点到第四页,再输入北京查询,请求仍带 current=4。因此查询时应执行 setCurrent(1),让检索结果从第一页开始看。
清空表单与清空按钮禁用
为什么需要:清空不仅要让界面上的输入框变空,还要让查询条件状态复位,并重新请求列表;否则界面空了但列表仍按旧条件显示。
怎么想:清空分两步:form.resetFields() 重置表单显示,setHouseName(undefined)、setHouseCode(undefined) 重置请求状态;同时把 current 设回 1。没有条件时清空按钮应禁用。
怎么做:
tsx
const clear = () => {
form.resetFields()
setHouseName(undefined)
setHouseCode(undefined)
setCurrent(1)
}
<Button
disabled={houseName === undefined && houseCode === undefined}
onClick={clear}
>
清空
</Button>易错点:禁用条件要按项目约定统一,转写稿用 undefined 判断;若表单可能产生空字符串,要提前统一空值判断。清空后状态若没有变化,useEffect 不会重复请求,这也是状态驱动请求的特点。
课堂画面






代码示例
1. 类型定义与 API 封装(types.ts + api.ts)
ts
// ================= 类型定义 =================
/**
* 省市区 返回数据 对象类型
*/
export interface IDistrictItem {
id: number;
createTime: string;
updateTime: string;
name: string;
value: string;
hasChildren: boolean;
}
/**
* 省市区列表类型,也是获取省市区列表接口的返回值类型
*/
export type IDistrictList = IDistrictItem[];
/**
* 医院列表参数类型
*/
export interface IHospitalListParams {
page: number;
limit: number;
hoscode?: string;
hosname?: string;
hostype?: string;
provinceCode?: string;
cityCode?: string;
districtCode?: string;
status?: number;
}
/**
* 医院列表每一项 类型
*/
export interface IHospitalItem {
id: string;
createTime: string; // 创建时间
param: {
hostypeString: string; // 医院等级
fullAddress: string; // 医院地址
};
hoscode: string; // 医院编号
hosname: string; // 医院名
hostype: string; // 医院类型
provinceCode: string; // 省
cityCode: string; // 市
districtCode: string; // 区
address: string; // 地址
logoData: string; // 医院logo base64URL
route: string; // 乘车路线
status: number; // 医院状态
bookingRule: {
cycle: number; // 预约周期
releaseTime: string; // 放号时间
stopTime: string; // 停止挂号时间
quitDay: number; // 就诊结束日期
quitTime: string; // 结束时间
rule: string[]; // 取号规则
};
}
/**
* 医院列表类型
*/
export type IHospitalList = IHospitalItem[];
/**
* 请求医院列表响应对象类型
*/
export interface IHospitalListResponse {
content: IHospitalList;
totalElements: number;
}
// ================= API 函数封装 =================
/**
* 获取省市区列表(path 参数,直接拼在 URL 上,没有第二个配置对象)
*/
export const getDistrictList = (id: number) => {
return request.get<any, IDistrictList>('/admin/cmn/dict/findByParentId/' + id);
};
/**
* 获取医院列表分页数据 api 方法(query 参数,放在第二个配置对象的 params 里)
*/
export const getHospitalList = ({
page,
limit,
hoscode,
hosname,
hostype,
provinceCode,
cityCode,
districtCode,
status,
}: IHospitalListParams) => {
return request.get<any, IHospitalListResponse>(`/admin/hosp/hospital/${page}/${limit}`, {
params: {
hoscode,
hosname,
hostype,
provinceCode,
cityCode,
districtCode,
status,
},
});
};关键行 / 易错点
request.get<any, 返回类型>的第二个泛型才是返回值类型,只有定义了这个类型,res.content、res.totalElements才能通过 TS 语法检查。- path 参数(
/findByParentId/86)直接字符串拼接,没有第二个参数;query 参数必须放进{ params: {...} },这是两种写法的核心区别。 getDistrictList的入参是number,后面 Select 的onChange拿到的值要保证是数字,否则拼出来的 URL 或类型检查会出问题。- 类型封装要严格照着接口返回值写,字段拼错(如
contactsName写成contexName)会导致提交数据时出问题。
2. 医院列表:分页 + 查询 + 清空 + loading + 列宽/固定列/滚动条
tsx
import { useEffect, useState } from 'react';
import { Button, Card, Form, Input, Space, Table } from 'antd';
import { getHospitalList, IHospitalList, IHospitalItem } from '@/api/hospital/hospitalSet';
const HospitalSet = () => {
// 表单对象:既能取查询条件,也能一键清空
const [form] = Form.useForm();
// loading 状态
const [loading, setLoading] = useState(false);
// 列表 & 分页状态
const [hospitalList, setHospitalList] = useState<IHospitalList>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(3);
// 查询条件状态(请求参数全部状态化)
const [hosname, setHosname] = useState<string | undefined>();
const [hoscode, setHoscode] = useState<string | undefined>();
// 获取医院分页列表
const getHospitalListData = async () => {
setLoading(true); // 发请求前开启 loading
const res = await getHospitalList({
page: current,
limit: pageSize,
hosname,
hoscode,
});
setHospitalList(res.content);
setTotal(res.totalElements);
setLoading(false); // 数据回来关闭 loading
};
// 监听 当前页 / 每页条数 / 查询条件,任一变化就重新请求
useEffect(() => {
getHospitalListData();
}, [current, pageSize, hosname, hoscode]);
// 查询:从表单取最新条件 -> 重置状态 -> 触发 useEffect 重新请求
const search = () => {
const { hosname, hoscode } = form.getFieldsValue();
setHosname(hosname);
setHoscode(hoscode);
// 检索条件变了,必须回到第一页
if (current !== 1) {
setCurrent(1);
}
};
// 清空:重置表单(界面)+ 重置状态(重新发请求)
const clear = () => {
form.resetFields();
setHosname(undefined);
setHoscode(undefined);
setCurrent(1);
};
const columns = [
{
title: '序号',
width: 60,
align: 'center' as const,
render: (_: any, __: any, index: number) => (current - 1) * pageSize + index + 1,
},
{ title: '医院名称', dataIndex: 'hosname', width: 150 },
{ title: '医院编号', dataIndex: 'hoscode', width: 150 },
{ title: '医院等级', dataIndex: ['param', 'hostypeString'], width: 120 },
{ title: '医院地址', dataIndex: ['param', 'fullAddress'], width: 220 },
{
title: '状态',
dataIndex: 'status',
width: 80,
align: 'center' as const,
render: (status: number) => (status === 1 ? '可用' : '不可用'),
},
{
title: '操作',
width: 120,
fixed: 'right' as const, // 最右侧列固定
render: (_: any, record: IHospitalItem) => (
<Space>
<Button type="primary" size="small">
编辑
</Button>
<Button type="primary" danger size="small">
删除
</Button>
</Space>
),
},
];
return (
<Card>
{/* 查询表单:提交按钮触发 Form 的 onFinish */}
<Form form={form} onFinish={search} style={{ marginBottom: 16 }}>
<Space>
<Form.Item name="hosname" style={{ margin: 0 }}>
<Input placeholder="医院名称" allowClear />
</Form.Item>
<Form.Item name="hoscode" style={{ margin: 0 }}>
<Input placeholder="医院编号" allowClear />
</Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
<Button
onClick={clear}
disabled={hosname === undefined && hoscode === undefined}
>
清空
</Button>
</Space>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={hospitalList}
scroll={{ x: 1300 }} // 超出 1300 出现横向滚动条,固定列才生效
pagination={{
current,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['3', '5', '10'],
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
onChange: (page: number, size: number) => {
setCurrent(page);
setPageSize(size);
},
}}
/>
</Card>
);
};
export default HospitalSet;关键行 / 易错点
- loading:
loading必须是状态,请求前setLoading(true)、回来后setLoading(false),否则表格会一直转圈;<Table loading={loading} />不要写死true。 - 分页靠 useEffect 驱动:
current / pageSize / hosname / hoscode全部状态化并放进依赖数组,任一变化自动重发请求,不要在onChange里再手动调一次请求,否则会重复发。 - 查询条件必须回第一页:
if (current !== 1) setCurrent(1),否则在第 4 页输入“北京”会带着page=4去查,导致查不到数据(这是转写稿里现场发现的 bug)。 - 清空按钮的 disabled:
hosname === undefined && hoscode === undefined时才禁用,两个条件要用&&,写成||会在只填了一个条件时就禁用。 - 清空要做两件事:
form.resetFields()只改界面,还要把状态重置成undefined,状态变了才会重新发请求;同时setCurrent(1)。 - 列宽/对齐/固定列:
width、align: 'center'、fixed: 'right'都写在columns的列配置对象里;滚动条scroll={{ x: 1300 }}写在<Table>身上,不配scroll.x时fixed: 'right'看不到任何效果。 rowKey="id"不写会有 key 警告;dataIndex: ['param', 'hostypeString']用于取嵌套字段。
3. AddOrUpdate:省市区三级联动(含联动 bug 修复)
tsx
import { useEffect, useState } from 'react';
import { Button, Card, Form, Input, Select, Space } from 'antd';
import { getDistrictList, IDistrictList } from '@/api/hospital/hospitalSet';
const AddOrUpdate = () => {
// 使用 form 对象的第 2 步:创建 form
const [form] = Form.useForm();
const [provinceList, setProvinceList] = useState<IDistrictList>([]);
const [cityList, setCityList] = useState<IDistrictList>([]);
const [dictList, setDictList] = useState<IDistrictList>([]);
// 获取省列表
const getProvinceList = async () => {
const provinceList = await getDistrictList(86); // 86 是省一级的 parentId
setProvinceList(provinceList);
};
useEffect(() => {
getProvinceList();
}, []);
// 根据省 id 获取市列表并渲染
const getCityList = async (id: number) => {
// 切换省:清空市、区表单值,并把区列表重置为空数组
form.setFieldsValue({
cityCode: undefined,
districtCode: undefined,
});
setDictList([]);
const cityList = await getDistrictList(id);
setCityList(cityList);
};
// 根据市 id 获取区列表并渲染
const getDictList = async (id: number) => {
// 切换市:只清空区表单值
form.setFieldsValue({
districtCode: undefined,
});
const dictList = await getDistrictList(id);
setDictList(dictList);
};
// 保存:values 就是所有带 name 的表单项的值
const onFinish = (values: any) => {
console.log('提交给服务器的数据:', values);
};
return (
<Card>
{/* 使用 form 对象的第 3 步:绑定 form */}
<Form
form={form}
onFinish={onFinish}
labelCol={{ span: 2 }}
wrapperCol={{ span: 22 }}
>
<Form.Item
label="医院名称"
name="hosname"
rules={[{ required: true, message: '请输入医院名称' }]}
>
<Input placeholder="请输入医院名称" />
</Form.Item>
<Form.Item
label="医院编号"
name="hoscode"
rules={[{ required: true, message: '请输入医院编号' }]}
>
<Input placeholder="请输入医院编号" />
</Form.Item>
<Form.Item
label="省"
name="provinceCode"
rules={[{ required: true, message: '请选择省' }]}
>
<Select
placeholder="请选择省"
onChange={getCityList}
options={provinceList.map((item) => ({ value: item.id, label: item.name }))}
/>
</Form.Item>
<Form.Item
label="市"
name="cityCode"
rules={[{ required: true, message: '请选择市' }]}
>
<Select
placeholder="请选择市"
onChange={getDictList}
options={cityList.map((item) => ({ value: item.id, label: item.name }))}
/>
</Form.Item>
<Form.Item
label="区"
name="districtCode"
rules={[{ required: true, message: '请选择区' }]}
>
<Select
placeholder="请选择区"
options={dictList.map((item) => ({ value: item.id, label: item.name }))}
/>
</Form.Item>
<Form.Item wrapperCol={{ span: 22, offset: 2 }}>
<Space>
<Button type="primary" htmlType="submit">
保存
</Button>
<Button>返回</Button>
</Space>
</Form.Item>
</Form>
</Card>
);
};
export default AddOrUpdate;关键行 / 易错点
Form.Item必须写name:不写 name,form.getFieldsValue()只会返回空对象{},这是转写稿里现场踩到的坑;name也决定了提交时后端收到的字段名,写错(如contexPhone)会导致提交数据错误。- 使用 form 对象四步:①
Form.Item加name→ ②const [form] = Form.useForm()→ ③<Form form={form}>绑定 → ④form.getFieldsValue()/form.setFieldsValue({...})。 - 联动 bug 修复的关键:切换省时用
form.setFieldsValue({ cityCode: undefined, districtCode: undefined })把下级的显示值清空,同时setDictList([])把区列表清空,否则会残留上一个省已选中的市/区;切换市时只清districtCode。 - 三级请求的触发时机:省在
useEffect挂载后取getDistrictList(86);市在省onChange拿到省 id 后取;区在市onChange拿到市 id 后取,不要三个都在 useEffect 里一次性请求。 Select的options用item.id作为value,这样onChange回调拿到的就是数字 id,可直接传给getDistrictList(id),省去类型转换。- 新增与编辑共用
AddOrUpdate.tsx,靠路由参数id区分:有id时是编辑(需要先取当前条医院设置数据回显到表单),无id时是新增。
常见错误
1. 带 id 进编辑页,表单是空的(或残留上一次新增的数据)
- 现象:URL 上是
/hospital/hospitalSet/addOrUpdate/xxx,但表单没有回显。 - 原因:只写了“挂载时请求一次”,没有用路由参数 id 判断当前是编辑还是新增;或
useEffect依赖数组为空,id 变化后不重新执行。 - 修法:
useParams()取 id,useEffect(..., [id])内判断if (id) { 请求详情 → form.setFieldsValue(详情) },新增分支不请求。
2. form.getFieldsValue() 返回 {}
- 现象:在
onFinish里打印 values 或调getFieldsValue(),结果永远是空对象。 - 原因:
Form.Item漏写name,表单没有 key,自然取不到值。 - 修法:逐个给需要取值的
Form.Item补name(如hosname、hoscode),再取值。
3. 切换省份后,市、区还留着上一次的选择
- 现象:换了省,市下拉里已经是新数据,但表单上旧的市、区仍显示,区列表也还是旧的。
- 原因:只
setCityList(新数据),没有清空子级表单值和子级列表状态。 - 修法:
getCityList中先form.setFieldsValue({ cityCode: undefined, districtCode: undefined })+setDictList([]);getDictList中先清districtCode。顺序是“先清、再请求、再赋值”。
4. 在第 4 页点查询,列表变空白
- 现象:翻到第 4 页,再输入条件点查询,提示无数据;看 Network 请求是
page=4。 - 原因:查询只更新了 hosname/hoscode 状态,
current仍是 4。 - 修法:查询回调里
setCurrent(1),且 current 必须同时作为请求参数和useEffect依赖。
5. 加了 fixed: 'right' 但列没有固定
- 现象:操作列配置了 fixed,横向滚动时依然跟着滚。
- 原因:表格没有横向溢出,没有产生滚动容器,fixed 无从生效。
- 修法:Table 上加
scroll={{ x: 1300 }},同时给操作列配width,两者配合才有效果。
6. loading 一直转圈,或请求失败后永远停在加载态
- 现象:接口报错后表格一直遮罩。
- 原因:只在成功分支里关闭 loading。
- 修法:请求前
setLoading(true),用try { await ... } finally { setLoading(false) }保证闭合。
7. 点“查询”按钮没反应
- 现象:按钮能点,但
onFinish不触发。 - 原因:
Button没写htmlType="submit",或Form没绑onFinish。 - 修法:两处补齐——
<Form onFinish={onSearch}>+<Button htmlType="submit">。
8. 清空按钮在表单已空时仍可点击
- 原因:没有写
disabled条件,或用了空字符串判断(表单清空后实际是undefined)。 - 修法:
disabled={hosname === undefined && hoscode === undefined},清空动作同时做三件事:form.resetFields()、重置两个状态、setCurrent(1)。
练习
练习 1|编辑回显闭环 改造 AddOrUpdate.tsx:有 id 时请求详情并回显,无 id 时保持空白;提交时根据有无 id 分别调“更新”与“新增”接口,提交成功后返回列表页。要求用浏览器前进/后退在“列表 → 编辑 → 列表”之间来回切换时,表单不串数据。
练习 2|三级联动清空 故意先不写清空逻辑,复现“换省后市区残留”的 bug,用截图记录现象;再按“父变 → 清子表单 → 清子列表状态 → 请求新子列表”的顺序修复。最后补充:切换省份时是否也要清空 districtCode?说明理由。
练习 3|查询 + 分页 + 清空 三者联动 在列表页验证三个场景并记录 Network 中每次请求的 page、hosname、hoscode 参数: ① 第 3 页输入“北京”查询;② 查询后翻到第 2 页再点清空;③ 连续点两次查询(条件不变)。说明第三个场景为什么不会重复发请求。
练习 4|loading 与异常处理 给列表请求加 loading,并人为让接口返回 500(或断网),验证 loading 能正常关闭、页面不白屏。再给清空按钮加上 disabled 逻辑,验证“有值时可用、清空后置灰”。
练习 5|表格宽度与固定列 给序号列设 width: 60 + 居中,操作列设 width: 120 + 右侧固定,Table 加 scroll={{ x: 1300 }}。然后做对比实验:去掉 scroll 后 fixed 是否还有效?把结论写成一行注释贴在代码旁。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 医院设置分页数据渲染笔记整理 | 04:03 |
| 2 | 表格loading效果 | 01:48 |
| 3 | 列宽度-列固定-滚动条 | 05:21 |
| 4 | 表单搜索查询功能实现 | 20:17 |
| 5 | 清空表单 | 05:03 |
| 6 | 添加医院设置组件创建及路由配置 | 06:59 |
| 7 | 添加医院静态布局完成 | 06:35 |
小结
- “一页两用”的判断依据只有路由参数:有没有 id 决定请求详情还是留空、决定提交时调哪个接口,不要在组件里再引入额外标志位。
- 表单值有两条通路:状态和 form 实例。整表重置用
resetFields,只清某几个字段用setFieldsValue({ key: undefined }),联动场景优先后者。 - 级联下拉的固定套路是“父变 → 清子 → 取子”,清表单值(给用户看)和清列表状态(给下次请求用)是两件事,缺一个就会留 bug。
- 列表页的请求参数应全部来自状态,请求函数里不出现硬编码常量;这样查询、分页、清空共用同一个
useEffect,改一处即生效。 - 检索条件变化必须把页码归 1,这是分页与筛选共存时的必备动作,否则用户会看到莫名其妙的空列表。
- 交互反馈类状态(loading、disabled)遵循“请求前开、结束后关”,用
try/finally兜底,避免异常路径把界面卡死。 - Table 的列级配置与表级配置要分清:宽度、对齐、fixed 属于 columns;横向滚动条属于 Table 的
scroll,而 fixed 依赖scroll才生效。 - 复用优先于重写:查询、分页、编辑都复用同一套 interface 与 api 函数,新增功能时先判断“前几步是否已经做过”,只补真正缺的那一环。