Selaa lähdekoodia

feat : 新增系列字段

master
leiyun 3 viikkoa sitten
vanhempi
commit
026b0b5af6
10 muutettua tiedostoa jossa 524 lisäystä ja 56 poistoa
  1. +37
    -0
      sql/sample_type.sql
  2. +10
    -0
      src/config/router.js
  3. +79
    -27
      src/controller/admin/patient.js
  4. +75
    -0
      src/controller/admin/sample_type.js
  5. +15
    -0
      src/controller/common.js
  6. +31
    -22
      src/controller/mp.js
  7. +14
    -0
      src/model/sample_type.js
  8. +10
    -0
      view/admin/common/_sidebar.html
  9. +54
    -7
      view/admin/patient_detail.html
  10. +199
    -0
      view/admin/sample_type_index.html

+ 37
- 0
sql/sample_type.sql Näytä tiedosto

@@ -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是');

+ 10
- 0
src/config/router.js Näytä tiedosto

@@ -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'],


+ 79
- 27
src/controller/admin/patient.js Näytä tiedosto

@@ -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++) {


+ 75
- 0
src/controller/admin/sample_type.js Näytä tiedosto

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

+ 15
- 0
src/controller/common.js Näytä tiedosto

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


+ 31
- 22
src/controller/mp.js Näytä tiedosto

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


+ 14
- 0
src/model/sample_type.js Näytä tiedosto

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

+ 10
- 0
view/admin/common/_sidebar.html Näytä tiedosto

@@ -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">


+ 54
- 7
view/admin/patient_detail.html Näytä tiedosto

@@ -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 = [
'身份证照片模糊,请重新上传',
'病历资料不完整,请补充',
'检查报告缺失,请上传',


+ 199
- 0
view/admin/sample_type_index.html Näytä tiedosto

@@ -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 %}

Ladataan…
Peruuta
Tallenna