Skip to content

渲染科室列表与排班日期分页数据获取(Day13 2/3) ​

本页对应 React + TypeScript + 医通项目,共 7 段视频,约 55 分钟。

学习目标 ​

  • 能够根据接口文档定义医院新增请求参数类型,并封装新增医院设置的 API 函数,正确传入请求体数据。
  • 能够使用表单实例获取表单数据,完成新增医院提交、成功提示、列表页跳转及失败提示处理。
  • 能够为单条删除按钮绑定事件,使用确认弹窗获取当前记录 ID,调用删除 API,并在删除成功后提示并刷新列表。
  • 能够配置表格行选择功能,获取并维护选中项 ID 数组,根据选中数量控制批量删除按钮的禁用状态,并完成批量删除及状态清空。
  • 能够配置带路由参数的编辑路由,为编辑按钮绑定跳转事件并携带当前记录 ID。
  • 能够从路由参数中获取 ID,在组件挂载时按条件请求医院详情,并使用表单批量赋值方法将数据回显到表单,同时区分添加与编辑场景。

知识点 ​

一、先明确目标:接口数据要变成 Tree 能用的数据 ​

为什么需要:Tree 组件不直接认识接口返回的科室对象,必须通过字段映射、展开项、禁用项、选中项等配置,才能正确渲染。
怎么想:先把请求回来的 departmentList 打印出来,确认一级科室和 children 二级科室的结构,再决定哪些字段映射成标题、哪些字段作为 key。转写稿里反复强调的套路是“先看接口返回,再写状态和渲染”,这里同样适用。
怎么做:在生命周期里调用 _getDepartmentList,请求成功后先 console.log('departmentList: ', departmentList) 验证数据,再处理禁用、展开、默认选中。
易错点:不要把接口数据直接原样丢给 Tree;没有打印确认结构就写映射,容易出现标题或 key 取错。

二、fieldNames:把 depname/depcode 映射给 Tree ​

为什么需要:接口字段叫 depname、depcode,而 Tree 默认读取 title、key 等字段,不做映射就显示不出正确文本,节点 key 也不稳定。
怎么想:笔记文字里提到 key、code、children 的映射关系,但以代码为准:title 映射 depname,key 映射 depcode,children 使用默认字段即可。
怎么做:

tsx
fieldNames={{
  title: 'depname',
  key: 'depcode'
}}

易错点:属性名是 fieldNames,不是 fieldsName;key 要写 depcode,不要写成不存在的 code。

三、expandedKeys:默认展开一级科室 ​

为什么需要:科室树默认收起时,用户看不到二级科室,体验差;通常希望一级科室默认展开。
怎么想:expandedKeys 接收的是“要展开节点的 key 数组”。一级科室的 key 是 depcode,所以把所有一级科室的 depcode 收集成数组。
怎么做:

tsx
let expandedKeys = departmentList.map(item => item.depcode);
setExpandedKeys(expandedKeys);

易错点:expandedKeys 必须是数组;不要传单个字符串。设置前要确认 departmentList 已经请求成功。

四、disabled:禁用一级科室节点 ​

为什么需要:一级科室只是分组,不应被选中作为实际科室;禁用后用户只能选二级科室。
怎么想:Tree 节点支持 disabled: true。遍历一级科室对象,给每个一级对象加上 disabled,而不是给二级科室加。
怎么做:

tsx
departmentList = departmentList.map(item => {
  item.disabled = true;
  return item;
});

易错点:map 后要返回对象并重新赋值给 departmentList;如果只写 item.disabled = true 不返回,会得到 undefined 数组。

五、selectedKeys:设置默认选中科室 ​

为什么需要:进入页面时右侧通常要显示一个默认科室,不能让用户先猜着点。
怎么想:取第一个一级科室下的第一个二级科室,拿到它的 depname 和 depcode,分别设置名称和选中 key。
怎么做:

tsx
let depname = (departmentList[0].children as IDepartmentList)[0].depname;
let depcode = (departmentList[0].children as IDepartmentList)[0].depcode;
setDepname(depname);
setDepcode(depcode);
tsx
selectedKeys={[depcode as string]}

易错点:selectedKeys 是数组,不是字符串;children 可能为空,真实项目中要判空,不能假设一定有第一个二级科室。

六、onSelect:点击节点切换科室 ​

为什么需要:用户点击不同二级科室时,页面要同步更新当前科室名称和编码。
怎么想:onSelect 回调里能拿到 info.node,从节点上重新取 depcode、depname,再更新状态。
怎么做:

tsx
onSelect={(selectedKeys: any, info: any) => {
  setDepcode(info.node.depcode);
  setDepname(info.node.depname);
}}

易错点:不要从 selectedKeys 猜名称;名称在 info.node 上。类型不要长期写 any,可后续补 DataNode 等类型。

七、状态与生命周期:请求成功后再 set ​

为什么需要:科室列表、展开项、选中科室都是会变化的数据,必须用 state 驱动视图。
怎么想:先声明 departmentList、expandedKeys、depname、depcode,请求成功后统一设置;转写稿中“生命周期钩子里调用、打印 data 验证、类型可能 undefined 时做断言”的调试思路可迁移到这里。
怎么做:在 useEffect 中判断 hoscode 存在再请求;请求后依次处理禁用、展开、列表、默认选中。
易错点:笔记中依赖数组写 [],若 hoscode 后续变化不会重新请求;更稳妥是依赖 hoscode。类型断言 as string 只是告诉 TS“我确定有值”,不是运行时兜底。

完整代码 ​

tsx
let [departmentList, setDepartmentList] = useState<IDepartmentList>([]);
let [expandedKeys, setExpandedKeys] = useState<string[]>([]);// 一级科室depcode组成的数组
let [depname, setDepname] = useState<string>();
let [depcode, setDepcode] = useState<string>();
const _getDepartmentList = async ()=>{
    let departmentList = await getDepartmentList(hoscode as string);
    console.log('departmentList: ', departmentList);

    // 禁用一级科室树节点,就是给所有的一级科室对象,添加一个disabled:true
    departmentList = departmentList.map(item=>{
        item.disabled = true;
        return item;
    })
    // 展开所有的一级科室
    // 1. 获取所有一级科室 depcode组成的数组
    let expandedKeys = departmentList.map(item=>item.depcode)
    console.log('expandedKeys', expandedKeys);
    setExpandedKeys(expandedKeys);
    setDepartmentList(departmentList);// 设置科室列表状态数据

    // 处理默认选中科室
    let depname = (departmentList[0].children as IDepartmentList)[0].depname;
    let depcode = (departmentList[0].children as IDepartmentList)[0].depcode;
    // console.log(depname);
    // console.log(depcode);
    // 设置科室名和科室code状态
    setDepname(depname);
    setDepcode(depcode);
}

useEffect(()=>{
    hoscode && _getDepartmentList();
}, [])

// tree组件
<Tree                      
    // onSelect={onSelect}
    // onCheck={onCheck}
    treeData={departmentList as []}
    fieldNames={{
        title:'depname',
        key:'depcode'
    }}
    expandedKeys={expandedKeys}
    selectedKeys={[depcode as string]}
    onSelect={(selectedKeys:any,info:any)=>{
        // console.log('selectedKeys: ', selectedKeys);
        // console.log('info:', info);
        // 重新设置depcode和depname
        setDepcode(info.node.depcode);
        setDepname(info.node.depname);
    }}
/>

本节要解决的问题 ​

本节笔记只有一句核心描述:排班日期分页数据依赖于 depcode,所以当 depcode 发生改变时,要重新获取该科室的排班日期列表。 这句话看起来短,但它决定了这一块数据的请求时机和状态组织方式。排班日期分页不是“页面一进来请求一次就结束”的数据,而是“跟着科室走”的数据:当前选的是哪个科室,就查哪个科室的排班日期;科室变了,原来的分页数据就不再成立,必须重新查。

为什么需要把 depcode 作为依赖 ​

排班日期列表通常不是孤立的。它描述的是某个科室在某段时间内的排班日期分页信息,因此 depcode 是查询条件,不是普通展示字段。如果把排班日期列表当成页面初始化数据,只在组件挂载时请求一次,那么用户切换科室后,页面仍然显示上一个科室的排班日期,数据与当前科室不一致。更隐蔽的问题是分页:用户可能已经在第 3 页,切换科室后如果还保留原来的页码,就会出现“新科室没有第 3 页”或“页码与结果不匹配”的情况。所以 depcode 变化不仅是“再发一次请求”,还意味着旧的分页上下文要失效。

怎么想:把 depcode 当作查询条件,而不是页面常量 ​

设计时先分清两类状态:一类是查询条件,比如 depcode、页码、每页条数;另一类是查询结果,比如排班日期列表、总数、加载状态。depcode 属于查询条件,它一变,结果就应重新计算。由此可以推导出三个动作:第一,监听 depcode 的变化;第二,变化后重置分页条件,避免沿用旧页码;第三,用新的 depcode 和分页参数发起请求。这里的关键不是“在哪里写请求”,而是“谁决定请求”。本节笔记已经明确:决定因素是 depcode,所以请求逻辑应围绕 depcode 组织,而不是围绕某个按钮或某个生命周期写死。

怎么做:按触发链路落地 ​

实现时可以按以下链路检查。首先,在状态中保留当前 depcode,并确保它来自当前选中的科室,而不是从旧数据里临时拼出来。其次,在 depcode 变化时执行重新获取逻辑:先处理分页重置,再调用排班日期分页查询。第三,请求函数应显式接收 depcode 与分页参数,避免函数内部读取到过期值。第四,请求返回后只更新当前仍有效的查询结果;如果用户快速切换科室,要防止旧请求的响应覆盖新科室数据。第五,加载态和空态要跟着这次查询走,不能把上一个科室的“加载中”或“无数据”直接复用。以上是本节笔记能够确定的推导;笔记没有给出具体接口名、字段名和代码,因此不能补造接口细节。

易错点 ​

第一,只在页面初始化时请求一次,切换科室不重新请求,导致列表与当前科室不一致。第二,depcode 没变也重复请求,造成无意义请求。第三,depcode 改变后没有重置页码,仍带着旧页码查询新科室。第四,异步竞态:先发的旧科室请求后返回,覆盖了新科室结果。第五,depcode 为空或未定义时仍然发请求,得到无意义结果。第六,把 depcode 只放在请求函数内部读取,依赖收集不完整,变化后不触发。第七,分页参数与筛选条件混在一起更新,导致一次变化触发多次请求。排查时可以先打印当前 depcode、页码和请求参数,确认“变化前是什么、变化后是什么、请求发了几次、返回顺序是否颠倒”。

与转写稿的边界说明 ​

需要说明:提供的转写稿主要讲的是医院设置的新增、删除、批量删除、编辑路由和根据 ID 获取医院设置并渲染表单,与本条笔记“排班日期分页数据获取”不是同一主题,转写稿中也没有出现 depcode 相关推导。因此不能把医院设置里的 addHospital、删除接口、批量删除等代码硬套到本节。本节笔记没有附带图片和代码,故教程中不补造图片与代码,只保留笔记能够支撑的依赖关系和触发时机。后续若补充排班日期分页接口定义、请求参数和返回结构,再按“为什么需要 → 怎么想 → 怎么做 → 易错点”继续展开即可。

先看接口:类型定义决定封装方式 ​

本节虽名为“医生列表渲染”,实际落在医院设置列表的增删改与编辑回显上。做任何列表操作前,先查接口:请求方式、URL、参数、响应结果。为什么需要?因为只有看清接口,才知道要不要定义类型、API 函数怎么传参。怎么想?先判断响应结果和请求参数是否复杂:如果返回值是 null,就不定义响应类型;如果请求体是多个字段组成的对象,就定义请求参数类型。怎么做?在 model 中定义类似 IHospitalSetData 的请求体类型,字段按接口文档保留,删掉不需要的字段。易错点:从接口文档复制参数对象后,必须删掉多余字段和最后一个多余逗号;转写中测试“创建失败”就是因为对象末尾多了逗号,删掉后立即成功。

ts
// 请求体参数类型按接口保留所需字段
export interface IHospitalSetData {
  contactsName: string
  contactsPhone: string
  houseCode: string
  houseName: string
}

封装新增接口与表单取值 ​

为什么需要?新增医院时要提交表单数据,不能手写散乱参数。怎么想?API 层只负责发请求,页面层负责取表单值和提示跳转。怎么做?封装 addHospitalSet(data: IHospitalSetData),内部用 request.post,第二个参数直接放 data。页面里用 Form.useForm() 创建表单实例,绑定到表单,再用 form.getFieldsValue() 一次拿到全部字段;注意是带 s 的 getFieldsValue,不是单个字段的 getFieldValue。提交时加 try/catch,成功 message.success('添加成功') 并 navigate 到医院设置列表;失败提示“添加失败”,不跳转。易错点:按钮要设 htmlType="submit";成功后才跳转;失败时留在当前页。

ts
const data = form.getFieldsValue()
await addHospitalSet(data)
message.success('添加成功')
navigate('/hospital/list')

单个删除与列表刷新 ​

为什么需要?列表每行都要能删当前记录。怎么想?删除按钮在 columns 的自定义渲染里,点击时要把当前行 id 传出去,先弹确认框,再调接口。怎么做?封装 deleteHospitalSetById(id: string),用 request.delete,URL 用模板字符串拼 id。按钮绑定 onClick={() => deleteById(record.id)}。在 deleteById 中 Modal.confirm,点确定后 await deleteHospitalSetById(id),成功 message.success('删除成功'),再重新调用获取列表函数刷新。易错点:确认框的确定回调里再发请求;删除后必须刷新列表;系统数据可能不允许删除,失败要提示,不要误以为接口坏了。

ts
const deleteById = (id: string) => {
  Modal.confirm({
    title: '确定删除当前记录吗?',
    onOk: async () => {
      await deleteHospitalSetById(id)
      message.success('删除成功')
      getList()
    }
  })
}

批量删除与选中状态 ​

为什么需要?表格需要多选后批量删除,按钮还要根据是否选中决定禁用/激活。怎么想?Table 的 rowSelection 负责多选,selectedRowKeys 存选中的 ID 数组,onChange 在选中状态变化时触发。怎么做?配置 rowSelection={{ selectedRowKeys, onChange }},用 useState<(string | number)[]>([]) 保存选中 ID;onChange 中 setSelectedRowKeys(keys)。批量删除按钮的 disabled 根据 selectedRowKeys.length === 0 判断。封装 removeBatch(ids: (string | number)[]),注意转写中该接口是 DELETE 请求,但 ID 数组要放在请求体里,所以配置项用 data: ids。删除成功后提示、刷新列表,并把选中状态清空。易错点:删除后不清空 selectedRowKeys,批量删除按钮会一直激活;DELETE 请求体不是 params,要按接口文档放 data。

ts
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([])

<Table
  rowSelection={{
    selectedRowKeys,
    onChange: (keys) => setSelectedRowKeys(keys)
  }}
/>

编辑路由与参数传递 ​

为什么需要?点编辑要进入编辑页,并把当前记录 ID 带过去。怎么想?添加和编辑可共用同一个页面组件,因此不必新建静态页面,只补路由配置。怎么做?在路由中配置 /hospital/edit/:id,:id 是路径占位符,表示 params 参数;编辑按钮点击时 navigate('/hospital/edit/' + record.id)。易错点:路径参数要在路由里写占位符;跳转地址最好复制路由配置,避免手写错;编辑路由通常也要隐藏侧边栏,和添加页保持一致。

编辑回显与添加/编辑共用页面 ​

为什么需要?进入编辑页后要把当前 ID 的医院数据回显到表单。怎么想?进页面就发请求,属于生命周期调用,不是按钮交互调用。怎么做?用 useParams() 取路由中的 id,在 useEffect 中调 getHospitalSetById(id),拿到 data 后用 form.setFieldsValue(data) 批量回显。转写中强调:id 可能被推断为 string | undefined,可断言其一定存在。共用页面时,添加操作没有 ID,不应发获取详情请求;因此要判断“ID 存在说明是编辑,发请求;ID 不存在说明是添加,不发请求”。易错点:getFieldsValue 是取值,setFieldsValue 是回显;不要混淆;添加页误发详情请求会带 undefined,要加存在性判断。

说明 ​

本节笔记主题是“项目完成打包上线部署”。转写稿主要记录医院设置相关功能的开发过程,与本节部署主题不一致,因此下面只依据本节笔记扩写部署流程,不把无关功能写入本节。

先构建最终目录 ​

为什么需要:开发环境依赖开发服务器、热更新和代理配置,不能直接把这些文件丢到线上。上线前必须生成浏览器可直接访问的静态资源。

怎么想:先执行构建命令,得到最终产物目录,再把该目录交给静态服务器托管。笔记中构建命令是 npm run build,产物目录是 build。

怎么做:

bash
npm run build

执行后检查项目根目录是否生成 build 目录,里面应包含 index.html、JS、CSS、图片等静态资源。

易错点:不要直接部署 src;不要漏掉构建;部署时要以实际产物目录为准。若服务文件与 build 不同级,静态目录不能照抄 ./,应改成 ./build 或把服务文件放进 build 内再运行。

部署需要解决的三类问题 ​

为什么需要:构建后的项目不是双击 index.html 就能完整运行。笔记明确列出三个问题:刷新子路由出现 404、静态资源目录要配置、接口请求要跨域。

怎么想:用 Express 启动一个 Node 服务:先处理前端路由回退,再托管静态资源,最后把 /prod-api 开头的请求代理到目标服务器。

怎么做:安装依赖并编写服务文件:

bash
npm i express connect-history-api-fallback http-proxy-middleware

易错点:三个中间件的顺序和路径要匹配;代理前缀必须与前端请求前缀一致;不要把后端地址写错。

用 history 解决刷新 404 ​

为什么需要:前端使用 history 路由时,刷新 /hospital 这类地址,浏览器会向服务器请求该路径。服务器若没有对应文件,就返回 404。

怎么想:所有找不到的页面请求都回退到 index.html,交给前端路由处理。

怎么做:笔记代码如下:

js
const express = require('express');
const history = require('connect-history-api-fallback');
const { createProxyMiddleware } = require('http-proxy-middleware');

const app = express();

// 0. 所有404的相应,使用 index.html替换
app.use(history());

易错点:history() 要放在静态资源托管之前,保持笔记顺序;它主要处理页面请求,不要误以为它能替代接口代理。

配置静态资源目录 ​

为什么需要:浏览器要能访问构建后的 index.html、JS、CSS 等文件,必须由 Express 指定静态目录。

怎么想:把构建产物所在目录交给 express.static。笔记写的是当前目录 ./,说明部署时服务文件与静态资源处于同一可访问根目录。

怎么做:

js
// 1. 配置当前目录为静态资源目录
app.use(express.static('./'));

易错点:如果 server.js 在项目根目录、产物在 build,应改为 express.static('./build');否则会访问不到构建文件。

配置代理服务器完成跨域 ​

为什么需要:生产环境前端由 Express 提供,接口在另一台服务器。浏览器直接请求目标域名会跨域。通过同源 Express 转发,可规避浏览器跨域限制。

怎么想:前端统一请求 /prod-api,Express 收到后转发到 http://syt-api.atguigu.cn,并用 pathRewrite 去掉 /prod-api 前缀。

怎么做:

js
// 2. 进行跨域配置
app.use('/prod-api', createProxyMiddleware(
    {
        target: "http://syt-api.atguigu.cn", // 目标服务器地址
        changeOrigin: true, // 允许跨域
        pathRewrite: {
            // 路径重写
            "^/prod-api": "",
        },
    }
));
app.listen(5000, () => {
    console.log('server run at 5000');
})

易错点:target 不要带错协议;changeOrigin: true 要保留;pathRewrite 的键要与代理前缀一致;前端请求地址也要以 /prod-api 开头,否则代理不生效。

启动与验证 ​

怎么做:在服务文件目录执行 node 服务文件名.js,浏览器访问 http://localhost:5000。重点验证:首页能打开、刷新子路由不 404、静态资源能加载、接口请求走 /prod-api 并成功返回。

易错点:端口被占用要换端口;修改服务文件后要重启 Node;部署服务器要允许对应端口访问。

类组件复习:state、props、事件与生命周期 ​

为什么需要:类组件是 React 早期的主要写法,复习它有助于理解 state、props、生命周期和事件绑定这些基础模型。怎么想:state 是组件内部可变数据,props 是外部传入数据;事件回调里的 this 容易丢,是因为回调执行时不再由组件实例直接调用。怎么做:state 可用属性直接赋值或构造函数赋值;props 用 this.props 读取;事件绑定写成 <button onClick={事件处理函数}></button>。this 问题常用三种方式处理:包裹箭头函数、bind、直接定义成箭头函数。事件对象是 React 合成事件,已做兼容处理,e.nativeEvent 可拿原生事件;React 默认把事件对象传给第一个形参。

jsx
const click1 = (e)=>{
    // e 作为第一个形参,就可以接收到事件对象
}
<button onClick={click1}>点击</button>

生命周期要抓住“挂载后、更新后、卸载前”:

js
// componentDidMount 组件挂载后执行, 只会执行一次
// 发送ajax请求,开启定时器,订阅消息,绑定自定义事件
// componentDidUpdate 组件更新执行 render--》componentDidUpdate
// newprops  setState forceUpdate ==> render
// 有机会执行多次
// componentWillUnmount 组件卸载前执行  只会执行一次
// 取消定时器、取消订阅、解绑自定义事件

易错点:不要在 render 里发请求或改 state;卸载时要清理定时器、订阅和事件;普通函数事件回调要处理 this,否则拿不到组件实例。

JSX 插值表达式:条件渲染只能写表达式 ​

为什么需要:JSX 里要嵌入动态内容,但 JSX 不是普通语句块。怎么想:花括号里放“表达式”,不能直接写 if 语句。怎么做:条件渲染用三元、逻辑与、函数调用。

js
// 条件渲染:在jsx模板中,只能使用三元表达式、逻辑运算
return (
    <>
      <div>三元表达式:{条件?<div>111</div>:<div>222</div>}</div>
      <div>逻辑运算:{条件 && <div>222</div>}</div>
      <div>函数调用:{fn()}</div>
    </>
)

易错点:三元表达式闭合标签要写完整;逻辑与左边为假值时会直接返回假值;不要在 JSX 花括号里写 if。

函数组件与 Hooks:state、props、事件、useEffect ​

为什么需要:函数组件更简洁,Hooks 让函数组件也能拥有状态和生命周期能力。怎么想:useState 管状态,props 用形参接收,事件没有类组件 this 问题。怎么做:props 可逐个传,也可用扩展运算符一起传。

js
let [user, setUser] = useState({
    count:1,
    msg:'atguigu'
})
<Child {...user}/>

生命周期用 useEffect 模拟:

js
useEffect(()=>{// didMount 组件挂载调用一次
    return ()=>{ // willUnmount  调用一次
    }
},[])

useEffect(()=>{
})
useEffect(()=>{
},[count])

易错点:依赖数组不同,执行时机不同;清理函数写在 return 里;函数组件事件回调中的 this 是 undefined,不要照搬类组件写法。

Redux 与路由复习 ​

为什么需要:中大型页面要把共享状态和页面切换管清楚。怎么想:Redux 用 Provider 注入 store,组件用 useSelector 取数、useDispatch 派发;路由用 BrowserRouter、Link/NavLink、useNavigate、useRoutes 组织。怎么做:

js
<Provider store={}>
</Provider>
useSelector
useDispatch
useDispatch(actionCreator(payload))==> useDispatch(action对象)
js
<BrowserRouter></BrowserRouter>
<NavLink > 
<Link>
useNavigate 创建编程式导航
路由表:useRoutes(路由表数组)   path  element  children
路由参数:
1. useParams()        path
2. useSearchParams()  query
3. useLocation()      state
BrowserRouter NavLink  Link Outlet  from  'react-router'

易错点:Provider 要包住使用 store 的组件;path 参数、query 参数、state 参数要用对应 Hook 取;路由表里子路由要配 Outlet。

ref、受控与非受控、Redux Toolkit ​

为什么需要:ref 用于直接拿 DOM 或组件实例;受控/非受控决定表单值由谁管理;Redux Toolkit 简化 Redux 写法。怎么做:根节点用 ReactDOM.createRoot 创建。

js
const root = ReactDOM.createRoot(dom元素)

ref 可绑定 DOM 获取元素、模拟 componentDidUpdate、给类组件拿实例、给函数组件配合 React.forwardRef 让父组件拿子组件 DOM。受控组件由状态控制,value/checked 赋值后只读,靠 onChange 更新状态;非受控用 defaultValue/defaultChecked,通过 ref 取真实 DOM 的 .value。Redux Toolkit 先 createSlice,再 configureStore:

js
configureStore({
    reducer:{
        count:countSlice.reducer
    }
})

取数据用 store.getState(),改数据用 store.dispatch(actionCreator(payload)),等价于派发 action 对象。易错点:受控组件漏写 onChange 会无法输入;函数组件不能直接绑 ref;切片 name 必填。

项目串联:添加、删除、批量删除、编辑的调试思路 ​

转写稿里的医院项目把复习点串起来了。添加医院时先看接口:请求方式、URL、参数、响应结果;响应为 null 时不必定义响应类型,只定义请求体类型。调试时最后多写一个逗号会导致创建失败,删掉逗号才成功。删除按 ID 删除时,接口是 delete,参数简单、返回 null,可不定义类型;点击删除要传当前行 id,弹确认框,请求成功后提示并刷新列表。批量删除要看 Table 的 rowSelection:用 selectedRowKeys 和 onChange 配合,把选中 ID 存进 state;按钮是否禁用由选中数组长度决定;接口要求把 ID 数组放在请求体,所以配置里用 data 传,成功后要清空选中状态并刷新。编辑与添加常共用页面:路由配 :id 占位符,编辑按钮 navigate 带上 ID;进页面用 useParams 取 ID,在 useEffect 中判断“ID 存在才请求”,否则就是添加操作;请求回来用 form.setFieldsValue(data) 批量回填。易错点:添加时也会进同一页面,不判断 ID 会多发请求;useParams 的 ID 可能为 undefined;批量删除后不清空选中,按钮会一直激活。

先看接口,再决定封装什么 ​

添加医院接口字段示意图

做“添加医院”时,先别急着写页面。为什么需要先看接口?因为要确认请求方式、URL、请求参数和响应结果,才能决定要不要定义类型、API 函数怎么传参。怎么想:打开新增医院接口,发现是 POST,请求体里实际只需要 5 个字段;接口文档里多出来的 apiUrl、signKey 等要删掉。怎么做:在 hospital 的 model 里定义请求体类型,再封装 API。易错点:转写里测试接口时,对象最后一个字段后多了一个逗号,结果“创建失败”;删掉逗号、其他不动就成功了。所以接口测试和代码里都不能多逗号。

ts
export interface IHospitalSetData {
  contactName: string
  contactPhone: string
  houseCode: string
  houseName: string
  status: number
}

export const addHospitalSet = (data: IHospitalSetData) => {
  return request.post('接口地址', data)
}

这里响应结果不需要额外定义类型;请求参数类型明确,添加接口就够用了。

添加医院:表单取值与提交 ​

为什么用 Form.useForm()?因为要统一拿到表单值并做校验。怎么想:创建 form,绑定到表单,提交时用 getFieldsValue() 一次性拿对象;getFieldValue('字段') 是单个取值。怎么做:在保存回调里 try/catch 调 API,成功提示并跳转列表,失败只提示、不跳转。易错点:getFieldsValue 带 s,不要写成单个取值;跳转地址最好复制路由配置,避免手写出错。

ts
const [form] = Form.useForm()

const onFinish = async () => {
  const data = form.getFieldsValue()
  try {
    await addHospitalSet(data)
    message.success('添加成功')
    navigate('/hospital-set')
  } catch (e) {
    message.error('添加失败')
  }
}

单个删除:参数简单时可不定义类型 ​

为什么先看删除接口?删除也有“按 ID 删除”和“批量删除”两个接口。怎么想:点击当前行删除,用按 ID 删除;请求方式 DELETE,参数只有 id,响应是 null。怎么做:不需要定义响应类型,也不需要复杂参数类型,直接封装 API。易错点:删除前要 Modal.confirm;系统数据不能删,测试时用自己新增的数据。

ts
export const deleteHospitalSetById = (id: string) => {
  return request.delete(`接口地址/${id}`)
}

const deleteById = async (id: string) => {
  await deleteHospitalSetById(id)
  message.success('删除成功')
  getList()
}

批量删除:先拿选中 ID,再控制按钮状态 ​

为什么需要 rowSelection?因为表格要出现复选框,并把选中行 ID 组成数组。怎么想:selectedRowKeys 与 onChange 配合;onChange 的参数就是当前选中 ID 数组。怎么做:用状态保存 selectedRowKeys,按钮根据长度决定禁用/激活。易错点:批量删除成功后必须清空 selectedRowKeys,否则按钮仍按旧长度保持激活。

ts
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])

<Table
  rowSelection={{
    selectedRowKeys,
    onChange: setSelectedRowKeys
  }}
/>

<Button disabled={selectedRowKeys.length === 0}>批量删除</Button>

批量删除接口:DELETE 也可能带请求体 ​

为什么批量删除要单独封装?因为它传的是 ID 数组,不是单个 ID。怎么想:接口要求 DELETE,并把数组放在请求体里;request.delete 的请求体通常放在配置对象的 data 中。怎么做:封装 removeBatch,成功后提示、刷新列表、清空选中状态。易错点:不要误把数组放到 URL 上;清空状态这一步不能漏。

ts
export const removeBatch = (ids: React.Key[]) => {
  return request.delete('接口地址', { data: ids })
}

const onBatchDelete = async () => {
  await removeBatch(selectedRowKeys)
  message.success('批量删除成功')
  setSelectedRowKeys([])
  getList()
}

编辑:复用添加页面,配置动态路由 ​

为什么编辑不新建页面?因为添加和编辑表单结构一样,可以复用同一个路由组件。怎么想:编辑要携带当前行 ID,且是路径参数,所以路由要配占位符。怎么做:配置 /hospital-set/edit/:id,编辑按钮点击后 navigate 到带 ID 的地址。易错点:路径参数要拼在路径里,不要当查询参数写。

ts
{ path: '/hospital-set/edit/:id', element: <AddOrUpdate /> }

onClick={() => navigate(`/hospital-set/edit/${record.id}`)}

编辑回显:有 ID 才请求,再批量设置表单 ​

为什么在生命周期里请求?因为进入编辑路由组件就要拿当前 ID 的数据,不是用户再点按钮才拿。怎么想:useParams 取路径 ID,useEffect 中请求,form.setFieldsValue(data) 批量回显。怎么做:添加和编辑共用页面时,用“有没有 ID”区分操作。易错点:转写里调试发现,点添加也进入同一页面并发了请求,只是 ID 为 undefined;所以要加 if (!id) return。

ts
const { id } = useParams()

useEffect(() => {
  if (!id) return
  const getData = async () => {
    const data = await getHospitalSetById(id)
    form.setFieldsValue(data)
  }
  getData()
}, [id])

这样,编辑时回显原数据;添加时没有 ID,就不发获取详情请求。

17. 未来三年:医院设置增删改与批量操作 ​

本节围绕医院设置模块,把“添加、单条删除、批量删除、编辑回显”串成一条完整开发链路。转写中的同音字按上下文纠正为:interface、POST、DELETE、path 参数、try/catch。整体套路是:先看接口的请求方式、URL、参数、响应结果,再判断要不要定义类型,最后封装 API 函数并在交互或生命周期里调用。

添加医院:先看接口,再定类型与 API ​

为什么需要: 添加功能不能先写页面逻辑,必须先确认接口要什么。转写里点开“新增医院设置”接口后,发现是 POST 请求,界面实际只需要 5 个字段,因此要把接口示例里多余的 apiUrl、时间、houseCode/houseName、ID、签名等不需要项删掉。
怎么想: 返回值是 null,所以不需要定义响应结果类型;但请求体有多个字段,需要定义请求参数类型。类型定义完,再封装 addHospitalSet。
怎么做: 在 model 里定义请求体类型,在 API 文件里写函数,把 data 作为 POST 请求体传入。示意如下:

ts
// 字段按接口只保留需要的 5 个
export interface IHospitalSetData {
  // contactName、contactPhone 等以实际接口为准
}

export const addHospitalSet = (data: IHospitalSetData) =>
  request.post(url, data)

易错点: 转写中专门调试了“对象最后一个逗号”:多留一个逗号时创建失败,删掉后其他不动就成功。所以测试接口和写请求体时,要检查 JSON/对象末尾逗号。

添加医院:表单取值、提交与跳转 ​

为什么需要: 接口封装好后,要在一个具体交互里调用;添加页的入口是“保存”按钮。
怎么想: 先用 useForm 创建表单实例并绑定,提交时用 getFieldsValue() 一次性拿到表单对象,而不是逐个 getFieldValue。发请求可能失败,所以用 try/catch。
怎么做: 点击保存后取表单值,调用 addHospitalSet(data);成功后 message.success 提示并 navigate 回医院设置列表;失败则提示“添加失败”,不跳转。

ts
const [form] = useForm()
const data = form.getFieldsValue()

try {
  await addHospitalSet(data)
  message.success('添加成功')
  navigate('/医院设置列表地址')
} catch (e) {
  message.error('添加失败')
}

易错点: getFieldsValue() 带 s,拿的是整个表单对象;getFieldValue 是单个字段。转写中先打印 data 确认 5 个值都有,再发请求,这是很实用的调试步骤。

单条删除:按 ID 删除并刷新列表 ​

为什么需要: 列表每行都有删除按钮,点击后要删除当前记录,不能跳新页面。
怎么想: 删除接口是 DELETE,参数只有 ID,返回值是 null,所以不需要定义参数类型和响应类型,直接封装 deleteHospitalSetById(id) 即可。
怎么做: 在列配置的删除按钮上绑定点击事件,把当前行 ID 传进去;弹确认框,确认后发请求,成功提示并重新调用列表查询函数刷新。
易错点: 转写中系统前 10 条数据不能删,点击会提示“系统数据不能操作”,这是业务限制,不是接口写错。删除成功后要刷新列表;取消确认框时不应发请求。

ts
export const deleteHospitalSetById = (id: string | number) =>
  request.delete(`/接口地址/${id}`)

批量删除:rowSelection、选中 ID 状态与清空 ​

为什么需要: 表格要支持勾选多行后批量删除,需要拿到所有选中行的 ID 数组。
怎么想: 用 Table 的 rowSelection,配置 selectedRowKeys 和 onChange。onChange 的第一个参数就是选中 ID 数组,把它放进状态 selectedKeys,再用长度控制“批量删除”按钮是否禁用。
怎么做: 批量删除接口要求 DELETE 请求体携带 ID 数组,因此封装 removeBatch(ids),请求配置里用 { data: ids }。点击批量删除时弹确认框,成功后提示、刷新列表,并执行 setSelectedKeys([]) 清空选中状态。

ts
export const removeBatch = (ids: React.Key[]) =>
  request.delete(url, { data: ids })

易错点: 转写中强调:批量删除成功后必须清空 selectedKeys,否则按钮会一直处于激活状态,因为按钮状态是根据选中数组长度判断的。另外 DELETE 一般少用请求体,但本接口明确要求放在 body 里,要按接口说明写 data。

编辑回显:path 参数、useParams 与表单回填 ​

为什么需要: 点击编辑要进入编辑页,并显示当前医院原来的数据。添加和编辑可共用同一个 ADD/UPDATE 页面,所以只需配置编辑路由,不必重新建页面。
怎么想: 编辑路由要携带 ID,属于 path 参数,路由配置需要占位符。进入组件后通过 useParams() 取 ID;在生命周期里请求详情。若 ID 存在说明是编辑,发请求;ID 不存在说明是添加,不发请求。
怎么做: 封装 getHospitalSetById(id),用 useEffect 调用;拿到数据后用 form.setFieldsValue(data) 批量回填表单。

ts
const { id } = useParams()
useEffect(() => {
  if (id) {
    // 请求详情并 setFieldsValue
  }
}, [])

易错点: 转写中调试发现:编辑页和添加页共用组件时,如果无条件发详情请求,添加页的 ID 是 undefined,也会发无效请求。因此必须先判断 id 是否存在。另一个易错点是路由参数从地址栏来,类型可能被推断为 string | undefined,必要时按业务断言,但断言前要确保路由一定带 ID。

说明:本节笔记未附图片和独立代码块,以下代码片段按转写稿中的函数名、接口信息整理为“代码要点”,不补造未出现的字段。

先看接口,再决定要不要定义类型 ​

为什么需要:添加医院不是先写页面,而是先确认接口的请求方式、URL、参数和响应结果。只有接口清楚了,才能判断要不要定义请求参数类型、响应结果类型。

怎么想:新增医院大概率是 POST 请求。点开接口后看请求体,页面上实际只提交 5 个数据,而接口示例里给了更多字段,因此要把不需要的字段删掉。转写稿里特别提醒:测试接口时对象最后多了一个逗号,导致“创建失败”;删掉逗号后,其他都没动就成功了。这说明接口调试要先排除 JSON 格式问题。

怎么做:在 model 中定义请求体参数类型,例如 IHospitalSetData,只保留接口需要的字段;响应结果不需要额外定义。然后在 API 文件中封装 addHospitalSet(data),发送 POST 请求,把 data 作为请求体传入。

易错点:不要照抄接口示例里的全部字段;删除字段后要检查最后一个属性后面的逗号;先测接口成功,再写前端调用。

添加医院:表单取值、发请求、提示并跳转 ​

为什么需要:接口封装好后,要在“保存”按钮里拿到表单数据并提交,否则 API 函数没有调用入口。

怎么想:用 useForm 创建表单实例,绑定到表单;提交时用 getFieldsValue() 一次性拿到对象。转写稿中区分了 getFieldValue(name) 和 getFieldsValue():前者按字段取值,后者拿整个表单对象。添加成功后要提示并跳转到医院设置列表页。

怎么做:保存回调中先 try/catch,调用 addHospitalSet(data);成功后 message.success('添加成功'),再 navigate 到列表页;失败则提示“添加失败”,不跳转。请求期间可加 loading 状态。

易错点:getFieldsValue 带 s;跳转地址最好复制路由配置,避免手写错;失败时不要跳转。

单条删除:接口简单,也要确认参数和返回值 ​

为什么需要:列表中的“删除”只删当前记录,必须拿到当前行 ID。

怎么想:删除接口有两个,一个是按 ID 列表批量删除,一个是 remove 按单个 ID 删除。当前按钮应使用单条删除接口。该接口返回 data 为 null,参数只有一个 ID,因此不需要定义响应类型和参数类型,直接封装 API 函数即可。

怎么做:封装 deleteHospitalSetById(id),发送 DELETE 请求,URL 中用模板字符串拼接 ID。表格自定义列里给删除按钮绑定 onClick,传入 row.id;点击后弹 Modal.confirm,确认后调用 API,成功提示并重新获取列表。

易错点:删除前必须确认,避免误操作;不要误用批量删除接口;确认框里要写清“删除当前记录”。

批量删除:rowSelection 与选中 ID 状态 ​

为什么需要:批量删除要先让表格出现复选框,并实时拿到选中的 ID 数组。

怎么想:查看 Table 的 rowSelection,它需要 selectedRowKeys 和 onChange 配合。onChange 的第一个参数就是选中 ID 组成的数组,类型为字符串或数字的联合类型数组。

怎么做:给 Table 配置 rowSelection,用 selectedKeys 状态保存选中 ID;onChange 中 setSelectedKeys(keys)。批量删除按钮用 disabled={selectedKeys.length === 0} 控制禁用/激活。

易错点:selectedRowKeys 和 onChange 要成对使用;状态不更新,按钮激活状态就不会变。

批量删除请求:DELETE 也可能带请求体 ​

为什么需要:批量删除接口要求传 ID 数组,且转写稿明确该参数在请求体 body 中,不是普通查询参数。

怎么想:虽然 DELETE 请求较少带请求体,但要以接口文档为准。返回值是 null,不需要定义响应类型,直接封装 removeBatch(ids)。

怎么做:removeBatch(ids) 中发送 DELETE 请求,把 ids 放到配置的 data 字段。点击批量删除后弹确认框,调用 removeBatch(selectedKeys);成功后提示、刷新列表,并执行 setSelectedKeys([]) 清空选中状态。

易错点:删除成功后必须清空选中 ID,否则批量删除按钮仍可能保持激活;不要想当然把数组拼到 URL 上。

编辑路由与按钮跳转 ​

为什么需要:点击编辑要进入编辑页,并携带当前医院 ID。

怎么想:添加和编辑共用同一个页面组件,因此不用再创建新组件和静态结构,只配置路由即可。编辑路由需要路径参数占位符,例如 /hospital/edit/:id,并隐藏侧边栏、复用添加页组件。

怎么做:在路由中复制添加路由并改为编辑路由;表格编辑按钮的 onClick 中调用 navigate,路径拼接 row.id。

易错点:ID 是路径参数,路由必须配置 :id;跳转地址不要手写错。

编辑页回显与共用页面的小 bug ​

为什么需要:进入编辑页后,要把当前 ID 的医院数据回显到表单。

怎么想:根据 ID 获取医院设置接口,返回的数据结构之前已定义过,可直接复用类型并封装 getHospitalSetById(id)。进入路由组件属于生命周期时机,不是用户点击,所以用 useEffect;路径参数用 useParams 获取。

怎么做:在 useEffect 中根据 ID 调接口,拿到 data 后调用 form.setFieldsValue(data) 批量回显。转写稿调试发现:添加页和编辑页共用组件时,添加也会发获取数据请求,此时 ID 为 undefined。因此要判断:ID 存在才是编辑,发请求;ID 不存在就是添加,不发请求。

易错点:useParams 拿到的 ID 可能为空,调用前要判断;setFieldsValue 传的是对象,不是单个字段值。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

一、渲染科室列表(Tree 组件 + 状态数据) ​

tsx
import { useEffect, useState } from 'react';
import { Tree } from 'antd';
import { getDepartmentList } from '@/api/hospital'; // 科室列表接口

interface IDepartment {
  depcode: string;
  depname: string;
  disabled?: boolean;
  children?: IDepartment[];
}
type IDepartmentList = IDepartment[];

const DepartmentList = ({ hoscode }: { hoscode: string }) => {
  const [departmentList, setDepartmentList] = useState<IDepartmentList>([]);
  const [expandedKeys, setExpandedKeys] = useState<string[]>([]); // 一级科室 depcode 数组
  const [depname, setDepname] = useState<string>();
  const [depcode, setDepcode] = useState<string>();

  const _getDepartmentList = async () => {
    let departmentList: IDepartmentList = await getDepartmentList(hoscode as string);
    console.log('departmentList: ', departmentList);

    // 禁用一级科室树节点:给所有一级科室对象添加 disabled: true
    departmentList = departmentList.map((item) => {
      item.disabled = true;
      return item;
    });

    // 展开所有一级科室:取所有一级科室 depcode 组成的数组
    const expandedKeys = departmentList.map((item) => item.depcode);
    console.log('expandedKeys', expandedKeys);
    setExpandedKeys(expandedKeys);
    setDepartmentList(departmentList);

    // 处理默认选中科室:第一个一级科室下的第一个二级科室
    const first = (departmentList[0].children as IDepartmentList)[0];
    setDepname(first.depname);
    setDepcode(first.depcode);
  };

  useEffect(() => {
    hoscode && _getDepartmentList();
  }, []);

  return (
    <Tree
      treeData={departmentList as []}
      fieldNames={{ title: 'depname', key: 'depcode' }}
      expandedKeys={expandedKeys}
      selectedKeys={[depcode as string]}
      onSelect={(_selectedKeys: any, info: any) => {
        // 点击切换树节点,重新设置 depcode 和 depname
        setDepcode(info.node.depcode);
        setDepname(info.node.depname);
      }}
    />
  );
};

export default DepartmentList;

关键行与易错点

  • fieldNames={{ title: 'depname', key: 'depcode' }}:把后端字段映射成 Tree 需要的 title / key(还有 children),不配置树就渲染不出来。
  • item.disabled = true 只加在一级科室上,二级科室保持可点击;map 里必须 return item,漏掉返回值列表会变成 undefined。
  • expandedKeys 必须是受控状态:先用 departmentList.map 取出所有一级 depcode 再 setExpandedKeys,否则树默认是收起的。
  • selectedKeys={[depcode as string]} 是数组形式;onSelect 里取值用 info.node.depcode,不是 selectedKeys(那是 key 数组)。
  • useEffect 依赖数组为空 + hoscode && 判断,避免 hoscode 还没拿到就发请求。

二、排班日期分页数据获取(依赖 depcode) ​

tsx
import { useEffect, useState } from 'react';
import { getScheduleList } from '@/api/hospital';

interface ScheduleItem {
  workDate: string;
  availableNumber: number;
}

const ScheduleList = ({ hoscode, depcode }: { hoscode: string; depcode?: string }) => {
  const [scheduleList, setScheduleList] = useState<ScheduleItem[]>([]);
  const [total, setTotal] = useState(0);
  const [current, setCurrent] = useState(1);
  const [pageSize] = useState(5);
  const [loading, setLoading] = useState(false);

  const _getScheduleList = async () => {
    setLoading(true);
    try {
      const res = await getScheduleList({
        page: current,
        limit: pageSize,
        hoscode,
        depcode: depcode as string,
      });
      setScheduleList(res.bookingScheduleList);
      setTotal(res.total);
    } finally {
      setLoading(false);
    }
  };

  // depcode 发生变化 → 重新获取该科室的排班日期列表
  useEffect(() => {
    depcode && _getScheduleList();
  }, [depcode, current]);

  return (
    <>
      {/* 日期列表 + 分页渲染 */}
      <ul>
        {scheduleList.map((item) => (
          <li key={item.workDate}>
            {item.workDate} 剩余 {item.availableNumber}
          </li>
        ))}
      </ul>
      <button disabled={current === 1} onClick={() => setCurrent(current - 1)}>
        上一页
      </button>
      <span>
        {current} / {Math.ceil(total / pageSize)}
      </span>
      <button
        disabled={current >= Math.ceil(total / pageSize)}
        onClick={() => setCurrent(current + 1)}
      >
        下一页
      </button>
    </>
  );
};

export default ScheduleList;

关键行与易错点

  • 排班数据依赖 depcode:useEffect 依赖数组必须写 [depcode, current],只写 [] 时切换科室不会重新请求。
  • depcode && _getScheduleList():科室未选中时 depcode 是 undefined,直接请求会带上空参数报错。
  • 分页参数(page / limit)和 depcode 放在同一个 useEffect 里,切科室时 current 应重置为 1,否则可能停在不存在的页码上。
  • finally 里关 loading,避免请求失败后 loading 永远转圈。
  • 总页数用 Math.ceil(total / pageSize) 计算,别用 total / pageSize 直接比。

三、打包上线部署(Express 静态资源 + history 回退 + 代理跨域) ​

js
// server.js —— npm run build 之后,把 build 目录作为站点根目录启动
const express = require('express');
const history = require('connect-history-api-fallback');
const { createProxyMiddleware } = require('http-proxy-middleware');

const app = express();

// 0. 所有 404 的响应,使用 index.html 替换(解决刷新子路由 404)
app.use(history());
// 1. 配置当前目录为静态资源目录
app.use(express.static('./'));
// 2. 进行跨域配置
app.use(
  '/prod-api',
  createProxyMiddleware({
    target: 'http://syt-api.atguigu.cn', // 目标服务器地址
    changeOrigin: true, // 允许跨域
    pathRewrite: {
      // 路径重写
      '^/prod-api': '',
    },
  })
);

app.listen(5000, () => {
  console.log('server run at 5000');
});

关键行与易错点

  • npm run build 生成 build 目录后,把 server.js 放进 build 里再启动,express.static('./') 才能正确读到 index.html 和静态资源。
  • app.use(history()) 必须放在静态资源中间件之前(按笔记顺序),否则刷新 /hospital/setting 这类前端路由会直接 404。
  • pathRewrite 把 ^/prod-api 重写为空,因此前端请求 baseURL 必须写成 /prod-api,两边不一致就会 404 或打到错误路径。
  • changeOrigin: true 不能省,否则服务端拿到的 Host 不匹配会被目标服务器拒绝。
  • 代理中间件挂在 /prod-api 上,注意它要写在 history() 之后,否则接口请求可能被 index.html 拦截。

常见错误 ​

  1. 新增医院接口测试失败/创建失败 → 直接复制接口文档示例,保留了 apiUrl、tenantId、signKey 等多余字段,或对象末尾多逗号 → 只保留业务需要的 5 个字段,删除末尾逗号,并用 IHospitalSetData 约束请求体。
  2. 保存后提示成功但页面不跳转,或失败也跳转 → navigate 写在 try/catch 外层或 catch 中也执行 → 成功分支内先 message.success 再 navigate;catch 只提示失败,不跳转。
  3. 单个删除/批量删除后列表不刷新、按钮仍激活 → 删除成功后未重新拉列表,批量删除后未清空 selectedKeys → 成功回调里重新获取列表;批量删除成功执行 setSelectedKeys([]),禁用态由选中数量派生。
  4. 新增页一进入就请求医院详情,控制台出现 id undefined → 添加/编辑共用组件,useEffect 无条件调用 getHospitalSetById(id) → useEffect 内先判断 id 是否存在,无 id 直接 return,有 id 才请求详情。
  5. Tree 科室默认展开/选中异常,切换科室右侧不更新 → fieldNames 映射错、selectedKeys 未与 depcode 同步,或排班请求 useEffect 漏依赖 depcode → 固定 title:'depname'、key:'depcode';selectedKeys={[depcode]};onSelect 同步 depcode/depname;排班请求以 depcode 为依赖并重置页码。
  6. 部署后刷新 404、接口跨域或静态资源 404 → 未启用 history fallback、静态目录配置不对、代理前缀或 pathRewrite 不匹配 → 按 history() → express.static() → /prod-api 代理的顺序配置,pathRewrite 与前端请求前缀保持一致。

练习 ​

  1. 补全“新增医院保存”流程:从表单整体取值、调用新增 API、成功提示并跳转列表、失败只提示不跳转。写出关键代码顺序。
  2. 现有删除按钮点击后直接删除且列表不刷新。改为 Modal.confirm 确认后删除,成功后提示并刷新列表,失败提示错误。
  3. 批量删除按钮在未选中时仍可点击,删除后仍保持激活。指出需要调整的状态、派生条件和删除成功后的清理动作。
  4. 编辑页与新增页共用组件,新增时仍请求详情并报 id undefined。给出 useEffect 的修正方案,并说明如何区分新增/编辑。
  5. 科室树切换后右侧医生列表和排班日期不更新。检查并修正 depcode 同步、selectedKeys 和请求依赖。

原视频对照 ​

#视频时长
1添加医院接口类型定义及api函数封装06:38
2添加医院功能实现04:52
3根据id删除医院设置12:11
4批量删除id状态获取及设置08:01
5批量删除实现07:26
6编辑按钮事件绑定-及路由配置03:21
7根据id获取医院设置并渲染到表单12:45

小结 ​

  1. 接口封装前先确认请求方式、URL、参数和响应,再决定是否需要定义请求/响应类型;简单参数不必强行建类型。
  2. 新增/编辑共用页面时,id 就是模式开关:有 id 走编辑并拉详情,无 id 走新增且不拉详情。
  3. 删除类操作统一按“确认 → 请求 → 提示 → 刷新/清状态”收口,批量删除必须清空选中 keys。
  4. 树、列表、分页联动的核心是依赖状态:depcode 变化触发排班/医生请求,selectedKeys 与 depcode 保持同步。
  5. 表单整体取值用 getFieldsValue(),单字段取值用 getFieldValue(),详情回填用 setFieldsValue()。
  6. 成功跳转与失败提示必须分离,catch 中不要继续执行 navigate。
  7. 路由参数可能为 undefined,共用组件中先判空再请求,断言只能用于确定存在的场景。
  8. 部署重点检查三件事:history fallback 解决刷新 404、静态目录托管 build 产物、代理与 pathRewrite 解决跨域。