| @@ -0,0 +1,37 @@ | |||
| -- 患者表新增字段 | |||
| ALTER TABLE `patient` | |||
| ADD COLUMN `hospital` varchar(100) DEFAULT '' COMMENT '医院名称' AFTER `address`, | |||
| ADD COLUMN `sample_types` varchar(255) DEFAULT '' COMMENT '送检样本类型(JSON数组)', | |||
| ADD COLUMN `wax_return` tinyint(1) DEFAULT 0 COMMENT '是否需寄回: 0否 1是', | |||
| ADD COLUMN `return_name` varchar(50) DEFAULT '' COMMENT '寄回收件人姓名', | |||
| ADD COLUMN `return_phone` varchar(20) DEFAULT '' COMMENT '寄回收件人电话', | |||
| ADD COLUMN `return_province_code` varchar(10) DEFAULT '' COMMENT '寄回省份编码', | |||
| ADD COLUMN `return_city_code` varchar(10) DEFAULT '' COMMENT '寄回城市编码', | |||
| ADD COLUMN `return_district_code` varchar(10) DEFAULT '' COMMENT '寄回区县编码', | |||
| ADD COLUMN `return_address` varchar(255) DEFAULT '' COMMENT '寄回详细地址', | |||
| ADD COLUMN `report_email` varchar(100) DEFAULT '' COMMENT '报告接收邮箱', | |||
| ADD COLUMN `sample_tracking_no` varchar(100) DEFAULT '' COMMENT '送检样本物流单号', | |||
| ADD COLUMN `sample_photos` json DEFAULT NULL COMMENT '送检单照片(JSON数组)'; | |||
| -- 送检样本类型配置表 | |||
| CREATE TABLE `sample_type` ( | |||
| `id` int(11) NOT NULL AUTO_INCREMENT, | |||
| `name` varchar(50) NOT NULL COMMENT '样本类型名称', | |||
| `need_return` tinyint(1) DEFAULT 0 COMMENT '是否可选需寄回: 0否 1是', | |||
| `sort` int(11) DEFAULT 0 COMMENT '排序(越大越前)', | |||
| `status` tinyint(1) DEFAULT 1 COMMENT '状态: 1启用 0禁用', | |||
| `is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除', | |||
| `create_time` datetime DEFAULT CURRENT_TIMESTAMP, | |||
| `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | |||
| PRIMARY KEY (`id`) | |||
| ) COMMENT='送检样本类型配置'; | |||
| INSERT INTO `sample_type` (`name`, `need_return`, `sort`, `status`) VALUES | |||
| ('蜡块', 1, 4, 1), | |||
| ('白片', 0, 3, 1), | |||
| ('血液', 0, 2, 1), | |||
| ('新鲜组织', 0, 1, 1); | |||
| -- sys_config 新增送检样本是否必选配置 | |||
| INSERT INTO `sys_config` (`config_key`, `config_value`, `remark`) VALUES | |||
| ('sample_required', '0', '送检样本是否必选: 0否 1是'); | |||
| @@ -55,6 +55,7 @@ module.exports = [ | |||
| ['/common/regions', 'common/regions'], | |||
| ['/common/ocr/idcard', 'common/ocrIdcard', 'post'], | |||
| ['/common/ocr/hmt', 'common/ocrHmt', 'post'], | |||
| ['/common/sampleTypes', 'common/sampleTypes'], | |||
| ['/api/content', 'common/content'], | |||
| // 小程序接口 | |||
| @@ -85,6 +86,15 @@ module.exports = [ | |||
| ['/admin/tag/delete', 'admin/tag/delete', 'post'], | |||
| ['/admin/tag/sort', 'admin/tag/sort', 'post'], | |||
| // 送检样本管理 | |||
| ['/admin/sample_type', 'admin/sample_type/index'], | |||
| ['/admin/sample_type/list', 'admin/sample_type/list'], | |||
| ['/admin/sample_type/add', 'admin/sample_type/add', 'post'], | |||
| ['/admin/sample_type/edit', 'admin/sample_type/edit', 'post'], | |||
| ['/admin/sample_type/delete', 'admin/sample_type/delete', 'post'], | |||
| ['/admin/sample_type/sort', 'admin/sample_type/sort', 'post'], | |||
| ['/admin/sample_type/setRequired', 'admin/sample_type/setRequired', 'post'], | |||
| // 内容管理 | |||
| ['/admin/content', 'admin/content/index'], | |||
| ['/admin/content/list', 'admin/content/list'], | |||
| @@ -91,25 +91,27 @@ module.exports = class extends Base { | |||
| } | |||
| // 解析 JSON 字段 | |||
| try { | |||
| patient.documents = JSON.parse(patient.documents || '[]'); | |||
| } catch (e) { | |||
| patient.documents = []; | |||
| } | |||
| // 查询省市区名称 | |||
| if (patient.province_code || patient.city_code || patient.district_code) { | |||
| const codes = [patient.province_code, patient.city_code, patient.district_code].filter(Boolean); | |||
| if (codes.length) { | |||
| const regions = await this.model('sys_region') | |||
| .where({ code: ['in', codes] }) | |||
| .select(); | |||
| const regionMap = {}; | |||
| regions.forEach(r => { regionMap[r.code] = r.name; }); | |||
| patient.province_name = regionMap[patient.province_code] || ''; | |||
| patient.city_name = regionMap[patient.city_code] || ''; | |||
| patient.district_name = regionMap[patient.district_code] || ''; | |||
| } | |||
| try { patient.documents = JSON.parse(patient.documents || '[]'); } catch (e) { patient.documents = []; } | |||
| try { patient.sample_types = JSON.parse(patient.sample_types || '[]'); } catch (e) { patient.sample_types = []; } | |||
| try { patient.sample_photos = JSON.parse(patient.sample_photos || '[]'); } catch (e) { patient.sample_photos = []; } | |||
| // 查询省市区名称(包含寄回地址) | |||
| const allCodes = [ | |||
| patient.province_code, patient.city_code, patient.district_code, | |||
| patient.return_province_code, patient.return_city_code, patient.return_district_code | |||
| ].filter(Boolean); | |||
| if (allCodes.length) { | |||
| const regions = await this.model('sys_region') | |||
| .where({ code: ['in', allCodes] }) | |||
| .select(); | |||
| const regionMap = {}; | |||
| regions.forEach(r => { regionMap[r.code] = r.name; }); | |||
| patient.province_name = regionMap[patient.province_code] || ''; | |||
| patient.city_name = regionMap[patient.city_code] || ''; | |||
| patient.district_name = regionMap[patient.district_code] || ''; | |||
| 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] || ''; | |||
| } | |||
| // 获取审核记录 | |||
| @@ -124,7 +126,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, emergency_contact, emergency_phone, tag, documents, 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, 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('请填写完整信息'); | |||
| @@ -175,10 +177,22 @@ module.exports = class extends Base { | |||
| city_code: city_code || '', | |||
| district_code: district_code || '', | |||
| address: address || '', | |||
| hospital: hospital || '', | |||
| emergency_contact: emergency_contact || '', | |||
| emergency_phone: emergency_phone || '', | |||
| tag: tag || '', | |||
| documents: JSON.stringify(documents || []), | |||
| sample_types: JSON.stringify(sample_types || []), | |||
| wax_return: wax_return ? 1 : 0, | |||
| return_name: return_name || '', | |||
| return_phone: return_phone || '', | |||
| return_province_code: return_province_code || '', | |||
| return_city_code: return_city_code || '', | |||
| return_district_code: return_district_code || '', | |||
| return_address: return_address || '', | |||
| report_email: report_email || '', | |||
| sample_tracking_no: sample_tracking_no || '', | |||
| sample_photos: JSON.stringify(sample_photos || []), | |||
| sign_income: sign_income || '', | |||
| sign_privacy: sign_privacy || '', | |||
| sign_promise: sign_promise || '', | |||
| @@ -318,7 +332,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, emergency_contact, emergency_phone, tag, documents, 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, 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('请填写完整信息'); | |||
| @@ -340,10 +354,22 @@ module.exports = class extends Base { | |||
| name, phone, id_card, gender, birth_date, | |||
| province_code, city_code, district_code, | |||
| address: address || '', | |||
| hospital: hospital || '', | |||
| emergency_contact: emergency_contact || '', | |||
| emergency_phone: emergency_phone || '', | |||
| tag: tag || '', | |||
| documents: JSON.stringify(documents || []), | |||
| sample_types: JSON.stringify(sample_types || []), | |||
| wax_return: wax_return ? 1 : 0, | |||
| return_name: return_name || '', | |||
| return_phone: return_phone || '', | |||
| return_province_code: return_province_code || '', | |||
| return_city_code: return_city_code || '', | |||
| return_district_code: return_district_code || '', | |||
| return_address: return_address || '', | |||
| report_email: report_email || '', | |||
| sample_tracking_no: sample_tracking_no || '', | |||
| sample_photos: JSON.stringify(sample_photos || []), | |||
| sign_income: sign_income || '', | |||
| sign_privacy: sign_privacy || '', | |||
| sign_promise: sign_promise || '', | |||
| @@ -365,12 +391,15 @@ module.exports = class extends Base { | |||
| const model = this.model('patient'); | |||
| const list = await model.getAll({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code }); | |||
| // 批量查省市区名称 | |||
| // 批量查省市区名称(包含寄回地址) | |||
| const allCodes = new Set(); | |||
| list.forEach(item => { | |||
| if (item.province_code) allCodes.add(item.province_code); | |||
| if (item.city_code) allCodes.add(item.city_code); | |||
| if (item.district_code) allCodes.add(item.district_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_district_code) allCodes.add(item.return_district_code); | |||
| }); | |||
| const regionMap = {}; | |||
| if (allCodes.size) { | |||
| @@ -394,7 +423,13 @@ module.exports = class extends Base { | |||
| } | |||
| const statusMap = { '-1': '待提交', 0: '待审核', 1: '审核通过', 2: '已驳回' }; | |||
| const header = ['ID', '姓名', '性别', '身份证', '手机号', '省份', '城市', '提交时间', '审核状态', '审核日期', '审核驳回原因', '瘤种']; | |||
| const header = [ | |||
| 'ID', '姓名', '性别', '身份证', '手机号', '省份', '城市', '详细地址', | |||
| '医院名称', '紧急联系人', '紧急联系电话', '瘤种', | |||
| '送检样本类型', '是否需寄回', '收件人', '收件电话', '收件地址', | |||
| '报告接收邮箱', '送检物流单号', | |||
| '提交时间', '审核状态', '审核日期', '审核驳回原因' | |||
| ]; | |||
| const ExcelJS = require('exceljs'); | |||
| const workbook = new ExcelJS.Workbook(); | |||
| @@ -402,9 +437,9 @@ module.exports = class extends Base { | |||
| // 表头 | |||
| sheet.addRow(header); | |||
| // 表头样式:绿色背景、白色加粗字体(仅到瘤种列,即K列=11列)、冻结首行 | |||
| // 表头样式:绿色背景、白色加粗字体、冻结首行 | |||
| const headerRow = sheet.getRow(1); | |||
| const colCount = header.length; // 11列 | |||
| const colCount = header.length; | |||
| for (let i = 1; i <= colCount; i++) { | |||
| const cell = headerRow.getCell(i); | |||
| cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; | |||
| @@ -428,6 +463,12 @@ module.exports = class extends Base { | |||
| // 数据行 | |||
| list.forEach(item => { | |||
| const audit = auditMap[item.id]; | |||
| let sampleTypes = ''; | |||
| try { sampleTypes = JSON.parse(item.sample_types || '[]').join('、'); } catch (e) { sampleTypes = ''; } | |||
| const returnAddr = item.return_address | |||
| ? [regionMap[item.return_province_code] || '', regionMap[item.return_city_code] || '', regionMap[item.return_district_code] || '', item.return_address].filter(Boolean).join('') | |||
| : ''; | |||
| const row = sheet.addRow([ | |||
| item.patient_no, | |||
| item.name, | |||
| @@ -436,11 +477,22 @@ module.exports = class extends Base { | |||
| item.phone, | |||
| regionMap[item.province_code] || '', | |||
| regionMap[item.city_code] || '', | |||
| item.address || '', | |||
| item.hospital || '', | |||
| item.emergency_contact || '', | |||
| item.emergency_phone || '', | |||
| item.tag || '', | |||
| sampleTypes, | |||
| item.wax_return ? '是' : '否', | |||
| item.return_name || '', | |||
| item.return_phone || '', | |||
| returnAddr, | |||
| item.report_email || '', | |||
| item.sample_tracking_no || '', | |||
| item.create_time || '', | |||
| statusMap[item.status] || '', | |||
| audit ? (audit.create_time || '') : '', | |||
| (audit && audit.action === 'reject') ? (audit.reason || '') : '', | |||
| item.tag || '' | |||
| (audit && audit.action === 'reject') ? (audit.reason || '') : '' | |||
| ]); | |||
| // 数据行加边框 | |||
| for (let i = 1; i <= colCount; i++) { | |||
| @@ -0,0 +1,75 @@ | |||
| const Base = require('../base'); | |||
| module.exports = class extends Base { | |||
| async indexAction() { | |||
| this.assign('currentPage', 'sample-type'); | |||
| this.assign('pageTitle', '送检样本管理'); | |||
| this.assign('breadcrumb', [ | |||
| { name: '系统管理', url: '/admin/system/user.html' }, | |||
| { name: '送检样本管理' } | |||
| ]); | |||
| // 获取当前必选配置 | |||
| const required = await this.model('sys_config').getByKey('sample_required'); | |||
| this.assign('sampleRequired', required === '1' || required === 1 ? '1' : '0'); | |||
| return this.display(); | |||
| } | |||
| async listAction() { | |||
| const list = await this.model('sample_type') | |||
| .where({ is_deleted: 0 }) | |||
| .order('sort DESC, id ASC') | |||
| .select(); | |||
| return this.json({ code: 0, data: list }); | |||
| } | |||
| async addAction() { | |||
| const { name, need_return, sort } = this.post(); | |||
| if (!name) return this.fail('请输入样本类型名称'); | |||
| await this.model('sample_type').add({ | |||
| name, | |||
| need_return: need_return ? 1 : 0, | |||
| sort: sort || 0, | |||
| status: 1 | |||
| }); | |||
| await this.log('add', '送检样本管理', `新增样本类型: ${name}`); | |||
| return this.success(); | |||
| } | |||
| async editAction() { | |||
| const { id, name, need_return, sort, status } = this.post(); | |||
| if (!id) return this.fail('参数错误'); | |||
| if (!name) return this.fail('请输入样本类型名称'); | |||
| await this.model('sample_type').where({ id }).update({ | |||
| name, | |||
| need_return: need_return ? 1 : 0, | |||
| sort: sort || 0, | |||
| status: status !== undefined ? parseInt(status) : 1 | |||
| }); | |||
| await this.log('edit', '送检样本管理', `编辑样本类型(ID:${id}): ${name}`); | |||
| return this.success(); | |||
| } | |||
| async deleteAction() { | |||
| const { id } = this.post(); | |||
| if (!id) return this.fail('参数错误'); | |||
| await this.model('sample_type').where({ id }).update({ is_deleted: 1 }); | |||
| await this.log('edit', '送检样本管理', `删除样本类型(ID:${id})`); | |||
| return this.success(); | |||
| } | |||
| async sortAction() { | |||
| const { ids } = this.post(); | |||
| if (!ids || !ids.length) return this.fail('参数错误'); | |||
| for (let i = 0; i < ids.length; i++) { | |||
| await this.model('sample_type').where({ id: ids[i] }).update({ sort: ids.length - i }); | |||
| } | |||
| return this.success(); | |||
| } | |||
| async setRequiredAction() { | |||
| const { value } = this.post(); | |||
| await this.model('sys_config').setByKey('sample_required', value ? '1' : '0', '送检样本是否必选'); | |||
| await this.log('edit', '送检样本管理', `设置送检样本必选: ${value ? '是' : '否'}`); | |||
| return this.success(); | |||
| } | |||
| }; | |||
| @@ -149,6 +149,21 @@ module.exports = class extends think.Controller { | |||
| } | |||
| } | |||
| /** | |||
| * 获取送检样本类型列表及配置 | |||
| * GET /common/sampleTypes | |||
| */ | |||
| async sampleTypesAction() { | |||
| try { | |||
| const list = await this.model('sample_type').getEnabledList(); | |||
| const required = await this.model('sys_config').getByKey('sample_required'); | |||
| return this.json({ code: 0, data: { list, required: required === '1' || required === 1 } }); | |||
| } catch (error) { | |||
| think.logger.error('获取送检样本类型失败:', error); | |||
| return this.json({ code: 0, data: { list: [], required: false } }); | |||
| } | |||
| } | |||
| /** | |||
| * 获取省市区树形数据(一次返回) | |||
| * GET /common/regions | |||
| @@ -355,6 +355,10 @@ module.exports = class extends Base { | |||
| } | |||
| let documents = []; | |||
| try { documents = JSON.parse(patient.documents || '[]'); } catch (e) { documents = []; } | |||
| let samplePhotos = []; | |||
| try { samplePhotos = JSON.parse(patient.sample_photos || '[]'); } catch (e) { samplePhotos = []; } | |||
| let sampleTypes = []; | |||
| try { sampleTypes = JSON.parse(patient.sample_types || '[]'); } catch (e) { sampleTypes = []; } | |||
| // 如果是驳回状态,查最近一条驳回原因 | |||
| let rejectReason = ''; | |||
| @@ -375,9 +379,21 @@ module.exports = class extends Base { | |||
| city_name: regionMap[patient.city_code] || '', | |||
| district_name: regionMap[patient.district_code] || '', | |||
| address: patient.address || '', | |||
| hospital: patient.hospital || '', | |||
| emergency_contact: patient.emergency_contact || '', | |||
| emergency_phone: patient.emergency_phone || '', | |||
| tag: patient.tag || '', documents, | |||
| sample_types: sampleTypes, | |||
| wax_return: patient.wax_return || 0, | |||
| return_name: patient.return_name || '', | |||
| return_phone: patient.return_phone || '', | |||
| return_province_code: patient.return_province_code || '', | |||
| return_city_code: patient.return_city_code || '', | |||
| return_district_code: patient.return_district_code || '', | |||
| return_address: patient.return_address || '', | |||
| report_email: patient.report_email || '', | |||
| sample_tracking_no: patient.sample_tracking_no || '', | |||
| sample_photos: samplePhotos, | |||
| sign_income: patient.sign_income || '', | |||
| sign_privacy: patient.sign_privacy || '', | |||
| sign_privacy_jhr: patient.sign_privacy_jhr || '', | |||
| @@ -399,11 +415,12 @@ 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, emergency_contact, emergency_phone, documents, 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, emergency_contact, emergency_phone, documents, tag, 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_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: '请选择省市区' }); | |||
| if (!address) return this.json({ code: 1, msg: '请填写详细地址' }); | |||
| if (!emergency_contact || !emergency_phone) return this.json({ code: 1, msg: '请填写紧急联系人信息' }); | |||
| const now = think.datetime(new Date()); | |||
| try { | |||
| @@ -414,8 +431,21 @@ module.exports = class extends Base { | |||
| 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 || '', | |||
| emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '', | |||
| tag: tag || '', | |||
| documents: JSON.stringify(documents || []), | |||
| sample_types: JSON.stringify(sample_types || []), | |||
| wax_return: (sample_types && sample_types.length && wax_return) ? 1 : 0, | |||
| return_name: (sample_types && sample_types.length && wax_return) ? (return_name || '') : '', | |||
| return_phone: (sample_types && sample_types.length && wax_return) ? (return_phone || '') : '', | |||
| return_province_code: (sample_types && sample_types.length && wax_return) ? (return_province_code || '') : '', | |||
| return_city_code: (sample_types && sample_types.length && wax_return) ? (return_city_code || '') : '', | |||
| return_district_code: (sample_types && sample_types.length && wax_return) ? (return_district_code || '') : '', | |||
| return_address: (sample_types && sample_types.length && wax_return) ? (return_address || '') : '', | |||
| report_email: (sample_types && sample_types.length) ? (report_email || '') : '', | |||
| sample_tracking_no: (sample_types && sample_types.length) ? (sample_tracking_no || '') : '', | |||
| sample_photos: (sample_types && sample_types.length) ? JSON.stringify(sample_photos || []) : '[]', | |||
| sign_income: sign_income || '', | |||
| sign_privacy: sign_privacy || '', | |||
| sign_privacy_jhr: sign_privacy_jhr || '', | |||
| @@ -435,27 +465,6 @@ module.exports = class extends Base { | |||
| operator_name: '用户自助提交' | |||
| }); | |||
| // 异步 OCR 识别瘤种(不阻塞用户提交) | |||
| const patientId = user.patient_id; | |||
| const docUrls = documents || []; | |||
| if (docUrls.length) { | |||
| setImmediate(async () => { | |||
| try { | |||
| const tagOptions = await think.model('sys_config').getByKey('tag_options'); | |||
| if (tagOptions && tagOptions.length) { | |||
| const ocrService = think.service('ocr'); | |||
| const matchedTag = await ocrService.matchTag(docUrls, tagOptions); | |||
| if (matchedTag) { | |||
| await think.model('patient').where({ id: patientId }).update({ tag: matchedTag }); | |||
| think.logger.info(`[OCR] 患者(ID:${patientId}) 识别瘤种: ${matchedTag}`); | |||
| } | |||
| } | |||
| } catch (err) { | |||
| think.logger.error(`[OCR] 患者(ID:${patientId}) 识别失败:`, err.message); | |||
| } | |||
| }); | |||
| } | |||
| return this.json({ code: 0, data: {}, msg: '提交成功' }); | |||
| } catch (error) { | |||
| think.logger.error('saveMyInfo error:', error); | |||
| @@ -0,0 +1,14 @@ | |||
| module.exports = class extends think.Model { | |||
| get tableName() { | |||
| return 'sample_type'; | |||
| } | |||
| /** | |||
| * 获取启用的样本类型列表 | |||
| */ | |||
| async getEnabledList() { | |||
| return this.where({ status: 1, is_deleted: 0 }) | |||
| .order('sort DESC, id ASC') | |||
| .select(); | |||
| } | |||
| }; | |||
| @@ -32,6 +32,16 @@ | |||
| </div> | |||
| {% endif %} | |||
| {# 送检样本管理 #} | |||
| {% if isSuperAdmin or (userPermissions and 'tag' in userPermissions) %} | |||
| <div class="menu-group"> | |||
| <a class="menu-item {% if currentPage == 'sample-type' %}active{% endif %}" href="/admin/sample_type.html"> | |||
| <span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M320 128v768h384V128H320zm320 704H384V192h256v640z" fill="currentColor"/><path d="M448 320h128v64H448zm0 128h128v64H448zm0 128h96v64h-96z" fill="currentColor"/></svg></span> | |||
| <span>送检样本管理</span> | |||
| </a> | |||
| </div> | |||
| {% endif %} | |||
| {# 内容管理 #} | |||
| {% if isSuperAdmin or (userPermissions and 'content' in userPermissions) %} | |||
| <div class="menu-group"> | |||
| @@ -13,6 +13,7 @@ | |||
| .doc-images { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; } | |||
| .sign-doc-card { width: 200px; border: 1px solid #EBEEF5; border-radius: 8px; padding: 16px; text-align: center; cursor: pointer; transition: all 0.2s; } | |||
| .sign-doc-card:hover { border-color: var(--el-color-primary); box-shadow: 0 2px 12px rgba(255,120,0,0.15); } | |||
| .sign-img-wrap .el-image__inner { object-position: center bottom !important; } | |||
| .timeline-list { padding: 0; list-style: none; } | |||
| .timeline-list li { position: relative; padding: 0 0 20px 24px; border-left: 2px solid #EBEEF5; } | |||
| .timeline-list li:last-child { border-left-color: transparent; padding-bottom: 0; } | |||
| @@ -70,6 +71,45 @@ | |||
| </div> | |||
| </div> | |||
| <!-- 情况描述 --> | |||
| <div class="detail-panel"> | |||
| <h3>情况描述</h3> | |||
| <div class="info-grid"> | |||
| <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"> | |||
| <el-tag v-if="patient.tag" type="danger" size="small">${ patient.tag }</el-tag> | |||
| <span v-else style="color:#999;">未选择</span> | |||
| </span> | |||
| </div> | |||
| <div class="info-item"><span class="label">送检样本:</span> | |||
| <span class="value"> | |||
| <template v-if="patient.sample_types && patient.sample_types.length"> | |||
| <el-tag v-for="st in patient.sample_types" :key="st" size="small" style="margin-right:4px;">${ st }</el-tag> | |||
| </template> | |||
| <span v-else style="color:#999;">未选择</span> | |||
| </span> | |||
| </div> | |||
| <div class="info-item" v-if="patient.wax_return"><span class="label">样本需寄回:</span><span class="value">是</span></div> | |||
| <div class="info-item" v-if="patient.return_name"><span class="label">收件人:</span><span class="value">${ patient.return_name }</span></div> | |||
| <div class="info-item" v-if="patient.return_phone"><span class="label">收件电话:</span><span class="value">${ patient.return_phone }</span></div> | |||
| <div class="info-item" v-if="patient.return_address"><span class="label">收件地址:</span> | |||
| <span class="value">${ patient.return_province_name || '' } ${ patient.return_city_name || '' } ${ patient.return_district_name || '' } ${ patient.return_address }</span> | |||
| </div> | |||
| <div class="info-item" v-if="patient.report_email"><span class="label">报告邮箱:</span><span class="value">${ patient.report_email }</span></div> | |||
| <div class="info-item" v-if="patient.sample_tracking_no"><span class="label">物流单号:</span><span class="value">${ patient.sample_tracking_no }</span></div> | |||
| </div> | |||
| <div v-if="patient.sample_photos && patient.sample_photos.length" style="margin-top:16px;"> | |||
| <div style="font-size:13px;color:#909399;margin-bottom:8px;">送检单照片</div> | |||
| <div class="doc-images"> | |||
| <div v-for="(photo, idx) in patient.sample_photos" :key="idx"> | |||
| <el-image :src="photo" fit="cover" :preview-src-list="patient.sample_photos" :initial-index="idx" | |||
| style="width:200px;height:140px;border-radius:8px;border:1px solid #EBEEF5;" /> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <!-- 实名认证照片 --> | |||
| <div class="detail-panel" v-if="patient.id_card_front || patient.id_card_back || patient.photo"> | |||
| <h3>实名认证照片</h3> | |||
| @@ -113,13 +153,13 @@ | |||
| <!-- 签字材料 --> | |||
| <div class="detail-panel"> | |||
| <h3>签字材料</h3> | |||
| <p style="font-size:13px;color:#909399;margin-bottom:16px;">患者签署的三份授权材料</p> | |||
| <p style="font-size:13px;color:#909399;margin-bottom:16px;">患者签署的授权材料</p> | |||
| <div style="display:flex;gap:20px;flex-wrap:wrap;"> | |||
| <div v-for="item in signDocs" :key="item.key" style="width:220px;"> | |||
| <div style="font-size:13px;color:#303133;font-weight:500;margin-bottom:8px;">${ item.label }</div> | |||
| <template v-if="item.url && isImageUrl(item.url)"> | |||
| <el-image :src="item.url" fit="cover" :preview-src-list="signImageList" :initial-index="signImageList.indexOf(item.url)" | |||
| style="width:220px;height:160px;border-radius:8px;border:1px solid #EBEEF5;cursor:pointer;" /> | |||
| class="sign-img-wrap" style="width:220px;height:160px;border-radius:8px;border:1px solid #EBEEF5;cursor:pointer;" /> | |||
| </template> | |||
| <template v-else-if="item.url"> | |||
| <div class="sign-doc-card" @click="downloadSign(item.url, item.label)"> | |||
| @@ -162,6 +202,7 @@ | |||
| <!-- 驳回弹窗 --> | |||
| <el-dialog v-model="rejectVisible" title="驳回审核" width="540px" destroy-on-close :close-on-click-modal="false" :z-index="2000"> | |||
| <p style="font-weight:500;color:#303133;margin-bottom:12px;">请选择或填写驳回原因(必填):</p> | |||
| <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;"> | |||
| <el-check-tag v-for="(r, i) in commonReasons" :key="i" :checked="selectedReasons.includes(i)" | |||
| @@ -173,8 +214,8 @@ | |||
| <el-button type="danger" @click="doReject" :loading="rejectSaving">确认驳回</el-button> | |||
| </div> | |||
| </el-dialog> | |||
| </div> | |||
| {% endblock %} | |||
| </div>{% endblock %} | |||
| {% block js %} | |||
| <script> | |||
| @@ -191,7 +232,14 @@ 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: '', tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '', | |||
| address: '', hospital: '', tag: '', documents: [], | |||
| sample_types: [], wax_return: 0, | |||
| return_name: '', return_phone: '', | |||
| return_province_code: '', return_city_code: '', return_district_code: '', | |||
| return_province_name: '', return_city_name: '', return_district_name: '', | |||
| return_address: '', | |||
| report_email: '', sample_tracking_no: '', sample_photos: [], | |||
| sign_income: '', sign_privacy: '', sign_promise: '', | |||
| emergency_contact: '', emergency_phone: '', | |||
| status: -1, create_time: '' | |||
| }); | |||
| @@ -199,8 +247,7 @@ var app = createApp({ | |||
| var rejectVisible = ref(false); | |||
| var rejectSaving = ref(false); | |||
| var rejectReason = ref(''); | |||
| var selectedReasons = ref([]); | |||
| var commonReasons = [ | |||
| var selectedReasons = ref([]); var commonReasons = [ | |||
| '身份证照片模糊,请重新上传', | |||
| '病历资料不完整,请补充', | |||
| '检查报告缺失,请上传', | |||
| @@ -0,0 +1,199 @@ | |||
| {% extends "./layout.html" %} | |||
| {% block title %}送检样本管理{% endblock %} | |||
| {% block css %} | |||
| <style> | |||
| .drag-handle { cursor: move; color: #999; font-size: 18px; } | |||
| .drag-handle:hover { color: #ff7800; } | |||
| .sortable-ghost { opacity: 0.4; background: #fff7ed; } | |||
| </style> | |||
| {% endblock %} | |||
| {% block content %} | |||
| <div id="sampleTypeApp" v-cloak> | |||
| <el-card shadow="never"> | |||
| <template #header> | |||
| <div class="flex items-center justify-between"> | |||
| <div class="flex items-center gap-4"> | |||
| <el-button type="primary" @click="showAdd">+ 新增样本类型</el-button> | |||
| <el-switch v-model="sampleRequired" active-text="送检样本必选" inactive-text="送检样本非必选" | |||
| @change="handleRequiredChange" /> | |||
| </div> | |||
| <span class="text-xs text-gray-400">拖拽行可调整排序</span> | |||
| </div> | |||
| </template> | |||
| <el-table :data="list" v-loading="loading" stripe border row-key="id"> | |||
| <el-table-column width="60" align="center"> | |||
| <template #header>排序</template> | |||
| <template #default> | |||
| <span class="drag-handle">⠿</span> | |||
| </template> | |||
| </el-table-column> | |||
| <el-table-column prop="id" label="ID" width="70"></el-table-column> | |||
| <el-table-column prop="name" label="样本类型名称"></el-table-column> | |||
| <el-table-column label="可选需寄回" width="120" align="center"> | |||
| <template #default="{ row }"> | |||
| <el-tag :type="row.need_return ? 'success' : 'info'" size="small">${ row.need_return ? '是' : '否' }</el-tag> | |||
| </template> | |||
| </el-table-column> | |||
| <el-table-column label="状态" width="100" align="center"> | |||
| <template #default="{ row }"> | |||
| <el-tag :type="row.status ? 'success' : 'danger'" size="small">${ row.status ? '启用' : '禁用' }</el-tag> | |||
| </template> | |||
| </el-table-column> | |||
| <el-table-column label="操作" width="180" 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> | |||
| </template> | |||
| </el-table-column> | |||
| </el-table> | |||
| </el-card> | |||
| <!-- 新增/编辑弹窗 --> | |||
| <el-dialog v-model="dialogVisible" :title="dialogTitle" width="480px" destroy-on-close draggable :close-on-click-modal="false"> | |||
| <el-form :model="form" label-width="110px"> | |||
| <el-form-item label="样本类型名称" required> | |||
| <el-input v-model="form.name" placeholder="请输入样本类型名称" maxlength="50" /> | |||
| </el-form-item> | |||
| <el-form-item label="可选需寄回"> | |||
| <el-switch v-model="form.need_return" /> | |||
| <span style="margin-left:8px;font-size:12px;color:#909399;">开启后,用户选择该样本类型时可选择是否需要寄回</span> | |||
| </el-form-item> | |||
| <el-form-item label="排序"> | |||
| <el-input-number v-model="form.sort" :min="0" :max="999" /> | |||
| </el-form-item> | |||
| <el-form-item label="状态"> | |||
| <el-switch v-model="form.status" :active-value="1" :inactive-value="0" /> | |||
| </el-form-item> | |||
| </el-form> | |||
| <template #footer> | |||
| <el-button @click="dialogVisible = false">取消</el-button> | |||
| <el-button type="primary" @click="handleSave" :loading="saving">确定</el-button> | |||
| </template> | |||
| </el-dialog> | |||
| </div> | |||
| {% endblock %} | |||
| {% block js %} | |||
| <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script> | |||
| <script> | |||
| const initRequired = '{{ sampleRequired }}'; | |||
| const { createApp, ref, reactive, onMounted, nextTick } = Vue; | |||
| const app = createApp({ | |||
| delimiters: ['${', '}'], | |||
| setup() { | |||
| const loading = ref(false); | |||
| const list = ref([]); | |||
| const sampleRequired = ref(initRequired === '1'); | |||
| const dialogVisible = ref(false); | |||
| const dialogTitle = ref('新增样本类型'); | |||
| const saving = ref(false); | |||
| const form = reactive({ id: null, name: '', need_return: false, sort: 0, status: 1 }); | |||
| let sortableInstance = null; | |||
| async function loadList() { | |||
| loading.value = true; | |||
| try { | |||
| const res = await fetch('/admin/sample_type/list').then(r => r.json()); | |||
| if (res.code === 0) { | |||
| list.value = res.data || []; | |||
| nextTick(() => initSortable()); | |||
| } | |||
| } finally { loading.value = false; } | |||
| } | |||
| function initSortable() { | |||
| if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null; } | |||
| const tbody = document.querySelector('#sampleTypeApp .el-table__body-wrapper tbody'); | |||
| if (!tbody) return; | |||
| sortableInstance = Sortable.create(tbody, { | |||
| handle: '.drag-handle', | |||
| animation: 150, | |||
| ghostClass: 'sortable-ghost', | |||
| onEnd(evt) { | |||
| if (evt.oldIndex === evt.newIndex) return; | |||
| const arr = list.value.slice(); | |||
| const item = arr.splice(evt.oldIndex, 1)[0]; | |||
| arr.splice(evt.newIndex, 0, item); | |||
| list.value = arr; | |||
| fetch('/admin/sample_type/sort', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ ids: arr.map(i => i.id) }) | |||
| }).then(r => r.json()).then(res => { | |||
| if (res.code === 0) ElementPlus.ElMessage.success('排序已保存'); | |||
| else loadList(); | |||
| }); | |||
| } | |||
| }); | |||
| } | |||
| function showAdd() { | |||
| dialogTitle.value = '新增样本类型'; | |||
| form.id = null; form.name = ''; form.need_return = false; form.sort = 0; form.status = 1; | |||
| dialogVisible.value = true; | |||
| } | |||
| function showEdit(row) { | |||
| dialogTitle.value = '编辑样本类型'; | |||
| form.id = row.id; form.name = row.name; form.need_return = !!row.need_return; form.sort = row.sort; form.status = row.status; | |||
| dialogVisible.value = true; | |||
| } | |||
| async function handleSave() { | |||
| if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入样本类型名称'); return; } | |||
| saving.value = true; | |||
| try { | |||
| const url = form.id ? '/admin/sample_type/edit' : '/admin/sample_type/add'; | |||
| const res = await fetch(url, { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(form) | |||
| }).then(r => r.json()); | |||
| if (res.code === 0) { | |||
| ElementPlus.ElMessage.success('保存成功'); | |||
| dialogVisible.value = false; | |||
| loadList(); | |||
| } else { | |||
| ElementPlus.ElMessage.error(res.msg || '保存失败'); | |||
| } | |||
| } finally { saving.value = false; } | |||
| } | |||
| async function handleDelete(row) { | |||
| try { | |||
| await ElementPlus.ElMessageBox.confirm(`确定要删除「${row.name}」吗?`, '提示', { type: 'warning' }); | |||
| const res = await fetch('/admin/sample_type/delete', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ id: row.id }) | |||
| }).then(r => r.json()); | |||
| if (res.code === 0) { ElementPlus.ElMessage.success('删除成功'); loadList(); } | |||
| else { ElementPlus.ElMessage.error(res.msg || '删除失败'); } | |||
| } catch (e) {} | |||
| } | |||
| async function handleRequiredChange(val) { | |||
| const res = await fetch('/admin/sample_type/setRequired', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ value: val ? 1 : 0 }) | |||
| }).then(r => r.json()); | |||
| if (res.code === 0) ElementPlus.ElMessage.success('设置已保存'); | |||
| else ElementPlus.ElMessage.error(res.msg || '设置失败'); | |||
| } | |||
| onMounted(() => loadList()); | |||
| return { loading, list, sampleRequired, dialogVisible, dialogTitle, saving, form, showAdd, showEdit, handleSave, handleDelete, handleRequiredChange }; | |||
| } | |||
| }); | |||
| app.use(ElementPlus, { locale: ElementPlusLocaleZhCn }); | |||
| app.mount('#sampleTypeApp'); | |||
| </script> | |||
| {% endblock %} | |||