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
+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>