const Base = require('../base.js'); const dayjs = require('dayjs'); module.exports = class extends Base { // 患者列表页面 async indexAction() { this.assign('currentPage', 'patient'); this.assign('pageTitle', '患者管理'); this.assign('breadcrumb', [{ name: '患者管理' }]); this.assign('adminUser', this.adminUser || {}); this.assign('canAdd', this.isSuperAdmin || (this.userPermissions || []).includes('patient:add')); this.assign('canEdit', this.isSuperAdmin || (this.userPermissions || []).includes('patient:edit')); this.assign('canExport', this.isSuperAdmin || (this.userPermissions || []).includes('patient:export')); this.assign('canAudit', this.isSuperAdmin || (this.userPermissions || []).includes('patient:audit')); this.assign('canView', this.isSuperAdmin || (this.userPermissions || []).includes('patient:view')); this.assign('canDelete', this.isSuperAdmin || (this.userPermissions || []).includes('patient:delete')); return this.display(); } // 获取患者列表接口 async listAction() { const { keyword, tag, status, startDate, endDate, province_code, city_code, district_code, page = 1, pageSize = 10 } = this.get(); const model = this.model('patient'); const list = await model.getList({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code, page, pageSize }); // 收集所有省市区 code 批量查询名称 const allCodes = new Set(); list.data.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); }); const regionMap = {}; if (allCodes.size) { const regions = await this.model('sys_region') .where({ code: ['in', [...allCodes]] }) .select(); regions.forEach(r => { regionMap[r.code] = r.name; }); } // 脱敏 + 拼接地区 list.data.forEach(item => { if (item.id_card && item.id_card.length === 18) { item.id_card_mask = item.id_card.slice(0, 3) + '***********' + item.id_card.slice(-4); } else { item.id_card_mask = item.id_card || ''; } if (item.phone && item.phone.length === 11) { item.phone_mask = item.phone.slice(0, 3) + '****' + item.phone.slice(-4); } else { item.phone_mask = item.phone || ''; } const pName = regionMap[item.province_code] || ''; const cName = regionMap[item.city_code] || ''; const dName = regionMap[item.district_code] || ''; item.region_name = [pName, cName, dName].filter(Boolean).join(' '); }); const counts = await model.getStatusCounts({ keyword, tag, startDate, endDate, province_code, city_code, district_code }); return this.success({ ...list, counts }); } // 患者详情页面 async detailAction() { const { id } = this.get(); if (!id) return this.redirect('/admin/patient.html'); this.assign('currentPage', 'patient'); this.assign('pageTitle', '患者详情'); this.assign('breadcrumb', [ { name: '患者管理', url: '/admin/patient.html' }, { name: '患者详情' } ]); this.assign('patientId', id); this.assign('adminUser', this.adminUser || {}); this.assign('canAudit', this.isSuperAdmin || (this.userPermissions || []).includes('patient:audit')); return this.display(); } // 获取患者详情接口(不脱敏) async infoAction() { const { id } = this.get(); if (!id) return this.fail('参数错误'); const patient = await this.model('patient') .where({ id, is_deleted: 0 }) .find(); if (think.isEmpty(patient)) { return this.fail('患者不存在'); } // 解析 JSON 字段 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, patient.hospital_province_code, patient.hospital_city_code, patient.hospital_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] || ''; 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] || ''; } // 获取审核记录 const audits = await this.model('patient_audit') .where({ patient_id: id }) .order('id DESC') .select(); return this.success({ patient, audits }); } // 新增患者 async addAction() { const data = this.post(); 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('请填写完整信息'); } if (!province_code || !city_code || !district_code) { return this.fail('请选择省市区'); } if (!address) { return this.fail('请填写详细地址'); } if (!/^1\d{10}$/.test(phone)) { return this.fail('手机号格式不正确'); } if (!/^\d{17}[\dXx]$/.test(id_card)) { return this.fail('身份证号格式不正确'); } const model = this.model('patient'); // 唯一性校验 const existByIdCardAndPhone = await model.where({ id_card, phone, is_deleted: 0 }).find(); if (!think.isEmpty(existByIdCardAndPhone)) { return this.fail('该患者已存在(身份证+手机号匹配)'); } const existByPhone = await model.where({ phone, is_deleted: 0 }).find(); if (!think.isEmpty(existByPhone)) { return this.fail('该手机号已被其他患者使用'); } const existByIdCard = await model.where({ id_card, is_deleted: 0 }).find(); if (!think.isEmpty(existByIdCard)) { return this.fail('该身份证号已被其他患者使用'); } const patientNo = model.generatePatientNo(); const id = await model.add({ patient_no: patientNo, name, phone, id_card, gender, birth_date, province_code: province_code || '', city_code: city_code || '', 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 || '', 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 || '', sign_privacy_jhr: sign_privacy_jhr || '', income_amount: income_amount || '', guardian_name: guardian_name || '', guardian_id_card: guardian_id_card || '', guardian_relation: guardian_relation || '', status: 1, create_by: this.adminUser?.id || 0 }); // 记录审核日志(后台新增直接审核通过) await this.model('patient_audit').add({ patient_id: id, action: 'approve', operator_id: this.adminUser?.id || 0, operator_name: this.adminUser?.nickname || this.adminUser?.username || '' }); await this.log('add', '患者管理', `新增患者「${name}」编号:${patientNo}`); return this.success({ id, patient_no: patientNo }); } // 审核通过 async approveAction() { const { id } = this.post(); if (!id) return this.fail('参数错误'); const patient = await this.model('patient') .where({ id, is_deleted: 0 }) .find(); if (think.isEmpty(patient)) return this.fail('患者不存在'); if (patient.status === 1) return this.fail('该患者已审核通过'); await this.model('patient').where({ id }).update({ status: 1, update_by: this.adminUser?.id || 0 }); await this.model('patient_audit').add({ patient_id: id, action: 'approve', operator_id: this.adminUser?.id || 0, operator_name: this.adminUser?.nickname || this.adminUser?.username || '' }); // 发送消息通知 await this.model('message').add({ patient_id: id, type: 1, title: '审核通过', content: '您提交的个人资料已审核通过。', reason: '' }); // 发送订阅消息 await this._sendAuditSubscribeMessage(id, patient.name, '审核通过'); // 发送短信通知 await this._sendAuditSms(patient.phone, 'approved'); await this.log('edit', '患者管理', `审核通过患者(ID:${id})`); return this.success(); } // 驳回 async rejectAction() { const { id, reason } = this.post(); if (!id) return this.fail('参数错误'); if (!reason) return this.fail('请填写驳回原因'); const patient = await this.model('patient') .where({ id, is_deleted: 0 }) .find(); if (think.isEmpty(patient)) return this.fail('患者不存在'); if (patient.status === 2) return this.fail('该患者已被驳回'); await this.model('patient').where({ id }).update({ status: 2, update_by: this.adminUser?.id || 0 }); await this.model('patient_audit').add({ patient_id: id, action: 'reject', reason, operator_id: this.adminUser?.id || 0, operator_name: this.adminUser?.nickname || this.adminUser?.username || '' }); // 发送消息通知 await this.model('message').add({ patient_id: id, type: 2, title: '审核未通过', content: '您提交的个人资料未通过审核,请根据以下原因修改后重新提交。', reason: reason }); // 发送订阅消息 await this._sendAuditSubscribeMessage(id, patient.name, '审核驳回'); // 发送短信通知 await this._sendAuditSms(patient.phone, 'rejected', reason); await this.log('edit', '患者管理', `驳回患者(ID:${id}),原因:${reason}`); return this.success(); } // 删除患者(软删除,支持批量) async deleteAction() { const { ids } = this.post(); if (!ids || !ids.length) return this.fail('参数错误'); const model = this.model('patient'); const patients = await model.where({ id: ['in', ids], is_deleted: 0 }).select(); if (!patients.length) return this.fail('患者不存在'); await model.where({ id: ['in', ids] }).update({ is_deleted: 1, update_by: this.adminUser?.id || 0 }); // 清除 wechat_user 表中的患者关联,避免用户重新认证时走 update 逻辑 await this.model('wechat_user') .where({ patient_id: ['in', ids] }) .update({ patient_id: 0 }); const names = patients.map(p => p.name).join('、'); await this.log('delete', '患者管理', `删除患者「${names}」共${patients.length}条`); return this.success(); } // 编辑患者 async editAction() { const data = this.post(); 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('请填写完整信息'); if (!province_code || !city_code || !district_code) return this.fail('请选择省市区'); if (!address) return this.fail('请填写详细地址'); if (!/^1\d{10}$/.test(phone)) return this.fail('手机号格式不正确'); if (!/^\d{17}[\dXx]$/.test(id_card)) return this.fail('身份证号格式不正确'); const patient = await this.model('patient').where({ id, is_deleted: 0 }).find(); if (think.isEmpty(patient)) return this.fail('患者不存在'); // 唯一性校验(排除自身) const existByPhone = await this.model('patient').where({ phone, id: ['!=', id], is_deleted: 0 }).find(); if (!think.isEmpty(existByPhone)) return this.fail('该手机号已被其他患者使用'); const existByIdCard = await this.model('patient').where({ id_card, id: ['!=', id], is_deleted: 0 }).find(); if (!think.isEmpty(existByIdCard)) return this.fail('该身份证号已被其他患者使用'); await this.model('patient').where({ id }).update({ name, phone, id_card, gender, birth_date, 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 || '', 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 || '', sign_privacy_jhr: sign_privacy_jhr || '', income_amount: income_amount || '', guardian_name: guardian_name || '', guardian_id_card: guardian_id_card || '', guardian_relation: guardian_relation || '', update_by: this.adminUser?.id || 0 }); await this.log('edit', '患者管理', `编辑患者「${name}」(ID:${id})`); return this.success(); } // 导出患者数据(CSV,不脱敏) async exportAction() { const { keyword, tag, status, startDate, endDate, province_code, city_code, district_code } = this.get(); 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); 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) { const regions = await this.model('sys_region') .where({ code: ['in', [...allCodes]] }) .select(); regions.forEach(r => { regionMap[r.code] = r.name; }); } // 批量查最近一条审核记录(审核日期、驳回原因) const patientIds = list.map(item => item.id); const auditMap = {}; if (patientIds.length) { const audits = await this.model('patient_audit') .where({ patient_id: ['in', patientIds], action: ['in', ['approve', 'reject']] }) .order('id DESC') .select(); audits.forEach(a => { if (!auditMap[a.patient_id]) auditMap[a.patient_id] = a; }); } const statusMap = { '-1': '待提交', 0: '待审核', 1: '审核通过', 2: '已驳回' }; const header = [ 'ID', '姓名', '性别', '身份证', '手机号', '省份', '城市', '详细地址', '医院名称', '医院省份', '医院城市', '医院区县', '紧急联系人', '紧急联系电话', '瘤种', '送检样本类型', '是否需寄回', '收件人', '收件电话', '收件地址', '报告接收邮箱', '送检物流单号', '提交时间', '审核状态', '审核日期', '审核驳回原因' ]; const ExcelJS = require('exceljs'); const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('患者信息'); // 表头 sheet.addRow(header); // 表头样式:绿色背景、白色加粗字体、冻结首行 const headerRow = sheet.getRow(1); const colCount = header.length; for (let i = 1; i <= colCount; i++) { const cell = headerRow.getCell(i); cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4CAF50' } }; cell.alignment = { vertical: 'middle', horizontal: 'center' }; } sheet.views = [{ state: 'frozen', ySplit: 1 }]; // 边框样式 const thinBorder = { top: { style: 'thin' }, left: { style: 'thin' }, bottom: { style: 'thin' }, right: { style: 'thin' } }; // 表头加边框 for (let i = 1; i <= colCount; i++) { headerRow.getCell(i).border = thinBorder; } // 数据行 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, item.gender, item.id_card, item.phone, regionMap[item.province_code] || '', 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 || '', 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 || '') : '' ]); // 数据行加边框 for (let i = 1; i <= colCount; i++) { row.getCell(i).border = thinBorder; } }); // 自动列宽 sheet.columns.forEach(col => { let maxLen = 10; col.eachCell({ includeEmpty: true }, cell => { const len = String(cell.value || '').length; if (len > maxLen) maxLen = len; }); col.width = Math.min(maxLen + 4, 40); }); const buffer = await workbook.xlsx.writeBuffer(); const ts = dayjs().format('YYYYMMDDHHmmss'); const fileName = encodeURIComponent(`患者信息_${ts}.xlsx`); this.ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); this.ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`); this.ctx.body = Buffer.from(buffer); await this.log('export', '患者管理', `导出患者数据 ${list.length} 条`); } // @private 发送审核结果订阅消息 async _sendAuditSubscribeMessage(patientId, patientName, result) { try { const APP_REMARK = 'pap_mini_cytx'; // 查找该患者关联的微信用户 const wechatUser = await this.model('wechat_user') .where({ patient_id: patientId, app_remark: APP_REMARK, status: 1 }) .find(); if (think.isEmpty(wechatUser) || !wechatUser.open_id) return; const wechatService = this.service('wechat'); const templates = await wechatService.getSubscribeTemplates(APP_REMARK); const templateId = templates.audit_result; if (!templateId) return; // 映射小程序版本: __wxConfig.envVersion -> miniprogram_state // develop -> developer, trial -> trial, release -> formal const envMap = { develop: 'developer', trial: 'trial', release: 'formal' }; const miniprogramState = envMap[wechatUser.mp_env_version] || 'formal'; const dayjs = require('dayjs'); await wechatService.sendSubscribeMessage({ remark: APP_REMARK, openid: wechatUser.open_id, templateId, page: 'pages/profile/profile', miniprogramState, data: { thing2: { value: '肠愈同行患者关爱' }, thing14: { value: patientName || '用户' }, phrase1: { value: result }, time13: { value: dayjs().format('YYYY-MM-DD HH:mm:ss') } } }); } catch (error) { // 订阅消息发送失败不影响审核流程 think.logger.error('[Subscribe] 审核消息发送失败:', error); } } // @private 发送审核结果短信通知 async _sendAuditSms(phone, type, reason) { if (!phone) return; try { const smsConfig = require('../../config/sms.js'); const templates = smsConfig.templates; think.logger.info(`[SMS] _sendAuditSms 开始 - phone: ${phone}, type: ${type}, reason: ${reason || '(无)'}`); think.logger.info(`[SMS] 模板配置 - auditApproved: ${templates.auditApproved || '(空)'}, auditRejected: ${templates.auditRejected || '(空)'}`); if (type === 'approved' && templates.auditApproved) { await this.sendNotifySms(phone, templates.auditApproved, [], 'audit_approved'); } else if (type === 'rejected' && templates.auditRejected) { // 驳回原因截取前15字符(短信模板变量有长度限制) const shortReason = (reason || '资料不符合要求').slice(0, 15); await this.sendNotifySms(phone, templates.auditRejected, [shortReason], 'audit_rejected'); } } catch (error) { think.logger.error('[SMS] 审核短信发送失败:', error); } } };