leiyun 2 недель назад
Родитель
Сommit
582551a3e7
11 измененных файлов: 515 добавлений и 55 удалений
  1. +14
    -1
      sql/hospital.sql
  2. +16
    -0
      sql/hospital_region_update.sql
  3. +1
    -0
      src/config/router.js
  4. +230
    -16
      src/controller/admin/hospital.js
  5. +20
    -4
      src/controller/admin/patient.js
  6. +15
    -1
      src/controller/common.js
  7. +28
    -2
      src/controller/mp.js
  8. +67
    -1
      src/model/hospital.js
  9. +81
    -25
      view/admin/hospital_index.html
  10. +18
    -2
      view/admin/patient_detail.html
  11. +25
    -3
      view/admin/patient_index.html

+ 14
- 1
sql/hospital.sql Просмотреть файл

@@ -2,10 +2,23 @@
CREATE TABLE `hospital` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`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 '排序(越大越前)',
`is_show` tinyint(1) DEFAULT 1 COMMENT '是否展示: 1是 0否',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除: 0正常 1已删除',
`create_time` datetime DEFAULT 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='医院列表';

-- 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`;

+ 16
- 0
sql/hospital_region_update.sql Просмотреть файл

@@ -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`;

+ 1
- 0
src/config/router.js Просмотреть файл

@@ -57,6 +57,7 @@ module.exports = [
['/common/ocr/hmt', 'common/ocrHmt', 'post'],
['/common/sampleTypes', 'common/sampleTypes'],
['/common/hospitals', 'common/hospitals'],
['/common/hospitalTree', 'common/hospitalTree'],
['/api/content', 'common/content'],

// 小程序接口


+ 230
- 16
src/controller/admin/hospital.js Просмотреть файл

@@ -8,13 +8,19 @@ module.exports = class extends Base {
return this.display();
}

// 列表
// 列表(分页)
async listAction() {
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 pageSize = this.get('pageSize') || 10;
const where = { is_deleted: 0 };
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')
.where(where)
.order('sort DESC, id ASC')
@@ -25,31 +31,47 @@ module.exports = class extends Base {

// 新增
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('请输入医院名称');
const exists = await this.model('hospital').where({ name: name.trim(), is_deleted: 0 }).find();
if (!think.isEmpty(exists)) return this.fail('该医院已存在');
const regionNames = await this._getRegionNames(province_code, city_code, district_code);
await this.model('hospital').add({
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,
is_show: is_show === undefined ? 1 : (is_show ? 1 : 0)
});
await this.model('hospital').clearTreeCache();
await this.log('add', '医院管理', `新增医院「${name.trim()}」`);
return this.success();
}

// 编辑
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 (!name || !name.trim()) return this.fail('请输入医院名称');
const exists = await this.model('hospital').where({ name: name.trim(), id: ['!=', id], is_deleted: 0 }).find();
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({
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,
is_show: is_show ? 1 : 0
});
await this.model('hospital').clearTreeCache();
await this.log('edit', '医院管理', `编辑医院(ID:${id})「${name.trim()}」`);
return this.success();
}
@@ -59,6 +81,7 @@ module.exports = class extends Base {
const { id } = this.post();
if (!id) return this.fail('参数错误');
await this.model('hospital').where({ id }).update({ is_deleted: 1 });
await this.model('hospital').clearTreeCache();
await this.log('delete', '医院管理', `删除医院(ID:${id})`);
return this.success();
}
@@ -68,6 +91,7 @@ module.exports = class extends Base {
const { id, is_show } = this.post();
if (!id) return this.fail('参数错误');
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})`);
return this.success();
}
@@ -79,32 +103,222 @@ module.exports = class extends Base {
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').clearTreeCache();
return this.success();
}

// 批量导入(粘贴文本,每行一个
// Excel 导入(表头:关联机构名称/省份/城市/区县
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 existNames = new Set(existRows.map(r => r.name));

const toInsert = [];
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('没有新增医院(均已存在)');
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] || ''
};
}
};

+ 20
- 4
src/controller/admin/patient.js Просмотреть файл

@@ -98,7 +98,8 @@ module.exports = class extends Base {
// 查询省市区名称(包含寄回地址)
const allCodes = [
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);
if (allCodes.length) {
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_city_name = regionMap[patient.return_city_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() {
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) {
return this.fail('请填写完整信息');
@@ -178,6 +182,9 @@ module.exports = class extends Base {
district_code: district_code || '',
address: address || '',
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_phone: emergency_phone || '',
tag: tag || '',
@@ -332,7 +339,7 @@ module.exports = class extends Base {
// 编辑患者
async editAction() {
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 (!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,
address: address || '',
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_phone: emergency_phone || '',
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_city_code) allCodes.add(item.return_city_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 = {};
if (allCodes.size) {
@@ -425,7 +438,7 @@ module.exports = class extends Base {
const statusMap = { '-1': '待提交', 0: '待审核', 1: '审核通过', 2: '已驳回' };
const header = [
'ID', '姓名', '性别', '身份证', '手机号', '省份', '城市', '详细地址',
'医院名称', '紧急联系人', '紧急联系电话', '瘤种',
'医院名称', '医院省份', '医院城市', '医院区县', '紧急联系人', '紧急联系电话', '瘤种',
'送检样本类型', '是否需寄回', '收件人', '收件电话', '收件地址',
'报告接收邮箱', '送检物流单号',
'提交时间', '审核状态', '审核日期', '审核驳回原因'
@@ -479,6 +492,9 @@ module.exports = class extends Base {
regionMap[item.city_code] || '',
item.address || '',
item.hospital || '',
regionMap[item.hospital_province_code] || '',
regionMap[item.hospital_city_code] || '',
regionMap[item.hospital_district_code] || '',
item.emergency_contact || '',
item.emergency_phone || '',
item.tag || '',


+ 15
- 1
src/controller/common.js Просмотреть файл

@@ -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
*/
async hospitalsAction() {


+ 28
- 2
src/controller/mp.js Просмотреть файл

@@ -363,7 +363,10 @@ module.exports = class extends Base {
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 });

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 = {};
if (codes.length) {
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] || '',
address: patient.address || '',
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_phone: patient.emergency_phone || '',
tag: patient.tag || '', documents,
@@ -431,7 +440,7 @@ module.exports = class extends Base {
async saveMyInfoAction() {
const mpUser = this.mpUser;
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();
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: '请选择省市区' });
@@ -444,10 +453,27 @@ module.exports = class extends Base {
if (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({
gender: gender || '', province_code: province_code || '', city_code: city_code || '',
district_code: district_code || '', address: address || '',
hospital: hospital || '',
hospital_province_code: finalHospitalProvinceCode,
hospital_city_code: finalHospitalCityCode,
hospital_district_code: finalHospitalDistrictCode,
emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '',
tag: tag || '',
documents: JSON.stringify(documents || []),


+ 67
- 1
src/model/hospital.js Просмотреть файл

@@ -1,10 +1,76 @@
const HOSPITAL_TREE_CACHE_KEY = 'hospital_tree';

module.exports = class extends think.Model {
get tableName() {
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 = '') {
const where = { is_show: 1, is_deleted: 0 };


+ 81
- 25
view/admin/hospital_index.html Просмотреть файл

@@ -18,6 +18,9 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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 @click="resetQuery">重置</el-button>
</div>
@@ -37,13 +40,19 @@
<span class="drag-handle">⠿</span>
</template>
</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 }">
<el-switch :model-value="row.is_show === 1" @change="(v) => handleToggle(row, v)" />
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center">
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="showEdit(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-form :model="form" label-width="90px">
<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 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 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>
<template #footer>
@@ -76,10 +90,16 @@
</template>
</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>
<div style="text-align:right;">
<el-button @click="showImport = false">取消</el-button>
@@ -105,22 +125,40 @@ const app = createApp({
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const regionTree = ref([]);
const regionFilter = ref([]);
const dialogVisible = ref(false);
const dialogTitle = ref('新增医院');
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 importText = ref('');
const importing = ref(false);
const importPlaceholder = '北京协和医院\n复旦大学附属肿瘤医院\n中山大学肿瘤防治中心';
const importFileList = ref([]);
let importFile = 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() {
loading.value = true;
try {
const params = new URLSearchParams({
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());
if (res.code === 0) {
list.value = res.data.data || [];
@@ -158,7 +196,7 @@ const app = createApp({

function showAdd() {
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;
}

@@ -169,6 +207,7 @@ const app = createApp({

function resetQuery() {
keyword.value = '';
regionFilter.value = [];
page.value = 1;
loadList();
}
@@ -180,6 +219,7 @@ const app = createApp({
function showEdit(row) {
dialogTitle.value = '编辑医院';
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;
}

@@ -188,10 +228,15 @@ const app = createApp({
saving.value = true;
try {
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, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
body: JSON.stringify(payload)
}).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
@@ -226,19 +271,30 @@ const app = createApp({
else { ElementPlus.ElMessage.error(res.msg || '操作失败'); }
}

function onFileChange(file) {
importFile = file.raw;
importFileList.value = [file];
}

function onFileRemove() {
importFile = null;
importFileList.value = [];
}

async function handleImport() {
if (!importText.value.trim()) { ElementPlus.ElMessage.warning('请输入医院名称'); return; }
if (!importFile) { ElementPlus.ElMessage.warning('请先选择 Excel 文件'); return; }
importing.value = true;
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) {
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;
importText.value = '';
importFile = null;
importFileList.value = [];
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '导入失败');
@@ -246,9 +302,9 @@ const app = createApp({
} 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 };
}
});



+ 18
- 2
view/admin/patient_detail.html Просмотреть файл

@@ -63,6 +63,10 @@
</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">${ 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_phone || '—' }</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: '',
province_code: '', city_code: '', district_code: '',
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,
return_name: '', return_phone: '',
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);
}

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 docs = [
{ key: 'income', label: '个人可支配收入声明', url: patient.sign_income },
@@ -362,7 +377,8 @@ var app = createApp({
return {
loading, patient, audits, canAudit,
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
};
}
});


+ 25
- 3
view/admin/patient_index.html Просмотреть файл

@@ -164,6 +164,18 @@
<el-input v-model="addForm.address" placeholder="请输入详细地址(街道门牌号等)" />
</el-form-item>
</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-form-item label="紧急联系人">
<el-input v-model="addForm.emergency_contact" placeholder="联系人姓名" />
@@ -322,7 +334,8 @@ const app = createApp({
const editingId = ref(null);
const addForm = reactive({
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: '',
sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: ''
});
@@ -480,7 +493,8 @@ const app = createApp({
editingId.value = null;
Object.assign(addForm, {
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: '',
sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: ''
});
@@ -516,7 +530,8 @@ const app = createApp({
// 先重置表单
Object.assign(addForm, {
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: '',
sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: ''
});
@@ -531,6 +546,8 @@ const app = createApp({
gender: p.gender || '',
birth_date: p.birth_date || '',
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_phone: p.emergency_phone || '',
tag: p.tag || '',
@@ -566,6 +583,7 @@ const app = createApp({
addSaving.value = true;
try {
var url = editingId.value ? '/admin/patient/edit' : '/admin/patient/add';
var hospitalRegionCodes = addForm.hospitalRegionCodes || [];
var body = {
name: addForm.name.trim(),
phone: addForm.phone,
@@ -576,6 +594,10 @@ const app = createApp({
city_code: addForm.regionCodes[1],
district_code: addForm.regionCodes[2],
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_phone: addForm.emergency_phone || '',
tag: addForm.tag || '',


Загрузка…
Отмена
Сохранить