| @@ -2,10 +2,23 @@ | |||||
| CREATE TABLE `hospital` ( | CREATE TABLE `hospital` ( | ||||
| `id` int(11) NOT NULL AUTO_INCREMENT, | `id` int(11) NOT NULL AUTO_INCREMENT, | ||||
| `name` varchar(100) NOT NULL COMMENT '医院名称', | `name` varchar(100) NOT NULL COMMENT '医院名称', | ||||
| `province_code` varchar(10) DEFAULT '' COMMENT '省份编码', | |||||
| `city_code` varchar(10) DEFAULT '' COMMENT '城市编码', | |||||
| `district_code` varchar(10) DEFAULT '' COMMENT '区县编码', | |||||
| `province_name` varchar(50) DEFAULT '' COMMENT '省份名称', | |||||
| `city_name` varchar(50) DEFAULT '' COMMENT '城市名称', | |||||
| `district_name` varchar(50) DEFAULT '' COMMENT '区县名称', | |||||
| `sort` int(11) DEFAULT 0 COMMENT '排序(越大越前)', | `sort` int(11) DEFAULT 0 COMMENT '排序(越大越前)', | ||||
| `is_show` tinyint(1) DEFAULT 1 COMMENT '是否展示: 1是 0否', | `is_show` tinyint(1) DEFAULT 1 COMMENT '是否展示: 1是 0否', | ||||
| `is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除: 0正常 1已删除', | `is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除: 0正常 1已删除', | ||||
| `create_time` datetime DEFAULT CURRENT_TIMESTAMP, | `create_time` datetime DEFAULT CURRENT_TIMESTAMP, | ||||
| `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | ||||
| PRIMARY KEY (`id`) | |||||
| PRIMARY KEY (`id`), | |||||
| KEY `idx_region` (`province_code`, `city_code`, `district_code`) | |||||
| ) COMMENT='医院列表'; | ) COMMENT='医院列表'; | ||||
| -- patient 表新增就诊医院省市区编码 | |||||
| ALTER TABLE `patient` | |||||
| ADD COLUMN `hospital_province_code` varchar(10) DEFAULT '' COMMENT '就诊医院省份编码' AFTER `hospital`, | |||||
| ADD COLUMN `hospital_city_code` varchar(10) DEFAULT '' COMMENT '就诊医院城市编码' AFTER `hospital_province_code`, | |||||
| ADD COLUMN `hospital_district_code` varchar(10) DEFAULT '' COMMENT '就诊医院区县编码' AFTER `hospital_city_code`; | |||||
| @@ -0,0 +1,16 @@ | |||||
| -- 存量库升级脚本:医院表增加省市区字段,患者表记录就诊医院省市区编码 | |||||
| -- 如果是全新建库,请使用 hospital.sql;如果已执行过对应字段,请不要重复执行本脚本。 | |||||
| ALTER TABLE `hospital` | |||||
| ADD COLUMN `province_code` varchar(10) DEFAULT '' COMMENT '省份编码' AFTER `name`, | |||||
| ADD COLUMN `city_code` varchar(10) DEFAULT '' COMMENT '城市编码' AFTER `province_code`, | |||||
| ADD COLUMN `district_code` varchar(10) DEFAULT '' COMMENT '区县编码' AFTER `city_code`, | |||||
| ADD COLUMN `province_name` varchar(50) DEFAULT '' COMMENT '省份名称' AFTER `district_code`, | |||||
| ADD COLUMN `city_name` varchar(50) DEFAULT '' COMMENT '城市名称' AFTER `province_name`, | |||||
| ADD COLUMN `district_name` varchar(50) DEFAULT '' COMMENT '区县名称' AFTER `city_name`, | |||||
| ADD KEY `idx_region` (`province_code`, `city_code`, `district_code`); | |||||
| ALTER TABLE `patient` | |||||
| ADD COLUMN `hospital_province_code` varchar(10) DEFAULT '' COMMENT '就诊医院省份编码' AFTER `hospital`, | |||||
| ADD COLUMN `hospital_city_code` varchar(10) DEFAULT '' COMMENT '就诊医院城市编码' AFTER `hospital_province_code`, | |||||
| ADD COLUMN `hospital_district_code` varchar(10) DEFAULT '' COMMENT '就诊医院区县编码' AFTER `hospital_city_code`; | |||||
| @@ -57,6 +57,7 @@ module.exports = [ | |||||
| ['/common/ocr/hmt', 'common/ocrHmt', 'post'], | ['/common/ocr/hmt', 'common/ocrHmt', 'post'], | ||||
| ['/common/sampleTypes', 'common/sampleTypes'], | ['/common/sampleTypes', 'common/sampleTypes'], | ||||
| ['/common/hospitals', 'common/hospitals'], | ['/common/hospitals', 'common/hospitals'], | ||||
| ['/common/hospitalTree', 'common/hospitalTree'], | |||||
| ['/api/content', 'common/content'], | ['/api/content', 'common/content'], | ||||
| // 小程序接口 | // 小程序接口 | ||||
| @@ -8,13 +8,19 @@ module.exports = class extends Base { | |||||
| return this.display(); | return this.display(); | ||||
| } | } | ||||
| // 列表 | |||||
| // 列表(分页) | |||||
| async listAction() { | async listAction() { | ||||
| const keyword = this.get('keyword') || ''; | const keyword = this.get('keyword') || ''; | ||||
| const provinceCode = this.get('province_code') || ''; | |||||
| const cityCode = this.get('city_code') || ''; | |||||
| const districtCode = this.get('district_code') || ''; | |||||
| const page = this.get('page') || 1; | const page = this.get('page') || 1; | ||||
| const pageSize = this.get('pageSize') || 10; | const pageSize = this.get('pageSize') || 10; | ||||
| const where = { is_deleted: 0 }; | const where = { is_deleted: 0 }; | ||||
| if (keyword) where.name = ['like', `%${keyword}%`]; | if (keyword) where.name = ['like', `%${keyword}%`]; | ||||
| if (provinceCode) where.province_code = provinceCode; | |||||
| if (cityCode) where.city_code = cityCode; | |||||
| if (districtCode) where.district_code = districtCode; | |||||
| const data = await this.model('hospital') | const data = await this.model('hospital') | ||||
| .where(where) | .where(where) | ||||
| .order('sort DESC, id ASC') | .order('sort DESC, id ASC') | ||||
| @@ -25,31 +31,47 @@ module.exports = class extends Base { | |||||
| // 新增 | // 新增 | ||||
| async addAction() { | async addAction() { | ||||
| const { name, sort, is_show } = this.post(); | |||||
| const { name, province_code, city_code, district_code, sort, is_show } = this.post(); | |||||
| if (!name || !name.trim()) return this.fail('请输入医院名称'); | if (!name || !name.trim()) return this.fail('请输入医院名称'); | ||||
| const exists = await this.model('hospital').where({ name: name.trim(), is_deleted: 0 }).find(); | const exists = await this.model('hospital').where({ name: name.trim(), is_deleted: 0 }).find(); | ||||
| if (!think.isEmpty(exists)) return this.fail('该医院已存在'); | if (!think.isEmpty(exists)) return this.fail('该医院已存在'); | ||||
| const regionNames = await this._getRegionNames(province_code, city_code, district_code); | |||||
| await this.model('hospital').add({ | await this.model('hospital').add({ | ||||
| name: name.trim(), | name: name.trim(), | ||||
| province_code: province_code || '', | |||||
| city_code: city_code || '', | |||||
| district_code: district_code || '', | |||||
| province_name: regionNames.province, | |||||
| city_name: regionNames.city, | |||||
| district_name: regionNames.district, | |||||
| sort: sort || 0, | sort: sort || 0, | ||||
| is_show: is_show === undefined ? 1 : (is_show ? 1 : 0) | is_show: is_show === undefined ? 1 : (is_show ? 1 : 0) | ||||
| }); | }); | ||||
| await this.model('hospital').clearTreeCache(); | |||||
| await this.log('add', '医院管理', `新增医院「${name.trim()}」`); | await this.log('add', '医院管理', `新增医院「${name.trim()}」`); | ||||
| return this.success(); | return this.success(); | ||||
| } | } | ||||
| // 编辑 | // 编辑 | ||||
| async editAction() { | async editAction() { | ||||
| const { id, name, sort, is_show } = this.post(); | |||||
| const { id, name, province_code, city_code, district_code, sort, is_show } = this.post(); | |||||
| if (!id) return this.fail('参数错误'); | if (!id) return this.fail('参数错误'); | ||||
| if (!name || !name.trim()) return this.fail('请输入医院名称'); | if (!name || !name.trim()) return this.fail('请输入医院名称'); | ||||
| const exists = await this.model('hospital').where({ name: name.trim(), id: ['!=', id], is_deleted: 0 }).find(); | const exists = await this.model('hospital').where({ name: name.trim(), id: ['!=', id], is_deleted: 0 }).find(); | ||||
| if (!think.isEmpty(exists)) return this.fail('该医院已存在'); | if (!think.isEmpty(exists)) return this.fail('该医院已存在'); | ||||
| const regionNames = await this._getRegionNames(province_code, city_code, district_code); | |||||
| await this.model('hospital').where({ id }).update({ | await this.model('hospital').where({ id }).update({ | ||||
| name: name.trim(), | name: name.trim(), | ||||
| province_code: province_code || '', | |||||
| city_code: city_code || '', | |||||
| district_code: district_code || '', | |||||
| province_name: regionNames.province, | |||||
| city_name: regionNames.city, | |||||
| district_name: regionNames.district, | |||||
| sort: sort || 0, | sort: sort || 0, | ||||
| is_show: is_show ? 1 : 0 | is_show: is_show ? 1 : 0 | ||||
| }); | }); | ||||
| await this.model('hospital').clearTreeCache(); | |||||
| await this.log('edit', '医院管理', `编辑医院(ID:${id})「${name.trim()}」`); | await this.log('edit', '医院管理', `编辑医院(ID:${id})「${name.trim()}」`); | ||||
| return this.success(); | return this.success(); | ||||
| } | } | ||||
| @@ -59,6 +81,7 @@ module.exports = class extends Base { | |||||
| const { id } = this.post(); | const { id } = this.post(); | ||||
| if (!id) return this.fail('参数错误'); | if (!id) return this.fail('参数错误'); | ||||
| await this.model('hospital').where({ id }).update({ is_deleted: 1 }); | await this.model('hospital').where({ id }).update({ is_deleted: 1 }); | ||||
| await this.model('hospital').clearTreeCache(); | |||||
| await this.log('delete', '医院管理', `删除医院(ID:${id})`); | await this.log('delete', '医院管理', `删除医院(ID:${id})`); | ||||
| return this.success(); | return this.success(); | ||||
| } | } | ||||
| @@ -68,6 +91,7 @@ module.exports = class extends Base { | |||||
| const { id, is_show } = this.post(); | const { id, is_show } = this.post(); | ||||
| if (!id) return this.fail('参数错误'); | if (!id) return this.fail('参数错误'); | ||||
| await this.model('hospital').where({ id }).update({ is_show: is_show ? 1 : 0 }); | await this.model('hospital').where({ id }).update({ is_show: is_show ? 1 : 0 }); | ||||
| await this.model('hospital').clearTreeCache(); | |||||
| await this.log('edit', '医院管理', `${is_show ? '展示' : '隐藏'}医院(ID:${id})`); | await this.log('edit', '医院管理', `${is_show ? '展示' : '隐藏'}医院(ID:${id})`); | ||||
| return this.success(); | return this.success(); | ||||
| } | } | ||||
| @@ -79,32 +103,222 @@ module.exports = class extends Base { | |||||
| for (let i = 0; i < ids.length; i++) { | for (let i = 0; i < ids.length; i++) { | ||||
| await this.model('hospital').where({ id: ids[i] }).update({ sort: ids.length - i }); | await this.model('hospital').where({ id: ids[i] }).update({ sort: ids.length - i }); | ||||
| } | } | ||||
| await this.model('hospital').clearTreeCache(); | |||||
| return this.success(); | return this.success(); | ||||
| } | } | ||||
| // 批量导入(粘贴文本,每行一个) | |||||
| // Excel 导入(表头:关联机构名称/省份/城市/区县) | |||||
| async importAction() { | async importAction() { | ||||
| const { text } = this.post(); | |||||
| if (!text || !text.trim()) return this.fail('请输入医院名称'); | |||||
| const names = text.split('\n').map(s => s.trim()).filter(Boolean); | |||||
| if (!names.length) return this.fail('没有有效的医院名称'); | |||||
| const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); | |||||
| let file = this.file('file'); | |||||
| if (!file || !file.path) { | |||||
| await sleep(1000); | |||||
| file = this.file('file'); | |||||
| } | |||||
| if (!file || !file.path) return this.fail('请上传 Excel 文件'); | |||||
| const ExcelJS = require('exceljs'); | |||||
| const workbook = new ExcelJS.Workbook(); | |||||
| try { | |||||
| await workbook.xlsx.readFile(file.path); | |||||
| } catch (e) { | |||||
| return this.fail('文件解析失败,请上传有效的 Excel 文件'); | |||||
| } | |||||
| const sheet = workbook.worksheets[0]; | |||||
| if (!sheet) return this.fail('Excel 内容为空'); | |||||
| // 读取数据行(跳过表头) | |||||
| const rows = []; | |||||
| sheet.eachRow((row, rowNumber) => { | |||||
| if (rowNumber === 1) return; // 表头 | |||||
| const name = this._cellText(row.getCell(1)); | |||||
| const province = this._cellText(row.getCell(2)); | |||||
| const city = this._cellText(row.getCell(3)); | |||||
| const district = this._cellText(row.getCell(4)); | |||||
| if (name) rows.push({ name, province, city, district }); | |||||
| }); | |||||
| if (!rows.length) return this.fail('没有有效的数据行'); | |||||
| // 查已存在的,去重 | |||||
| // 预加载全部地区,构建名称索引 | |||||
| const regions = await this.model('sys_region').where({ status: 1 }).select(); | |||||
| const regionIndex = this._buildRegionIndex(regions); | |||||
| // 已存在的医院名称 | |||||
| const existRows = await this.model('hospital').where({ is_deleted: 0 }).field('name').select(); | const existRows = await this.model('hospital').where({ is_deleted: 0 }).field('name').select(); | ||||
| const existNames = new Set(existRows.map(r => r.name)); | const existNames = new Set(existRows.map(r => r.name)); | ||||
| const toInsert = []; | const toInsert = []; | ||||
| const seen = new Set(); | const seen = new Set(); | ||||
| names.forEach(name => { | |||||
| if (!existNames.has(name) && !seen.has(name)) { | |||||
| seen.add(name); | |||||
| toInsert.push({ name, sort: 0, is_show: 1 }); | |||||
| const unmatched = []; // 地区未匹配的医院名 | |||||
| let skipped = 0; | |||||
| rows.forEach(r => { | |||||
| if (existNames.has(r.name) || seen.has(r.name)) { skipped++; return; } | |||||
| seen.add(r.name); | |||||
| const codes = this._matchRegion(regionIndex, r.province, r.city, r.district); | |||||
| if ((r.province && !codes.province_code) || (r.city && !codes.city_code) || (r.district && !codes.district_code)) { | |||||
| unmatched.push(r.name); | |||||
| } | } | ||||
| toInsert.push({ | |||||
| name: r.name, | |||||
| province_code: codes.province_code, | |||||
| city_code: codes.city_code, | |||||
| district_code: codes.district_code, | |||||
| province_name: r.province || '', | |||||
| city_name: r.city || '', | |||||
| district_name: r.district || '', | |||||
| sort: 0, | |||||
| is_show: 1 | |||||
| }); | |||||
| }); | }); | ||||
| if (!toInsert.length) return this.fail('没有新增医院(均已存在)'); | if (!toInsert.length) return this.fail('没有新增医院(均已存在)'); | ||||
| await this.model('hospital').addMany(toInsert); | |||||
| await this.log('import', '医院管理', `批量导入医院 ${toInsert.length} 条`); | |||||
| return this.success({ count: toInsert.length, skipped: names.length - toInsert.length }); | |||||
| // 分批插入 | |||||
| const batchSize = 200; | |||||
| for (let i = 0; i < toInsert.length; i += batchSize) { | |||||
| await this.model('hospital').addMany(toInsert.slice(i, i + batchSize)); | |||||
| } | |||||
| await this.model('hospital').clearTreeCache(); | |||||
| await this.log('import', '医院管理', `导入医院 ${toInsert.length} 条`); | |||||
| return this.success({ | |||||
| count: toInsert.length, | |||||
| skipped, | |||||
| unmatchedCount: unmatched.length, | |||||
| unmatched: unmatched.slice(0, 50) | |||||
| }); | |||||
| } | |||||
| // @private 读取单元格文本 | |||||
| _cellText(cell) { | |||||
| if (!cell || cell.value === null || cell.value === undefined) return ''; | |||||
| const v = cell.value; | |||||
| if (typeof v === 'object') { | |||||
| if (v.text) return String(v.text).trim(); | |||||
| if (v.result !== undefined) return String(v.result).trim(); | |||||
| if (v.richText) return v.richText.map(t => t.text).join('').trim(); | |||||
| return ''; | |||||
| } | |||||
| return String(v).trim(); | |||||
| } | |||||
| // @private 构建地区名称索引:{ provinceName: {code, cities: { cityName: {code, districts: { distName: code }}}}} | |||||
| _buildRegionIndex(regions) { | |||||
| const provinces = {}; | |||||
| const cityByParent = {}; | |||||
| const distByParent = {}; | |||||
| regions.forEach(r => { | |||||
| const code = r.code; | |||||
| if (code.endsWith('0000')) { | |||||
| provinces[r.name] = { code, cities: {} }; | |||||
| } else if (code.endsWith('00')) { | |||||
| const pCode = code.substring(0, 2) + '0000'; | |||||
| if (!cityByParent[pCode]) cityByParent[pCode] = []; | |||||
| cityByParent[pCode].push({ code, name: r.name }); | |||||
| } else { | |||||
| const cCode = code.substring(0, 4) + '00'; | |||||
| if (!distByParent[cCode]) distByParent[cCode] = []; | |||||
| distByParent[cCode].push({ code, name: r.name }); | |||||
| } | |||||
| }); | |||||
| // 组装 | |||||
| Object.values(provinces).forEach(prov => { | |||||
| const cities = cityByParent[prov.code] || []; | |||||
| cities.forEach(c => { | |||||
| prov.cities[c.name] = { code: c.code, districts: {} }; | |||||
| const dists = distByParent[c.code] || []; | |||||
| dists.forEach(d => { prov.cities[c.name].districts[d.name] = d.code; }); | |||||
| }); | |||||
| }); | |||||
| return provinces; | |||||
| } | |||||
| // @private 名称匹配编码(容错:直辖市省市同名、模糊包含) | |||||
| _matchRegion(index, provinceName, cityName, districtName) { | |||||
| const result = { province_code: '', city_code: '', district_code: '' }; | |||||
| if (!provinceName) return result; | |||||
| // 匹配省(容错:去掉"省/市/自治区"等后缀的包含匹配) | |||||
| let prov = index[provinceName]; | |||||
| if (!prov) { | |||||
| const pKey = Object.keys(index).find(k => k.includes(provinceName) || provinceName.includes(k)); | |||||
| if (pKey) prov = index[pKey]; | |||||
| } | |||||
| if (!prov) return result; | |||||
| result.province_code = prov.code; | |||||
| // 匹配市 | |||||
| let city = prov.cities[cityName]; | |||||
| if (!city && cityName) { | |||||
| const cKey = Object.keys(prov.cities).find(k => k.includes(cityName) || cityName.includes(k)); | |||||
| if (cKey) city = prov.cities[cKey]; | |||||
| } | |||||
| // 直辖市:省名=市名,市层可能只有一个 | |||||
| if (!city) { | |||||
| const cityKeys = Object.keys(prov.cities); | |||||
| if (cityKeys.length === 1) city = prov.cities[cityKeys[0]]; | |||||
| } | |||||
| // 省直辖县级行政区、部分直辖市导入数据可能出现「城市=区县」: | |||||
| // 此时城市名匹配不到标准市级节点,用「省 + 区县」在该省下横向查一次。 | |||||
| if (!city && districtName) { | |||||
| const matched = this._findDistrictInProvince(prov, districtName); | |||||
| if (matched) { | |||||
| result.city_code = matched.city.code; | |||||
| result.district_code = matched.districtCode; | |||||
| } | |||||
| return result; | |||||
| } | |||||
| if (!city) return result; | |||||
| result.city_code = city.code; | |||||
| // 匹配区县 | |||||
| if (districtName) { | |||||
| let dCode = city.districts[districtName]; | |||||
| if (!dCode) { | |||||
| const dKey = Object.keys(city.districts).find(k => k.includes(districtName) || districtName.includes(k)); | |||||
| if (dKey) dCode = city.districts[dKey]; | |||||
| } | |||||
| if (!dCode) { | |||||
| const matched = this._findDistrictInProvince(prov, districtName); | |||||
| if (matched) { | |||||
| result.city_code = matched.city.code; | |||||
| dCode = matched.districtCode; | |||||
| } | |||||
| } | |||||
| if (dCode) result.district_code = dCode; | |||||
| } | |||||
| return result; | |||||
| } | |||||
| // @private 在指定省份下按区县名称横向匹配,并返回其所属城市 | |||||
| _findDistrictInProvince(prov, districtName) { | |||||
| if (!prov || !districtName) return null; | |||||
| for (const city of Object.values(prov.cities)) { | |||||
| let dCode = city.districts[districtName]; | |||||
| if (!dCode) { | |||||
| const dKey = Object.keys(city.districts).find(k => k.includes(districtName) || districtName.includes(k)); | |||||
| if (dKey) dCode = city.districts[dKey]; | |||||
| } | |||||
| if (dCode) return { city, districtCode: dCode }; | |||||
| } | |||||
| return null; | |||||
| } | |||||
| // @private 根据编码查地区名称 | |||||
| async _getRegionNames(provinceCode, cityCode, districtCode) { | |||||
| const codes = [provinceCode, cityCode, districtCode].filter(Boolean); | |||||
| const map = {}; | |||||
| if (codes.length) { | |||||
| const regions = await this.model('sys_region').where({ code: ['in', codes] }).select(); | |||||
| regions.forEach(r => { map[r.code] = r.name; }); | |||||
| } | |||||
| return { | |||||
| province: map[provinceCode] || '', | |||||
| city: map[cityCode] || '', | |||||
| district: map[districtCode] || '' | |||||
| }; | |||||
| } | } | ||||
| }; | }; | ||||
| @@ -98,7 +98,8 @@ module.exports = class extends Base { | |||||
| // 查询省市区名称(包含寄回地址) | // 查询省市区名称(包含寄回地址) | ||||
| const allCodes = [ | const allCodes = [ | ||||
| patient.province_code, patient.city_code, patient.district_code, | patient.province_code, patient.city_code, patient.district_code, | ||||
| patient.return_province_code, patient.return_city_code, patient.return_district_code | |||||
| patient.return_province_code, patient.return_city_code, patient.return_district_code, | |||||
| patient.hospital_province_code, patient.hospital_city_code, patient.hospital_district_code | |||||
| ].filter(Boolean); | ].filter(Boolean); | ||||
| if (allCodes.length) { | if (allCodes.length) { | ||||
| const regions = await this.model('sys_region') | const regions = await this.model('sys_region') | ||||
| @@ -112,6 +113,9 @@ module.exports = class extends Base { | |||||
| patient.return_province_name = regionMap[patient.return_province_code] || ''; | patient.return_province_name = regionMap[patient.return_province_code] || ''; | ||||
| patient.return_city_name = regionMap[patient.return_city_code] || ''; | patient.return_city_name = regionMap[patient.return_city_code] || ''; | ||||
| patient.return_district_name = regionMap[patient.return_district_code] || ''; | patient.return_district_name = regionMap[patient.return_district_code] || ''; | ||||
| patient.hospital_province_name = regionMap[patient.hospital_province_code] || ''; | |||||
| patient.hospital_city_name = regionMap[patient.hospital_city_code] || ''; | |||||
| patient.hospital_district_name = regionMap[patient.hospital_district_code] || ''; | |||||
| } | } | ||||
| // 获取审核记录 | // 获取审核记录 | ||||
| @@ -126,7 +130,7 @@ module.exports = class extends Base { | |||||
| // 新增患者 | // 新增患者 | ||||
| async addAction() { | async addAction() { | ||||
| const data = this.post(); | const data = this.post(); | ||||
| const { name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, hospital, emergency_contact, emergency_phone, tag, documents, sample_types, wax_return, return_name, return_phone, return_province_code, return_city_code, return_district_code, return_address, report_email, sample_tracking_no, sample_photos, sign_income, sign_privacy, sign_promise, sign_privacy_jhr, income_amount, guardian_name, guardian_id_card, guardian_relation } = data; | |||||
| const { name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, hospital, hospital_province_code, hospital_city_code, hospital_district_code, emergency_contact, emergency_phone, tag, documents, sample_types, wax_return, return_name, return_phone, return_province_code, return_city_code, return_district_code, return_address, report_email, sample_tracking_no, sample_photos, sign_income, sign_privacy, sign_promise, sign_privacy_jhr, income_amount, guardian_name, guardian_id_card, guardian_relation } = data; | |||||
| if (!name || !phone || !id_card || !gender || !birth_date) { | if (!name || !phone || !id_card || !gender || !birth_date) { | ||||
| return this.fail('请填写完整信息'); | return this.fail('请填写完整信息'); | ||||
| @@ -178,6 +182,9 @@ module.exports = class extends Base { | |||||
| district_code: district_code || '', | district_code: district_code || '', | ||||
| address: address || '', | address: address || '', | ||||
| hospital: hospital || '', | hospital: hospital || '', | ||||
| hospital_province_code: hospital_province_code || '', | |||||
| hospital_city_code: hospital_city_code || '', | |||||
| hospital_district_code: hospital_district_code || '', | |||||
| emergency_contact: emergency_contact || '', | emergency_contact: emergency_contact || '', | ||||
| emergency_phone: emergency_phone || '', | emergency_phone: emergency_phone || '', | ||||
| tag: tag || '', | tag: tag || '', | ||||
| @@ -332,7 +339,7 @@ module.exports = class extends Base { | |||||
| // 编辑患者 | // 编辑患者 | ||||
| async editAction() { | async editAction() { | ||||
| const data = this.post(); | const data = this.post(); | ||||
| const { id, name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, hospital, emergency_contact, emergency_phone, tag, documents, sample_types, wax_return, return_name, return_phone, return_province_code, return_city_code, return_district_code, return_address, report_email, sample_tracking_no, sample_photos, sign_income, sign_privacy, sign_promise, sign_privacy_jhr, income_amount, guardian_name, guardian_id_card, guardian_relation } = data; | |||||
| const { id, name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, hospital, hospital_province_code, hospital_city_code, hospital_district_code, emergency_contact, emergency_phone, tag, documents, sample_types, wax_return, return_name, return_phone, return_province_code, return_city_code, return_district_code, return_address, report_email, sample_tracking_no, sample_photos, sign_income, sign_privacy, sign_promise, sign_privacy_jhr, income_amount, guardian_name, guardian_id_card, guardian_relation } = data; | |||||
| if (!id) return this.fail('参数错误'); | if (!id) return this.fail('参数错误'); | ||||
| if (!name || !phone || !id_card || !gender || !birth_date) return this.fail('请填写完整信息'); | if (!name || !phone || !id_card || !gender || !birth_date) return this.fail('请填写完整信息'); | ||||
| @@ -355,6 +362,9 @@ module.exports = class extends Base { | |||||
| province_code, city_code, district_code, | province_code, city_code, district_code, | ||||
| address: address || '', | address: address || '', | ||||
| hospital: hospital || '', | hospital: hospital || '', | ||||
| hospital_province_code: hospital_province_code || '', | |||||
| hospital_city_code: hospital_city_code || '', | |||||
| hospital_district_code: hospital_district_code || '', | |||||
| emergency_contact: emergency_contact || '', | emergency_contact: emergency_contact || '', | ||||
| emergency_phone: emergency_phone || '', | emergency_phone: emergency_phone || '', | ||||
| tag: tag || '', | tag: tag || '', | ||||
| @@ -400,6 +410,9 @@ module.exports = class extends Base { | |||||
| if (item.return_province_code) allCodes.add(item.return_province_code); | if (item.return_province_code) allCodes.add(item.return_province_code); | ||||
| if (item.return_city_code) allCodes.add(item.return_city_code); | if (item.return_city_code) allCodes.add(item.return_city_code); | ||||
| if (item.return_district_code) allCodes.add(item.return_district_code); | if (item.return_district_code) allCodes.add(item.return_district_code); | ||||
| if (item.hospital_province_code) allCodes.add(item.hospital_province_code); | |||||
| if (item.hospital_city_code) allCodes.add(item.hospital_city_code); | |||||
| if (item.hospital_district_code) allCodes.add(item.hospital_district_code); | |||||
| }); | }); | ||||
| const regionMap = {}; | const regionMap = {}; | ||||
| if (allCodes.size) { | if (allCodes.size) { | ||||
| @@ -425,7 +438,7 @@ module.exports = class extends Base { | |||||
| const statusMap = { '-1': '待提交', 0: '待审核', 1: '审核通过', 2: '已驳回' }; | const statusMap = { '-1': '待提交', 0: '待审核', 1: '审核通过', 2: '已驳回' }; | ||||
| const header = [ | const header = [ | ||||
| 'ID', '姓名', '性别', '身份证', '手机号', '省份', '城市', '详细地址', | 'ID', '姓名', '性别', '身份证', '手机号', '省份', '城市', '详细地址', | ||||
| '医院名称', '紧急联系人', '紧急联系电话', '瘤种', | |||||
| '医院名称', '医院省份', '医院城市', '医院区县', '紧急联系人', '紧急联系电话', '瘤种', | |||||
| '送检样本类型', '是否需寄回', '收件人', '收件电话', '收件地址', | '送检样本类型', '是否需寄回', '收件人', '收件电话', '收件地址', | ||||
| '报告接收邮箱', '送检物流单号', | '报告接收邮箱', '送检物流单号', | ||||
| '提交时间', '审核状态', '审核日期', '审核驳回原因' | '提交时间', '审核状态', '审核日期', '审核驳回原因' | ||||
| @@ -479,6 +492,9 @@ module.exports = class extends Base { | |||||
| regionMap[item.city_code] || '', | regionMap[item.city_code] || '', | ||||
| item.address || '', | item.address || '', | ||||
| item.hospital || '', | item.hospital || '', | ||||
| regionMap[item.hospital_province_code] || '', | |||||
| regionMap[item.hospital_city_code] || '', | |||||
| regionMap[item.hospital_district_code] || '', | |||||
| item.emergency_contact || '', | item.emergency_contact || '', | ||||
| item.emergency_phone || '', | item.emergency_phone || '', | ||||
| item.tag || '', | item.tag || '', | ||||
| @@ -165,7 +165,21 @@ module.exports = class extends think.Controller { | |||||
| } | } | ||||
| /** | /** | ||||
| * 获取医院列表(展示中的) | |||||
| * 获取医院树(省→市→区→医院),用于小程序逐级选择 | |||||
| * GET /common/hospitalTree | |||||
| */ | |||||
| async hospitalTreeAction() { | |||||
| try { | |||||
| const tree = await this.model('hospital').getTree(); | |||||
| return this.json({ code: 0, data: tree }); | |||||
| } catch (error) { | |||||
| think.logger.error('获取医院树失败:', error); | |||||
| return this.json({ code: 0, data: [] }); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取医院列表(兼容旧版小程序/H5) | |||||
| * GET /common/hospitals | * GET /common/hospitals | ||||
| */ | */ | ||||
| async hospitalsAction() { | async hospitalsAction() { | ||||
| @@ -363,7 +363,10 @@ module.exports = class extends Base { | |||||
| const patient = await this.model('patient').where({ id: user.patient_id, is_deleted: 0 }).find(); | const patient = await this.model('patient').where({ id: user.patient_id, is_deleted: 0 }).find(); | ||||
| if (think.isEmpty(patient)) return this.json({ code: 0, data: null }); | if (think.isEmpty(patient)) return this.json({ code: 0, data: null }); | ||||
| const codes = [patient.province_code, patient.city_code, patient.district_code].filter(Boolean); | |||||
| const codes = [ | |||||
| patient.province_code, patient.city_code, patient.district_code, | |||||
| patient.hospital_province_code, patient.hospital_city_code, patient.hospital_district_code | |||||
| ].filter(Boolean); | |||||
| const regionMap = {}; | const regionMap = {}; | ||||
| if (codes.length) { | if (codes.length) { | ||||
| const regions = await this.model('sys_region').where({ code: ['in', codes] }).select(); | const regions = await this.model('sys_region').where({ code: ['in', codes] }).select(); | ||||
| @@ -396,6 +399,12 @@ module.exports = class extends Base { | |||||
| district_name: regionMap[patient.district_code] || '', | district_name: regionMap[patient.district_code] || '', | ||||
| address: patient.address || '', | address: patient.address || '', | ||||
| hospital: patient.hospital || '', | hospital: patient.hospital || '', | ||||
| hospital_province_code: patient.hospital_province_code || '', | |||||
| hospital_city_code: patient.hospital_city_code || '', | |||||
| hospital_district_code: patient.hospital_district_code || '', | |||||
| hospital_province_name: regionMap[patient.hospital_province_code] || '', | |||||
| hospital_city_name: regionMap[patient.hospital_city_code] || '', | |||||
| hospital_district_name: regionMap[patient.hospital_district_code] || '', | |||||
| emergency_contact: patient.emergency_contact || '', | emergency_contact: patient.emergency_contact || '', | ||||
| emergency_phone: patient.emergency_phone || '', | emergency_phone: patient.emergency_phone || '', | ||||
| tag: patient.tag || '', documents, | tag: patient.tag || '', documents, | ||||
| @@ -431,7 +440,7 @@ module.exports = class extends Base { | |||||
| async saveMyInfoAction() { | async saveMyInfoAction() { | ||||
| const mpUser = this.mpUser; | const mpUser = this.mpUser; | ||||
| if (!mpUser) return this.json({ code: 1009, msg: '请先登录' }); | if (!mpUser) return this.json({ code: 1009, msg: '请先登录' }); | ||||
| const { gender, province_code, city_code, district_code, address, hospital, emergency_contact, emergency_phone, documents, tag, sign_income, sign_privacy, sign_privacy_jhr, sign_promise, income_amount, guardian_name, guardian_id_card, guardian_relation, mp_env_version } = this.post(); | |||||
| const { gender, province_code, city_code, district_code, address, hospital, hospital_province_code, hospital_city_code, hospital_district_code, emergency_contact, emergency_phone, documents, tag, sign_income, sign_privacy, sign_privacy_jhr, sign_promise, income_amount, guardian_name, guardian_id_card, guardian_relation, mp_env_version } = this.post(); | |||||
| const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find(); | const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find(); | ||||
| if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 1, msg: '请先完成实名认证' }); | if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 1, msg: '请先完成实名认证' }); | ||||
| if (!province_code || !city_code || !district_code) return this.json({ code: 1, msg: '请选择省市区' }); | if (!province_code || !city_code || !district_code) return this.json({ code: 1, msg: '请选择省市区' }); | ||||
| @@ -444,10 +453,27 @@ module.exports = class extends Base { | |||||
| if (mp_env_version) { | if (mp_env_version) { | ||||
| await this.model('wechat_user').where({ id: mpUser.id }).update({ mp_env_version }); | await this.model('wechat_user').where({ id: mpUser.id }).update({ mp_env_version }); | ||||
| } | } | ||||
| let finalHospitalProvinceCode = hospital_province_code || ''; | |||||
| let finalHospitalCityCode = hospital_city_code || ''; | |||||
| let finalHospitalDistrictCode = hospital_district_code || ''; | |||||
| if (hospital && (!finalHospitalProvinceCode || !finalHospitalCityCode || !finalHospitalDistrictCode)) { | |||||
| const hospitalRow = await this.model('hospital') | |||||
| .where({ name: hospital, is_deleted: 0 }) | |||||
| .find(); | |||||
| if (!think.isEmpty(hospitalRow)) { | |||||
| finalHospitalProvinceCode = finalHospitalProvinceCode || hospitalRow.province_code || ''; | |||||
| finalHospitalCityCode = finalHospitalCityCode || hospitalRow.city_code || ''; | |||||
| finalHospitalDistrictCode = finalHospitalDistrictCode || hospitalRow.district_code || ''; | |||||
| } | |||||
| } | |||||
| await this.model('patient').where({ id: user.patient_id }).update({ | await this.model('patient').where({ id: user.patient_id }).update({ | ||||
| gender: gender || '', province_code: province_code || '', city_code: city_code || '', | gender: gender || '', province_code: province_code || '', city_code: city_code || '', | ||||
| district_code: district_code || '', address: address || '', | district_code: district_code || '', address: address || '', | ||||
| hospital: hospital || '', | hospital: hospital || '', | ||||
| hospital_province_code: finalHospitalProvinceCode, | |||||
| hospital_city_code: finalHospitalCityCode, | |||||
| hospital_district_code: finalHospitalDistrictCode, | |||||
| emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '', | emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '', | ||||
| tag: tag || '', | tag: tag || '', | ||||
| documents: JSON.stringify(documents || []), | documents: JSON.stringify(documents || []), | ||||
| @@ -1,10 +1,76 @@ | |||||
| const HOSPITAL_TREE_CACHE_KEY = 'hospital_tree'; | |||||
| module.exports = class extends think.Model { | module.exports = class extends think.Model { | ||||
| get tableName() { | get tableName() { | ||||
| return 'hospital'; | return 'hospital'; | ||||
| } | } | ||||
| /** | /** | ||||
| * 获取展示中的医院列表(小程序用) | |||||
| * 获取展示中的医院树(省→市→区→医院),带缓存 | |||||
| */ | |||||
| async getTree() { | |||||
| const cached = await think.cache(HOSPITAL_TREE_CACHE_KEY); | |||||
| if (cached) return cached; | |||||
| const list = await this.where({ is_show: 1, is_deleted: 0 }) | |||||
| .order('sort DESC, id ASC') | |||||
| .select(); | |||||
| // 组装 省→市→区→医院 树 | |||||
| const provinceMap = {}; | |||||
| list.forEach(h => { | |||||
| const pCode = h.province_code || ''; | |||||
| const pName = h.province_name || '其他'; | |||||
| const pKey = pCode || `unknown:${pName}`; | |||||
| const cCode = h.city_code || ''; | |||||
| const cName = h.city_name || '其他'; | |||||
| const cKey = cCode || `unknown:${cName}`; | |||||
| const dCode = h.district_code || ''; | |||||
| const dName = h.district_name || '其他'; | |||||
| const dKey = dCode || `unknown:${dName}`; | |||||
| if (!provinceMap[pKey]) { | |||||
| provinceMap[pKey] = { provinceCode: pCode, provinceName: pName, _cityMap: {} }; | |||||
| } | |||||
| const prov = provinceMap[pKey]; | |||||
| if (!prov._cityMap[cKey]) { | |||||
| prov._cityMap[cKey] = { cityCode: cCode, cityName: cName, _distMap: {} }; | |||||
| } | |||||
| const city = prov._cityMap[cKey]; | |||||
| if (!city._distMap[dKey]) { | |||||
| city._distMap[dKey] = { districtCode: dCode, districtName: dName, hospitals: [] }; | |||||
| } | |||||
| city._distMap[dKey].hospitals.push({ | |||||
| id: h.id, | |||||
| name: h.name, | |||||
| province_code: h.province_code, | |||||
| city_code: h.city_code, | |||||
| district_code: h.district_code | |||||
| }); | |||||
| }); | |||||
| // map 转 array | |||||
| const tree = Object.values(provinceMap).map(prov => { | |||||
| const cities = Object.values(prov._cityMap).map(city => { | |||||
| const districts = Object.values(city._distMap); | |||||
| return { cityCode: city.cityCode, cityName: city.cityName, districts }; | |||||
| }); | |||||
| return { provinceCode: prov.provinceCode, provinceName: prov.provinceName, cities }; | |||||
| }); | |||||
| await think.cache(HOSPITAL_TREE_CACHE_KEY, tree, { timeout: 24 * 60 * 60 * 1000 }); | |||||
| return tree; | |||||
| } | |||||
| /** | |||||
| * 清除医院树缓存(增删改后调用) | |||||
| */ | |||||
| async clearTreeCache() { | |||||
| await think.cache(HOSPITAL_TREE_CACHE_KEY, null); | |||||
| } | |||||
| /** | |||||
| * 获取展示中的医院列表(兼容旧版小程序/H5) | |||||
| */ | */ | ||||
| async getShowList(keyword = '') { | async getShowList(keyword = '') { | ||||
| const where = { is_show: 1, is_deleted: 0 }; | const where = { is_show: 1, is_deleted: 0 }; | ||||
| @@ -18,6 +18,9 @@ | |||||
| <div class="flex items-center justify-between"> | <div class="flex items-center justify-between"> | ||||
| <div class="flex items-center gap-3"> | <div class="flex items-center gap-3"> | ||||
| <el-input v-model="keyword" placeholder="搜索医院名称" style="width:240px;" clearable @keyup.enter="onSearch"></el-input> | <el-input v-model="keyword" placeholder="搜索医院名称" style="width:240px;" clearable @keyup.enter="onSearch"></el-input> | ||||
| <el-cascader v-model="regionFilter" :options="regionTree" | |||||
| :props="{ value: 'code', label: 'name', children: 'children', checkStrictly: true }" | |||||
| placeholder="按地区筛选" clearable filterable :filter-method="filterRegionNode" style="width:240px;"></el-cascader> | |||||
| <el-button type="primary" @click="onSearch">搜索</el-button> | <el-button type="primary" @click="onSearch">搜索</el-button> | ||||
| <el-button @click="resetQuery">重置</el-button> | <el-button @click="resetQuery">重置</el-button> | ||||
| </div> | </div> | ||||
| @@ -37,13 +40,19 @@ | |||||
| <span class="drag-handle">⠿</span> | <span class="drag-handle">⠿</span> | ||||
| </template> | </template> | ||||
| </el-table-column> | </el-table-column> | ||||
| <el-table-column prop="name" label="医院名称"></el-table-column> | |||||
| <el-table-column label="是否展示" width="120" align="center"> | |||||
| <el-table-column prop="name" label="医院名称" min-width="200"></el-table-column> | |||||
| <el-table-column label="所在地区" min-width="180"> | |||||
| <template #default="{ row }"> | |||||
| <span v-if="row.province_name">${ row.province_name } ${ row.city_name } ${ row.district_name }</span> | |||||
| <span v-else style="color:#c0c4cc;">未设置</span> | |||||
| </template> | |||||
| </el-table-column> | |||||
| <el-table-column label="是否展示" width="100" align="center"> | |||||
| <template #default="{ row }"> | <template #default="{ row }"> | ||||
| <el-switch :model-value="row.is_show === 1" @change="(v) => handleToggle(row, v)" /> | <el-switch :model-value="row.is_show === 1" @change="(v) => handleToggle(row, v)" /> | ||||
| </template> | </template> | ||||
| </el-table-column> | </el-table-column> | ||||
| <el-table-column label="操作" width="180" align="center"> | |||||
| <el-table-column label="操作" width="160" align="center"> | |||||
| <template #default="{ row }"> | <template #default="{ row }"> | ||||
| <el-button type="primary" link @click="showEdit(row)">编辑</el-button> | <el-button type="primary" link @click="showEdit(row)">编辑</el-button> | ||||
| <el-button type="danger" link @click="handleDelete(row)">删除</el-button> | <el-button type="danger" link @click="handleDelete(row)">删除</el-button> | ||||
| @@ -61,13 +70,18 @@ | |||||
| <el-dialog v-model="dialogVisible" :title="dialogTitle" width="460px" destroy-on-close draggable :close-on-click-modal="false"> | <el-dialog v-model="dialogVisible" :title="dialogTitle" width="460px" destroy-on-close draggable :close-on-click-modal="false"> | ||||
| <el-form :model="form" label-width="90px"> | <el-form :model="form" label-width="90px"> | ||||
| <el-form-item label="医院名称" required> | <el-form-item label="医院名称" required> | ||||
| <el-input v-model="form.name" placeholder="请输入医院名称" maxlength="100" /> | |||||
| <el-input v-model="form.name" placeholder="请输入医院名称" maxlength="100"></el-input> | |||||
| </el-form-item> | |||||
| <el-form-item label="所在地区"> | |||||
| <el-cascader v-model="form.regionCodes" :options="regionTree" | |||||
| :props="{ value: 'code', label: 'name', children: 'children' }" | |||||
| placeholder="请选择省/市/区" clearable filterable :filter-method="filterRegionNode" style="width:100%;"></el-cascader> | |||||
| </el-form-item> | </el-form-item> | ||||
| <el-form-item label="排序"> | <el-form-item label="排序"> | ||||
| <el-input-number v-model="form.sort" :min="0" :max="9999" /> | |||||
| <el-input-number v-model="form.sort" :min="0" :max="9999"></el-input-number> | |||||
| </el-form-item> | </el-form-item> | ||||
| <el-form-item label="是否展示"> | <el-form-item label="是否展示"> | ||||
| <el-switch v-model="form.is_show" :active-value="1" :inactive-value="0" /> | |||||
| <el-switch v-model="form.is_show" :active-value="1" :inactive-value="0"></el-switch> | |||||
| </el-form-item> | </el-form-item> | ||||
| </el-form> | </el-form> | ||||
| <template #footer> | <template #footer> | ||||
| @@ -76,10 +90,16 @@ | |||||
| </template> | </template> | ||||
| </el-dialog> | </el-dialog> | ||||
| <!-- 批量导入弹窗 --> | |||||
| <el-dialog v-model="showImport" title="批量导入医院" width="500px" destroy-on-close :close-on-click-modal="false"> | |||||
| <p style="font-size:13px;color:var(--el-color-primary);margin-bottom:12px;">每行输入一个医院名称,重复的将自动跳过。</p> | |||||
| <el-input v-model="importText" type="textarea" :rows="10" :placeholder="importPlaceholder"></el-input> | |||||
| <!-- Excel 导入弹窗 --> | |||||
| <el-dialog v-model="showImport" title="导入医院" width="500px" destroy-on-close :close-on-click-modal="false"> | |||||
| <p style="font-size:13px;color:var(--el-color-primary);margin-bottom:12px;"> | |||||
| 请上传 Excel 文件(.xlsx),表头依次为:关联机构名称、省份、城市、区县。重复医院将自动跳过。 | |||||
| </p> | |||||
| <el-upload drag :auto-upload="false" :limit="1" :on-change="onFileChange" :on-remove="onFileRemove" | |||||
| accept=".xlsx" :file-list="importFileList"> | |||||
| <el-icon class="el-icon--upload"><Upload /></el-icon> | |||||
| <div class="el-upload__text">将 Excel 文件拖到此处,或<em>点击上传</em></div> | |||||
| </el-upload> | |||||
| <template #footer> | <template #footer> | ||||
| <div style="text-align:right;"> | <div style="text-align:right;"> | ||||
| <el-button @click="showImport = false">取消</el-button> | <el-button @click="showImport = false">取消</el-button> | ||||
| @@ -105,22 +125,40 @@ const app = createApp({ | |||||
| const total = ref(0); | const total = ref(0); | ||||
| const page = ref(1); | const page = ref(1); | ||||
| const pageSize = ref(10); | const pageSize = ref(10); | ||||
| const regionTree = ref([]); | |||||
| const regionFilter = ref([]); | |||||
| const dialogVisible = ref(false); | const dialogVisible = ref(false); | ||||
| const dialogTitle = ref('新增医院'); | const dialogTitle = ref('新增医院'); | ||||
| const saving = ref(false); | const saving = ref(false); | ||||
| const form = reactive({ id: null, name: '', sort: 0, is_show: 1 }); | |||||
| const form = reactive({ id: null, name: '', regionCodes: [], sort: 0, is_show: 1 }); | |||||
| const showImport = ref(false); | const showImport = ref(false); | ||||
| const importText = ref(''); | |||||
| const importing = ref(false); | const importing = ref(false); | ||||
| const importPlaceholder = '北京协和医院\n复旦大学附属肿瘤医院\n中山大学肿瘤防治中心'; | |||||
| const importFileList = ref([]); | |||||
| let importFile = null; | |||||
| let sortableInstance = null; | let sortableInstance = null; | ||||
| async function loadRegionTree() { | |||||
| const res = await fetch('/common/regions').then(r => r.json()); | |||||
| if (res.code === 0) regionTree.value = res.data || []; | |||||
| } | |||||
| function filterRegionNode(node, keyword) { | |||||
| const kw = (keyword || '').trim().toLowerCase(); | |||||
| if (!kw) return true; | |||||
| const text = String(node.text || '').toLowerCase(); | |||||
| const pathText = (node.pathLabels || []).join('').toLowerCase(); | |||||
| return text.includes(kw) || pathText.includes(kw); | |||||
| } | |||||
| async function loadList() { | async function loadList() { | ||||
| loading.value = true; | loading.value = true; | ||||
| try { | try { | ||||
| const params = new URLSearchParams({ | const params = new URLSearchParams({ | ||||
| keyword: keyword.value, page: page.value, pageSize: pageSize.value | keyword: keyword.value, page: page.value, pageSize: pageSize.value | ||||
| }); | }); | ||||
| if (regionFilter.value && regionFilter.value.length >= 1) params.set('province_code', regionFilter.value[0]); | |||||
| if (regionFilter.value && regionFilter.value.length >= 2) params.set('city_code', regionFilter.value[1]); | |||||
| if (regionFilter.value && regionFilter.value.length >= 3) params.set('district_code', regionFilter.value[2]); | |||||
| const res = await fetch('/admin/hospital/list?' + params).then(r => r.json()); | const res = await fetch('/admin/hospital/list?' + params).then(r => r.json()); | ||||
| if (res.code === 0) { | if (res.code === 0) { | ||||
| list.value = res.data.data || []; | list.value = res.data.data || []; | ||||
| @@ -158,7 +196,7 @@ const app = createApp({ | |||||
| function showAdd() { | function showAdd() { | ||||
| dialogTitle.value = '新增医院'; | dialogTitle.value = '新增医院'; | ||||
| form.id = null; form.name = ''; form.sort = 0; form.is_show = 1; | |||||
| form.id = null; form.name = ''; form.regionCodes = []; form.sort = 0; form.is_show = 1; | |||||
| dialogVisible.value = true; | dialogVisible.value = true; | ||||
| } | } | ||||
| @@ -169,6 +207,7 @@ const app = createApp({ | |||||
| function resetQuery() { | function resetQuery() { | ||||
| keyword.value = ''; | keyword.value = ''; | ||||
| regionFilter.value = []; | |||||
| page.value = 1; | page.value = 1; | ||||
| loadList(); | loadList(); | ||||
| } | } | ||||
| @@ -180,6 +219,7 @@ const app = createApp({ | |||||
| function showEdit(row) { | function showEdit(row) { | ||||
| dialogTitle.value = '编辑医院'; | dialogTitle.value = '编辑医院'; | ||||
| form.id = row.id; form.name = row.name; form.sort = row.sort; form.is_show = row.is_show; | form.id = row.id; form.name = row.name; form.sort = row.sort; form.is_show = row.is_show; | ||||
| form.regionCodes = [row.province_code, row.city_code, row.district_code].filter(Boolean); | |||||
| dialogVisible.value = true; | dialogVisible.value = true; | ||||
| } | } | ||||
| @@ -188,10 +228,15 @@ const app = createApp({ | |||||
| saving.value = true; | saving.value = true; | ||||
| try { | try { | ||||
| const url = form.id ? '/admin/hospital/edit' : '/admin/hospital/add'; | const url = form.id ? '/admin/hospital/edit' : '/admin/hospital/add'; | ||||
| const codes = form.regionCodes || []; | |||||
| const payload = { | |||||
| id: form.id, name: form.name, sort: form.sort, is_show: form.is_show, | |||||
| province_code: codes[0] || '', city_code: codes[1] || '', district_code: codes[2] || '' | |||||
| }; | |||||
| const res = await fetch(url, { | const res = await fetch(url, { | ||||
| method: 'POST', | method: 'POST', | ||||
| headers: { 'Content-Type': 'application/json' }, | headers: { 'Content-Type': 'application/json' }, | ||||
| body: JSON.stringify(form) | |||||
| body: JSON.stringify(payload) | |||||
| }).then(r => r.json()); | }).then(r => r.json()); | ||||
| if (res.code === 0) { | if (res.code === 0) { | ||||
| ElementPlus.ElMessage.success('保存成功'); | ElementPlus.ElMessage.success('保存成功'); | ||||
| @@ -226,19 +271,30 @@ const app = createApp({ | |||||
| else { ElementPlus.ElMessage.error(res.msg || '操作失败'); } | else { ElementPlus.ElMessage.error(res.msg || '操作失败'); } | ||||
| } | } | ||||
| function onFileChange(file) { | |||||
| importFile = file.raw; | |||||
| importFileList.value = [file]; | |||||
| } | |||||
| function onFileRemove() { | |||||
| importFile = null; | |||||
| importFileList.value = []; | |||||
| } | |||||
| async function handleImport() { | async function handleImport() { | ||||
| if (!importText.value.trim()) { ElementPlus.ElMessage.warning('请输入医院名称'); return; } | |||||
| if (!importFile) { ElementPlus.ElMessage.warning('请先选择 Excel 文件'); return; } | |||||
| importing.value = true; | importing.value = true; | ||||
| try { | try { | ||||
| const res = await fetch('/admin/hospital/import', { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify({ text: importText.value }) | |||||
| }).then(r => r.json()); | |||||
| const fd = new FormData(); | |||||
| fd.append('file', importFile); | |||||
| const res = await fetch('/admin/hospital/import', { method: 'POST', body: fd }).then(r => r.json()); | |||||
| if (res.code === 0) { | if (res.code === 0) { | ||||
| ElementPlus.ElMessage.success(`导入成功 ${res.data.count} 条,跳过 ${res.data.skipped} 条`); | |||||
| let msg = `导入成功 ${res.data.count} 条,跳过 ${res.data.skipped} 条`; | |||||
| if (res.data.unmatchedCount > 0) msg += `,${res.data.unmatchedCount} 条地区未匹配`; | |||||
| ElementPlus.ElMessage.success(msg); | |||||
| showImport.value = false; | showImport.value = false; | ||||
| importText.value = ''; | |||||
| importFile = null; | |||||
| importFileList.value = []; | |||||
| loadList(); | loadList(); | ||||
| } else { | } else { | ||||
| ElementPlus.ElMessage.error(res.msg || '导入失败'); | ElementPlus.ElMessage.error(res.msg || '导入失败'); | ||||
| @@ -246,9 +302,9 @@ const app = createApp({ | |||||
| } finally { importing.value = false; } | } finally { importing.value = false; } | ||||
| } | } | ||||
| onMounted(() => loadList()); | |||||
| onMounted(() => { loadList(); loadRegionTree(); }); | |||||
| return { loading, list, keyword, total, page, pageSize, dialogVisible, dialogTitle, saving, form, showImport, importText, importing, importPlaceholder, loadList, onSearch, resetQuery, onSizeChange, showAdd, showEdit, handleSave, handleDelete, handleToggle, handleImport, Plus, Upload }; | |||||
| return { loading, list, keyword, total, page, pageSize, regionTree, regionFilter, dialogVisible, dialogTitle, saving, form, showImport, importing, importFileList, loadList, onSearch, resetQuery, onSizeChange, showAdd, showEdit, handleSave, handleDelete, handleToggle, handleImport, onFileChange, onFileRemove, filterRegionNode, Plus, Upload }; | |||||
| } | } | ||||
| }); | }); | ||||
| @@ -63,6 +63,10 @@ | |||||
| </div> | </div> | ||||
| <div class="info-item"><span class="label">提交时间:</span><span class="value">${ patient.create_time }</span></div> | <div class="info-item"><span class="label">提交时间:</span><span class="value">${ patient.create_time }</span></div> | ||||
| <div class="info-item"><span class="label">医院名称:</span><span class="value">${ patient.hospital || '—' }</span></div> | <div class="info-item"><span class="label">医院名称:</span><span class="value">${ patient.hospital || '—' }</span></div> | ||||
| <div class="info-item"> | |||||
| <span class="label">医院所在地:</span> | |||||
| <span class="value">${ hospitalRegionText || '—' }</span> | |||||
| </div> | |||||
| <div class="info-item"><span class="label">紧急联系人:</span><span class="value">${ patient.emergency_contact || '—' }</span></div> | <div class="info-item"><span class="label">紧急联系人:</span><span class="value">${ patient.emergency_contact || '—' }</span></div> | ||||
| <div class="info-item"><span class="label">紧急联系电话:</span><span class="value">${ patient.emergency_phone || '—' }</span></div> | <div class="info-item"><span class="label">紧急联系电话:</span><span class="value">${ patient.emergency_phone || '—' }</span></div> | ||||
| <div class="info-item" v-if="patient.income_amount"><span class="label">年可支配收入:</span><span class="value">${ patient.income_amount } 元</span></div> | <div class="info-item" v-if="patient.income_amount"><span class="label">年可支配收入:</span><span class="value">${ patient.income_amount } 元</span></div> | ||||
| @@ -226,7 +230,10 @@ var app = createApp({ | |||||
| id: '', patient_no: '', name: '', phone: '', id_card: '', gender: '', birth_date: '', | id: '', patient_no: '', name: '', phone: '', id_card: '', gender: '', birth_date: '', | ||||
| province_code: '', city_code: '', district_code: '', | province_code: '', city_code: '', district_code: '', | ||||
| province_name: '', city_name: '', district_name: '', | province_name: '', city_name: '', district_name: '', | ||||
| address: '', hospital: '', tag: '', documents: [], | |||||
| address: '', hospital: '', | |||||
| hospital_province_code: '', hospital_city_code: '', hospital_district_code: '', | |||||
| hospital_province_name: '', hospital_city_name: '', hospital_district_name: '', | |||||
| tag: '', documents: [], | |||||
| sample_types: [], wax_return: 0, | sample_types: [], wax_return: 0, | ||||
| return_name: '', return_phone: '', | return_name: '', return_phone: '', | ||||
| return_province_code: '', return_city_code: '', return_district_code: '', | return_province_code: '', return_city_code: '', return_district_code: '', | ||||
| @@ -282,6 +289,14 @@ var app = createApp({ | |||||
| return /\.(png|jpg|jpeg|gif|bmp|webp|svg)$/.test(lower); | return /\.(png|jpg|jpeg|gif|bmp|webp|svg)$/.test(lower); | ||||
| } | } | ||||
| var hospitalRegionText = Vue.computed(function() { | |||||
| return [ | |||||
| patient.hospital_province_name, | |||||
| patient.hospital_city_name, | |||||
| patient.hospital_district_name | |||||
| ].filter(Boolean).join(' '); | |||||
| }); | |||||
| var signDocs = Vue.computed(function() { | var signDocs = Vue.computed(function() { | ||||
| var docs = [ | var docs = [ | ||||
| { key: 'income', label: '个人可支配收入声明', url: patient.sign_income }, | { key: 'income', label: '个人可支配收入声明', url: patient.sign_income }, | ||||
| @@ -362,7 +377,8 @@ var app = createApp({ | |||||
| return { | return { | ||||
| loading, patient, audits, canAudit, | loading, patient, audits, canAudit, | ||||
| rejectVisible, rejectSaving, rejectReason, selectedReasons, commonReasons, | rejectVisible, rejectSaving, rejectReason, selectedReasons, commonReasons, | ||||
| goBack, downloadSign, isImageUrl, signDocs, signImageList, authImageList, handleApprove, showRejectDialog, toggleReason, doReject | |||||
| hospitalRegionText, goBack, downloadSign, isImageUrl, signDocs, signImageList, | |||||
| authImageList, handleApprove, showRejectDialog, toggleReason, doReject | |||||
| }; | }; | ||||
| } | } | ||||
| }); | }); | ||||
| @@ -164,6 +164,18 @@ | |||||
| <el-input v-model="addForm.address" placeholder="请输入详细地址(街道门牌号等)" /> | <el-input v-model="addForm.address" placeholder="请输入详细地址(街道门牌号等)" /> | ||||
| </el-form-item> | </el-form-item> | ||||
| </el-col> | </el-col> | ||||
| <el-col :span="12"> | |||||
| <el-form-item label="医院名称"> | |||||
| <el-input v-model="addForm.hospital" placeholder="请输入就诊医院名称" /> | |||||
| </el-form-item> | |||||
| </el-col> | |||||
| <el-col :span="12"> | |||||
| <el-form-item label="医院地区"> | |||||
| <el-cascader v-model="addForm.hospitalRegionCodes" :options="regionTree" | |||||
| :props="{ value: 'code', label: 'name', children: 'children' }" | |||||
| placeholder="请选择医院省/市/区" clearable style="width:100%;" /> | |||||
| </el-form-item> | |||||
| </el-col> | |||||
| <el-col :span="12"> | <el-col :span="12"> | ||||
| <el-form-item label="紧急联系人"> | <el-form-item label="紧急联系人"> | ||||
| <el-input v-model="addForm.emergency_contact" placeholder="联系人姓名" /> | <el-input v-model="addForm.emergency_contact" placeholder="联系人姓名" /> | ||||
| @@ -322,7 +334,8 @@ const app = createApp({ | |||||
| const editingId = ref(null); | const editingId = ref(null); | ||||
| const addForm = reactive({ | const addForm = reactive({ | ||||
| name: '', phone: '', id_card: '', gender: '', birth_date: '', | name: '', phone: '', id_card: '', gender: '', birth_date: '', | ||||
| regionCodes: [], address: '', emergency_contact: '', emergency_phone: '', | |||||
| regionCodes: [], address: '', hospital: '', hospitalRegionCodes: [], | |||||
| emergency_contact: '', emergency_phone: '', | |||||
| tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '', | tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '', | ||||
| sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: '' | sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: '' | ||||
| }); | }); | ||||
| @@ -480,7 +493,8 @@ const app = createApp({ | |||||
| editingId.value = null; | editingId.value = null; | ||||
| Object.assign(addForm, { | Object.assign(addForm, { | ||||
| name: '', phone: '', id_card: '', gender: '', birth_date: '', | name: '', phone: '', id_card: '', gender: '', birth_date: '', | ||||
| regionCodes: [], address: '', emergency_contact: '', emergency_phone: '', | |||||
| regionCodes: [], address: '', hospital: '', hospitalRegionCodes: [], | |||||
| emergency_contact: '', emergency_phone: '', | |||||
| tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '', | tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '', | ||||
| sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: '' | sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: '' | ||||
| }); | }); | ||||
| @@ -516,7 +530,8 @@ const app = createApp({ | |||||
| // 先重置表单 | // 先重置表单 | ||||
| Object.assign(addForm, { | Object.assign(addForm, { | ||||
| name: '', phone: '', id_card: '', gender: '', birth_date: '', | name: '', phone: '', id_card: '', gender: '', birth_date: '', | ||||
| regionCodes: [], address: '', emergency_contact: '', emergency_phone: '', | |||||
| regionCodes: [], address: '', hospital: '', hospitalRegionCodes: [], | |||||
| emergency_contact: '', emergency_phone: '', | |||||
| tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '', | tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '', | ||||
| sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: '' | sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: '' | ||||
| }); | }); | ||||
| @@ -531,6 +546,8 @@ const app = createApp({ | |||||
| gender: p.gender || '', | gender: p.gender || '', | ||||
| birth_date: p.birth_date || '', | birth_date: p.birth_date || '', | ||||
| address: p.address || '', | address: p.address || '', | ||||
| hospital: p.hospital || '', | |||||
| hospitalRegionCodes: [p.hospital_province_code, p.hospital_city_code, p.hospital_district_code].filter(Boolean), | |||||
| emergency_contact: p.emergency_contact || '', | emergency_contact: p.emergency_contact || '', | ||||
| emergency_phone: p.emergency_phone || '', | emergency_phone: p.emergency_phone || '', | ||||
| tag: p.tag || '', | tag: p.tag || '', | ||||
| @@ -566,6 +583,7 @@ const app = createApp({ | |||||
| addSaving.value = true; | addSaving.value = true; | ||||
| try { | try { | ||||
| var url = editingId.value ? '/admin/patient/edit' : '/admin/patient/add'; | var url = editingId.value ? '/admin/patient/edit' : '/admin/patient/add'; | ||||
| var hospitalRegionCodes = addForm.hospitalRegionCodes || []; | |||||
| var body = { | var body = { | ||||
| name: addForm.name.trim(), | name: addForm.name.trim(), | ||||
| phone: addForm.phone, | phone: addForm.phone, | ||||
| @@ -576,6 +594,10 @@ const app = createApp({ | |||||
| city_code: addForm.regionCodes[1], | city_code: addForm.regionCodes[1], | ||||
| district_code: addForm.regionCodes[2], | district_code: addForm.regionCodes[2], | ||||
| address: addForm.address.trim(), | address: addForm.address.trim(), | ||||
| hospital: addForm.hospital || '', | |||||
| hospital_province_code: hospitalRegionCodes[0] || '', | |||||
| hospital_city_code: hospitalRegionCodes[1] || '', | |||||
| hospital_district_code: hospitalRegionCodes[2] || '', | |||||
| emergency_contact: addForm.emergency_contact || '', | emergency_contact: addForm.emergency_contact || '', | ||||
| emergency_phone: addForm.emergency_phone || '', | emergency_phone: addForm.emergency_phone || '', | ||||
| tag: addForm.tag || '', | tag: addForm.tag || '', | ||||