Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 
 

815 řádky
36 KiB

  1. {% extends "./layout.html" %}
  2. {% block title %}患者管理{% endblock %}
  3. {% block content %}
  4. <div id="patientApp" v-cloak>
  5. <!-- 搜索栏 -->
  6. <el-card shadow="never" class="mb-4">
  7. <el-form :inline="true" @submit.prevent="loadList()" class="flex items-center flex-wrap gap-2">
  8. <el-form-item label="关键词" class="!mb-0">
  9. <el-input v-model="keyword" placeholder="姓名/手机号/编号" clearable style="width:180px;" />
  10. </el-form-item>
  11. <el-form-item label="日期范围" class="!mb-0">
  12. <el-date-picker v-model="dateRange" type="daterange" range-separator="至"
  13. start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
  14. style="width:260px;" />
  15. </el-form-item>
  16. <el-form-item label="瘤种" class="!mb-0">
  17. <el-select v-model="tagFilter" placeholder="全部瘤种" clearable filterable style="width:160px;">
  18. <el-option v-for="t in tagOptions" :key="t" :label="t" :value="t"></el-option>
  19. <el-option label="无瘤种" value="none"></el-option>
  20. </el-select>
  21. </el-form-item>
  22. <el-form-item label="地区" class="!mb-0">
  23. <el-cascader v-model="regionFilter" :options="regionTree"
  24. :props="{ value: 'code', label: 'name', children: 'children', checkStrictly: true }"
  25. placeholder="全部地区" clearable style="width:220px;" />
  26. </el-form-item>
  27. <el-form-item class="!mb-0">
  28. <el-button type="primary" @click="loadList()">搜索</el-button>
  29. <el-button @click="resetFilter">重置</el-button>
  30. </el-form-item>
  31. </el-form>
  32. <div style="display:flex;gap:8px;margin-top:12px;">
  33. <el-button v-if="perms.canAdd" type="success" :icon="Plus" @click="showAddDialog">新增患者</el-button>
  34. <el-button v-if="perms.canExport" :icon="Download" @click="handleExport" :loading="exporting">导出</el-button>
  35. <el-button v-if="perms.canExport" type="warning" :icon="Download" @click="showExportFilesDialog">异步下载附件</el-button>
  36. <el-button v-if="perms.canEdit" type="primary" plain @click="showSampleReceiverDialog">送检信息配置</el-button>
  37. <el-button v-if="perms.canDelete && selectedIds.length" type="danger" :icon="Delete" @click="handleBatchDelete">批量删除 (${ selectedIds.length })</el-button>
  38. </div>
  39. </el-card>
  40. <!-- 状态 Tab -->
  41. <el-card shadow="never">
  42. <el-tabs v-model="activeTab" @tab-change="onTabChange">
  43. <el-tab-pane name="all">
  44. <template #label>全部 <el-badge :value="counts.all" type="info" class="ml-1" /></template>
  45. </el-tab-pane>
  46. <el-tab-pane name="draft">
  47. <template #label>待提交 <el-badge :value="counts.draft" class="ml-1" /></template>
  48. </el-tab-pane>
  49. <el-tab-pane name="pending">
  50. <template #label>待审核 <el-badge :value="counts.pending" type="warning" class="ml-1" /></template>
  51. </el-tab-pane>
  52. <el-tab-pane name="rejected">
  53. <template #label>已驳回 <el-badge :value="counts.rejected" type="danger" class="ml-1" /></template>
  54. </el-tab-pane>
  55. <el-tab-pane name="approved">
  56. <template #label>审核通过 <el-badge :value="counts.approved" type="success" class="ml-1" /></template>
  57. </el-tab-pane>
  58. </el-tabs>
  59. <el-table :data="tableData" v-loading="loading" stripe border @selection-change="onSelectionChange">
  60. <el-table-column v-if="perms.canDelete" type="selection" width="45"></el-table-column>
  61. <el-table-column prop="patient_no" label="编号" min-width="200"></el-table-column>
  62. <el-table-column prop="name" label="姓名" min-width="80"></el-table-column>
  63. <el-table-column label="身份证号" min-width="180">
  64. <template #default="{ row }">${ row.id_card_mask }</template>
  65. </el-table-column>
  66. <el-table-column label="手机号" min-width="130">
  67. <template #default="{ row }">${ row.phone_mask }</template>
  68. </el-table-column>
  69. <el-table-column label="地区" min-width="160">
  70. <template #default="{ row }">${ row.region_name || '—' }</template>
  71. </el-table-column>
  72. <el-table-column label="瘤种" min-width="120">
  73. <template #default="{ row }">
  74. <el-tag v-if="row.tag" type="danger" size="small">${ row.tag }</el-tag>
  75. <span v-else style="color:#999;">—</span>
  76. </template>
  77. </el-table-column>
  78. <el-table-column label="提交时间" min-width="170">
  79. <template #default="{ row }">${ row.create_time }</template>
  80. </el-table-column>
  81. <el-table-column label="状态" min-width="100" align="center">
  82. <template #default="{ row }">
  83. <el-tag v-if="row.status === -1" type="info" size="small">待提交</el-tag>
  84. <el-tag v-else-if="row.status === 0" type="warning" size="small">待审核</el-tag>
  85. <el-tag v-else-if="row.status === 1" type="success" size="small">审核通过</el-tag>
  86. <el-tag v-else-if="row.status === 2" type="danger" size="small">已驳回</el-tag>
  87. </template>
  88. </el-table-column>
  89. <el-table-column label="送检状态" min-width="130" align="center">
  90. <template #default="{ row }">
  91. <el-tag :type="row.sample_info_status_type || 'info'" size="small">${ row.sample_info_status_text || '未提交' }</el-tag>
  92. </template>
  93. </el-table-column>
  94. <el-table-column label="操作" width="240" align="center" fixed="right">
  95. <template #default="{ row }">
  96. <el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
  97. <el-button v-if="perms.canEdit" type="info" link @click="showEditDialog(row)">编辑</el-button>
  98. <el-button v-if="(row.status === 0 || row.sample_info_status == 3) && perms.canAudit" type="warning" link @click="viewDetail(row)">去审核</el-button>
  99. <el-button v-if="perms.canDelete" type="danger" link @click="handleDelete(row)">删除</el-button>
  100. </template>
  101. </el-table-column>
  102. </el-table>
  103. <div class="flex justify-end mt-4">
  104. <el-pagination
  105. v-model:current-page="pagination.page"
  106. v-model:page-size="pagination.pageSize"
  107. :page-sizes="[10, 20, 50, 100]"
  108. :total="pagination.total"
  109. layout="total, sizes, prev, pager, next"
  110. @current-change="loadList"
  111. @size-change="onSizeChange"
  112. />
  113. </div>
  114. </el-card>
  115. <!-- 新增患者弹窗 -->
  116. <el-dialog v-model="addVisible" :title="editingId ? '编辑患者' : '新增患者'" width="720px" destroy-on-close draggable :close-on-click-modal="false">
  117. <el-form :model="addForm" label-width="120px">
  118. <el-row :gutter="16">
  119. <el-col :span="12">
  120. <el-form-item label="姓名" required>
  121. <el-input v-model="addForm.name" placeholder="请输入姓名" />
  122. </el-form-item>
  123. </el-col>
  124. <el-col :span="12">
  125. <el-form-item label="瘤种">
  126. <el-select v-model="addForm.tag" placeholder="请选择或输入瘤种" clearable filterable allow-create style="width:100%;">
  127. <el-option v-for="t in tagOptions" :key="t" :label="t" :value="t"></el-option>
  128. </el-select>
  129. </el-form-item>
  130. </el-col>
  131. <el-col :span="12">
  132. <el-form-item label="身份证号" required>
  133. <el-input v-model="addForm.id_card" placeholder="请输入身份证号" maxlength="18" @input="onIdCardInput" />
  134. </el-form-item>
  135. </el-col>
  136. <el-col :span="12">
  137. <el-form-item label="手机号" required>
  138. <el-input v-model="addForm.phone" placeholder="请输入手机号" maxlength="11" />
  139. </el-form-item>
  140. </el-col>
  141. <el-col :span="12">
  142. <el-form-item label="出生日期" required>
  143. <el-date-picker v-model="addForm.birth_date" type="date" placeholder="选择日期"
  144. value-format="YYYY-MM-DD" style="width:100%;" />
  145. </el-form-item>
  146. </el-col>
  147. <el-col :span="12">
  148. <el-form-item label="性别" required>
  149. <el-select v-model="addForm.gender" placeholder="请选择" style="width:100%;">
  150. <el-option label="男" value="男"></el-option>
  151. <el-option label="女" value="女"></el-option>
  152. </el-select>
  153. </el-form-item>
  154. </el-col>
  155. <el-col :span="24">
  156. <el-form-item label="所在地区" required>
  157. <el-cascader v-model="addForm.regionCodes" :options="regionTree"
  158. :props="{ value: 'code', label: 'name', children: 'children' }"
  159. placeholder="请选择省/市/区" clearable style="width:100%;" />
  160. </el-form-item>
  161. </el-col>
  162. <el-col :span="24">
  163. <el-form-item label="详细地址" required>
  164. <el-input v-model="addForm.address" placeholder="请输入详细地址(街道门牌号等)" />
  165. </el-form-item>
  166. </el-col>
  167. <el-col :span="12">
  168. <el-form-item label="医院名称">
  169. <el-input v-model="addForm.hospital" placeholder="请输入就诊医院名称" />
  170. </el-form-item>
  171. </el-col>
  172. <el-col :span="12">
  173. <el-form-item label="医院地区">
  174. <el-cascader v-model="addForm.hospitalRegionCodes" :options="regionTree"
  175. :props="{ value: 'code', label: 'name', children: 'children' }"
  176. placeholder="请选择医院省/市/区" clearable style="width:100%;" />
  177. </el-form-item>
  178. </el-col>
  179. <el-col :span="12">
  180. <el-form-item label="紧急联系人">
  181. <el-input v-model="addForm.emergency_contact" placeholder="联系人姓名" />
  182. </el-form-item>
  183. </el-col>
  184. <el-col :span="12">
  185. <el-form-item label="紧急联系电话">
  186. <el-input v-model="addForm.emergency_phone" placeholder="联系人电话" maxlength="11" />
  187. </el-form-item>
  188. </el-col>
  189. <el-col :span="12">
  190. <el-form-item label="年可支配收入(元)">
  191. <el-input v-model="addForm.income_amount" placeholder="请输入年可支配收入" />
  192. </el-form-item>
  193. </el-col>
  194. <el-col :span="12" v-if="isMinorComputed">
  195. <el-form-item label="监护人姓名">
  196. <el-input v-model="addForm.guardian_name" placeholder="请输入监护人姓名" />
  197. </el-form-item>
  198. </el-col>
  199. <el-col :span="12" v-if="isMinorComputed">
  200. <el-form-item label="监护人身份证">
  201. <el-input v-model="addForm.guardian_id_card" placeholder="请输入监护人身份证号" maxlength="18" />
  202. </el-form-item>
  203. </el-col>
  204. <el-col :span="12" v-if="isMinorComputed">
  205. <el-form-item label="与患者关系">
  206. <el-select v-model="addForm.guardian_relation" placeholder="请选择" style="width:100%;">
  207. <el-option label="父亲" value="父亲"></el-option>
  208. <el-option label="母亲" value="母亲"></el-option>
  209. <el-option label="其他" value="其他"></el-option>
  210. </el-select>
  211. </el-form-item>
  212. </el-col>
  213. <el-col :span="24">
  214. <el-form-item label="上传资料">
  215. <div class="flex flex-wrap gap-2">
  216. <div v-for="(doc, idx) in addForm.documents" :key="idx"
  217. style="position:relative;width:80px;height:80px;">
  218. <el-image :src="doc" fit="cover" style="width:80px;height:80px;border-radius:6px;border:1px solid #eee;"></el-image>
  219. <div @click="addForm.documents.splice(idx, 1)"
  220. style="position:absolute;top:-6px;right:-6px;cursor:pointer;background:rgba(0,0,0,0.6);color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:12px;z-index:10;line-height:1;">×</div>
  221. </div>
  222. <el-upload action="/admin/upload" :show-file-list="false" accept="image/*"
  223. :on-success="onDocUpload" :headers="uploadHeaders" style="width:80px;height:80px;">
  224. <div class="flex items-center justify-center border-2 border-dashed border-gray-300 rounded-md cursor-pointer text-2xl text-gray-400"
  225. style="width:80px;height:80px;">+</div>
  226. </el-upload>
  227. </div>
  228. </el-form-item>
  229. </el-col>
  230. <el-col :span="24">
  231. <el-form-item label="签字材料">
  232. <div class="flex flex-wrap gap-3">
  233. <el-upload action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
  234. :on-success="(res) => onSignUpload(res, 'sign_income')" :headers="uploadHeaders">
  235. <el-button :type="addForm.sign_income ? 'success' : 'default'" plain>
  236. ${ addForm.sign_income ? '✅' : '📄' } 个人可支配收入声明
  237. </el-button>
  238. </el-upload>
  239. <el-upload action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
  240. :on-success="(res) => onSignUpload(res, 'sign_privacy')" :headers="uploadHeaders">
  241. <el-button :type="addForm.sign_privacy ? 'success' : 'default'" plain>
  242. ${ addForm.sign_privacy ? '✅' : '📄' } 个人信息处理同意书
  243. </el-button>
  244. </el-upload>
  245. <el-upload action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
  246. :on-success="(res) => onSignUpload(res, 'sign_promise')" :headers="uploadHeaders">
  247. <el-button :type="addForm.sign_promise ? 'success' : 'default'" plain>
  248. ${ addForm.sign_promise ? '✅' : '📄' } 声明与承诺
  249. </el-button>
  250. </el-upload>
  251. <el-upload v-if="isMinorComputed" action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
  252. :on-success="(res) => onSignUpload(res, 'sign_privacy_jhr')" :headers="uploadHeaders">
  253. <el-button :type="addForm.sign_privacy_jhr ? 'success' : 'default'" plain>
  254. ${ addForm.sign_privacy_jhr ? '✅' : '📄' } 监护人个人信息处理同意书
  255. </el-button>
  256. </el-upload>
  257. </div>
  258. </el-form-item>
  259. </el-col>
  260. </el-row>
  261. </el-form>
  262. <template #footer>
  263. <el-button @click="addVisible = false">取消</el-button>
  264. <el-button type="primary" @click="submitAdd" :loading="addSaving">${ editingId ? '保存修改' : '确认新增' }</el-button>
  265. </template>
  266. </el-dialog>
  267. <!-- 送检信息配置弹窗 -->
  268. <el-dialog v-model="sampleReceiverVisible" title="送检信息配置" width="560px" destroy-on-close :close-on-click-modal="false">
  269. <el-form :model="sampleReceiverForm" label-width="90px">
  270. <el-form-item label="收件地址" required>
  271. <el-input v-model="sampleReceiverForm.address" type="textarea" :rows="3" placeholder="请输入收件地址" />
  272. </el-form-item>
  273. <el-form-item label="收件人" required>
  274. <el-input v-model="sampleReceiverForm.receiver" placeholder="请输入收件人" />
  275. </el-form-item>
  276. <el-form-item label="电话" required>
  277. <el-input v-model="sampleReceiverForm.phone" placeholder="请输入联系电话" />
  278. </el-form-item>
  279. <el-form-item label="平台电话">
  280. <el-input v-model="sampleReceiverForm.contact_phone" placeholder="选填,用于小程序修改权限联系平台" />
  281. </el-form-item>
  282. </el-form>
  283. <template #footer>
  284. <el-button @click="sampleReceiverVisible = false">取消</el-button>
  285. <el-button type="primary" @click="saveSampleReceiverConfig" :loading="sampleReceiverSaving">保存</el-button>
  286. </template>
  287. </el-dialog>
  288. <!-- 导出附件弹窗 -->
  289. <el-dialog v-model="exportFilesVisible" title="异步下载附件" width="480px" destroy-on-close :close-on-click-modal="false">
  290. <p style="color:#606266;margin-bottom:16px;">将按当前筛选条件导出患者附件,打包为 ZIP 文件。请选择需要导出的附件类型:</p>
  291. <el-checkbox-group v-model="exportFileTypes">
  292. <div style="display:flex;flex-direction:column;gap:12px;">
  293. <el-checkbox label="id_photos">实名认证照片(身份证人像面、国徽面、免冠照片)</el-checkbox>
  294. <el-checkbox label="documents">上传资料(检查报告单、出院诊断证明书)</el-checkbox>
  295. <el-checkbox label="signs">签字材料(收入声明、信息同意书、声明与承诺等)</el-checkbox>
  296. </div>
  297. </el-checkbox-group>
  298. <template #footer>
  299. <div style="display:flex;justify-content:space-between;align-items:center;width:100%;">
  300. <el-button type="primary" link @click="goExportTask">查看已有任务</el-button>
  301. <div>
  302. <el-button @click="exportFilesVisible = false">取消</el-button>
  303. <el-button type="primary" @click="submitExportFiles" :loading="exportFilesLoading">创建任务</el-button>
  304. </div>
  305. </div>
  306. </template>
  307. </el-dialog>
  308. <!-- 导出任务创建成功弹窗 -->
  309. <el-dialog v-model="exportSuccessVisible" title="任务创建成功" width="420px" :close-on-click-modal="false">
  310. <div style="text-align:center;padding:16px 0;">
  311. <div style="font-size:48px;margin-bottom:12px;">✅</div>
  312. <p style="font-size:15px;color:#303133;">导出任务已创建,系统正在后台打包中。</p>
  313. <p style="font-size:13px;color:#909399;margin-top:8px;">您可以前往「下载管理」页面查看进度和下载文件。</p>
  314. </div>
  315. <template #footer>
  316. <el-button @click="exportSuccessVisible = false">稍后再看</el-button>
  317. <el-button type="primary" @click="goExportTask">前往下载管理</el-button>
  318. </template>
  319. </el-dialog>
  320. </div>
  321. {% endblock %}
  322. {% block js %}
  323. <script>
  324. const { createApp, ref, reactive, onMounted, computed } = Vue;
  325. const { Plus, Download, Delete } = ElementPlusIconsVue;
  326. const perms = {
  327. canAdd: {{ canAdd | dump | safe }},
  328. canEdit: {{ canEdit | dump | safe }},
  329. canExport: {{ canExport | dump | safe }},
  330. canAudit: {{ canAudit | dump | safe }},
  331. canView: {{ canView | dump | safe }},
  332. canDelete: {{ canDelete | dump | safe }}
  333. };
  334. const app = createApp({
  335. delimiters: ['${', '}'],
  336. setup() {
  337. const keyword = ref('');
  338. const dateRange = ref(null);
  339. const tagFilter = ref('');
  340. const activeTab = ref('all');
  341. const loading = ref(false);
  342. const tableData = ref([]);
  343. const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
  344. const counts = reactive({ all: 0, draft: 0, pending: 0, approved: 0, rejected: 0 });
  345. const uploadHeaders = {};
  346. // 新增弹窗
  347. const addVisible = ref(false);
  348. const addSaving = ref(false);
  349. const editingId = ref(null);
  350. const addForm = reactive({
  351. name: '', phone: '', id_card: '', gender: '', birth_date: '',
  352. regionCodes: [], address: '', hospital: '', hospitalRegionCodes: [],
  353. emergency_contact: '', emergency_phone: '',
  354. tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '',
  355. sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: ''
  356. });
  357. // 省市区树形数据
  358. const regionTree = ref([]);
  359. // 瘤种选项(从接口加载)
  360. const tagOptions = ref([]);
  361. // 判断是否未成年(根据身份证号计算年龄)
  362. const isMinorComputed = computed(function() {
  363. var idCard = addForm.id_card;
  364. if (!idCard || idCard.length !== 18) return false;
  365. var birthStr = idCard.substring(6, 10) + '-' + idCard.substring(10, 12) + '-' + idCard.substring(12, 14);
  366. var birth = new Date(birthStr);
  367. if (isNaN(birth.getTime())) return false;
  368. var now = new Date();
  369. var age = now.getFullYear() - birth.getFullYear();
  370. var m = now.getMonth() - birth.getMonth();
  371. if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--;
  372. return age < 18;
  373. });
  374. const regionFilter = ref([]);
  375. const exporting = ref(false);
  376. const sampleReceiverVisible = ref(false);
  377. const sampleReceiverSaving = ref(false);
  378. const sampleReceiverForm = reactive({ address: '', receiver: '', phone: '', contact_phone: '' });
  379. const tabStatusMap = { all: '', draft: '-1', pending: '0', rejected: '2', approved: '1' };
  380. async function loadTagOptions() {
  381. var res = await fetch('/common/tagOptions').then(function(r) { return r.json(); });
  382. if (res.code === 0) tagOptions.value = res.data || [];
  383. }
  384. async function loadRegionTree() {
  385. var res = await fetch('/common/regions').then(function(r) { return r.json(); });
  386. if (res.code === 0) regionTree.value = res.data || [];
  387. }
  388. async function loadList(page) {
  389. if (typeof page === 'number') pagination.page = page;
  390. loading.value = true;
  391. try {
  392. var params = new URLSearchParams({
  393. page: pagination.page,
  394. pageSize: pagination.pageSize,
  395. keyword: keyword.value,
  396. tag: tagFilter.value,
  397. status: tabStatusMap[activeTab.value] || ''
  398. });
  399. if (dateRange.value && dateRange.value.length === 2) {
  400. params.set('startDate', dateRange.value[0]);
  401. params.set('endDate', dateRange.value[1]);
  402. }
  403. if (regionFilter.value && regionFilter.value.length >= 1) params.set('province_code', regionFilter.value[0]);
  404. if (regionFilter.value && regionFilter.value.length >= 2) params.set('city_code', regionFilter.value[1]);
  405. if (regionFilter.value && regionFilter.value.length >= 3) params.set('district_code', regionFilter.value[2]);
  406. var res = await fetch('/admin/patient/list?' + params).then(function(r) { return r.json(); });
  407. if (res.code === 0) {
  408. tableData.value = res.data.data || [];
  409. pagination.total = res.data.count || 0;
  410. if (res.data.counts) Object.assign(counts, res.data.counts);
  411. } else {
  412. ElementPlus.ElMessage.error(res.msg || '加载失败');
  413. }
  414. } finally {
  415. loading.value = false;
  416. }
  417. }
  418. function resetFilter() {
  419. keyword.value = '';
  420. dateRange.value = null;
  421. tagFilter.value = '';
  422. regionFilter.value = [];
  423. activeTab.value = 'all';
  424. pagination.page = 1;
  425. loadList();
  426. }
  427. function onTabChange() {
  428. pagination.page = 1;
  429. loadList();
  430. }
  431. function onSizeChange() {
  432. pagination.page = 1;
  433. loadList();
  434. }
  435. // 多选
  436. const selectedIds = ref([]);
  437. function onSelectionChange(rows) {
  438. selectedIds.value = rows.map(function(r) { return r.id; });
  439. }
  440. // 单条删除
  441. async function handleDelete(row) {
  442. try {
  443. await ElementPlus.ElMessageBox.confirm(
  444. '确定要删除患者「' + row.name + '」吗?删除后不可恢复。',
  445. '确认删除',
  446. { confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
  447. );
  448. var res = await fetch('/admin/patient/delete', {
  449. method: 'POST',
  450. headers: { 'Content-Type': 'application/json' },
  451. body: JSON.stringify({ ids: [row.id] })
  452. }).then(function(r) { return r.json(); });
  453. if (res.code === 0) {
  454. ElementPlus.ElMessage.success('删除成功');
  455. loadList();
  456. } else {
  457. ElementPlus.ElMessage.error(res.msg || '删除失败');
  458. }
  459. } catch(e) {}
  460. }
  461. // 批量删除
  462. async function handleBatchDelete() {
  463. if (!selectedIds.value.length) return;
  464. try {
  465. await ElementPlus.ElMessageBox.confirm(
  466. '确定要删除选中的 ' + selectedIds.value.length + ' 条患者记录吗?删除后不可恢复。',
  467. '批量删除确认',
  468. { confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
  469. );
  470. var res = await fetch('/admin/patient/delete', {
  471. method: 'POST',
  472. headers: { 'Content-Type': 'application/json' },
  473. body: JSON.stringify({ ids: selectedIds.value })
  474. }).then(function(r) { return r.json(); });
  475. if (res.code === 0) {
  476. ElementPlus.ElMessage.success('删除成功');
  477. selectedIds.value = [];
  478. loadList();
  479. } else {
  480. ElementPlus.ElMessage.error(res.msg || '删除失败');
  481. }
  482. } catch(e) {}
  483. }
  484. function viewDetail(row) {
  485. if (!perms.canView) {
  486. ElementPlus.ElMessageBox.alert('暂无查看详情权限,请先联系管理员授权', '温馨提示', {
  487. confirmButtonText: '知道了',
  488. type: 'warning'
  489. });
  490. return;
  491. }
  492. window.location.href = '/admin/patient/detail.html?id=' + row.id;
  493. }
  494. function showAddDialog() {
  495. editingId.value = null;
  496. Object.assign(addForm, {
  497. name: '', phone: '', id_card: '', gender: '', birth_date: '',
  498. regionCodes: [], address: '', hospital: '', hospitalRegionCodes: [],
  499. emergency_contact: '', emergency_phone: '',
  500. tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '',
  501. sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: ''
  502. });
  503. addVisible.value = true;
  504. }
  505. function onIdCardInput() {
  506. var v = addForm.id_card;
  507. if (v.length === 18) {
  508. addForm.birth_date = v.substring(6, 10) + '-' + v.substring(10, 12) + '-' + v.substring(12, 14);
  509. addForm.gender = parseInt(v.charAt(16)) % 2 === 0 ? '女' : '男';
  510. }
  511. }
  512. function onDocUpload(res) {
  513. if (res.code === 0 && res.data && res.data.url) {
  514. addForm.documents.push(res.data.url);
  515. } else {
  516. ElementPlus.ElMessage.error(res.msg || '上传失败');
  517. }
  518. }
  519. function onSignUpload(res, field) {
  520. if (res.code === 0 && res.data && res.data.url) {
  521. addForm[field] = res.data.url;
  522. } else {
  523. ElementPlus.ElMessage.error(res.msg || '上传失败');
  524. }
  525. }
  526. async function showEditDialog(row) {
  527. editingId.value = row.id;
  528. // 先重置表单
  529. Object.assign(addForm, {
  530. name: '', phone: '', id_card: '', gender: '', birth_date: '',
  531. regionCodes: [], address: '', hospital: '', hospitalRegionCodes: [],
  532. emergency_contact: '', emergency_phone: '',
  533. tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '',
  534. sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: ''
  535. });
  536. try {
  537. var res = await fetch('/admin/patient/info?id=' + row.id).then(function(r) { return r.json(); });
  538. if (res.code === 0) {
  539. var p = res.data.patient;
  540. Object.assign(addForm, {
  541. name: p.name || '',
  542. phone: p.phone || '',
  543. id_card: p.id_card || '',
  544. gender: p.gender || '',
  545. birth_date: p.birth_date || '',
  546. address: p.address || '',
  547. hospital: p.hospital || '',
  548. hospitalRegionCodes: [p.hospital_province_code, p.hospital_city_code, p.hospital_district_code].filter(Boolean),
  549. emergency_contact: p.emergency_contact || '',
  550. emergency_phone: p.emergency_phone || '',
  551. tag: p.tag || '',
  552. documents: (typeof p.documents === 'string' ? JSON.parse(p.documents || '[]') : p.documents) || [],
  553. sign_income: p.sign_income || '',
  554. sign_privacy: p.sign_privacy || '',
  555. sign_promise: p.sign_promise || '',
  556. sign_privacy_jhr: p.sign_privacy_jhr || '',
  557. income_amount: p.income_amount || '',
  558. guardian_name: p.guardian_name || '',
  559. guardian_id_card: p.guardian_id_card || '',
  560. guardian_relation: p.guardian_relation || '',
  561. regionCodes: [p.province_code, p.city_code, p.district_code].filter(Boolean)
  562. });
  563. addVisible.value = true;
  564. } else {
  565. ElementPlus.ElMessage.error(res.msg || '获取患者信息失败');
  566. }
  567. } catch(e) {
  568. ElementPlus.ElMessage.error('获取患者信息失败');
  569. }
  570. }
  571. async function submitAdd() {
  572. if (!addForm.name.trim()) return ElementPlus.ElMessage.warning('请输入姓名');
  573. if (!addForm.phone || !/^1\d{10}$/.test(addForm.phone)) return ElementPlus.ElMessage.warning('请输入正确的手机号');
  574. if (!addForm.id_card || !/^\d{17}[\dXx]$/.test(addForm.id_card)) return ElementPlus.ElMessage.warning('请输入正确的身份证号');
  575. if (!addForm.birth_date) return ElementPlus.ElMessage.warning('请选择出生日期');
  576. if (!addForm.gender) return ElementPlus.ElMessage.warning('请选择性别');
  577. if (!addForm.regionCodes || addForm.regionCodes.length !== 3) return ElementPlus.ElMessage.warning('请选择省市区');
  578. if (!addForm.address.trim()) return ElementPlus.ElMessage.warning('请输入详细地址');
  579. addSaving.value = true;
  580. try {
  581. var url = editingId.value ? '/admin/patient/edit' : '/admin/patient/add';
  582. var hospitalRegionCodes = addForm.hospitalRegionCodes || [];
  583. var body = {
  584. name: addForm.name.trim(),
  585. phone: addForm.phone,
  586. id_card: addForm.id_card,
  587. gender: addForm.gender,
  588. birth_date: addForm.birth_date,
  589. province_code: addForm.regionCodes[0],
  590. city_code: addForm.regionCodes[1],
  591. district_code: addForm.regionCodes[2],
  592. address: addForm.address.trim(),
  593. hospital: addForm.hospital || '',
  594. hospital_province_code: hospitalRegionCodes[0] || '',
  595. hospital_city_code: hospitalRegionCodes[1] || '',
  596. hospital_district_code: hospitalRegionCodes[2] || '',
  597. emergency_contact: addForm.emergency_contact || '',
  598. emergency_phone: addForm.emergency_phone || '',
  599. tag: addForm.tag || '',
  600. documents: addForm.documents,
  601. sign_income: addForm.sign_income,
  602. sign_privacy: addForm.sign_privacy,
  603. sign_promise: addForm.sign_promise,
  604. sign_privacy_jhr: addForm.sign_privacy_jhr || '',
  605. income_amount: addForm.income_amount || '',
  606. guardian_name: addForm.guardian_name || '',
  607. guardian_id_card: addForm.guardian_id_card || '',
  608. guardian_relation: addForm.guardian_relation || ''
  609. };
  610. if (editingId.value) body.id = editingId.value;
  611. var res = await fetch(url, {
  612. method: 'POST',
  613. headers: { 'Content-Type': 'application/json' },
  614. body: JSON.stringify(body)
  615. }).then(function(r) { return r.json(); });
  616. if (res.code === 0) {
  617. ElementPlus.ElMessage.success(editingId.value ? '保存成功' : '新增成功');
  618. addVisible.value = false;
  619. loadList();
  620. } else {
  621. ElementPlus.ElMessage.error(res.msg || '操作失败');
  622. }
  623. } finally {
  624. addSaving.value = false;
  625. }
  626. }
  627. function handleExport() {
  628. var params = new URLSearchParams({
  629. keyword: keyword.value,
  630. tag: tagFilter.value,
  631. status: tabStatusMap[activeTab.value] || ''
  632. });
  633. if (dateRange.value && dateRange.value.length === 2) {
  634. params.set('startDate', dateRange.value[0]);
  635. params.set('endDate', dateRange.value[1]);
  636. }
  637. if (regionFilter.value && regionFilter.value.length >= 1) params.set('province_code', regionFilter.value[0]);
  638. if (regionFilter.value && regionFilter.value.length >= 2) params.set('city_code', regionFilter.value[1]);
  639. if (regionFilter.value && regionFilter.value.length >= 3) params.set('district_code', regionFilter.value[2]);
  640. window.open('/admin/patient/export?' + params.toString(), '_blank');
  641. }
  642. async function showSampleReceiverDialog() {
  643. try {
  644. var res = await fetch('/admin/patient/sampleReceiverConfig').then(function(r) { return r.json(); });
  645. if (res.code === 0) {
  646. Object.assign(sampleReceiverForm, {
  647. address: res.data.address || '',
  648. receiver: res.data.receiver || '',
  649. phone: res.data.phone || '',
  650. contact_phone: res.data.contact_phone || ''
  651. });
  652. sampleReceiverVisible.value = true;
  653. } else {
  654. ElementPlus.ElMessage.error(res.msg || '获取配置失败');
  655. }
  656. } catch(e) {
  657. ElementPlus.ElMessage.error('获取配置失败');
  658. }
  659. }
  660. async function saveSampleReceiverConfig() {
  661. if (!sampleReceiverForm.address.trim()) return ElementPlus.ElMessage.warning('请填写收件地址');
  662. if (!sampleReceiverForm.receiver.trim()) return ElementPlus.ElMessage.warning('请填写收件人');
  663. if (!sampleReceiverForm.phone.trim()) return ElementPlus.ElMessage.warning('请填写电话');
  664. sampleReceiverSaving.value = true;
  665. try {
  666. var res = await fetch('/admin/patient/saveSampleReceiverConfig', {
  667. method: 'POST',
  668. headers: { 'Content-Type': 'application/json' },
  669. body: JSON.stringify({
  670. address: sampleReceiverForm.address.trim(),
  671. receiver: sampleReceiverForm.receiver.trim(),
  672. phone: sampleReceiverForm.phone.trim(),
  673. contact_phone: sampleReceiverForm.contact_phone.trim()
  674. })
  675. }).then(function(r) { return r.json(); });
  676. if (res.code === 0) {
  677. ElementPlus.ElMessage.success('保存成功');
  678. sampleReceiverVisible.value = false;
  679. } else {
  680. ElementPlus.ElMessage.error(res.msg || '保存失败');
  681. }
  682. } finally {
  683. sampleReceiverSaving.value = false;
  684. }
  685. }
  686. // 导出附件
  687. const exportFilesVisible = ref(false);
  688. const exportFilesLoading = ref(false);
  689. const exportFileTypes = ref(['id_photos', 'documents', 'signs']);
  690. const exportSuccessVisible = ref(false);
  691. function showExportFilesDialog() {
  692. exportFileTypes.value = ['id_photos', 'documents', 'signs'];
  693. exportFilesVisible.value = true;
  694. }
  695. async function submitExportFiles() {
  696. if (!exportFileTypes.value.length) {
  697. return ElementPlus.ElMessage.warning('请至少选择一种附件类型');
  698. }
  699. exportFilesLoading.value = true;
  700. try {
  701. var filterObj = {
  702. keyword: keyword.value,
  703. tag: tagFilter.value,
  704. status: tabStatusMap[activeTab.value] || ''
  705. };
  706. if (dateRange.value && dateRange.value.length === 2) {
  707. filterObj.startDate = dateRange.value[0];
  708. filterObj.endDate = dateRange.value[1];
  709. }
  710. if (regionFilter.value && regionFilter.value.length >= 1) filterObj.province_code = regionFilter.value[0];
  711. if (regionFilter.value && regionFilter.value.length >= 2) filterObj.city_code = regionFilter.value[1];
  712. if (regionFilter.value && regionFilter.value.length >= 3) filterObj.district_code = regionFilter.value[2];
  713. var res = await fetch('/admin/patient/exportFiles', {
  714. method: 'POST',
  715. headers: { 'Content-Type': 'application/json' },
  716. body: JSON.stringify({
  717. file_types: exportFileTypes.value,
  718. filter_params: filterObj
  719. })
  720. }).then(function(r) { return r.json(); });
  721. if (res.code === 0) {
  722. exportFilesVisible.value = false;
  723. exportSuccessVisible.value = true;
  724. } else if (res.code === 1001) {
  725. exportFilesVisible.value = false;
  726. ElementPlus.ElMessageBox.confirm(
  727. res.msg || '有任务正在打包中,请稍后再试',
  728. '提示',
  729. { confirmButtonText: '去查看', cancelButtonText: '关闭', type: 'warning' }
  730. ).then(function() {
  731. goExportTask();
  732. }).catch(function() {});
  733. } else {
  734. ElementPlus.ElMessage.error(res.msg || '创建任务失败');
  735. }
  736. } finally {
  737. exportFilesLoading.value = false;
  738. }
  739. }
  740. function goExportTask() {
  741. window.location.href = '/admin/export_task.html';
  742. }
  743. onMounted(function() {
  744. loadList();
  745. loadRegionTree();
  746. loadTagOptions();
  747. });
  748. return {
  749. keyword, dateRange, tagFilter, regionFilter, activeTab, loading, tableData, pagination, counts,
  750. uploadHeaders, addVisible, addSaving, addForm, exporting, editingId, perms, selectedIds,
  751. regionTree, tagOptions, isMinorComputed, Plus, Download, Delete,
  752. sampleReceiverVisible, sampleReceiverSaving, sampleReceiverForm,
  753. exportFilesVisible, exportFilesLoading, exportFileTypes, exportSuccessVisible,
  754. loadList, resetFilter, onTabChange, onSizeChange, onSelectionChange, viewDetail, showAddDialog, showEditDialog, handleExport,
  755. onIdCardInput, onDocUpload, onSignUpload, submitAdd, handleDelete, handleBatchDelete,
  756. showSampleReceiverDialog, saveSampleReceiverConfig, showExportFilesDialog, submitExportFiles, goExportTask
  757. };
  758. }
  759. });
  760. app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
  761. app.mount('#patientApp');
  762. </script>
  763. {% endblock %}