|
- 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);
- }
- }
-
- };
|