You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

295 lines
14 KiB

  1. {% extends "./layout.html" %}
  2. {% block title %}患者详情{% endblock %}
  3. {% block css %}
  4. <style>
  5. .detail-panel { background: #fff; border-radius: 8px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); margin-bottom: 16px; }
  6. .detail-panel h3 { font-size: 16px; color: #303133; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid #EBEEF5; }
  7. .info-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
  8. .info-item { display: flex; }
  9. .info-item .label { width: 100px; color: #909399; font-size: 14px; flex-shrink: 0; }
  10. .info-item .value { color: #303133; font-size: 14px; }
  11. .doc-images { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; }
  12. .sign-doc-card { width: 200px; border: 1px solid #EBEEF5; border-radius: 8px; padding: 16px; text-align: center; cursor: pointer; transition: all 0.2s; }
  13. .sign-doc-card:hover { border-color: var(--el-color-primary); box-shadow: 0 2px 12px rgba(255,120,0,0.15); }
  14. .timeline-list { padding: 0; list-style: none; }
  15. .timeline-list li { position: relative; padding: 0 0 20px 24px; border-left: 2px solid #EBEEF5; }
  16. .timeline-list li:last-child { border-left-color: transparent; padding-bottom: 0; }
  17. .timeline-list li::before { content: ''; position: absolute; left: -6px; top: 4px; width: 10px; height: 10px; border-radius: 50%; background: var(--el-color-primary); }
  18. .timeline-list li .time { font-size: 12px; color: #909399; margin-bottom: 4px; }
  19. .timeline-list li .desc { font-size: 14px; color: #303133; }
  20. .timeline-list li .reason { font-size: 13px; color: #F56C6C; margin-top: 4px; }
  21. .fixed-bottom-bar { position: fixed; bottom: 0; left: 220px; right: 0; height: 64px; background: #fff; box-shadow: 0 -2px 8px rgba(0,0,0,0.08); display: flex; align-items: center; justify-content: center; gap: 16px; z-index: 10; padding: 0 24px; }
  22. </style>
  23. {% endblock %}
  24. {% block content %}
  25. <div id="detailApp" v-cloak>
  26. <div v-loading="loading" style="padding-bottom:80px;">
  27. <!-- 页头 -->
  28. <el-page-header @back="goBack" style="margin-bottom:16px;">
  29. <template #content>
  30. <span style="font-size:16px;font-weight:600;">患者详情</span>
  31. <el-tag v-if="patient.status === -1" type="info" size="small" style="margin-left:12px;">待提交</el-tag>
  32. <el-tag v-else-if="patient.status === 0" type="warning" size="small" style="margin-left:12px;">待审核</el-tag>
  33. <el-tag v-else-if="patient.status === 1" type="success" size="small" style="margin-left:12px;">审核通过</el-tag>
  34. <el-tag v-else-if="patient.status === 2" type="danger" size="small" style="margin-left:12px;">已驳回</el-tag>
  35. </template>
  36. </el-page-header>
  37. <!-- 基本信息 -->
  38. <div class="detail-panel">
  39. <h3>基本信息</h3>
  40. <div class="info-grid">
  41. <div class="info-item"><span class="label">患者编号:</span><span class="value">${ patient.patient_no }</span></div>
  42. <div class="info-item"><span class="label">姓名:</span><span class="value">${ patient.name }</span></div>
  43. <div class="info-item"><span class="label">性别:</span><span class="value">${ patient.gender }</span></div>
  44. <div class="info-item"><span class="label">手机号:</span><span class="value">${ patient.phone }</span></div>
  45. <div class="info-item"><span class="label">身份证号:</span><span class="value">${ patient.id_card }</span></div>
  46. <div class="info-item"><span class="label">出生日期:</span><span class="value">${ patient.birth_date }</span></div>
  47. <div class="info-item">
  48. <span class="label">所在地区:</span>
  49. <span class="value">${ patient.province_name } ${ patient.city_name } ${ patient.district_name }</span>
  50. </div>
  51. <div class="info-item"><span class="label">详细地址:</span><span class="value">${ patient.address }</span></div>
  52. <div class="info-item">
  53. <span class="label">瘤种:</span>
  54. <span class="value">
  55. <el-tag v-if="patient.tag" type="danger" size="small">${ patient.tag }</el-tag>
  56. <span v-else style="color:#999;">无</span>
  57. </span>
  58. </div>
  59. <div class="info-item"><span class="label">提交时间:</span><span class="value">${ patient.create_time }</span></div>
  60. <div class="info-item"><span class="label">紧急联系人:</span><span class="value">${ patient.emergency_contact || '—' }</span></div>
  61. <div class="info-item"><span class="label">紧急联系电话:</span><span class="value">${ patient.emergency_phone || '—' }</span></div>
  62. </div>
  63. </div>
  64. <!-- 上传资料 -->
  65. <div class="detail-panel">
  66. <h3>上传资料</h3>
  67. <p style="font-size:13px;color:#909399;margin-bottom:12px;">患者上传的检查报告单或出院诊断证明书</p>
  68. <div class="doc-images" v-if="patient.documents && patient.documents.length">
  69. <div v-for="(doc, idx) in patient.documents" :key="idx">
  70. <el-image :src="doc" fit="cover" :preview-src-list="patient.documents" :initial-index="idx"
  71. style="width:200px;height:140px;border-radius:8px;border:1px solid #EBEEF5;" />
  72. <div style="font-size:12px;color:#909399;text-align:center;margin-top:6px;">资料 ${ idx + 1 }</div>
  73. </div>
  74. </div>
  75. <el-empty v-else description="暂无上传资料" :image-size="60" />
  76. </div>
  77. <!-- 签字材料 -->
  78. <div class="detail-panel">
  79. <h3>签字材料</h3>
  80. <p style="font-size:13px;color:#909399;margin-bottom:16px;">患者签署的三份授权材料</p>
  81. <div style="display:flex;gap:20px;flex-wrap:wrap;">
  82. <div v-for="item in signDocs" :key="item.key" style="width:220px;">
  83. <div style="font-size:13px;color:#303133;font-weight:500;margin-bottom:8px;">${ item.label }</div>
  84. <template v-if="item.url && isImageUrl(item.url)">
  85. <el-image :src="item.url" fit="cover" :preview-src-list="signImageList" :initial-index="signImageList.indexOf(item.url)"
  86. style="width:220px;height:160px;border-radius:8px;border:1px solid #EBEEF5;cursor:pointer;" />
  87. </template>
  88. <template v-else-if="item.url">
  89. <div class="sign-doc-card" @click="downloadSign(item.url, item.label)">
  90. <div style="font-size:36px;margin-bottom:8px;">📝</div>
  91. <div style="font-size:12px;color:#67C23A;margin-bottom:8px;">已签署</div>
  92. <div style="font-size:13px;color:var(--el-color-primary);font-weight:500;">⬇ 下载</div>
  93. </div>
  94. </template>
  95. <template v-else>
  96. <div style="width:220px;height:160px;border:1px dashed #DCDFE6;border-radius:8px;display:flex;align-items:center;justify-content:center;">
  97. <span style="font-size:13px;color:#909399;">未上传</span>
  98. </div>
  99. </template>
  100. </div>
  101. </div>
  102. </div>
  103. <!-- 审核记录 -->
  104. <div class="detail-panel">
  105. <h3>审核记录</h3>
  106. <ul class="timeline-list" v-if="audits.length">
  107. <li v-for="(item, idx) in audits" :key="idx">
  108. <div class="time">${ item.create_time }</div>
  109. <div class="desc" v-if="item.action === 'submit'">${ item.operator_name || '系统' } 提交了患者资料,等待审核</div>
  110. <div class="desc" v-else-if="item.action === 'approve'">${ item.operator_name } 审核通过</div>
  111. <div class="desc" v-else-if="item.action === 'reject'">${ item.operator_name } 驳回了资料</div>
  112. <div class="reason" v-if="item.reason">驳回原因:${ item.reason }</div>
  113. </li>
  114. </ul>
  115. <el-empty v-else description="暂无审核记录" :image-size="60" />
  116. </div>
  117. </div>
  118. <!-- 固定底部操作栏 -->
  119. <div class="fixed-bottom-bar">
  120. <el-button @click="goBack">返 回</el-button>
  121. <el-button v-if="patient.status === 0 && canAudit" type="success" @click="handleApprove">审核通过</el-button>
  122. <el-button v-if="patient.status === 0 && canAudit" type="danger" @click="showRejectDialog">驳 回</el-button>
  123. </div>
  124. <!-- 驳回弹窗 -->
  125. <el-dialog v-model="rejectVisible" title="驳回审核" width="540px" destroy-on-close :close-on-click-modal="false" :z-index="2000">
  126. <p style="font-weight:500;color:#303133;margin-bottom:12px;">请选择或填写驳回原因(必填):</p>
  127. <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;">
  128. <el-check-tag v-for="(r, i) in commonReasons" :key="i" :checked="selectedReasons.includes(i)"
  129. @change="toggleReason(i)">${ r }</el-check-tag>
  130. </div>
  131. <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因,或点击上方常见原因快速选择"></el-input>
  132. <div style="text-align:right;margin-top:20px;">
  133. <el-button @click="rejectVisible = false">取消</el-button>
  134. <el-button type="danger" @click="doReject" :loading="rejectSaving">确认驳回</el-button>
  135. </div>
  136. </el-dialog>
  137. </div>
  138. {% endblock %}
  139. {% block js %}
  140. <script>
  141. var patientId = '{{ patientId }}';
  142. var canAuditVal = {{ canAudit | dump | safe }};
  143. var { createApp, ref, reactive, onMounted } = Vue;
  144. var app = createApp({
  145. delimiters: ['${', '}'],
  146. setup() {
  147. var loading = ref(true);
  148. var canAudit = ref(canAuditVal);
  149. var patient = reactive({
  150. id: '', patient_no: '', name: '', phone: '', id_card: '', gender: '', birth_date: '',
  151. province_code: '', city_code: '', district_code: '',
  152. province_name: '', city_name: '', district_name: '',
  153. address: '', tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '',
  154. emergency_contact: '', emergency_phone: '',
  155. status: -1, create_time: ''
  156. });
  157. var audits = ref([]);
  158. var rejectVisible = ref(false);
  159. var rejectSaving = ref(false);
  160. var rejectReason = ref('');
  161. var selectedReasons = ref([]);
  162. var commonReasons = [
  163. '身份证照片模糊,请重新上传',
  164. '病历资料不完整,请补充',
  165. '检查报告缺失,请上传',
  166. '姓名与身份证信息不一致',
  167. '手机号无法联系,请核实',
  168. '提交信息存在明显错误',
  169. '资料过期,请提供最新版本'
  170. ];
  171. async function loadDetail() {
  172. loading.value = true;
  173. try {
  174. var res = await fetch('/admin/patient/info?id=' + patientId).then(function(r) { return r.json(); });
  175. if (res.code === 0) {
  176. Object.assign(patient, res.data.patient);
  177. audits.value = res.data.audits || [];
  178. } else {
  179. ElementPlus.ElMessage.error(res.msg || '加载失败');
  180. }
  181. } finally {
  182. loading.value = false;
  183. }
  184. }
  185. function goBack() {
  186. window.location.href = '/admin/patient.html';
  187. }
  188. function downloadSign(url, name) {
  189. if (!url) { ElementPlus.ElMessage.warning('该材料未上传'); return; }
  190. var a = document.createElement('a');
  191. a.href = url; a.target = '_blank'; a.download = name; a.click();
  192. }
  193. function isImageUrl(url) {
  194. if (!url) return false;
  195. var lower = url.split('?')[0].toLowerCase();
  196. return /\.(png|jpg|jpeg|gif|bmp|webp|svg)$/.test(lower);
  197. }
  198. var signDocs = Vue.computed(function() {
  199. return [
  200. { key: 'income', label: '个人可支配收入声明', url: patient.sign_income },
  201. { key: 'privacy', label: '个人信息处理同意书', url: patient.sign_privacy },
  202. { key: 'promise', label: '声明与承诺', url: patient.sign_promise }
  203. ];
  204. });
  205. var signImageList = Vue.computed(function() {
  206. return [patient.sign_income, patient.sign_privacy, patient.sign_promise].filter(function(u) { return u && isImageUrl(u); });
  207. });
  208. async function handleApprove() {
  209. try {
  210. await ElementPlus.ElMessageBox.confirm(
  211. '确定要通过该患者的审核吗?通过后患者将收到审核通过通知。',
  212. '确认审核通过',
  213. { confirmButtonText: '确认通过', cancelButtonText: '取消', type: 'success' }
  214. );
  215. var res = await fetch('/admin/patient/approve', {
  216. method: 'POST',
  217. headers: { 'Content-Type': 'application/json' },
  218. body: JSON.stringify({ id: patient.id })
  219. }).then(function(r) { return r.json(); });
  220. if (res.code === 0) {
  221. ElementPlus.ElMessage.success('审核已通过');
  222. loadDetail();
  223. } else {
  224. ElementPlus.ElMessage.error(res.msg || '操作失败');
  225. }
  226. } catch(e) {}
  227. }
  228. function showRejectDialog() {
  229. rejectReason.value = '';
  230. selectedReasons.value = [];
  231. rejectVisible.value = true;
  232. }
  233. function toggleReason(index) {
  234. var pos = selectedReasons.value.indexOf(index);
  235. if (pos > -1) { selectedReasons.value.splice(pos, 1); }
  236. else { selectedReasons.value.push(index); }
  237. rejectReason.value = selectedReasons.value.map(function(i) { return commonReasons[i]; }).join(';');
  238. }
  239. async function doReject() {
  240. if (!rejectReason.value.trim()) { ElementPlus.ElMessage.warning('请填写驳回原因'); return; }
  241. rejectSaving.value = true;
  242. try {
  243. var res = await fetch('/admin/patient/reject', {
  244. method: 'POST',
  245. headers: { 'Content-Type': 'application/json' },
  246. body: JSON.stringify({ id: patient.id, reason: rejectReason.value.trim() })
  247. }).then(function(r) { return r.json(); });
  248. if (res.code === 0) {
  249. ElementPlus.ElMessage.success('已驳回');
  250. rejectVisible.value = false;
  251. loadDetail();
  252. } else {
  253. ElementPlus.ElMessage.error(res.msg || '操作失败');
  254. }
  255. } finally {
  256. rejectSaving.value = false;
  257. }
  258. }
  259. onMounted(function() { loadDetail(); });
  260. return {
  261. loading, patient, audits, canAudit,
  262. rejectVisible, rejectSaving, rejectReason, selectedReasons, commonReasons,
  263. goBack, downloadSign, isImageUrl, signDocs, signImageList, handleApprove, showRejectDialog, toggleReason, doReject
  264. };
  265. }
  266. });
  267. app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
  268. app.mount('#detailApp');
  269. </script>
  270. {% endblock %}