Skip to content

省市区三级联动与查询医院(Day15 1/3) ​

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

学习目标 ​

  • 能够为科室树组件绑定选中事件,解析事件参数中的科室 code 与 name,更新状态并验证科室选中态与面包屑名称同步切换。
  • 能够为排班日期分页接口定义日期项、日期列表、响应数据等 TypeScript 类型,并封装接收 current、limit、houseCode、depCode 参数的 API 函数。
  • 能够在组件中声明分页、排班日期列表、医院名称、选中日期等状态,并通过 useEffect 监听科室 code 与分页参数发送排班日期请求。
  • 能够解构排班日期接口响应,更新总数、排班日期列表和医院名称,完成日期列表与分页器渲染,并验证切换页码后数据更新。
  • 能够通过 workDate 状态控制排班日期的选中样式,点击日期后更新选中状态并同步页面日期显示。
  • 能够定义排班医生项与列表类型,封装获取排班医生列表 API,并在 workDate 有值时调用接口、打印医生列表以验证请求参数与返回数据。

知识点 ​

说明:本节转写未附图片,且实际内容不是省市区三级联动,而是“科室切换、排班日期分页、医生列表”的联动;以下严格按转写整理,不虚构省市区代码。

点击科室切换选中状态 ​

为什么需要:首页只默认选中一个科室,用户点击其他科室后,列表选中态和面包屑都要跟着变。怎么想:点击是事件,先给树形控件绑定节点点击/选中事件,打印参数确认结构,再把最新的 deptCode、deptName 重置到状态里。怎么做:转写中打印后确认,第一个参数是科室 deptCode,第二个参数对象里 node.deptCode、node.deptName 可用,于是重置这两个状态。易错点:只改 deptCode 不改 deptName,面包屑不会实时更新;参数名不要照抄“A/B”,要改成有意义的名字。

ts
// 按转写参数含义整理
const onSelect = (deptCode: string, info: any) => {
  setDeptCode(info.node.deptCode)
  setDeptName(info.node.deptName)
}

排班日期接口类型与 API 封装 ​

为什么需要:要根据医院 houseCode 和科室 deptCode 获取该科室未来的排班日期分页数据。怎么想:接口有 current、limit、houseCode、deptCode 四个必填参数,先定义“日期项类型 → 日期列表类型 → 响应类型”,再封装 API。怎么做:按转写定义 IBookingScheduleItem、IBookingScheduleList、IScheduleResponse,响应里用到 total、bookingScheduleList、baseMap.houseName。易错点:字段含义不清要查接口文档或问后端,不能长期靠猜;houseCode、deptCode 可能被推断为联合类型,调用前要断言为 string。

ts
export interface IBookingScheduleItem {
  workDate: string
  dayOfWeek: string
  availableNumber: number
  reserveNumber: number
}
export type IBookingScheduleList = IBookingScheduleItem[]
export interface IScheduleResponse {
  total: number
  bookingScheduleList: IBookingScheduleList
  baseMap: { houseName: string }
}

export const getScheduleList = (
  current: number, pageSize: number,
  houseCode: string, deptCode: string
) => request.get<IScheduleResponse>(url, {
  params: { current, pageSize, houseCode, deptCode }
})

请求时机:监听 deptCode 与分页 ​

为什么需要:组件挂载时 deptCode 还没有值,直接请求会发出无效请求。怎么想:排班日期请求依赖 deptCode,分页变化也要重新请求,所以监听 deptCode、current、pageSize。怎么做:在 useEffect 开头加 if (!deptCode) return,再调用 getScheduleList。易错点:转写中调试时在 Network 看到 deptCode=undefined 的请求,就是没加守卫;它不能和科室列表请求共用一个 useEffect,因为调用时机不同。

ts
useEffect(() => {
  if (!deptCode) return
  getSchedule()
}, [deptCode, current, pageSize])

渲染分页列表与日期选中 ​

为什么需要:日期列表要分页展示,并且默认选中第一项;点击日期后选中态要切换。怎么想:转写先尝试用索引判断选中,但后续点击时索引不能表达“当前选中日期”,所以改用 workDate 状态比较。怎么做:请求回来设置 total、bookingScheduleList、houseName,并把列表第 0 项的 workDate 设为默认;渲染时用 workDate === item.workDate 判断颜色,点击时 setWorkDate(item.workDate)。易错点:key 用不会重复的 workDate;分页 onChange 要同时更新 current 和 pageSize。

tsx
{bookingScheduleList.map(item => (
  <div key={item.workDate}
    color={workDate === item.workDate ? 'green' : 'gray'}
    onClick={() => setWorkDate(item.workDate)}>
    {item.workDate} {item.dayOfWeek} 可约{item.availableNumber}
  </div>
))}
<Pagination current={current} total={total} pageSize={pageSize}
  onChange={(page, size) => { setCurrent(page); setPageSize(size) }} />

医生列表 API 与调用时机 ​

为什么需要:选中排班日期后,要展示该医院、科室、日期下的医生列表。怎么想:三个参数 houseCode、deptCode、workDate 中,workDate 最后获得;它一变医生列表就要变,所以只监听 workDate。怎么做:定义 IDoctorItem、IDoctorList,封装 getDoctorList,在 useEffect 里先判断 if (!workDate) return 再请求。易错点:组件挂载后 useEffect 也会执行一次,若不判断会发出 workDate=undefined 的请求;切换科室后排班第一项会重置 workDate,因此不必再额外监听 deptCode。

ts
useEffect(() => {
  if (!workDate) return
  getDoctorList()
}, [workDate])

点击树节点切换科室 ​

为什么需要:首页虽然已经渲染科室列表并默认选中第一个科室,但用户还要能点其他科室,侧边树和面包屑要同步变化。

怎么想:用户点击是事件,应先找树形控件的选中事件;不要猜参数,先把回调参数打印出来看结构。转写稿里先打印 selectedKeys 和事件对象 e,发现 selectedKeys 是科室 ID,e.node.data 里还有 code、name。

怎么做:绑定 select 事件,调试后重置 deptCode 和 deptName。关键代码示意:

js
const handleSelect = (selectedKeys, e) => {
  // 调试:先打印,确认参数结构
  console.log(selectedKeys, e)
  const { code, name } = e.node.data
  setDeptCode(code)
  setDeptName(name)
}

易错点:不要直接把事件对象当科室 ID;deptName 也要重置,因为面包屑要实时显示;字段名以实际 node.data 为准。

排班日期分页列表:类型与 API 封装 ​

为什么需要:切换科室后,要根据 houseCode + deptCode 获取该科室未来的排班日期分页数据。

怎么想:接口有四个必填参数:current、pageSize、houseCode、deptCode。先调一次接口看响应,再按“列表项类型 → 列表类型 → 响应类型”定义。

怎么做:定义 IBookingScheduleItem、IBookingScheduleList、IScheduleResponse,再封装 getScheduleList。字段中 workday、dayOfWeek、availableNumber、reserveNumber 等按响应保留;不确定含义的要问后端。

易错点:houseCode、deptCode 要做 string 断言;分页参数是组件状态;不要凭英文单词硬猜业务含义。

组件中请求排班日期:依赖 deptCode ​

为什么需要:一进页面 deptCode 还没有值,不能立刻请求排班日期。

怎么想:deptCode 是科室列表请求回来后才设置的,所以排班日期请求要监听 deptCode;页码 current、每页条数 pageSize 变化也要重新请求。

怎么做:

js
useEffect(() => {
  if (!deptCode) return
  getScheduleList(current, pageSize, houseCode, deptCode)
}, [deptCode, current, pageSize])

调试时在 Network 里看到过 deptCode=undefined 的请求,说明挂载时无条件发请求是错的,加 if (!deptCode) return 后才正确。

易错点:不要和科室列表请求共用一个 useEffect,两者时机不同;依赖别漏 current、pageSize;deptCode 为空不发请求。

渲染日期列表与选中切换 ​

为什么需要:响应里有 total、bookingScheduleList、baseMap.houseName,页面要显示医院名、日期标签和分页。

怎么想:默认选中排班日期列表第一项的 workday,用 workDate 状态保存;点击某个日期时更新 workDate,颜色判断也用 workDate 比较。

怎么做:请求后解构并设置状态;渲染 Tag,key 用 workday,颜色用 workDate === item.workday 判断;分页 onChange 更新 current、pageSize。

易错点:key 不要用索引;选中判断不要只用索引,点击后索引不变会出错;医院名来自排班日期响应,某些科室无数据时显示不出来不是渲染代码问题。

排班医生 API 类型与封装 ​

为什么需要:选中日期后,要拿 houseCode、deptCode、workDate 获取医生排班列表。

怎么想:先调接口看医生项结构,再定义 IDoctorItem 和 IDoctorList,最后封装 getDoctorList。

怎么做:

js
const getDoctorList = (houseCode, deptCode, workDate) => {
  return request.get(/* 接口地址 */, {
    params: { houseCode, deptCode, workDate }
  })
}

易错点:参数顺序别错;字段类型按响应定义;不用的字段可以删,但不要改错业务字段。

调用医生列表并处理 workDate 初始值 ​

为什么需要:workDate 变化后医生列表要跟着变。

怎么想:三个参数里 workDate 最后获取;deptCode 变化会导致日期重新请求,所以监听 workDate 即可。但组件挂载时 workDate 初始为 undefined,不能直接发请求。

怎么做:useEffect 依赖 [workDate],先判断有值再请求。调试时 Network 里出现 workDate=undefined 的请求,加判断后才会在日期有值时正常返回数据。

易错点:不能只监听 deptCode;workDate 初始为空仍发请求是常见错误;异步请求与挂载同步执行要区分清楚。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例 1:科室树点击切换选中科室(onSelect 事件) ​

tsx
import { useState } from 'react';
import { Tree } from 'antd';

interface DeptNode {
  deptCode: string;
  deptName: string;
  children?: DeptNode[];
}

export default function DeptTree({ deptList }: { deptList: DeptNode[] }) {
  // 当前选中的科室 code / 名称,名称用于面包屑显示
  const [deptCode, setDeptCode] = useState<string>('');
  const [deptName, setDeptName] = useState<string>('');

  // 第一个参数是 selectedKeys,第二个参数是事件对象(里面带 node 节点信息)
  const onSelect = (selectedKeys: React.Key[], e: any) => {
    // 想要的信息在第二个参数的 node 节点里
    setDeptCode(e.node.deptCode);
    setDeptName(e.node.deptName);
  };

  return (
    <>
      <Tree
        treeData={deptList as any}
        fieldNames={{ title: 'deptName', key: 'deptCode' }}
        selectedKeys={[deptCode]}   // 受控:状态一变,树上的高亮就跟着变
        onSelect={onSelect}
      />
      <div>当前科室:{deptName}</div>
    </>
  );
}

要点与易错点

  • 树控件的选中是“受控”的:selectedKeys={[deptCode]},所以只要 setDeptCode 一改,选中状态就跟着切换,不需要手动操作 DOM。
  • onSelect 回调有两个形参:第一个是 selectedKeys(数组),第二个是事件对象,真正的科室信息在 e.node 里(deptCode / deptName),不要直接从第一个参数取 deptName。
  • 两个状态必须一起重置:deptCode 决定高亮,deptName 用于面包屑等位置实时显示,漏掉 deptName 会出现“树切换了但面包屑没变”。
  • 写事件时容易多打一个字符导致 TS 报错(转写稿里 on select 多了个空格/符号),先检查 JSX 属性拼写再看类型错误。

示例 2:排班日期分页列表(类型定义 + API 封装 + 组件内调用) ​

tsx
import { useState, useEffect } from 'react';
import { Pagination } from 'antd';
import request from '@/utils/request'; // 项目里已有的请求封装

/* ---------- 类型定义:先定义“每一项”,再定义“列表”,最后定义“响应” ---------- */
export interface IBookingScheduleItem {
  workDate: string;        // 排班日期
  dayOfWeek: string;       // 星期几
  reservedNumber: number;  // 已预约人数
  totalNumber: number;     // 总预约数
  availableNumber: number; // 剩余可预约数
}
export type IBookingScheduleList = IBookingScheduleItem[];

export interface IScheduleResponse {
  total: number;
  bookingScheduleList: IBookingScheduleList;
  baseMap: { hospitalName: string }; // 医院信息在这个字段里
}

/* ---------- API 封装 ---------- */
export const getScheduleList = (
  current: number,
  pageSize: number,
  houseCode: string,
  deptCode: string
) =>
  request.get<IScheduleResponse>(
    `/schedule/page?current=${current}&pageSize=${pageSize}&houseCode=${houseCode}&deptCode=${deptCode}`
  );

/* ---------- 组件 ---------- */
export default function ScheduleList({
  houseCode,
  deptCode,
}: {
  houseCode: string;
  deptCode: string;
}) {
  const [current, setCurrent] = useState<number>(1);
  const [pageSize, setPageSize] = useState<number>(3);
  const [total, setTotal] = useState<number>(1);
  const [bookingScheduleList, setBookingScheduleList] = useState<IBookingScheduleList>([]);
  const [houseName, setHouseName] = useState<string>('');
  const [workDate, setWorkDate] = useState<string>('');

  const getSchedule = async () => {
    const res = await getScheduleList(current, pageSize, houseCode, deptCode);
    const { total, bookingScheduleList, baseMap } = res;
    setTotal(total);
    setBookingScheduleList(bookingScheduleList);
    setHouseName(baseMap?.hospitalName || '');
    // 每页数据回来后,默认选中第一项
    setWorkDate(bookingScheduleList[0]?.workDate || '');
  };

  // 依赖:科室变了要重新查;翻页、改每页条数也要重新查
  useEffect(() => {
    if (!deptCode) return; // deptCode 没有值时不发请求
    getSchedule();
  }, [deptCode, current, pageSize]);

  return (
    <div>
      <div>{houseName}</div>

      {bookingScheduleList.map((item) => (
        <div
          key={item.workDate}                       // workDate 不会重复,可作 key
          className={workDate === item.workDate ? 'tag active' : 'tag'}
          onClick={() => setWorkDate(item.workDate)}
        >
          <p>{item.workDate}</p>
          <p>{item.dayOfWeek}</p>
          <p>可预约:{item.availableNumber}</p>
        </div>
      ))}

      <Pagination
        current={current}
        pageSize={pageSize}
        total={total}
        onChange={(page, size) => {
          setCurrent(page);
          setPageSize(size);
        }}
      />
    </div>
  );
}

要点与易错点

  • deptCode 在一进页面时是空的(科室列表接口还没回来),此时不能发请求,否则会请求出 deptCode=any find 这种无效数据;所以 useEffect 里先 if (!deptCode) return。
  • 这个请求和“科室列表”请求时机不同,必须写两个独立的 useEffect,不能合并。
  • houseCode / deptCode 在 TS 里可能是联合类型,调用 API 时要断言成字符串(转写稿里的 as string),否则类型报错。
  • 选中态用 workDate === item.workDate 判断,不要用索引 index === 0:用索引虽然首屏默认选中没问题,但点击切换后索引就不再是 0,高亮会失效。
  • 每页数据回来后要 setWorkDate(list[0].workDate) 重置,否则翻页后还停在上页的日期。
  • 医院名称 hospitalName 只能从排班日期接口的 baseMap 里取,科室列表接口里没有这个字段,别去错接口找。

示例 3:排班医生列表(监听 workDate,有值才请求) ​

tsx
import { useState, useEffect } from 'react';
import request from '@/utils/request';

/* ---------- 类型定义 ---------- */
export interface IDoctorItem {
  id: string;
  createTime: string;
  updateTime: string;
  houseCode: string;
  deptCode: string;
  skill: string;           // 技能/擅长,字段名以接口文档为准
  workDate: string;
  reservedNumber: number;
  totalNumber: number;
  availableNumber: number;
}
export type IDoctorList = IDoctorItem[];

/* ---------- API 封装 ---------- */
export const getDoctorList = (houseCode: string, deptCode: string, workDate: string) =>
  request.get<IDoctorList>(
    `/doctor/list?houseCode=${houseCode}&deptCode=${deptCode}&workDate=${workDate}`
  );

/* ---------- 组件 ---------- */
export default function DoctorList({
  houseCode,
  deptCode,
  workDate,
}: {
  houseCode: string;
  deptCode: string;
  workDate: string;
}) {
  const [doctorList, setDoctorList] = useState<IDoctorList>([]);

  const getDoctor = async () => {
    const res = await getDoctorList(houseCode, deptCode, workDate);
    setDoctorList(res);
  };

  // 只监听 workDate:科室变了会先重新取排班日期,日期重置后这里自然会重新请求
  useEffect(() => {
    if (!workDate) return; // 没有日期就不发请求
    getDoctor();
  }, [workDate]);

  return (
    <ul>
      {doctorList.map((item) => (
        <li key={item.id}>{item.skill}</li>
      ))}
    </ul>
  );
}

要点与易错点

  • 三个参数里 workDate 是最后才拿到的,它一有值,houseCode 和 deptCode 必然已有值,所以依赖数组只写 [workDate] 就够了,不需要再监听 deptCode。
  • 组件挂载完成时 workDate 是空字符串/any find,此时若不加 if (!workDate) return,会白发一次无效请求,返回的列表也拿不到数据。
  • 字段名不要靠猜:转写稿里明确说“不知道的字段就跟后台沟通”,类型定义中的注释字段务必对照接口文档核对。
  • useEffect 依赖数组漏项会少请求,多写无关依赖会重复请求;这里的“时机”就是 workDate 变化 + 组件挂载(且有值)这两种。

常见错误 ​

  1. 点击科室后选中态不切换
    现象:侧边科室列表可点,但高亮和面包屑不变。
    原因:只监听了点击事件,没有把最新科室信息写回选中状态。
    修法:从事件参数中取 node.deptCode、node.deptName,同时更新科室编码和科室名称状态。

  2. 科室切换后面包屑名称不更新
    现象:科室高亮变了,但顶部仍显示旧科室名。
    原因:只重置了 deptCode,没有同步重置 deptName。
    修法:切换科室时同时 setDeptCode 和 setDeptName,保证列表选中与面包屑同源。

  3. 首次进入就发出 deptCode=undefined 的排班日期请求
    现象:Network 中出现参数为 undefined 的无效请求。
    原因:排班日期请求依赖 deptCode,但在组件挂载时 deptCode 尚未由科室列表接口返回。
    修法:监听 deptCode,有值后再请求;不要与科室列表请求共用同一执行时机。

  4. 分页切换后日期列表不刷新
    现象:点击页码后当前页变了,但排班日期数据不变。
    原因:请求只依赖 deptCode,没有监听 current、pageSize。
    修法:在请求副作用中同时监听 deptCode、current、pageSize,变化后重新拉取。

  5. 日期选中态用索引判断,点击后逻辑失效
    现象:默认第一项高亮正常,但点击其他日期后选中态不符合预期。
    原因:用 index === 0 判断选中,只能表达“第一项”,不能表达“当前选中日期”。
    修法:维护 workDate 状态,用 workDate === item.workday 判断选中,点击时更新 workDate。

  6. 医生列表在 workDate 为空时也发请求
    现象:页面初始化出现 workDate=undefined 的医生列表请求。
    原因:副作用执行时只监听了变化,没有加“参数齐全”守卫。
    修法:请求前判断 houseCode && deptCode && workDate,有值再调用 API;通常监听 workDate 即可,因为它变化会带动医生列表刷新。

练习 ​

  1. 实现科室点击切换:点击任意科室后,科室高亮、面包屑科室名、后续排班日期请求参数三者同步变化。说明为什么不能只更新 deptCode。
  2. 为排班日期列表补全请求副作用:避免首次 deptCode 为空时发请求,并支持 current、pageSize 变化后重新请求。
  3. 实现排班日期按钮选中态:默认选中当前页第一项,点击其他日期后切换高亮,并同步顶部日期显示。要求不用索引作为最终选中依据。
  4. 封装并调用排班医生列表接口:写出请求前置守卫,确保 houseCode、deptCode、workDate 都有值时才发请求,并解释为什么通常只需监听 workDate。
  5. 检查排班日期响应类型:从响应中解构 total、bookingScheduleList、baseMap.houseName,说明 houseName 为什么不能从科室列表接口直接获得。

原视频对照 ​

#视频时长
1点击切换科室选中状态06:54
2排班日期分页列表-类型定义及api函数封装10:47
3在组件中发送请求获取排班日期的数据08:36
4排班日期分页列表数据渲染及切换完成13:25
5排班医生api-类型及函数封装05:30
6排班医生api调用并打印列表05:38

小结 ​

  1. 科室切换的核心是“事件参数取最新值 → 更新状态 → 状态驱动选中态和面包屑”。
  2. 选中态不要只靠 DOM 或索引,最终应落到可复用的业务状态上,例如 deptCode、workDate。
  3. 有依赖关系的接口不能机械地放在同一个挂载时机里,要按参数就绪顺序拆分副作用。
  4. 分页列表请求必须把分页状态纳入依赖,否则页码变化不会触发数据刷新。
  5. 日期选中态用 workday 比较比用索引更稳,因为点击后选中项不一定还是第 0 项。
  6. 请求副作用要加参数守卫,避免 undefined 参数造成无效请求和错误数据。
  7. 接口响应类型应显式定义,字段含义不明确时及时与后端确认,不要长期靠猜。
  8. 页面展示所需数据可能来自不同接口,例如医院名称来自排班日期响应的 baseMap,不能假设科室列表里一定有。