Skip to content

编辑医院设置与医院省市区列表(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。

怎么做:按笔记中的四步走:

  1. 给 Form.Item 设置 name 属性;
  2. 创建 form:const [form] = Form.useForm();
  3. 把 form 绑定到表单组件;
  4. 使用 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 体验配置,属于同一轮调试内容。

查询功能的整体思路 ​

本节笔记给出的查询实现原理是:

  1. 点击查询按钮,获取表单中的数据,用来重置状态数据;
  2. 在 useEffect 中监听表单中对应状态数据的变化,触发重新发送请求;
  3. 请求的方法参数,都换成状态数据。

为什么需要:查询不跳转新页面,只是让列表按条件重新取数,所以不需要新建路由组件、配置路由或重做静态布局;真正要做的是复用医院分页接口,把查询条件变成可监听的状态。
怎么想:先判断“点击查询后发生了什么”——获取表单值,重置状态,状态变化触发 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

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

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

小结 ​

  1. “一页两用”的判断依据只有路由参数:有没有 id 决定请求详情还是留空、决定提交时调哪个接口,不要在组件里再引入额外标志位。
  2. 表单值有两条通路:状态和 form 实例。整表重置用 resetFields,只清某几个字段用 setFieldsValue({ key: undefined }),联动场景优先后者。
  3. 级联下拉的固定套路是“父变 → 清子 → 取子”,清表单值(给用户看)和清列表状态(给下次请求用)是两件事,缺一个就会留 bug。
  4. 列表页的请求参数应全部来自状态,请求函数里不出现硬编码常量;这样查询、分页、清空共用同一个 useEffect,改一处即生效。
  5. 检索条件变化必须把页码归 1,这是分页与筛选共存时的必备动作,否则用户会看到莫名其妙的空列表。
  6. 交互反馈类状态(loading、disabled)遵循“请求前开、结束后关”,用 try/finally 兜底,避免异常路径把界面卡死。
  7. Table 的列级配置与表级配置要分清:宽度、对齐、fixed 属于 columns;横向滚动条属于 Table 的 scroll,而 fixed 依赖 scroll 才生效。
  8. 复用优先于重写:查询、分页、编辑都复用同一套 interface 与 api 函数,新增功能时先判断“前几步是否已经做过”,只补真正缺的那一环。