主题
工作前三个月与半年后(Day13 3/3)
本页对应 React + TypeScript + 医通项目,共 6 段视频,约 55 分钟。
学习目标
- 能够依据接口文档完成医院设置编辑提交:根据路由参数 ID 区分新增与编辑,编辑时向表单数据补充 ID,定义更新请求参数类型并调用更新 API,成功后提示并跳转列表,失败时提示操作失败。
- 能够创建医院列表路由组件并完成路由配置,设置路由地址、meta.title 与懒加载 element,并通过页面跳转验证路由生效。
- 能够使用 Card、Form、Select、Input、Button、Table 等组件完成医院列表静态布局,配置行内表单、元素间距、下拉框占位与表格列定义。
- 能够根据数据字典接口定义省市区返回数据类型,并封装按 parentId 获取省市区列表的 API 函数。
- 能够在组件挂载时调用 API 获取省列表(parentId=86),定义状态并渲染省下拉选项。
- 能够为省、市、区下拉框绑定选择事件,按所选 ID 调用同一 API 获取下级列表,定义市/区状态并完成渲染,同时识别省市区联动中表单与下级列表需清空的问题。
知识点
先把“三个月”看成一条能力生产线
本节笔记的心法是:这里是尚硅谷带薪实训基地【60窝囊废 + 40 技术】。三个月要拿到的不是“听过多少课”,而是三样东西:行业经验、真实项目经验、编码能力,以及同行同事之间的协作能力。转写稿里的医院设置编辑、医院列表、省市区联动,正好可以作为“真实项目经验”和“编码能力”的样例:它们不是孤立知识点,而是一套从接口文档到页面联调的完整开发套路。
为什么需要:前三个月最容易陷入“跟着敲会了,换项目就不会”。
怎么想:把每个功能都拆成“查接口—定类型—封函数—调状态—渲染—调 bug”。
怎么做:每做完一个功能,回头写一句“我用了哪几步”。
易错点:只记代码,不记为什么这样分层。
行业经验:从“会写页面”到“知道为什么这样写”
转写稿里省市区接口来自“数据字典”的 by parentId 接口:传 86 获取省,传省 ID 获取市,传市 ID 获取区。文档没有把“传 86 获取省”写得很清楚,这本身就是行业经验:读文档、试接口、识别文档没写透的地方。
为什么需要:真实项目里,接口文档经常不完整,不能等别人喂参数。
怎么想:先看请求方式、参数、返回值,再用 try it out 验证。
怎么做:传 86 看省列表;拿省 ID 看市;拿市 ID 看区。转写稿还发现天津部分数据为空,这是服务器数据未配置,不一定是前端问题。
易错点:把“接口没数据”误判成“我代码错了”;把口误的“pa/put”当成新请求方式,实际应理解为 PUT。
真实项目经验:用医院设置编辑功能看完整开发套路
转写稿的核心推导是:添加和编辑共用同一个页面、同一个保存函数,靠“有没有 ID”区分。编辑时表单数据本身不带 ID,所以提交前要把路由里的 ID 补进 data,再发 PUT 请求。成功后提示“更新成功”并跳回列表;失败统一提示“操作失败”。
ts
const onSubmit = async () => {
const data = { ...formData }
if (id) {
data.id = id
await updateHospitalSet(data)
} else {
await addHospitalSet(data)
}
}为什么需要:共用页面能减少重复代码,但必须区分新增与编辑。
怎么想:有 ID 就是编辑,没有 ID 就是添加;编辑要补 ID 才能更新指定记录。
怎么做:先验证添加是否正常,再验证编辑分支是否带上 ID;请求体里字段按接口要求传。转写稿调试时还遇到多余逗号导致请求失败,删掉后成功。
易错点:只传表单数据、忘补 ID;成功提示和跳转写了两遍;失败提示仍写“添加失败”。
编码能力:在省市区联动里练“查接口—定类型—封函数—调状态”
省市区联动要定义地区项类型、列表类型,封装 getDistrictList(id),用 useEffect 首次获取省列表,用 useState 保存 provinceList,再用 map 渲染 Option。选择省时用 onSelect 拿到省 ID,再获取市;选择市再获取区。
ts
const [provinceList, setProvinceList] = useState<IDistrictList>([])
useEffect(() => { getProvinceList() }, [])
const getProvinceList = async () => {
const res = await getDistrictList(86)
setProvinceList(res)
}为什么需要:没有类型和状态,渲染与联调会失控。
怎么想:接口返回值先定类型,再封 API,再进组件状态。
怎么做:Select 上绑 onSelect,回调里拿 ID 请求下一级;列表用 map 渲染。
易错点:函数组件首字母没大写导致 Hook 报错;切换省后没有清空市、区,出现“辽宁省下面还显示阳泉市区”的错位。转写稿把这个 bug 留成作业,练的就是分析和解决问题。
同行同事:把 bug 留成作业,练分析和协作
转写稿最后没有直接把联动清空 bug 讲完,而是留作业第二天讲思路。这很符合前三个月:同事不是替你写完,而是给你接口、给你反馈、让你自己定位。
为什么需要:真实项目靠协作,不靠单打独斗。
怎么想:先自己复现、缩小范围,再问人。
怎么做:记录“选省后应清空市和区”的复现步骤,带着现象去讨论。
易错点:一遇红波浪线就慌;转写稿里编辑器报错但控制台正常,重启后消失,说明要分清工具提示和真实运行错误。
三个月后自检:能否独立查接口、定类型、封函数、调状态、复现并描述 bug;能否把“60%扛琐碎、40%练技术”落到每天的项目动作里。
编辑保存:用 ID 区分新增与编辑
为什么需要:新增和编辑共用同一个表单页面,保存按钮也共用同一个 onFinish。如果不区分,编辑时会走新增接口,数据更新不了。
怎么想:编辑接口要求请求体里带 ID,但表单提交的数据本身不带 ID,所以要在编辑分支手动把当前记录的 ID 补进 data。判断依据就是 data.id 是否存在:有 ID 走编辑,没有 ID 走新增。
怎么做:在 onFinish 里先判断 data.id,编辑时合并 ID 后调用更新接口,成功后提示“更新成功”并跳转列表;新增仍走原逻辑。失败统一提示“操作失败”。
ts
const onFinish = async (data: HospitalSetParams) => {
try {
if (data.id) {
await updateHospitalSet({ ...data, id: data.id })
message.success('更新成功')
} else {
await addHospitalSet(data)
message.success('添加成功')
}
navigate('/hospitalSet/list')
} catch {
message.error('操作失败')
}
}易错点:不要以为表单里天然有 ID;编辑请求体里要显式补 ID。调试时先验证新增仍可用,再点编辑观察 data 是否带上 ID。测试接口 JSON 时注意多余逗号会导致请求失败。
更新接口、请求体与类型处理
为什么需要:更新接口是 PUT,参数放在请求体里,返回值通常不需要单独定义业务类型,但请求参数类型要能兼容 ID。
怎么想:原医院设置类型没有 ID,编辑时需要可选 ID。可以用继承扩展一个“更新请求体”类型,也可以在原类型上加 id?: number。
怎么做:定义更新请求体类型并传给 updateHospitalSet,请求方法用 PUT,第二个参数就是请求体。
ts
export interface IHospitalSetUpdateData extends IHospitalSetParams {
id?: number
}
export const updateHospitalSet = (data: IHospitalSetUpdateData) =>
request.put<any, any>(UPDATE_URL, data)易错点:PUT 和 POST/PATCH 一样,第二个参数通常是请求体;不要误把 ID 拼到 URL 上。类型上 ID 应为可选,避免新增时强制传 ID。
医院列表路由与静态布局
为什么需要:编辑完成后要跳到医院列表,因此要先创建 HospitalList 路由组件并配置路由。
怎么想:仍按“创建路由组件 → 配置路由 → 搭静态结构”的套路。列表页上面是查询表单,下面是表格,外层用 Card。
怎么做:创建 HospitalList.tsx,注意文件名大小写;在路由中配置 path、meta.title 和懒加载 element。静态布局用 Card、Form、Select、Input、Button、Table。表单项横向排列用 layout="inline";按钮间距不要用 margin-top,而用 margin-bottom,避免响应式换行后错位。
易错点:文件名大小写不一致会导致路由找不到;编辑器出现红线但控制台无错时,可重启开发服务。Select 宽度可直接用 style 控制。
省市区接口类型与 API 封装
为什么需要:省、市、区数据都来自同一个“根据 parentId 查询字典”的接口,只是传入 ID 不同:86 取省,省 ID 取市,市 ID 取区。
怎么想:先看接口文档和返回结构,再定义地区项类型与列表类型,最后封装 getDistrictList(id)。
怎么做:定义 IDistrictItem 和 IDistrictList,在 API 文件中用 GET 请求,把 ID 拼到地址后,响应类型设为 IDistrictList。
ts
export interface IDistrictItem {
id: number
name: string
value: string
hasChildren: boolean
}
export type IDistrictList = IDistrictItem[]
export const getDistrictList = (id: number) =>
request.get<any, IDistrictList>(`${DICT_URL}/${id}`)易错点:接口文档不一定写清“传 86 取省”,要看调试结果;请求地址拼接不要漏 ID。测试天津等数据为空时,可能是服务端未配置,不一定是前端问题。
省列表获取、状态定义与渲染
为什么需要:页面一进入就要显示省下拉框,所以要在生命周期中请求省列表。
怎么想:用 useEffect 调 getDistrictList(86),把结果存入 provinceList 状态,再用 map 渲染 Option。
怎么做:定义 provinceList 状态和获取函数,在 useEffect 中调用;Option 的 key 用 id,value 用 value,显示 name。
易错点:函数组件首字母必须大写,否则 Hook 报错;useEffect 依赖别漏。渲染时注意 map 返回 Option,不要写错 key。
市、区联动与待解决 bug
为什么需要:选中省后要获取市,选中市后要获取区,形成三级联动。
怎么想:给 Select 绑定 onSelect 或 onChange,回调参数就是选中值,用它作为 parentId 请求下一级列表。
怎么做:定义 getCityList(provinceId)、getAreaList(cityId),分别设置 cityList、areaList 状态并渲染。
易错点:当前代码存在联动残留问题:切换省后,市、区表单显示和下级列表没有清空,需要清空表单值和下级列表数据。这是本节留下的作业,调试时先确认事件能拿到 ID,再发请求验证数据。
先说明材料边界
本节笔记标题是“必须进大厂”,但笔记正文只有“学历、3==》1、年终奖”等关键词,转写稿实际讲的是医院列表项目实战:新增/编辑共用页面、更新接口、列表路由、静态布局、省市区接口封装与联动。两者主题不一致。下面以转写稿中可验证的技术内容为主扩写;职业建议部分只做边界说明,不展开编造。转写稿中“请酒体”应理解为“请求体”,“circle”应理解为“SQL”,“unfinish”应理解为表单提交回调,“死锥”应理解为string,“福利”应理解为boolean。
编辑与新增为什么要用 ID 区分
为什么需要:新增和编辑共用一个页面、一个保存按钮,但提交逻辑不同。怎么想:编辑时表单数据本身不带 ID,而更新接口需要知道更新哪一条,所以要在编辑分支把当前路由/列表中的 ID 塞进提交数据。怎么做:在保存回调里判断有没有 ID;无 ID 走新增,有 ID 走编辑,并在编辑时执行 data.id = id 后再请求更新接口。易错点:不要以为表单里天然有 ID;转写稿调试时特意查看 data,确认编辑分支已带上 ID,再以请求体方式提交。
更新接口与类型扩展
为什么需要:更新接口参数与新增不同,类型不扩展会导致 TS 报错或字段丢失。怎么想:原医院设置类型没有 ID,可单独定义更新类型继承原类型,再补可选 id;也可在原类型上加 id?: number。怎么做:按接口文档复制地址,封装 updateHospitalSet(data),请求体传 data;成功后提示“更新成功”并跳转列表,失败统一提示“操作失败”。易错点:转写稿测试时因多余逗号导致请求异常,删掉后成功;返回值不需要强行定义复杂类型。
列表路由与静态布局
为什么需要:医院列表要独立路由和页面结构。怎么想:先创建路由组件、配置路由,再做静态布局。怎么做:在 hospital 下建 hospitalList 目录与 .tsx 文件,路由配置 path、meta.title、懒加载 element;页面用 Card 包 Form,表单项放 Select、Input、Button,下方放 Table 和 columns。易错点:文件名大小写不一致会导致导入红线;转写稿中控制台无错但编辑器有红线,重启可消。按钮间距用 margin-bottom 而不是 margin-top,避免响应式换行后间距错乱。
省市区接口封装
为什么需要:省、市、区数据都来自同一个“根据 parentId 查询”的接口。怎么想:传 86 取省;传省 ID 取市;传市 ID 取区。怎么做:定义 IDistrictItem、IDistrictList,字段按返回数据保留 id、name、value、string、boolean 等;封装 getDistrictList(id),用 request.get<IDistrictList>(地址/id)。易错点:转写稿强调地址要从接口文档复制;request 要从项目封装的 HTTP 模块导入,不能从错误包导入。
获取省列表并渲染
为什么需要:页面一进入就要有省下拉数据。怎么想:在 useEffect 中调用接口,用状态保存列表。怎么做:
tsx
const [provinceList, setProvinceList] = useState<IDistrictList>([])
useEffect(() => { getProvinceList() }, [])渲染时 provinceList.map(item => <Option key={item.id} value={item.value}>{item.name}</Option>)。易错点:转写稿报“hook 只能在函数组件中调用”,原因是组件首字母没大写,被当成普通函数;改正后正常。
市、区联动与作业 bug
为什么需要:选中省后要取市,选中市后要取区。怎么想:给 Select 绑定 onSelect 或 onChange,回调拿到当前 ID 再请求下一级。怎么做:定义 getCityList(id)、getAreaList(id),分别设置 cityList、areaList 并渲染。易错点:转写稿测试天津时区列表为空,是服务端未配置该数据,不是前端问题;切换省时若不清空市/区表单和列表,会出现“省已变、市/区还显示旧值”的错位,这是本节留下的作业。
职业笔记部分
笔记中“学历:本科,学信网可查的成人教育,2—3 年”应理解为学历提升要走正规成人教育、自考等途径,确保学信网可查,不能理解为买卖学历;“3==》1”“年终奖”缺少上下文,无法可靠扩写,故不编造。技术学习上,转写稿体现的调试方法更值得保留:先看接口文档,再看参数和返回值,打印关键数据,分清新增/编辑分支,最后回归测试。
本节笔记配图与代码保留
jsx
onClick={(e)=>{}} // React 合成事件对象,e.nativeEvent 可访问原生事件
<Button onClick={send}></Button>
function send(e){}
<Button onClick={()=>{ this }}></Button>笔记里“事件处理函数参数的默认值是当前组件实例对象”容易误解:事件回调收到的首先是事件对象;类组件里想稳定拿到
this,要用箭头函数或在构造器中绑定。函数组件没有组件实例,这一点要分清。
React 事件对象:为什么需要、怎么想、怎么做
为什么需要:我们既要拿到用户点击、选择的值,又不想直接依赖浏览器原生事件。React 做了合成事件,跨浏览器更一致。
怎么想:先看回调参数 e,它通常是合成事件;需要原生能力时再看 e.nativeEvent。
怎么做:
jsx
<Button onClick={(e) => {
console.log(e); // 合成事件
console.log(e.nativeEvent); // 原生事件
}}>
保存
</Button>易错点:不要以为 e 是组件实例;类组件里普通函数写法会丢 this,用箭头函数更稳。
编辑与新增共用页面:先判断有没有 ID
为什么需要:医院设置的新增和编辑共用同一个页面、同一个保存函数,必须区分当前是新增还是编辑。
怎么想:转写稿里的推导是:编辑接口要求请求体里带医院 ID,但表单提交的数据本身不带 ID;所以点击保存时,要先判断有没有 ID。有 ID 走编辑分支,并把 ID 补进表单数据;没有 ID 走新增分支。
怎么做:
ts
const data = await form.validateFields();
if (data.id) {
await updateHospital({ ...data, id: data.id }); // 实际以当前编辑ID为准
message.success('更新成功');
} else {
await addHospital(data);
message.success('添加成功');
}
message.success 后跳转医院设置列表;
失败统一 message.error('操作失败');易错点:编辑时忘了把 ID 放进请求体;成功提示写成“添加成功”;添加和编辑分支跳转重复,可把跳转提到分支外。
医院列表:路由组件与静态布局
为什么需要:医院列表是独立页面,要先有路由组件,再做静态结构。
怎么想:沿用“创建路由组件 → 配置路由 → 搭静态布局”的套路。转写稿中创建 HospitalList.tsx,在路由里配置 path、meta.title: '医院列表'、懒加载 element。
怎么做:页面外层用 Card,上方 Form layout="inline",包含省、市、区、医院名称、医院编号、医院类型、医院状态等 Form.Item;下方用 Table + columns 展示序号、医院名称、等级、详细地址、状态、创建时间、操作。
易错点:组件文件名大小写要一致;编辑器红色波浪线但控制台无错时,可重启验证;表单横向排列用 layout="inline";间距优先用 margin-bottom,避免响应式换行后错位。
省市区接口:类型与 API 封装
为什么需要:省、市、区数据都来自同一个“数据字典”接口,只是 parentId 不同。
怎么想:传 86 取省;传省 ID 取市;传市 ID 取区。先定义返回项类型,再封装请求函数。
怎么做:
ts
export interface IDistrictItem {
id: number;
name: string;
value: string;
// 其余字段按接口返回保留
}
export type IDistrictList = IDistrictItem[];
export const getDistrictList = (id: number) =>
request.get<IDistrictList>(`/api/.../${id}`);易错点:请求地址要拼上 ID;request 要从项目封装的请求模块导入;不要把 http 包和项目 request 混用。
省列表获取与渲染:useEffect + 状态
为什么需要:页面一进入就要展示省列表,不需要用户先点按钮。
怎么想:在 useEffect 中调用接口,拿到数据后存入状态,再用 map 渲染 Option。
怎么做:
tsx
const [provinceList, setProvinceList] = useState<IDistrictList>([]);
useEffect(() => {
const getProvinceList = async () => {
const res = await getDistrictList(86);
setProvinceList(res);
};
getProvinceList();
}, []);
<Select onSelect={(id) => getCityList(id)}>
{provinceList.map((p) => (
<Option key={p.id} value={p.value}>{p.name}</Option>
))}
</Select>易错点:函数组件首字母必须大写,否则 useEffect 会报 Hook 调用错误;map 要有 key;Option 的 value 与显示文本要区分。
市、区联动与调试过程
为什么需要:选中省后才请求市,选中市后才请求区,减少无效请求。
怎么想:给 Select 绑定 onSelect 或 onChange,回调参数就是当前选中的值;拿到 ID 后调用同一个 getDistrictList。
怎么做:分别定义 cityList、districtList 状态;省 onSelect 里请求市并设置 cityList;市 onSelect 里请求区并设置 districtList。转写稿中先打印 ID 验证 onSelect 能拿到省 ID,再发请求,这是很好的调试顺序。
易错点:切换省后,原来的市、区数据和表单选中值没有清空,会出现“省变了、区还是上一个省”的错位。转写稿把“清空表单数据和下级列表数据”留作练习,解决时要在省变化时重置市、区状态及表单对应字段。
医院设置页面与编辑更新

医院设置是医院管理的基础数据入口。编辑与新增共用同一个页面,所以核心不是“再写一个页面”,而是先判断当前操作到底是新增还是编辑。转写稿里的推导是:点击保存时,表单数据本身不带 ID;但更新接口需要根据 ID 知道更新哪一条记录,因此编辑分支要手动把当前 ID 放进提交对象。
为什么需要:没有 ID,后端无法定位更新记录。怎么想:有 ID 就是编辑,没有 ID 就是添加。怎么做:在 onFinish 中区分分支,编辑时补 ID 并调用更新接口;成功后提示“更新成功”并跳回医院设置列表,失败统一提示“操作失败”。类型上可给原医院设置类型增加可选 id,或用继承扩展更新参数类型。
ts
interface IHospitalSetUpdateData extends IHospitalSet {
id?: number
}
export const updateHospitalSet = (data: IHospitalSetUpdateData) =>
request.put('/api/hospitalSet/update', data)易错点:更新接口是 PUT,参数放在请求体;不要以为表单里天然有 ID。调试时先验证“添加”仍正常,再点编辑观察提交 data 是否带上 ID;接口文档 Try it out 时不要误删测试数据或漏掉逗号。

医院列表路由组件与路由配置
为什么需要:医院列表是独立页面,必须先有路由组件和路由配置,后续静态布局才有落点。怎么想:仍按“创建路由组件—配置路由—搭静态结构”的套路。怎么做:创建 hospitalList.tsx,在路由中配置 path、meta.title 为“医院列表”,element 使用懒加载。易错点:文件名大小写要一致;转写中曾因大小写导致编辑器红色浪线,但控制台无错,项目仍可运行,必要时重启编辑器。
医院列表静态布局
为什么需要:列表页上方是查询表单,下方是表格。怎么想:外层 Card,内部 Form,每项放 FormItem;省份用 Select + Option,医院名称/编号用 Input,按钮用 Space + Button,表格用 Table + columns。怎么做:给 Form 设 layout="inline" 让表单项横向排列;下拉框宽度可直接用 style 控制,如 180;间距不要用 margin-top,因为分辨率低时被挤下来的元素不确定,应用 margin-bottom。易错点:查询按钮要写文字;Select 需要 placeholder;表格 columns 先定义序号、医院名称、等级、详细地址、状态、创建时间、操作。
省市区接口类型与 API 封装
为什么需要:省、市、区数据都来自同一个“数据字典 by parentId”接口。怎么想:传 86 取省;传省 ID 取市;传市 ID 取区。怎么做:定义地区项 IDistrictItem 和列表 IDistrictList,封装 getDistrictList(id: number),GET 请求并把 ID 拼到地址中。易错点:转写中强调 86 是中国区号,文档本身不够直观;天津等数据可能为空,不一定是前端问题。
ts
export type IDistrictList = IDistrictItem[]
export const getDistrictList = (id: number) =>
request.get(`/api/dict/findByParentId/${id}`)获取省列表并渲染
为什么需要:页面一进入就应展示省份。怎么想:在 useEffect 中调用接口,传 86,把结果放入状态。怎么做:useState<IDistrictList>([]),请求后 setProvinceList,再用 map 渲染 Option,value 用 value 或 id,显示 name。易错点:组件名首字母必须大写,否则 Hook 报错;转写中正是函数组件首字母小写导致 useEffect 报错。
tsx
const [provinceList, setProvinceList] = useState<IDistrictList>([])
useEffect(() => {
getProvinceList()
}, [])
async function getProvinceList() {
const res = await getDistrictList(86)
setProvinceList(res)
}市、区联动与待解决 bug
为什么需要:选中省后要取市,选中市后要取区。怎么想:给 Select 绑定 onSelect 或 onChange,回调能拿到当前选中值。怎么做:定义 getCityList(id)、getDistrictListByCity(id),分别请求同一接口并设置 cityList、districtList,渲染套路与省列表一致。易错点:转写中已验证 onSelect 能拿到省 ID;切换省后,市、区及表单显示没有清空,会出现“辽宁省却显示阳泉市区”的错位,这个作为作业处理:选省后应清空市/区列表和表单选择值。
需求先拆清楚:医院设置模块要做什么
本节笔记给出的是“需求介绍”,一共 8 项:医院设置静态组件、医院列表数据分页展示、根据医院名称和医院编号查询医院列表、清空搜索条件、添加医院、修改医院、删除医院、批量删除医院。为什么要先列需求?因为后面写代码不能上来就堆组件,而是要先知道页面要解决哪些业务动作。怎么想?把需求分成三类:展示类(静态组件、分页列表)、查询类(名称/编号查询、清空)、数据操作类(添加、修改、删除、批量删除)。怎么做?按项目里的开发套路推进:先查接口文档,确认请求方式、参数、返回值;再定义类型、封装 API 函数;然后创建路由组件、配置路由、搭静态结构;最后接状态、事件和接口。易错点是不要把“需求介绍”直接当成实现细节,分页、查询、删除等具体参数必须回到接口文档确认,不能凭感觉写。
以“修改医院”为例:添加和编辑共用页面,用 ID 分流
转写稿里反复强调:添加和编辑共用同一个页面,点击保存都会触发同一个 onFinish。为什么需要分流?因为添加没有 ID,编辑有 ID;后端更新接口要求请求体里带 ID,才能在 SQL 里知道更新哪一条。怎么想?先看接口:更新一般是 PUT 请求,参数放在请求体里,接口文档没有让你单独传 ID,但请求体数据里必须有 ID。怎么做?在 onFinish 里判断表单数据有没有 ID:没有 ID 走添加;有 ID 走编辑,并且把当前 ID 合并进表单数据再发请求。转写稿中的关键思路可整理为:
ts
const onFinish = async (data: HospitalSetForm) => {
if (data.id) {
// 编辑:把 ID 合并进表单数据,按 PUT 请求体提交
await updateHospitalSet({ ...data, id: data.id })
message.success('更新成功')
} else {
await addHospitalSet(data)
message.success('添加成功')
}
navigate('/hospital/set')
}类型上,原来添加用的类型没有 ID,可以新增一个继承类型并加可选 id?: number,也可以在原类型上加可选 ID。易错点:编辑时忘了把 ID 放进 data;成功提示写成“添加成功”;失败提示不统一。转写稿最后把失败统一成“操作失败”,成功后再跳回医院设置列表。
列表页路由与静态布局:先能跳转,再搭骨架
医院列表要先创建路由组件并配置路由。转写稿里的步骤是:建 hospital list 目录和 HospitalList.tsx,注意是 .tsx 不是 .ts;在路由里配置 path、meta.title: '医院列表'、element 懒加载。易错点:文件名大小写写错、路径不对,编辑器可能出现红色波浪线,但只要控制台没报错,项目仍可运行;不放心可重启开发服务。
静态布局按“上面 Form、下面 Table”搭。外层用 Card,表单用 Form,每项放 FormItem;省、市、区用 Select + Option,医院名称、编号用 Input,按钮用 Button 并放在 Space 里;表格用 Table + columns。转写稿特别提醒:表单项横向排列要给 Form 加 layout="inline";间距不要用 margin-top,因为分辨率低时被挤下来的元素不确定是谁,应该给前一项加 margin-bottom,这样不管谁被挤下来都在下面。Select 宽度可直接用样式控制,转写稿试过 size 后最终用 style 调宽度。
省市区接口封装与联动渲染
省市区数据也来自接口。转写稿说明:数据字典接口根据 parentId 查询,传 86 获取省列表;传省 ID 获取市;传市 ID 获取区。为什么要封装?因为同一个接口地址靠参数不同复用,必须把返回类型和 API 函数统一起来。怎么做?定义 IDistrictItem、IDistrictList 类型,再封装 getDistrictList(id: number),用 GET 请求并把 ID 拼到地址上。页面里用 useEffect 一进来获取省列表,用 useState 存 provinceList,再 map 渲染 Option。选中省时给 Select 绑定 onSelect,回调参数就是选中的省 ID,用它请求市列表并存 cityList;市再选时请求区列表并存 districtList。易错点:函数组件首字母必须大写,否则 useEffect 会报 Hook 调用错误;onSelect 能拿到选中值;切换省后要清空表单显示和市、区列表,否则会出现“省变了,区还是上一个省”的错位。转写稿还提到天津数据特殊,服务器没配时列表出不来,不一定是代码问题。
从“能显示”开始搭建静态组件
本节的核心不是一上来就把交互写完整,而是先把页面“搭出来”:能从 antd 官网复制基础组件、看懂组件和属性、让页面有基本显示效果,再逐步修改。开发流程可以概括为三步:
- 先从
antd官网复制基本组件代码,再查看组件上的属性,需要哪个就把对应代码复制过来,保证页面不报错、有基本显示效果。 - 浏览整个组件,确认组件本身和组件上的属性是否都明白。
- 最后再对部分内容进行修改。
为什么需要这样做?因为后台页面大多是“表单 + 表格 + 下拉框”的组合,直接手写容易漏掉导入、属性和布局;先复制官方示例,能快速得到可运行的结构。怎么想?先保证静态结构正确,再补数据与事件。怎么做?按页面区域拆:外层 Card,上方 Form,下方 Table。易错点是只复制了标签却忘了导入组件,或者属性名拼错导致控制台报错。
创建医院列表路由组件并配置路由
做医院列表时,先创建路由组件,再配置路由。转写稿里明确提到:在 hospital 相关目录下创建 hospitalList 目录,文件应为 hospitalList.tsx,注意不是 .ts。这一步为什么需要?因为路由组件必须先存在,路由配置才有东西可加载。怎么想?沿用已有“医院设置”的路由配置套路,复制一份同级对象,改 path、meta.title 和 element。怎么做?路由地址使用医院列表对应地址,meta.title 写“医院列表”,element 使用懒加载的列表组件。
易错点有两个:一是文件名大小写不一致,转写稿里就出现了小写导致编辑器红色波浪线;二是编辑器报错但控制台没有错时,不一定是项目真的坏了,可以重启开发服务验证。调试时先看控制台,再看页面能否跳转。
医院列表静态布局:Card、Form、Select、Table
页面结构是“上面一个 form,下面一个列表”,所以最外层用 Card。表单用 Form,每一项放在 FormItem 里。省、市、区都是下拉框,需要从 antd 导入 Select,并从 Select 中解构出 Option。转写稿中的关键结构是:
tsx
<Select placeholder="请选择省">
<Option value={province.value}>{province.name}</Option>
</Select>为什么需要 Option?因为 Select 只是选择器外壳,真正的选项由 Option 提供。怎么做?先写死“北京”等选项验证显示,再补 placeholder。宽度不要硬猜 API,转写稿里试过 size="small",发现太窄后改用 style 控制宽度,例如 180。表单默认纵向排列时,给 Form 加 layout="inline" 变成横向。
按钮区域用 Space 和 Button,查询按钮设 type="primary"、htmlType="submit",清空按钮加 disabled 属性。间距不要用 margin-top,因为分辨率低时被挤下来的元素不确定是谁;应给第一个 FormItem 加 margin-bottom,这样无论谁换行都在下方。转写稿里还出现 className 加在 FormItem 上不生效、改到 Select 上才生效的调试过程,说明样式要加在真正渲染的元素上。
下方表格用 Table 和 columns,列包括序号、医院名称、等级、详细地址、状态、创建时间、操作。columns 类型从组件库导入 ColumnsType,先传 any,后续再改。
省市区接口类型与 API 函数封装
省市区数据不是写死的,而是请求接口。转写稿里说明:数据字典接口根据 parentId 获取下级地区,传 86 获取省;传省的 ID 获取市;传市的 ID 获取区。同一个接口地址,根据参数不同获取省、市、区。
为什么需要定义类型?因为接口返回的 data 要用于渲染,必须知道每一项有哪些字段。转写稿中定义了地区项类型 IDistrict,字段包括 id、name、value 等;再定义列表类型 IDistrictList。API 函数写成 getDistrictList(id: number),内部调用 request.get,请求地址拼接 ID,返回值类型为 IDistrictList。
易错点:id 是 number;hasChildren 是布尔值;从 HTTP 包导入的 request 不要和别的包混用;请求地址没写会报错。转写稿里还提醒,文档只写参数、不说明“传 86 获取省”,需要自己试出来。
调用省列表并完成渲染
一进入页面就应获取省列表,所以放在 useEffect 中。定义 getProvinceList,内部 await getDistrictList(86),再把结果 setProvinceList。状态定义为:
tsx
const [provinceList, setProvinceList] = useState<IDistrictList>([]);渲染时用 provinceList.map 生成 Option,key 用 province.id,value 用 province.value,显示 province.name。
易错点:转写稿里出现 useEffect is calling function 一类报错,原因是函数组件首字母没大写,被当成普通函数处理;组件名必须大写。另一个易错点是 map 返回 JSX 时圆括号和花括号的使用,写花括号要显式 return。
市、区联动与遗留问题
省选中后触发 Select 的 onSelect 或 onChange,拿到省 ID,再请求市列表并渲染;市选中后再请求区列表。转写稿里先打印 ID 验证事件是否触发,再发请求,这是很好的调试顺序。测试时发现天津数据特殊,可能返回空,不一定是代码问题。
最后转写稿留下一个作业:切换省时,市、区表单显示和列表数据没有清空,出现“错位”。解决思路是切换省时清空表单数据和下级列表数据。这里不展开完整实现,留作练习。
从静态表格到真实分页列表
前面已经完成医院列表路由组件创建、路由配置和静态页面布局,Table 的 columns 也搭好了。此时表格能显示,但数据还是静态的。真实业务里医院数据很多,不能一次性全部加载,所以需要“分页列表”:表格只显示当前页数据,分页器负责切换页码和每页条数,切换后重新请求接口。转写稿里反复强调“创建路由组件、配置路由、静态布局”的套路,本节就是在这个静态表格基础上,把 dataSource 换成接口返回的分页数据。转写中出现的 hospital、useEffect 等口误或同音字,以代码为准。
先想清楚要维护哪些状态
动态分页列表至少要维护四类状态:
hospitalList:当前页的医院记录,交给Table的dataSource;total:总条数,交给分页器;current:当前页码;pageSize:每页条数。
请求函数 getHospitalList 接收 page 和 limit,请求成功后把接口返回的 total、records 分别写入总数和列表,同时把本次页码、每页条数同步回状态。这样表格、分页器和接口数据保持一致。
核心代码
本节笔记代码如下,保留原样:
tsx
...
...
const [hospitalList, setHospitalList] = useState<HospitalSetList>([])
// 异步请求获取医院列表显示
const getHospitalList = async (page=current, limit=pageSize) => {
// console.log(page, limit)
// 请求获取分页数据
const result = await reqGetHospitalSetList({page, limit})
// 成功后, 更新相关数据, 显示分页列表
const {total, records} = result
setTotal(total)
setHospitalList(records)
setCurrent(page)
setPageSize(limit)
}
// 初始获取分页列表显示
useEffect(() => {
getHospitalList()
}, [])
const columns: ColumnsType<HospitalSetItem> = [
...
...
];
...
...
<Table
...
...
dataSource={hospitalList}
pagination={{
...
...
onChange: getHospitalList // 当页码或每页数量发生改变时调用
}}
/>请求函数如何被表格调用
Table 的 dataSource 绑定 hospitalList,所以接口返回的 records 一旦 setHospitalList,表格就会重新渲染。分页器配置里的 onChange: getHospitalList 是关键:当用户点击下一页、上一页或修改每页条数时,Table 会把新的页码和每页条数传给这个函数,于是再次发起 reqGetHospitalSetList({ page, limit })。请求成功后更新 total、records、current、pageSize,页面就完成了“切页—请求—刷新表格”的闭环。
初始化与依赖
useEffect(() => { getHospitalList() }, []) 表示组件挂载后先拉取一次默认分页数据。这里空依赖数组表示只执行一次,适合“进入页面加载第一页”的场景。需要注意:getHospitalList 的默认参数 page = current、limit = pageSize 依赖组件内的状态,首次调用时取的是初始值。如果后续加入查询条件、删除后刷新等逻辑,不能只依赖这个空数组,需要根据实际依赖重新调用或把请求函数稳定下来。
易错点
第一,分页器 onChange 的参数顺序通常是页码在前、每页条数在后,函数形参要按这个顺序接,否则会出现“页码传给 limit”的错误。第二,请求成功后四个状态要一起更新,只更新列表不更新总数,分页器会不准。第三,useEffect 空依赖只适合初始化;如果查询条件变化,仍用空依赖会拿到旧条件。第四,删除或筛选后当前页可能超出总页数,需要根据业务重新计算页码。第五,异步请求存在先后返回问题,快速切页时可用请求标识或取消机制避免旧数据覆盖新数据。按本节代码先把基础分页跑通,再逐步补这些边界处理。
课堂画面






代码示例
示例一:医院设置列表 —— 分页数据动态渲染
tsx
// src/api/hospital/model/hospitalSetModel.ts
export interface HospitalSetItem {
id?: number
hosname: string
hoscode: string
apiUrl: string
signKey: string
contactsName: string
contactsPhone: string
status: number
}
export type HospitalSetList = HospitalSetItem[]tsx
// src/pages/hospital/hospitalSet/index.tsx
import { useEffect, useState } from 'react'
import { Card, Table, Button, Space } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { reqGetHospitalSetList } from '@/api/hospital/hospitalSet'
import type { HospitalSetItem, HospitalSetList } from '@/api/hospital/model/hospitalSetModel'
const HospitalSet = () => {
const [hospitalList, setHospitalList] = useState<HospitalSetList>([])
const [total, setTotal] = useState<number>(0)
const [current, setCurrent] = useState<number>(1)
const [pageSize, setPageSize] = useState<number>(5)
// 异步请求获取医院列表显示
const getHospitalList = async (page = current, limit = pageSize) => {
// 请求获取分页数据
const result = await reqGetHospitalSetList({ page, limit })
// 成功后, 更新相关数据, 显示分页列表
const { total, records } = result
setTotal(total)
setHospitalList(records)
setCurrent(page)
setPageSize(limit)
}
// 初始获取分页列表显示
useEffect(() => {
getHospitalList()
}, [])
const columns: ColumnsType<HospitalSetItem> = [
{
title: '序号',
width: 70,
render: (_: any, __: any, index: number) => (current - 1) * pageSize + index + 1,
},
{ title: '医院名称', dataIndex: 'hosname' },
{ title: '医院编号', dataIndex: 'hoscode' },
{ title: 'api基础路径', dataIndex: 'apiUrl' },
{ title: '签名', dataIndex: 'signKey' },
{ title: '联系人', dataIndex: 'contactsName' },
{ title: '联系人手机', dataIndex: 'contactsPhone' },
{
title: '操作',
render: () => (
<Space>
<Button type="primary" size="small">编辑</Button>
<Button type="primary" danger size="small">删除</Button>
</Space>
),
},
]
return (
<Card>
<Table
rowKey="id"
columns={columns}
dataSource={hospitalList}
pagination={{
current,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t) => `共 ${t} 条`,
onChange: getHospitalList, // 当页码或每页数量发生改变时调用
}}
/>
</Card>
)
}
export default HospitalSet关键行与易错点
getHospitalList = async (page = current, limit = pageSize):用默认参数兜住首次调用(useEffect里不传参),同时又能接收 antd 分页器传进来的实参,一个函数两用。onChange: getHospitalList:直接把函数引用交给 antd,它内部会以(page, pageSize)调用,正好对上形参。写成onChange: getHospitalList()会立刻执行并把返回值(Promise)当回调,是高频错误。const { total, records } = result:分页返回的字段名就是total/records,解构写错则列表恒为空。- 请求成功后除了
setTotal/setHospitalList,还必须setCurrent(page)/setPageSize(limit),否则受控分页器不回显当前页。 rowKey="id"不能省,否则控制台报 key 警告、勾选/批量删除会错乱。- 序号列用
render的第三个参数index计算:(current - 1) * pageSize + index + 1,漏掉current部分会导致每页都从 1 开始。 - 顺带记牢:
onClick={(e) => {}}里的e是 React 自己合成的合成事件对象(已做过兼容),要拿原生对象得用e.nativeEvent。
示例二:添加 / 编辑共用表单 —— 用 ID 区分两种操作
tsx
// src/api/hospital/hospitalSet.ts
import request from '@/utils/request'
import type { HospitalSetItem } from './model/hospitalSetModel'
// 更新医院设置请求体参数类型(在原类型基础上加必填 id)
export interface HospitalSetUpdateData extends HospitalSetItem {
id: number
}
export const reqUpdateHospitalSet = (data: HospitalSetUpdateData) => {
return request.put<any, null>('/admin/hosp/hospitalSet/update', data)
}tsx
// src/pages/hospital/hospitalSet/addOrUpdate.tsx
import { Form, Input, Button, message } from 'antd'
import { useNavigate, useParams } from 'react-router-dom'
import { reqAddHospitalSet, reqUpdateHospitalSet } from '@/api/hospital/hospitalSet'
import type { HospitalSetUpdateData } from '@/api/hospital/hospitalSet'
const AddOrUpdate = () => {
const [form] = Form.useForm()
const navigate = useNavigate()
const { id } = useParams() // 路由参数: 有 id 就是编辑, 没有就是添加
const onFinish = async () => {
// 获取表单数据(注意:这里面没有 id)
const data = form.getFieldsValue()
try {
if (id) {
// 编辑操作: 把路由里的 id 补进请求体
const updateData = { ...data, id: Number(id) } as HospitalSetUpdateData
await reqUpdateHospitalSet(updateData)
message.success('更新成功')
} else {
// 添加操作
await reqAddHospitalSet(data)
message.success('添加成功')
}
// 成功提示与跳转放在 if/else 外面,避免重复
navigate('/hospital/hospitalSet')
} catch (error) {
message.error('操作失败')
}
}
return (
<Form form={form} onFinish={onFinish} labelCol={{ span: 2 }} wrapperCol={{ span: 10 }}>
<Form.Item label="医院名称" name="hosname" rules={[{ required: true }]}>
<Input placeholder="医院名称" />
</Form.Item>
<Form.Item label="医院编号" name="hoscode" rules={[{ required: true }]}>
<Input placeholder="医院编号" />
</Form.Item>
<Form.Item label="api基础路径" name="apiUrl" rules={[{ required: true }]}>
<Input placeholder="api基础路径" />
</Form.Item>
<Form.Item label="签名" name="signKey" rules={[{ required: true }]}>
<Input placeholder="签名" />
</Form.Item>
<Form.Item label="联系人" name="contactsName" rules={[{ required: true }]}>
<Input placeholder="联系人" />
</Form.Item>
<Form.Item label="联系人手机" name="contactsPhone" rules={[{ required: true }]}>
<Input placeholder="联系人手机" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">保存</Button>
<Button style={{ marginLeft: 20 }} onClick={() => navigate(-1)}>取消</Button>
</Form.Item>
</Form>
)
}
export default AddOrUpdate关键行与易错点
- 添加和编辑共用同一个路由组件,所以
onFinish里必须靠useParams()拿到的id来分支:有 id 走更新,无 id 走新增。 - 最核心的一行是
{ ...data, id: Number(id) }:form.getFieldsValue()取到的表单数据不带 id,而更新接口又要求请求体里有 id 才能知道更新哪条记录,所以必须手动把路由 id 合并进去。 - 类型有两种写法:① 原来的
HospitalSetItem上把id写成可选id?: number;② 新定义interface HospitalSetUpdateData extends HospitalSetItem { id: number }把 id 变必填。两种都行,别把 id 忘了。 request.put(url, data):put / post / patch 属于一类,第二个参数就是请求体,不用再包一层{ data }。- 更新接口返回值没有可用的业务数据,所以泛型直接写
request.put<any, null>,不必再定义返回类型。 - 成功提示 +
navigate提到if/else之外,失败统一message.error('操作失败'),避免"添加失败/更新失败"各写一遍。 - 易错点:照抄接口文档参数时,把注释里多出来的逗号一起粘进请求体,会直接报错;复制完一定检查 JSON 结构。
示例三:医院列表省市区联动下拉
tsx
// src/api/hospital/model/hospitalListModel.ts
export interface DistrictItem {
id: number
createTime: string
updateTime: string
isDeleted: number
name: string
value: string
hasChildren: boolean
}
export type DistrictList = DistrictItem[]tsx
// src/api/hospital/hospitalList.ts
import request from '@/utils/request'
import type { DistrictList } from './model/hospitalListModel'
// 根据 id 获取省市区列表:传 86 拿省,传省的 id 拿市,传市的 id 拿区
export const reqGetDistrictList = (id: number) => {
return request.get<any, DistrictList>(`/admin/cmn/dict/findByParentId/${id}`)
}tsx
// src/pages/hospital/hospitalList/index.tsx
import { useEffect, useState } from 'react'
import { Card, Form, Input, Select, Button, Space } from 'antd'
import { reqGetDistrictList } from '@/api/hospital/hospitalList'
import type { DistrictList } from '@/api/hospital/model/hospitalListModel'
const HospitalList = () => {
const [provinceList, setProvinceList] = useState<DistrictList>([])
const [cityList, setCityList] = useState<DistrictList>([])
const [districtList, setDistrictList] = useState<DistrictList>([])
// 获取省列表(id 固定传 86)
const getProvinceList = async () => {
const provinceList = await reqGetDistrictList(86)
setProvinceList(provinceList)
}
// 根据省 ID 获取市列表
const getCityList = async (id: number) => {
const cityList = await reqGetDistrictList(id)
setCityList(cityList)
}
// 根据市 ID 获取区列表
const getDistrictList = async (id: number) => {
const districtList = await reqGetDistrictList(id)
setDistrictList(districtList)
}
// 一进页面就请求省列表
useEffect(() => {
getProvinceList()
}, [])
return (
<Card>
<Form layout="inline">
<Form.Item style={{ marginBottom: 20 }}>
<Select
placeholder="请选择省"
style={{ width: 180 }}
onSelect={(value: any) => getCityList(value)}
>
{provinceList.map((province) => (
<Select.Option key={province.id} value={province.id}>
{province.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item style={{ marginBottom: 20 }}>
<Select
placeholder="请选择市"
style={{ width: 180 }}
onSelect={(value: any) => getDistrictList(value)}
>
{cityList.map((city) => (
<Select.Option key={city.id} value={city.id}>
{city.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item style={{ marginBottom: 20 }}>
<Select placeholder="请选择区" style={{ width: 180 }}>
{districtList.map((district) => (
<Select.Option key={district.id} value={district.id}>
{district.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item style={{ marginBottom: 20 }}>
<Input placeholder="医院名称" />
</Form.Item>
<Form.Item style={{ marginBottom: 20 }}>
<Input placeholder="医院编号" />
</Form.Item>
<Form.Item style={{ marginBottom: 20 }}>
<Space>
<Button type="primary" htmlType="submit">查询</Button>
<Button>清空</Button>
</Space>
</Form.Item>
</Form>
</Card>
)
}
export default HospitalList关键行与易错点
- 省、市、区用的是同一个接口
/admin/cmn/dict/findByParentId/{id},只是 id 不同:86是中国的国际区号,传 86 拿省;传省的 id 拿市;传市的 id 拿区。接口文档里看不出这层含义,别被文档误导。 useEffect(() => { getProvinceList() }, [])空依赖数组,只在挂载时请求一次省列表;市和区靠用户操作触发。onSelect(选中项时调用)和onChange(值变化时调用)都能拿到选中项的value,这里value绑的是province.id,所以回调里拿到的就是父级 ID,可以直接丢给请求函数。- 三个 state 的初始值必须给
[],给null/undefined会让.map直接崩。 Form layout="inline"让表单项横向排列;间距用marginBottom而不是marginTop——分辨率低时被挤下去的元素不确定是谁,统一给底部间距才稳妥。- 已知 bug(转写稿里留作作业的):切换省之后,市/区下拉里还残留上一个省的数据,出现"辽宁省 + 阳泉市矿区"的错位。修复方向是:选择省时清空
cityList/districtList以及表单里已选的市、区值,即"清空表单数据 + 清空列表数据"两件事都要做。 - 测试注意:选"天津"拿不到市数据是后端没配数据,不是代码问题,换山西、河北验证即可。
常见错误
1. 点“保存”后提示更新成功,但列表数据没变
- 现象:编辑弹窗提交后 message 显示“更新成功”,返回列表页该行数据仍是旧值;或后端日志里 update 的 where 条件为空。
- 原因:新增和编辑共用同一个
onFinish,表单data里只有表单字段(hosname/hoscode/...),没有id;而更新接口要求请求体里带id,只靠路由参数是拿不到“更新哪一行”的。 - 修法:进入页面时从路由参数取
id存起来;提交前判断id是否存在,存在则const params = { ...data, id }再调更新接口,不存在走新增分支;请求参数类型用interface IHospitalSetUpdateData extends HospitalSetItem { id?: number }声明,避免any。
2. 页面白屏,控制台报 “Invalid hook call / hooks can only be called inside of the body of a function component”
- 现象:路由组件里写
useState/useEffect后直接报 hook 错误,编译还能过。 - 原因:组件函数名或文件名首字母小写(如
hospitalList),React 把它当普通函数调用而不是组件,hook 调用环境不合法。 - 修法:文件名与组件名统一大驼峰(
HospitalList.tsx/function HospitalList());路由懒加载的import()路径大小写必须与磁盘文件名完全一致,编辑器红线但控制台无报错时先重启 dev server 再核对大小写。
3. 分页点击第 2 页,列表不刷新或页码跳回第 1 页
- 现象:Table 页码变化,数据仍是第一页;或请求发出但
current又被打回初始值。 - 原因:
pagination.onChange直接挂getHospitalList,其默认参数写成(page = current, limit = pageSize),而 antd 回调顺序是(page, pageSize);同时函数内部setCurrent/setPageSize与useEffect([])的初始请求互相覆盖。 - 修法:改成显式透传
onChange: (page, pageSize) => getHospitalList(page, pageSize);函数签名统一(page = 1, limit = pageSize)或去掉默认值,初始加载显式传getHospitalList(1, pageSize)。
4. 切换省份后,市/区下拉框还显示上一个省的数据
- 现象:从山西切到辽宁,市下拉展开后是辽宁的城市,但已选中的“阳泉市 / 矿区”仍留在框里,甚至区和市错位。
- 原因:
onSelect里只更新了cityList/districtList状态和请求,没有重置表单字段与下级列表状态。 - 修法:选中省时执行
form.setFieldsValue({ city: undefined, district: undefined })并setCityList([]) / setDistrictList([]);选市时同样清空区。把“清空下级”抽成一个小函数,三级共用。
5. 省市区接口 onSelect 拿到的值不是 id,请求返回空数组
- 现象:
getCityList(value)里打印的 value 是 “河北省” 而不是130000,接口返回空。 - 原因:
<Option value={province.name}>写成了 name,onSelect回传的是 option 的 value。 - 修法:
value用province.value(或 id),key用province.id;需要同时拿名称时用onSelect={(value, option) => ...}的第二个参数。
6. 响应式换行时间距错乱,按钮/下拉被挤下来后贴在一起
- 现象:分辨率降低,Form 的 item 换行后上下没有间距。
- 原因:给“第一个元素”加
margin-top,但换行的是谁不确定,没换行的元素凭空多出上边距。 - 修法:统一给上一行元素加
margin-bottom(如mb-5类或行内样式),这样无论谁被挤到下一行都自带间距。
练习
补完编辑闭环:在
AddOrUpdateHospitalSet中,用路由参数id区分新增/更新;进入页面时若有id先请求详情回填表单;提交时把id合并进请求体。要求:新增、编辑分别给出不同的成功提示,失败统一提示“操作失败”,成功后跳回列表页。省市区三级联动 + 修复 bug:实现省→市→区联动;并解决“切换上级后下级选中值与列表未清空”的问题。验收标准:任意切换省/市后,下级框显示“请选择”,且展开列表为空,不出现市与区错位。
分页与搜索联动:让“查询/清空”与 Table 分页共存——点击查询回到第 1 页,清空后重置所有查询条件并重新拉取第一页;切换每页条数时页码不越界。
事件对象辨析:写一个受控输入组件,在
onChange中分别打印e、e.target.value、e.nativeEvent,观察三者差异;再写一个“删除”按钮,onClick需要传行 id,验证onClick={handleDelete(id)}与onClick={() => handleDelete(id)}的执行时机差异并说明原因。类型收口:把医院列表、省市区数据的
any全部替换为显式类型(IDistrictItem / IDistrictList / HospitalSetList),并保证请求函数返回值为泛型约束后的类型,tsc无报错。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 编辑操作完成 | 12:03 |
| 2 | 医院列表路由组件创建及路由配置 | 03:35 |
| 3 | 医院列表静态布局 | 11:21 |
| 4 | 省市区api类型及函数封装 | 09:25 |
| 5 | 调用api函数获取省列表并定义状态完成渲染 | 05:18 |
| 6 | 获取市-区列表并渲染 | 13:46 |
小结
- 新增与编辑共用页面时,“是否携带 id”是唯一的判定依据,判定动作要放在提交前而不是提交后。
- 表单字段与接口请求体往往不是一一对应的,
id这类业务主键需要手工合并,别指望表单自动带上。 - React 组件名与文件名大小写是硬约束,编辑器红线 ≠ 运行错误,先看控制台再判断问题归属。
- 受控组件 + 联动下拉的关键不是“取到数据”,而是“切换时清干净上一级残留”,否则状态污染比请求失败更难排查。
- antd 组件的回调参数顺序与自定义函数签名必须显式对齐,靠默认参数“兜底”会埋下分页错乱的隐患。
- 接口类型先行:先定义
Item / List / UpdateData三类类型,再写请求函数,可以避免大量any和后期返工。 - 布局间距优先用
margin-bottom而非margin-top,这是响应式换行场景下的稳定解法。 - 每个功能都按“查接口 → 定类型 → 封 API → 状态与渲染 → 交互闭环”的顺序推进,出错时能快速定位在链路的哪一环。