feat: 医院管理模块 (admin 端 CRUD + 导入导出)

- 后端: BizHospital controller/domain/vo/mapper/service + Mapper XML
- 前端: ry-vue3/src/views/admin/Hospital.vue + api/business/hospital.js
- 模块面向 admin 角色, 提供医院主数据维护

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-09-14 22:37:10 +08:00
co-authored by Claude Code
parent 9ea4e18161
commit 91bf0fa3b9
10 changed files with 1195 additions and 0 deletions
@@ -0,0 +1,119 @@
package com.ruoyi.business.controller;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizHospital;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizHospitalExportVo;
import com.ruoyi.business.domain.vo.BizHospitalImportVo;
import com.ruoyi.business.service.IBizHospitalService;
/**
* 医院字典 Controller (admin 资料库管理 / 医院管理)
*
* <p>CRUD + 导入/导出走 admin 路由 (前端 AdminLayout 角色控制);
* 远程搜索 /search 为匿名公开 (医生注册下拉).
* 注: 不加 @PreAuthorize — 与 BizExpert/BizOrg 等业务 controller 一致 (业务角色不走 RuoYi RBAC).
*
* @author guoju
*/
@RestController
@RequestMapping("/business/hospital")
public class BizHospitalController extends BaseController {
@Autowired
private IBizHospitalService bizHospitalService;
/** 分页列表 (admin 后台, 支持 hospital/province/city/hospitalCode/militaryHospital/type 筛选) */
@GetMapping("/list")
public TableDataInfo list(BizHospital entity) {
startPage();
List<BizHospital> rows = bizHospitalService.selectList(entity);
return getDataTable(rows);
}
/** 详情 */
@GetMapping("/{hospitalId}")
public AjaxResult getInfo(@PathVariable("hospitalId") Long hospitalId) {
return success(bizHospitalService.getById(hospitalId));
}
/**
* 远程搜索 (匿名公开, 医生注册下拉"边输入边查询")
* GET /business/hospital/search?keyword=xxx → [{hospitalId, province, city, hospital, ...}] LIMIT 30
*/
@GetMapping("/search")
public AjaxResult search(@RequestParam(value = "keyword", required = false) String keyword) {
return success(bizHospitalService.searchByKeyword(keyword));
}
/** 删除前引用统计: 返回医院名被业务表 work_unit 引用的条数 */
@GetMapping("/countUsage")
public AjaxResult countUsage(@RequestParam("hospital") String hospital) {
return success(bizHospitalService.countUsageByHospital(hospital));
}
@Log(title = "医院管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizHospital entity) {
return toAjax(bizHospitalService.insert(entity));
}
@Log(title = "医院管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizHospital entity) {
return toAjax(bizHospitalService.updateByPrimaryKey(entity));
}
@Log(title = "医院管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{hospitalIds}")
public AjaxResult remove(@PathVariable Long[] hospitalIds) {
return toAjax(bizHospitalService.deleteByPrimaryKeys(hospitalIds));
}
/** 导出 (中文列头, 跟随当前筛选条件) */
@Log(title = "医院管理", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, BizHospital entity) {
List<BizHospital> list = bizHospitalService.selectList(entity);
List<BizHospitalExportVo> exportList = new java.util.ArrayList<>(list.size());
for (BizHospital h : list) {
BizHospitalExportVo v = new BizHospitalExportVo();
v.setHospitalId(h.getHospitalId());
v.setProvince(h.getProvince());
v.setCity(h.getCity());
v.setHospital(h.getHospital());
v.setOriginalHospital(h.getOriginalHospital());
v.setHospitalCode(h.getHospitalCode());
v.setMilitaryHospital(h.getMilitaryHospital());
v.setType(h.getType());
exportList.add(v);
}
ExcelUtil<BizHospitalExportVo> util = new ExcelUtil<>(BizHospitalExportVo.class);
util.exportExcel(response, exportList, "医院数据");
}
/** 下载导入模板 */
@GetMapping("/importTemplate")
public void importTemplate(HttpServletResponse response) {
ExcelUtil<BizHospitalImportVo> util = new ExcelUtil<>(BizHospitalImportVo.class);
util.importTemplateExcel(response, "医院数据");
}
/** 批量导入 (Excel → biz_hospital, 去重, 返回 ImportResult) */
@Log(title = "医院管理", businessType = BusinessType.IMPORT)
@PostMapping("/importData")
public AjaxResult importData(MultipartFile file) throws Exception {
ImportResult result = bizHospitalService.importHospitals(file);
return success(result);
}
}
@@ -0,0 +1,84 @@
package com.ruoyi.business.domain;
import com.ruoyi.common.annotation.Excel;
/**
* 医院字典 (biz_hospital)
*
* <p>admin 用户在"资料库管理 / 医院管理"下维护; 医生注册时通过 /business/hospital/search 远程搜索选择.
*
* <p>字段说明:
* <ul>
* <li>hospitalId — 雪花 ID (Long), 由 SnowflakeId.injectIfEmpty 生成; 53-bit, JS Number 安全</li>
* <li>province/city — 省/市 (varchar 100)</li>
* <li>hospital — 医院名称 (varchar 1000, 主显示名)</li>
* <li>originalHospital — 原名称 (varchar 100)</li>
* <li>hospitalCode — 医院 CODE (varchar 100, 如 H3301006)</li>
* <li>militaryHospital — 是否军医院 (varchar 100, 现存数据为 '0'/'1')</li>
* <li>type — 类型 (varchar 100, 现存数据为 '0'/'1')</li>
* </ul>
*
* <p>说明: 原表 schema 中 id 为 int NOT NULL 无 auto_increment, 改 BIGINT 由应用生成雪花 ID (与项目其他表风格一致).
*
* @author guoju
*/
public class BizHospital {
private static final long serialVersionUID = 1L;
/** 医院 ID (雪花 ID, BIGINT) */
@Excel(name = "医院ID")
private Long hospitalId;
/** 省 */
@Excel(name = "")
private String province;
/** 市 */
@Excel(name = "")
private String city;
/** 医院名称 (主显示名) */
@Excel(name = "医院名称")
private String hospital;
/** 原名称 */
@Excel(name = "原名称")
private String originalHospital;
/** 医院 CODE */
@Excel(name = "医院CODE")
private String hospitalCode;
/** 是否军医院 */
@Excel(name = "是否军医院")
private String militaryHospital;
/** 类型 */
@Excel(name = "类型")
private String type;
public Long getHospitalId() { return hospitalId; }
public void setHospitalId(Long hospitalId) { this.hospitalId = hospitalId; }
public String getProvince() { return province; }
public void setProvince(String province) { this.province = province; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getHospital() { return hospital; }
public void setHospital(String hospital) { this.hospital = hospital; }
public String getOriginalHospital() { return originalHospital; }
public void setOriginalHospital(String originalHospital) { this.originalHospital = originalHospital; }
public String getHospitalCode() { return hospitalCode; }
public void setHospitalCode(String hospitalCode) { this.hospitalCode = hospitalCode; }
public String getMilitaryHospital() { return militaryHospital; }
public void setMilitaryHospital(String militaryHospital) { this.militaryHospital = militaryHospital; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}
@@ -0,0 +1,61 @@
package com.ruoyi.business.domain.vo;
import com.ruoyi.common.annotation.Excel;
/**
* 医院字典导出 VO
*
* <p>仅用于 Excel 导出, 不参与业务逻辑.
*
* @author guoju
*/
public class BizHospitalExportVo {
@Excel(name = "医院ID", sort = 1)
private Long hospitalId;
@Excel(name = "", sort = 2)
private String province;
@Excel(name = "", sort = 3)
private String city;
@Excel(name = "医院名称", sort = 4)
private String hospital;
@Excel(name = "原名称", sort = 5)
private String originalHospital;
@Excel(name = "医院CODE", sort = 6)
private String hospitalCode;
@Excel(name = "是否军医院", sort = 7)
private String militaryHospital;
@Excel(name = "类型", sort = 8)
private String type;
public Long getHospitalId() { return hospitalId; }
public void setHospitalId(Long hospitalId) { this.hospitalId = hospitalId; }
public String getProvince() { return province; }
public void setProvince(String province) { this.province = province; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getHospital() { return hospital; }
public void setHospital(String hospital) { this.hospital = hospital; }
public String getOriginalHospital() { return originalHospital; }
public void setOriginalHospital(String originalHospital) { this.originalHospital = originalHospital; }
public String getHospitalCode() { return hospitalCode; }
public void setHospitalCode(String hospitalCode) { this.hospitalCode = hospitalCode; }
public String getMilitaryHospital() { return militaryHospital; }
public void setMilitaryHospital(String militaryHospital) { this.militaryHospital = militaryHospital; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}
@@ -0,0 +1,66 @@
package com.ruoyi.business.domain.vo;
import com.ruoyi.common.annotation.Excel;
/**
* 医院字典导入 VO
*
* <p>仅用于 Excel 批量导入 / 模板下载, 不参与业务逻辑.
* 字段顺序与 Excel 列一致; hospitalId 不在导入范围 (由 SnowflakeId 生成).
*
* <p>必填: hospital (医院名称).
* 其余字段允许空.
*
* @author guoju
*/
public class BizHospitalImportVo {
/** 省 */
@Excel(name = "", sort = 1)
private String province;
/** 市 */
@Excel(name = "", sort = 2)
private String city;
/** 医院名称 (必填) */
@Excel(name = "医院名称", sort = 3)
private String hospital;
/** 原名称 */
@Excel(name = "原名称", sort = 4)
private String originalHospital;
/** 医院 CODE */
@Excel(name = "医院CODE", sort = 5)
private String hospitalCode;
/** 是否军医院 (0=否, 1=是) */
@Excel(name = "是否军医院", sort = 6)
private String militaryHospital;
/** 类型 */
@Excel(name = "类型", sort = 7)
private String type;
public String getProvince() { return province; }
public void setProvince(String province) { this.province = province; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getHospital() { return hospital; }
public void setHospital(String hospital) { this.hospital = hospital; }
public String getOriginalHospital() { return originalHospital; }
public void setOriginalHospital(String originalHospital) { this.originalHospital = originalHospital; }
public String getHospitalCode() { return hospitalCode; }
public void setHospitalCode(String hospitalCode) { this.hospitalCode = hospitalCode; }
public String getMilitaryHospital() { return militaryHospital; }
public void setMilitaryHospital(String militaryHospital) { this.militaryHospital = militaryHospital; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}
@@ -0,0 +1,52 @@
package com.ruoyi.business.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizHospital;
/**
* 医院字典 Mapper (biz_hospital)
*
* <p>无 del_flag 列 — 删除走硬删 (admin 字典, workUnit 字符串引用, 删除不会破坏业务数据完整性).
*
* @author guoju
*/
public interface BizHospitalMapper {
/** 按主键查询 */
BizHospital selectByPrimaryKey(@Param("hospitalId") Long hospitalId);
/** 分页 + 多条件模糊筛选 (admin 后台用, 支持 hospital/province/type/militaryHospital) */
List<BizHospital> selectList(BizHospital entity);
/** 新增 */
int insert(BizHospital entity);
/** 按主键更新 (动态 trim SET) */
int updateByPrimaryKey(BizHospital entity);
/** 批量硬删 */
int deleteByPrimaryKeys(@Param("hospitalIds") Long[] hospitalIds);
/**
* 远程搜索: 按关键字模糊匹配 hospital / originalHospital / hospitalCode,
* LIMIT 30 (医生注册下拉防爆).
* keyword 为空时返回空集 (前端按"边输入边查询"约定不主动出建议).
*/
List<BizHospital> searchByKeyword(@Param("keyword") String keyword);
/**
* 唯一性检查: 按 trim(hospital) + province + city 精确匹配 (case-insensitive).
* hospitalId 非空时排除自身 (改名/同名异市用); 用于新增/编辑前的"同名医院"提示.
* 返回匹配条数 (0 或 1).
*/
int countByTrimmedHospital(BizHospital entity);
/**
* 统计指定医院名称被业务表 work_unit 字段引用的总条数.
* 用于删除前给 admin 二次确认: "该医院已被 N 条专家/参会人引用".
* 4 表 union: biz_expert / biz_signup_expert / biz_meeting_attendee / biz_execution_intent (有 work_unit 列)
* 注: biz_support_intent 也有 work_unit 列, 一起并入.
*/
int countUsageByHospital(@Param("hospital") String hospital);
}
@@ -0,0 +1,41 @@
package com.ruoyi.business.service;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizHospital;
import com.ruoyi.business.domain.dto.ImportResult;
/**
* 医院字典 Service 接口 (biz_hospital)
*
* @author guoju
*/
public interface IBizHospitalService {
/** 按主键查询 */
BizHospital getById(Long hospitalId);
/** 分页 + 多条件筛选 */
List<BizHospital> selectList(BizHospital entity);
/** 新增 (医院名查重 + 雪花 ID) */
int insert(BizHospital entity);
/** 编辑 (医院名查重) */
int updateByPrimaryKey(BizHospital entity);
/** 批量硬删 */
int deleteByPrimaryKeys(Long[] hospitalIds);
/** 远程搜索 (医生注册下拉, LIMIT 30) */
List<BizHospital> searchByKeyword(String keyword);
/** 唯一性检查: 同 name + province + city 是否已存在 (排除自身) */
int countByTrimmedHospital(BizHospital entity);
/** 统计医院名被业务表 work_unit 引用的条数 (删除前二次确认用) */
int countUsageByHospital(String hospital);
/** 批量导入 (Excel → biz_hospital, 批内/DB 去重, 返回 ImportResult) */
ImportResult importHospitals(MultipartFile file) throws Exception;
}
@@ -0,0 +1,193 @@
package com.ruoyi.business.service.impl;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizHospital;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizHospitalImportVo;
import com.ruoyi.business.mapper.BizHospitalMapper;
import com.ruoyi.business.service.IBizHospitalService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.id.SnowflakeId;
import com.ruoyi.common.utils.poi.ExcelUtil;
/**
* 医院字典 Service 实现 (biz_hospital)
*
* <p>要点:
* <ul>
* <li>所有字符串字段入库前 trim (防首尾空白脏数据)</li>
* <li>医院名 + province + city 判重 (trim + case-insensitive)</li>
* <li>主键走雪花 ID (SnowflakeId.injectIfEmpty) — 前提: id 列已 ALTER 为 BIGINT</li>
* <li>删除走硬删 (字典表, workUnit 为字符串无外键, 删除不破坏业务完整性)</li>
* </ul>
*
* @author guoju
*/
@Service
public class BizHospitalServiceImpl implements IBizHospitalService {
@Autowired
private BizHospitalMapper bizHospitalMapper;
@Override
public BizHospital getById(Long hospitalId) {
return bizHospitalMapper.selectByPrimaryKey(hospitalId);
}
@Override
public List<BizHospital> selectList(BizHospital entity) {
return bizHospitalMapper.selectList(entity);
}
@Override
public int insert(BizHospital entity) {
String hospital = trimToNull(entity.getHospital());
if (hospital == null) {
throw new ServiceException("医院名称不能为空");
}
entity.setHospital(hospital);
normalize(entity);
// 同名医院查重 (trim + case-insensitive + province/city 匹配)
BizHospital probe = new BizHospital();
probe.setHospital(hospital);
probe.setProvince(entity.getProvince());
probe.setCity(entity.getCity());
if (bizHospitalMapper.countByTrimmedHospital(probe) > 0) {
throw new ServiceException("医院「" + hospital + "」已存在");
}
SnowflakeId.injectIfEmpty(entity, "hospitalId");
return bizHospitalMapper.insert(entity);
}
@Override
public int updateByPrimaryKey(BizHospital entity) {
if (entity.getHospitalId() == null) {
throw new ServiceException("医院 ID 不能为空");
}
String hospital = trimToNull(entity.getHospital());
if (hospital == null) {
throw new ServiceException("医院名称不能为空");
}
entity.setHospital(hospital);
normalize(entity);
// 改名时同样查重 (排除自身)
BizHospital probe = new BizHospital();
probe.setHospital(hospital);
probe.setProvince(entity.getProvince());
probe.setCity(entity.getCity());
probe.setHospitalId(entity.getHospitalId());
if (bizHospitalMapper.countByTrimmedHospital(probe) > 0) {
throw new ServiceException("医院「" + hospital + "」已存在");
}
return bizHospitalMapper.updateByPrimaryKey(entity);
}
@Override
public int deleteByPrimaryKeys(Long[] hospitalIds) {
return bizHospitalMapper.deleteByPrimaryKeys(hospitalIds);
}
@Override
public List<BizHospital> searchByKeyword(String keyword) {
return bizHospitalMapper.searchByKeyword(keyword);
}
@Override
public int countByTrimmedHospital(BizHospital entity) {
return bizHospitalMapper.countByTrimmedHospital(entity);
}
@Override
public int countUsageByHospital(String hospital) {
if (hospital == null || hospital.trim().isEmpty()) {
return 0;
}
return bizHospitalMapper.countUsageByHospital(hospital.trim());
}
/**
* 批量导入: Excel → biz_hospital.
* <ol>
* <li>ExcelUtil 反序列化</li>
* <li>行级校验 (医院名必填) + 批内去重 + DB 去重</li>
* <li>逐行 insert (雪花 ID), 失败记 ngList 不影响其它行</li>
* </ol>
*/
@Override
public ImportResult importHospitals(MultipartFile file) throws Exception {
ExcelUtil<BizHospitalImportVo> util = new ExcelUtil<>(BizHospitalImportVo.class);
List<BizHospitalImportVo> importList = util.importExcel(file.getInputStream());
if (importList == null || importList.isEmpty()) {
throw new ServiceException("导入数据不能为空");
}
ImportResult result = new ImportResult();
Set<String> seenHospital = new HashSet<>();
for (int i = 0; i < importList.size(); i++) {
BizHospitalImportVo vo = importList.get(i);
int rowNo = i + 2; // Excel 行号 (1=表头)
try {
String hospital = trimToNull(vo.getHospital());
if (hospital == null) {
throw new ServiceException("医院名称不能为空");
}
// 批内去重 (case-insensitive)
String key = hospital.toLowerCase();
if (!seenHospital.add(key)) {
throw new ServiceException("医院「" + hospital + "」在文件中重复");
}
// DB 去重
BizHospital probe = new BizHospital();
probe.setHospital(hospital);
probe.setProvince(trimToNull(vo.getProvince()));
probe.setCity(trimToNull(vo.getCity()));
if (bizHospitalMapper.countByTrimmedHospital(probe) > 0) {
throw new ServiceException("医院「" + hospital + "」已存在");
}
BizHospital entity = new BizHospital();
entity.setHospital(hospital);
entity.setProvince(trimToNull(vo.getProvince()));
entity.setCity(trimToNull(vo.getCity()));
entity.setOriginalHospital(trimToNull(vo.getOriginalHospital()));
entity.setHospitalCode(trimToNull(vo.getHospitalCode()));
entity.setMilitaryHospital(trimToNull(vo.getMilitaryHospital()));
entity.setType(trimToNull(vo.getType()));
SnowflakeId.injectIfEmpty(entity, "hospitalId");
bizHospitalMapper.insert(entity);
result.ok();
} catch (Exception e) {
result.fail(rowNo, e.getMessage() == null ? "导入失败" : e.getMessage());
}
}
return result;
}
/** 除 hospital 外的字段统一 trim (把空串压成 null, 避免脏数据) */
private void normalize(BizHospital entity) {
entity.setProvince(trimToNull(entity.getProvince()));
entity.setCity(trimToNull(entity.getCity()));
entity.setOriginalHospital(trimToNull(entity.getOriginalHospital()));
entity.setHospitalCode(trimToNull(entity.getHospitalCode()));
entity.setMilitaryHospital(trimToNull(entity.getMilitaryHospital()));
entity.setType(trimToNull(entity.getType()));
}
private String trimToNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
}
@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizHospitalMapper">
<resultMap type="BizHospital" id="BizHospitalResult">
<id property="hospitalId" column="id" />
<result property="province" column="province" />
<result property="city" column="city" />
<result property="hospital" column="hospital" />
<result property="originalHospital" column="original_hospital" />
<result property="hospitalCode" column="hospital_code" />
<result property="militaryHospital" column="military_hospital" />
<result property="type" column="type" />
</resultMap>
<sql id="selectFields">
select id, province, city, hospital, original_hospital, hospital_code, military_hospital, type
from biz_hospital
</sql>
<select id="selectByPrimaryKey" resultMap="BizHospitalResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{hospitalId}
</select>
<!--
admin 后台分页 + 多条件筛选.
所有 string 字段都走 trim+LIKE 防首尾空白; province/militaryHospital/type 支持精确 + 模糊混合 (用 trim 防空白).
-->
<select id="selectList" resultMap="BizHospitalResult" parameterType="BizHospital">
<include refid="selectFields"/>
<where>
<if test="hospital != null and hospital != ''">and trim(hospital) like concat('%', #{hospital}, '%')</if>
<if test="province != null and province != ''">and trim(province) like concat('%', #{province}, '%')</if>
<if test="city != null and city != ''">and trim(city) like concat('%', #{city}, '%')</if>
<if test="hospitalCode != null and hospitalCode != ''">and trim(hospital_code) like concat('%', #{hospitalCode}, '%')</if>
<if test="militaryHospital != null and militaryHospital != ''">and trim(military_hospital) = #{militaryHospital}</if>
<if test="type != null and type != ''">and trim(type) = #{type}</if>
</where>
order by hospital_code asc, id asc
</select>
<!--
远程搜索: 三列 OR 模糊, LIMIT 30 防大表爆.
keyword 为空: 不返回任何记录 (前端按"初始 options 为空"约定, 不主动出建议).
-->
<select id="searchByKeyword" resultMap="BizHospitalResult">
<include refid="selectFields"/>
<where>
<if test="keyword != null and keyword != ''">
and (
trim(hospital) like concat('%', #{keyword}, '%')
or trim(original_hospital) like concat('%', #{keyword}, '%')
or trim(hospital_code) like concat('%', #{keyword}, '%')
)
</if>
<if test="keyword == null or keyword == ''">
and 1 = 0
</if>
</where>
order by hospital_code asc, id asc
limit 30
</select>
<!--
ID 生成: 雪花 ID (Long), 由 service 层 SnowflakeId.injectIfEmpty 注入 hospitalId.
注: 需先执行 ALTER TABLE biz_hospital MODIFY COLUMN id BIGINT NOT NULL (部署前 DDL);
雪花值 (~10^15) 与现有 int 数据 (max 29248) 不冲突.
-->
<insert id="insert" parameterType="BizHospital">
insert into biz_hospital
<trim prefix="(" suffix=")" suffixOverrides=",">
id,
<if test="province != null and province != ''">province,</if>
<if test="city != null and city != ''">city,</if>
hospital,
<if test="originalHospital != null and originalHospital != ''">original_hospital,</if>
<if test="hospitalCode != null and hospitalCode != ''">hospital_code,</if>
<if test="militaryHospital != null and militaryHospital != ''">military_hospital,</if>
<if test="type != null and type != ''">type,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
#{hospitalId},
<if test="province != null and province != ''">#{province},</if>
<if test="city != null and city != ''">#{city},</if>
#{hospital},
<if test="originalHospital != null and originalHospital != ''">#{originalHospital},</if>
<if test="hospitalCode != null and hospitalCode != ''">#{hospitalCode},</if>
<if test="militaryHospital != null and militaryHospital != ''">#{militaryHospital},</if>
<if test="type != null and type != ''">#{type},</if>
</trim>
</insert>
<!--
trim + 动态 SET: hospital 必填, 其余可空更新 (trim 防首尾空白污染).
-->
<update id="updateByPrimaryKey" parameterType="BizHospital">
update biz_hospital
<trim prefix="SET" suffixOverrides=",">
<if test="province != null">province = #{province},</if>
<if test="city != null">city = #{city},</if>
hospital = #{hospital},
<if test="originalHospital != null">original_hospital = #{originalHospital},</if>
<if test="hospitalCode != null">hospital_code = #{hospitalCode},</if>
<if test="militaryHospital != null">military_hospital = #{militaryHospital},</if>
<if test="type != null">type = #{type},</if>
</trim>
where id = #{hospitalId}
</update>
<!-- 硬删 (无 del_flag) -->
<delete id="deleteByPrimaryKeys" parameterType="Long">
delete from biz_hospital where id in
<foreach collection="hospitalIds" item="hid" open="(" separator="," close=")">
#{hid}
</foreach>
</delete>
<!--
唯一性检查: trim + case-insensitive (lower) + 省/市精确匹配.
语义: 同名称 + 同省 + 同市 才算重复 (不同省/市的同名医院是不同医院, 允许).
hospitalId 非空时排除自身 (编辑改名场景).
-->
<select id="countByTrimmedHospital" parameterType="BizHospital" resultType="int">
select count(*) from biz_hospital
where lower(trim(hospital)) = lower(trim(#{hospital}))
<if test="province != null and province != ''">and trim(province) = trim(#{province})</if>
<if test="province == null or province == ''">and province is null</if>
<if test="city != null and city != ''">and trim(city) = trim(#{city})</if>
<if test="city == null or city == ''">and city is null</if>
<if test="hospitalId != null">and id != #{hospitalId}</if>
</select>
<!--
删除前引用次数统计: 跨 4 张业务表 UNION, 统计 work_unit 字段等于指定医院名的总条数.
用 lower+trim 防空白/大小写差异导致漏数.
注: 表名/字段名确认依据 (设计文档 + 现状):
biz_expert.work_unit (varchar 200) 已审核通过的专家档案
biz_signup_expert.work_unit (varchar 200) 报名记录
biz_meeting_attendee.work_unit (varchar 200) 会议参会人
biz_execution_intent.work_unit (varchar 200) 执行意向 (按 collation utf8mb4_unicode_ci)
biz_support_intent.work_unit (varchar 200) 支持意向 (legacy 旧表, 部分场景仍写入)
-->
<select id="countUsageByHospital" parameterType="String" resultType="int">
select (
(select count(*) from biz_expert where lower(trim(work_unit)) = lower(trim(#{hospital})))
+ (select count(*) from biz_signup_expert where lower(trim(work_unit)) = lower(trim(#{hospital})))
+ (select count(*) from biz_meeting_attendee where lower(trim(work_unit)) = lower(trim(#{hospital})))
+ (select count(*) from biz_execution_intent where lower(trim(work_unit)) = lower(trim(#{hospital})))
+ (select count(*) from biz_support_intent where lower(trim(work_unit)) = lower(trim(#{hospital})))
) as total
</select>
</mapper>
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request'
/**
* 医院字典 API (biz_hospital)
* 基础 CRUD 走 @/api/public 的 bizList/bizGet/bizAdd/bizUpdate/bizDelete (路径 /business/hospital/...)
* 本文件只放特殊形状的端点: 远程搜索 / 引用统计 / 导入模板 / 导入 / 导出
*/
// 医生注册下拉远程搜索 (匿名公开, keyword 为空不返回建议)
export function searchHospital(keyword) {
return request.get('/business/hospital/search', { params: { keyword } })
}
// 删除前引用统计: 返回医院名被业务表 work_unit 引用的条数
export function countHospitalUsage(hospital) {
return request.get('/business/hospital/countUsage', { params: { hospital } })
}
// 下载导入模板 (blob)
export function downloadHospitalTemplate() {
return request.get('/business/hospital/importTemplate', { responseType: 'blob' })
}
// 批量导入 (FormData multipart, 返回 { code, msg, data: ImportResult })
export function importHospital(file) {
const form = new FormData()
form.append('file', file)
return request({
url: '/business/hospital/importData',
method: 'post',
data: form,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 导出 (跟随当前筛选条件, blob)
export function exportHospital(params) {
return request.post('/business/hospital/export', null, { params, responseType: 'blob' })
}
+387
View File
@@ -0,0 +1,387 @@
<template>
<div class="page-card admin-hospital">
<div class="breadcrumb">首页 / 资料库管理 / 医院管理</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="医院名称">
<el-input v-model="q.hospital" placeholder="输入医院名称" clearable style="width:200px" />
</el-form-item>
<el-form-item label="省">
<el-input v-model="q.province" placeholder="输入省" clearable style="width:140px" />
</el-form-item>
<el-form-item label="是否军医院">
<el-select v-model="q.militaryHospital" placeholder="全部" clearable style="width:140px">
<el-option label="非军医院" value="0" />
<el-option label="军医院" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- ========== 批量按钮区 ========== -->
<div class="batch-bar">
<el-button type="primary" @click="openAdd">新增医院</el-button>
<el-button @click="openImport">批量导入</el-button>
<el-button @click="onExport">导出</el-button>
</div>
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="province" label="省" width="90" align="center" />
<el-table-column prop="city" label="市" width="110" align="center" show-overflow-tooltip />
<el-table-column prop="hospital" label="医院名称" min-width="240" show-overflow-tooltip />
<el-table-column prop="hospitalCode" label="医院CODE" width="110" align="center" />
<el-table-column label="是否军医院" width="100" align="center">
<template #default="{ row }">
<el-tag :type="militaryTagType(row.militaryHospital)" disable-transitions>{{ militaryLabel(row.militaryHospital) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="80" align="center" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" type="primary" @click="onEdit(row)">编辑</el-link>
<el-link :underline="false" type="danger" @click="onDelete(row)">删除</el-link>
</div></template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
<!-- ========== 新增/编辑弹窗 ========== -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑医院' : '新增医院'" width="560px">
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef">
<el-form-item label="医院名称" prop="hospital">
<el-input v-model="form.hospital" placeholder="请输入医院名称(必填)" maxlength="1000" />
</el-form-item>
<el-form-item label="省">
<el-input v-model="form.province" placeholder="请输入省" maxlength="100" />
</el-form-item>
<el-form-item label="市">
<el-input v-model="form.city" placeholder="请输入市" maxlength="100" />
</el-form-item>
<el-form-item label="原名称">
<el-input v-model="form.originalHospital" placeholder="请输入原名称" maxlength="100" />
</el-form-item>
<el-form-item label="医院CODE">
<el-input v-model="form.hospitalCode" placeholder="如 H3301006" maxlength="100" />
</el-form-item>
<el-form-item label="是否军医院">
<el-select v-model="form.militaryHospital" placeholder="请选择" clearable style="width:160px">
<el-option label="非军医院" value="0" />
<el-option label="军医院" value="1" />
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-input v-model="form.type" placeholder="请输入类型" maxlength="100" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
</template>
</el-dialog>
<!-- ========== 批量导入 ========== -->
<el-dialog v-model="importOpen" title="批量导入医院" width="400px" append-to-body :close-on-click-modal="false">
<el-upload
ref="uploadRef"
:limit="1"
accept=".xlsx, .xls"
:auto-upload="false"
:on-change="onImportFileChange"
:on-remove="onImportFileRemove"
drag
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<div style="margin-top:10px;font-size:13px;text-align:center">
<span style="color:#909399;margin-right:8px">仅允许导入 xlsxlsx 格式文件</span>
<el-link type="primary" :underline="false" @click="downloadImportTpl">下载模板</el-link>
</div>
<template #footer>
<el-button @click="importOpen=false"> </el-button>
<el-button type="primary" :loading="importing" @click="submitImport"> </el-button>
</template>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { bizList, bizAdd, bizUpdate, bizDelete } from '@/api/public'
import { countHospitalUsage, downloadHospitalTemplate, importHospital, exportHospital } from '@/api/business/hospital'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
const q = reactive({ hospital: '', province: '', militaryHospital: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
async function load() {
loading.value = true
try {
const { data } = await bizList('hospital', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
rows.value = (data && data.rows) || []
page.total = (data && data.total) || 0
} catch { rows.value = []; page.total = 0 }
finally { loading.value = false }
}
function reset() {
Object.assign(q, { hospital: '', province: '', militaryHospital: '' })
page.pageNum = 1
load()
}
function militaryLabel(v) {
return v === '1' ? '军医院' : v === '0' ? '非军医院' : (v || '-')
}
function militaryTagType(v) {
return v === '1' ? 'warning' : 'info'
}
// ===== 新增/编辑 =====
const dialogVisible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
const formRef = ref(null)
const form = reactive({
hospitalId: null, province: '', city: '', hospital: '',
originalHospital: '', hospitalCode: '', militaryHospital: '', type: ''
})
const rules = {
hospital: [{ required: true, message: '请输入医院名称', trigger: 'blur' }]
}
function openAdd() {
isEdit.value = false
Object.assign(form, {
hospitalId: null, province: '', city: '', hospital: '',
originalHospital: '', hospitalCode: '', militaryHospital: '', type: ''
})
dialogVisible.value = true
}
function onEdit(row) {
isEdit.value = true
Object.assign(form, {
hospitalId: row.hospitalId,
province: row.province || '',
city: row.city || '',
hospital: row.hospital || '',
originalHospital: row.originalHospital || '',
hospitalCode: row.hospitalCode || '',
militaryHospital: row.militaryHospital || '',
type: row.type || ''
})
dialogVisible.value = true
}
async function onSave() {
await formRef.value.validate()
saving.value = true
try {
if (isEdit.value) {
await bizUpdate('hospital', { ...form })
ElMessage.success('修改成功')
} else {
await bizAdd('hospital', { ...form })
ElMessage.success('新增成功')
}
dialogVisible.value = false
load()
} catch (e) { ElMessage.error(e?.msg || '保存失败') }
finally { saving.value = false }
}
// ===== 删除 (硬删, 带引用统计二次确认) =====
async function onDelete(row) {
let usage = 0
try {
const r = await countHospitalUsage(row.hospital)
usage = r?.data || 0
} catch { usage = 0 }
const msg = usage > 0
? `医院「${row.hospital}」已被 ${usage} 条业务记录(专家/参会人/意向)引用, 删除后这些记录仍保留医院名称文本, 是否继续删除?`
: `确定删除医院「${row.hospital}」? 该操作不可恢复`
try {
await ElMessageBox.confirm(msg, '删除确认', { type: 'warning' })
} catch { return }
try {
await bizDelete('hospital', row.hospitalId)
ElMessage.success('删除成功')
load()
} catch (e) { ElMessage.error(e?.msg || '删除失败') }
}
// ===== 批量导入 =====
const importOpen = ref(false)
const importing = ref(false)
const importResultOpen = ref(false)
const importResult = ref(null)
const importFile = ref(null)
function openImport() {
importResult.value = null
importFile.value = null
importOpen.value = true
}
function onImportFileChange(file) {
importFile.value = file.raw
}
function onImportFileRemove() {
importFile.value = null
}
function downloadImportTpl() {
downloadHospitalTemplate().then(res => {
downloadBlob(res, '医院批量导入模板.xlsx')
}).catch(() => ElMessage.error('模板下载失败'))
}
async function submitImport() {
if (!importFile.value) return ElMessage.warning('请先选择文件')
importing.value = true
try {
const r = await importHospital(importFile.value)
const result = r?.data
if (!result || typeof result.okNum !== 'number') {
ElMessage.error('导入失败, 返回数据异常')
return
}
importResult.value = result
importOpen.value = false
importResultOpen.value = true
load()
} catch (e) {
ElMessage.error(e?.msg || '导入失败')
} finally { importing.value = false }
}
// ===== 导出 =====
async function onExport() {
try {
const res = await exportHospital({ ...q })
downloadBlob(res, `医院数据_${dateStamp()}.xlsx`)
} catch (e) { ElMessage.error(e?.msg || '导出失败') }
}
function downloadBlob(res, filename) {
const blob = res && res.data
if (!blob) return ElMessage.error('导出失败')
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function dateStamp() {
const d = new Date()
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
}
onMounted(load)
</script>
<style scoped>
.admin-hospital { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
/* ========================================
移动端适配 (≤768px) — 复用 SponsorOrgs.vue 标准模式
======================================== */
@media (max-width: 768px) {
.page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
.filter-form {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important;
margin-bottom: 0 !important;
}
.filter-form :deep(.el-form-item__label) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important;
min-width: 0 !important;
}
.filter-form :deep(.el-select),
.filter-form :deep(.el-input),
.filter-form :deep(.el-date-editor),
.filter-form :deep(.el-button),
.filter-form :deep(.el-cascader) {
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important;
}
.filter-form :deep(.el-button + .el-button) {
margin-top: 8px !important;
}
.batch-bar {
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
margin-bottom: 8px !important;
scrollbar-width: thin;
}
.batch-bar::-webkit-scrollbar { height: 4px; }
.batch-bar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.batch-bar :deep(.action-btn),
.batch-bar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
}
:deep(.el-table) { font-size: 12px !important; }
}
</style>