選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 
 

613 行
27 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.canDelete && selectedIds.length" type="danger" :icon="Delete" @click="handleBatchDelete">批量删除 (${ selectedIds.length })</el-button>
  36. </div>
  37. </el-card>
  38. <!-- 状态 Tab -->
  39. <el-card shadow="never">
  40. <el-tabs v-model="activeTab" @tab-change="onTabChange">
  41. <el-tab-pane name="all">
  42. <template #label>全部 <el-badge :value="counts.all" type="info" class="ml-1" /></template>
  43. </el-tab-pane>
  44. <el-tab-pane name="draft">
  45. <template #label>待提交 <el-badge :value="counts.draft" class="ml-1" /></template>
  46. </el-tab-pane>
  47. <el-tab-pane name="pending">
  48. <template #label>待审核 <el-badge :value="counts.pending" type="warning" class="ml-1" /></template>
  49. </el-tab-pane>
  50. <el-tab-pane name="rejected">
  51. <template #label>已驳回 <el-badge :value="counts.rejected" type="danger" class="ml-1" /></template>
  52. </el-tab-pane>
  53. <el-tab-pane name="approved">
  54. <template #label>审核通过 <el-badge :value="counts.approved" type="success" class="ml-1" /></template>
  55. </el-tab-pane>
  56. </el-tabs>
  57. <el-table :data="tableData" v-loading="loading" stripe border @selection-change="onSelectionChange">
  58. <el-table-column v-if="perms.canDelete" type="selection" width="45"></el-table-column>
  59. <el-table-column prop="patient_no" label="编号" min-width="200"></el-table-column>
  60. <el-table-column prop="name" label="姓名" min-width="80"></el-table-column>
  61. <el-table-column label="身份证号" min-width="180">
  62. <template #default="{ row }">${ row.id_card_mask }</template>
  63. </el-table-column>
  64. <el-table-column label="手机号" min-width="130">
  65. <template #default="{ row }">${ row.phone_mask }</template>
  66. </el-table-column>
  67. <el-table-column label="地区" min-width="160">
  68. <template #default="{ row }">${ row.region_name || '—' }</template>
  69. </el-table-column>
  70. <el-table-column label="瘤种" min-width="120">
  71. <template #default="{ row }">
  72. <el-tag v-if="row.tag" type="danger" size="small">${ row.tag }</el-tag>
  73. <span v-else style="color:#999;">—</span>
  74. </template>
  75. </el-table-column>
  76. <el-table-column label="提交时间" min-width="170">
  77. <template #default="{ row }">${ row.create_time }</template>
  78. </el-table-column>
  79. <el-table-column label="状态" min-width="100" align="center">
  80. <template #default="{ row }">
  81. <el-tag v-if="row.status === -1" type="info" size="small">待提交</el-tag>
  82. <el-tag v-else-if="row.status === 0" type="warning" size="small">待审核</el-tag>
  83. <el-tag v-else-if="row.status === 1" type="success" size="small">审核通过</el-tag>
  84. <el-tag v-else-if="row.status === 2" type="danger" size="small">已驳回</el-tag>
  85. </template>
  86. </el-table-column>
  87. <el-table-column label="操作" width="240" align="center" fixed="right">
  88. <template #default="{ row }">
  89. <el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
  90. <el-button v-if="perms.canEdit" type="info" link @click="showEditDialog(row)">编辑</el-button>
  91. <el-button v-if="row.status === 0 && perms.canAudit" type="warning" link @click="viewDetail(row)">去审核</el-button>
  92. <el-button v-if="perms.canDelete" type="danger" link @click="handleDelete(row)">删除</el-button>
  93. </template>
  94. </el-table-column>
  95. </el-table>
  96. <div class="flex justify-end mt-4">
  97. <el-pagination
  98. v-model:current-page="pagination.page"
  99. v-model:page-size="pagination.pageSize"
  100. :page-sizes="[10, 20, 50, 100]"
  101. :total="pagination.total"
  102. layout="total, sizes, prev, pager, next"
  103. @current-change="loadList"
  104. @size-change="onSizeChange"
  105. />
  106. </div>
  107. </el-card>
  108. <!-- 新增患者弹窗 -->
  109. <el-dialog v-model="addVisible" :title="editingId ? '编辑患者' : '新增患者'" width="720px" destroy-on-close draggable :close-on-click-modal="false">
  110. <el-form :model="addForm" label-width="120px">
  111. <el-row :gutter="16">
  112. <el-col :span="12">
  113. <el-form-item label="姓名" required>
  114. <el-input v-model="addForm.name" placeholder="请输入姓名" />
  115. </el-form-item>
  116. </el-col>
  117. <el-col :span="12">
  118. <el-form-item label="瘤种">
  119. <el-select v-model="addForm.tag" placeholder="请选择或输入瘤种" clearable filterable allow-create style="width:100%;">
  120. <el-option v-for="t in tagOptions" :key="t" :label="t" :value="t"></el-option>
  121. </el-select>
  122. </el-form-item>
  123. </el-col>
  124. <el-col :span="12">
  125. <el-form-item label="身份证号" required>
  126. <el-input v-model="addForm.id_card" placeholder="请输入身份证号" maxlength="18" @input="onIdCardInput" />
  127. </el-form-item>
  128. </el-col>
  129. <el-col :span="12">
  130. <el-form-item label="手机号" required>
  131. <el-input v-model="addForm.phone" placeholder="请输入手机号" maxlength="11" />
  132. </el-form-item>
  133. </el-col>
  134. <el-col :span="12">
  135. <el-form-item label="出生日期" required>
  136. <el-date-picker v-model="addForm.birth_date" type="date" placeholder="选择日期"
  137. value-format="YYYY-MM-DD" style="width:100%;" />
  138. </el-form-item>
  139. </el-col>
  140. <el-col :span="12">
  141. <el-form-item label="性别" required>
  142. <el-select v-model="addForm.gender" placeholder="请选择" style="width:100%;">
  143. <el-option label="男" value="男"></el-option>
  144. <el-option label="女" value="女"></el-option>
  145. </el-select>
  146. </el-form-item>
  147. </el-col>
  148. <el-col :span="24">
  149. <el-form-item label="所在地区" required>
  150. <el-cascader v-model="addForm.regionCodes" :options="regionTree"
  151. :props="{ value: 'code', label: 'name', children: 'children' }"
  152. placeholder="请选择省/市/区" clearable style="width:100%;" />
  153. </el-form-item>
  154. </el-col>
  155. <el-col :span="24">
  156. <el-form-item label="详细地址" required>
  157. <el-input v-model="addForm.address" placeholder="请输入详细地址(街道门牌号等)" />
  158. </el-form-item>
  159. </el-col>
  160. <el-col :span="12">
  161. <el-form-item label="紧急联系人">
  162. <el-input v-model="addForm.emergency_contact" placeholder="联系人姓名" />
  163. </el-form-item>
  164. </el-col>
  165. <el-col :span="12">
  166. <el-form-item label="紧急联系电话">
  167. <el-input v-model="addForm.emergency_phone" placeholder="联系人电话" maxlength="11" />
  168. </el-form-item>
  169. </el-col>
  170. <el-col :span="12">
  171. <el-form-item label="年可支配收入(元)">
  172. <el-input v-model="addForm.income_amount" placeholder="请输入年可支配收入" />
  173. </el-form-item>
  174. </el-col>
  175. <el-col :span="12" v-if="isMinorComputed">
  176. <el-form-item label="监护人姓名">
  177. <el-input v-model="addForm.guardian_name" placeholder="请输入监护人姓名" />
  178. </el-form-item>
  179. </el-col>
  180. <el-col :span="12" v-if="isMinorComputed">
  181. <el-form-item label="监护人身份证">
  182. <el-input v-model="addForm.guardian_id_card" placeholder="请输入监护人身份证号" maxlength="18" />
  183. </el-form-item>
  184. </el-col>
  185. <el-col :span="12" v-if="isMinorComputed">
  186. <el-form-item label="与患者关系">
  187. <el-select v-model="addForm.guardian_relation" placeholder="请选择" style="width:100%;">
  188. <el-option label="父亲" value="父亲"></el-option>
  189. <el-option label="母亲" value="母亲"></el-option>
  190. <el-option label="其他" value="其他"></el-option>
  191. </el-select>
  192. </el-form-item>
  193. </el-col>
  194. <el-col :span="24">
  195. <el-form-item label="上传资料">
  196. <div class="flex flex-wrap gap-2">
  197. <div v-for="(doc, idx) in addForm.documents" :key="idx"
  198. style="position:relative;width:80px;height:80px;">
  199. <el-image :src="doc" fit="cover" style="width:80px;height:80px;border-radius:6px;border:1px solid #eee;"></el-image>
  200. <div @click="addForm.documents.splice(idx, 1)"
  201. 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>
  202. </div>
  203. <el-upload action="/admin/upload" :show-file-list="false" accept="image/*"
  204. :on-success="onDocUpload" :headers="uploadHeaders" style="width:80px;height:80px;">
  205. <div class="flex items-center justify-center border-2 border-dashed border-gray-300 rounded-md cursor-pointer text-2xl text-gray-400"
  206. style="width:80px;height:80px;">+</div>
  207. </el-upload>
  208. </div>
  209. </el-form-item>
  210. </el-col>
  211. <el-col :span="24">
  212. <el-form-item label="签字材料">
  213. <div class="flex flex-wrap gap-3">
  214. <el-upload action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
  215. :on-success="(res) => onSignUpload(res, 'sign_income')" :headers="uploadHeaders">
  216. <el-button :type="addForm.sign_income ? 'success' : 'default'" plain>
  217. ${ addForm.sign_income ? '✅' : '📄' } 个人可支配收入声明
  218. </el-button>
  219. </el-upload>
  220. <el-upload action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
  221. :on-success="(res) => onSignUpload(res, 'sign_privacy')" :headers="uploadHeaders">
  222. <el-button :type="addForm.sign_privacy ? 'success' : 'default'" plain>
  223. ${ addForm.sign_privacy ? '✅' : '📄' } 个人信息处理同意书
  224. </el-button>
  225. </el-upload>
  226. <el-upload action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
  227. :on-success="(res) => onSignUpload(res, 'sign_promise')" :headers="uploadHeaders">
  228. <el-button :type="addForm.sign_promise ? 'success' : 'default'" plain>
  229. ${ addForm.sign_promise ? '✅' : '📄' } 声明与承诺
  230. </el-button>
  231. </el-upload>
  232. <el-upload v-if="isMinorComputed" action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
  233. :on-success="(res) => onSignUpload(res, 'sign_privacy_jhr')" :headers="uploadHeaders">
  234. <el-button :type="addForm.sign_privacy_jhr ? 'success' : 'default'" plain>
  235. ${ addForm.sign_privacy_jhr ? '✅' : '📄' } 监护人个人信息处理同意书
  236. </el-button>
  237. </el-upload>
  238. </div>
  239. </el-form-item>
  240. </el-col>
  241. </el-row>
  242. </el-form>
  243. <template #footer>
  244. <el-button @click="addVisible = false">取消</el-button>
  245. <el-button type="primary" @click="submitAdd" :loading="addSaving">${ editingId ? '保存修改' : '确认新增' }</el-button>
  246. </template>
  247. </el-dialog>
  248. </div>
  249. {% endblock %}
  250. {% block js %}
  251. <script>
  252. const { createApp, ref, reactive, onMounted, computed } = Vue;
  253. const { Plus, Download, Delete } = ElementPlusIconsVue;
  254. const perms = {
  255. canAdd: {{ canAdd | dump | safe }},
  256. canEdit: {{ canEdit | dump | safe }},
  257. canExport: {{ canExport | dump | safe }},
  258. canAudit: {{ canAudit | dump | safe }},
  259. canView: {{ canView | dump | safe }},
  260. canDelete: {{ canDelete | dump | safe }}
  261. };
  262. const app = createApp({
  263. delimiters: ['${', '}'],
  264. setup() {
  265. const keyword = ref('');
  266. const dateRange = ref(null);
  267. const tagFilter = ref('');
  268. const activeTab = ref('all');
  269. const loading = ref(false);
  270. const tableData = ref([]);
  271. const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
  272. const counts = reactive({ all: 0, pending: 0, approved: 0, rejected: 0 });
  273. const uploadHeaders = {};
  274. // 新增弹窗
  275. const addVisible = ref(false);
  276. const addSaving = ref(false);
  277. const editingId = ref(null);
  278. const addForm = reactive({
  279. name: '', phone: '', id_card: '', gender: '', birth_date: '',
  280. regionCodes: [], address: '', emergency_contact: '', emergency_phone: '',
  281. tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '',
  282. sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: ''
  283. });
  284. // 省市区树形数据
  285. const regionTree = ref([]);
  286. // 瘤种选项(从接口加载)
  287. const tagOptions = ref([]);
  288. // 判断是否未成年(根据身份证号计算年龄)
  289. const isMinorComputed = computed(function() {
  290. var idCard = addForm.id_card;
  291. if (!idCard || idCard.length !== 18) return false;
  292. var birthStr = idCard.substring(6, 10) + '-' + idCard.substring(10, 12) + '-' + idCard.substring(12, 14);
  293. var birth = new Date(birthStr);
  294. if (isNaN(birth.getTime())) return false;
  295. var now = new Date();
  296. var age = now.getFullYear() - birth.getFullYear();
  297. var m = now.getMonth() - birth.getMonth();
  298. if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--;
  299. return age < 18;
  300. });
  301. const regionFilter = ref([]);
  302. const exporting = ref(false);
  303. const tabStatusMap = { all: '', draft: '-1', pending: '0', rejected: '2', approved: '1' };
  304. async function loadTagOptions() {
  305. var res = await fetch('/common/tagOptions').then(function(r) { return r.json(); });
  306. if (res.code === 0) tagOptions.value = res.data || [];
  307. }
  308. async function loadRegionTree() {
  309. var res = await fetch('/common/regions').then(function(r) { return r.json(); });
  310. if (res.code === 0) regionTree.value = res.data || [];
  311. }
  312. async function loadList(page) {
  313. if (typeof page === 'number') pagination.page = page;
  314. loading.value = true;
  315. try {
  316. var params = new URLSearchParams({
  317. page: pagination.page,
  318. pageSize: pagination.pageSize,
  319. keyword: keyword.value,
  320. tag: tagFilter.value,
  321. status: tabStatusMap[activeTab.value] || ''
  322. });
  323. if (dateRange.value && dateRange.value.length === 2) {
  324. params.set('startDate', dateRange.value[0]);
  325. params.set('endDate', dateRange.value[1]);
  326. }
  327. if (regionFilter.value && regionFilter.value.length >= 1) params.set('province_code', regionFilter.value[0]);
  328. if (regionFilter.value && regionFilter.value.length >= 2) params.set('city_code', regionFilter.value[1]);
  329. if (regionFilter.value && regionFilter.value.length >= 3) params.set('district_code', regionFilter.value[2]);
  330. var res = await fetch('/admin/patient/list?' + params).then(function(r) { return r.json(); });
  331. if (res.code === 0) {
  332. tableData.value = res.data.data || [];
  333. pagination.total = res.data.count || 0;
  334. if (res.data.counts) Object.assign(counts, res.data.counts);
  335. } else {
  336. ElementPlus.ElMessage.error(res.msg || '加载失败');
  337. }
  338. } finally {
  339. loading.value = false;
  340. }
  341. }
  342. function resetFilter() {
  343. keyword.value = '';
  344. dateRange.value = null;
  345. tagFilter.value = '';
  346. regionFilter.value = [];
  347. activeTab.value = 'all';
  348. pagination.page = 1;
  349. loadList();
  350. }
  351. function onTabChange() {
  352. pagination.page = 1;
  353. loadList();
  354. }
  355. function onSizeChange() {
  356. pagination.page = 1;
  357. loadList();
  358. }
  359. // 多选
  360. const selectedIds = ref([]);
  361. function onSelectionChange(rows) {
  362. selectedIds.value = rows.map(function(r) { return r.id; });
  363. }
  364. // 单条删除
  365. async function handleDelete(row) {
  366. try {
  367. await ElementPlus.ElMessageBox.confirm(
  368. '确定要删除患者「' + row.name + '」吗?删除后不可恢复。',
  369. '确认删除',
  370. { confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
  371. );
  372. var res = await fetch('/admin/patient/delete', {
  373. method: 'POST',
  374. headers: { 'Content-Type': 'application/json' },
  375. body: JSON.stringify({ ids: [row.id] })
  376. }).then(function(r) { return r.json(); });
  377. if (res.code === 0) {
  378. ElementPlus.ElMessage.success('删除成功');
  379. loadList();
  380. } else {
  381. ElementPlus.ElMessage.error(res.msg || '删除失败');
  382. }
  383. } catch(e) {}
  384. }
  385. // 批量删除
  386. async function handleBatchDelete() {
  387. if (!selectedIds.value.length) return;
  388. try {
  389. await ElementPlus.ElMessageBox.confirm(
  390. '确定要删除选中的 ' + selectedIds.value.length + ' 条患者记录吗?删除后不可恢复。',
  391. '批量删除确认',
  392. { confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
  393. );
  394. var res = await fetch('/admin/patient/delete', {
  395. method: 'POST',
  396. headers: { 'Content-Type': 'application/json' },
  397. body: JSON.stringify({ ids: selectedIds.value })
  398. }).then(function(r) { return r.json(); });
  399. if (res.code === 0) {
  400. ElementPlus.ElMessage.success('删除成功');
  401. selectedIds.value = [];
  402. loadList();
  403. } else {
  404. ElementPlus.ElMessage.error(res.msg || '删除失败');
  405. }
  406. } catch(e) {}
  407. }
  408. function viewDetail(row) {
  409. if (!perms.canView) {
  410. ElementPlus.ElMessageBox.alert('暂无查看详情权限,请先联系管理员授权', '温馨提示', {
  411. confirmButtonText: '知道了',
  412. type: 'warning'
  413. });
  414. return;
  415. }
  416. window.location.href = '/admin/patient/detail.html?id=' + row.id;
  417. }
  418. function showAddDialog() {
  419. editingId.value = null;
  420. Object.assign(addForm, {
  421. name: '', phone: '', id_card: '', gender: '', birth_date: '',
  422. regionCodes: [], address: '', emergency_contact: '', emergency_phone: '',
  423. tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '',
  424. sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: ''
  425. });
  426. addVisible.value = true;
  427. }
  428. function onIdCardInput() {
  429. var v = addForm.id_card;
  430. if (v.length === 18) {
  431. addForm.birth_date = v.substring(6, 10) + '-' + v.substring(10, 12) + '-' + v.substring(12, 14);
  432. addForm.gender = parseInt(v.charAt(16)) % 2 === 0 ? '女' : '男';
  433. }
  434. }
  435. function onDocUpload(res) {
  436. if (res.code === 0 && res.data && res.data.url) {
  437. addForm.documents.push(res.data.url);
  438. } else {
  439. ElementPlus.ElMessage.error(res.msg || '上传失败');
  440. }
  441. }
  442. function onSignUpload(res, field) {
  443. if (res.code === 0 && res.data && res.data.url) {
  444. addForm[field] = res.data.url;
  445. } else {
  446. ElementPlus.ElMessage.error(res.msg || '上传失败');
  447. }
  448. }
  449. async function showEditDialog(row) {
  450. editingId.value = row.id;
  451. // 先重置表单
  452. Object.assign(addForm, {
  453. name: '', phone: '', id_card: '', gender: '', birth_date: '',
  454. regionCodes: [], address: '', emergency_contact: '', emergency_phone: '',
  455. tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '',
  456. sign_privacy_jhr: '', income_amount: '', guardian_name: '', guardian_id_card: '', guardian_relation: ''
  457. });
  458. try {
  459. var res = await fetch('/admin/patient/info?id=' + row.id).then(function(r) { return r.json(); });
  460. if (res.code === 0) {
  461. var p = res.data.patient;
  462. Object.assign(addForm, {
  463. name: p.name || '',
  464. phone: p.phone || '',
  465. id_card: p.id_card || '',
  466. gender: p.gender || '',
  467. birth_date: p.birth_date || '',
  468. address: p.address || '',
  469. emergency_contact: p.emergency_contact || '',
  470. emergency_phone: p.emergency_phone || '',
  471. tag: p.tag || '',
  472. documents: (typeof p.documents === 'string' ? JSON.parse(p.documents || '[]') : p.documents) || [],
  473. sign_income: p.sign_income || '',
  474. sign_privacy: p.sign_privacy || '',
  475. sign_promise: p.sign_promise || '',
  476. sign_privacy_jhr: p.sign_privacy_jhr || '',
  477. income_amount: p.income_amount || '',
  478. guardian_name: p.guardian_name || '',
  479. guardian_id_card: p.guardian_id_card || '',
  480. guardian_relation: p.guardian_relation || '',
  481. regionCodes: [p.province_code, p.city_code, p.district_code].filter(Boolean)
  482. });
  483. addVisible.value = true;
  484. } else {
  485. ElementPlus.ElMessage.error(res.msg || '获取患者信息失败');
  486. }
  487. } catch(e) {
  488. ElementPlus.ElMessage.error('获取患者信息失败');
  489. }
  490. }
  491. async function submitAdd() {
  492. if (!addForm.name.trim()) return ElementPlus.ElMessage.warning('请输入姓名');
  493. if (!addForm.phone || !/^1\d{10}$/.test(addForm.phone)) return ElementPlus.ElMessage.warning('请输入正确的手机号');
  494. if (!addForm.id_card || !/^\d{17}[\dXx]$/.test(addForm.id_card)) return ElementPlus.ElMessage.warning('请输入正确的身份证号');
  495. if (!addForm.birth_date) return ElementPlus.ElMessage.warning('请选择出生日期');
  496. if (!addForm.gender) return ElementPlus.ElMessage.warning('请选择性别');
  497. if (!addForm.regionCodes || addForm.regionCodes.length !== 3) return ElementPlus.ElMessage.warning('请选择省市区');
  498. if (!addForm.address.trim()) return ElementPlus.ElMessage.warning('请输入详细地址');
  499. addSaving.value = true;
  500. try {
  501. var url = editingId.value ? '/admin/patient/edit' : '/admin/patient/add';
  502. var body = {
  503. name: addForm.name.trim(),
  504. phone: addForm.phone,
  505. id_card: addForm.id_card,
  506. gender: addForm.gender,
  507. birth_date: addForm.birth_date,
  508. province_code: addForm.regionCodes[0],
  509. city_code: addForm.regionCodes[1],
  510. district_code: addForm.regionCodes[2],
  511. address: addForm.address.trim(),
  512. emergency_contact: addForm.emergency_contact || '',
  513. emergency_phone: addForm.emergency_phone || '',
  514. tag: addForm.tag || '',
  515. documents: addForm.documents,
  516. sign_income: addForm.sign_income,
  517. sign_privacy: addForm.sign_privacy,
  518. sign_promise: addForm.sign_promise,
  519. sign_privacy_jhr: addForm.sign_privacy_jhr || '',
  520. income_amount: addForm.income_amount || '',
  521. guardian_name: addForm.guardian_name || '',
  522. guardian_id_card: addForm.guardian_id_card || '',
  523. guardian_relation: addForm.guardian_relation || ''
  524. };
  525. if (editingId.value) body.id = editingId.value;
  526. var res = await fetch(url, {
  527. method: 'POST',
  528. headers: { 'Content-Type': 'application/json' },
  529. body: JSON.stringify(body)
  530. }).then(function(r) { return r.json(); });
  531. if (res.code === 0) {
  532. ElementPlus.ElMessage.success(editingId.value ? '保存成功' : '新增成功');
  533. addVisible.value = false;
  534. loadList();
  535. } else {
  536. ElementPlus.ElMessage.error(res.msg || '操作失败');
  537. }
  538. } finally {
  539. addSaving.value = false;
  540. }
  541. }
  542. function handleExport() {
  543. var params = new URLSearchParams({
  544. keyword: keyword.value,
  545. tag: tagFilter.value,
  546. status: tabStatusMap[activeTab.value] || ''
  547. });
  548. if (dateRange.value && dateRange.value.length === 2) {
  549. params.set('startDate', dateRange.value[0]);
  550. params.set('endDate', dateRange.value[1]);
  551. }
  552. if (regionFilter.value && regionFilter.value.length >= 1) params.set('province_code', regionFilter.value[0]);
  553. if (regionFilter.value && regionFilter.value.length >= 2) params.set('city_code', regionFilter.value[1]);
  554. if (regionFilter.value && regionFilter.value.length >= 3) params.set('district_code', regionFilter.value[2]);
  555. window.open('/admin/patient/export?' + params.toString(), '_blank');
  556. }
  557. onMounted(function() {
  558. loadList();
  559. loadRegionTree();
  560. loadTagOptions();
  561. });
  562. return {
  563. keyword, dateRange, tagFilter, regionFilter, activeTab, loading, tableData, pagination, counts,
  564. uploadHeaders, addVisible, addSaving, addForm, exporting, editingId, perms, selectedIds,
  565. regionTree, tagOptions, isMinorComputed, Plus, Download, Delete,
  566. loadList, resetFilter, onTabChange, onSizeChange, onSelectionChange, viewDetail, showAddDialog, showEditDialog, handleExport,
  567. onIdCardInput, onDocUpload, onSignUpload, submitAdd, handleDelete, handleBatchDelete
  568. };
  569. }
  570. });
  571. app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
  572. app.mount('#patientApp');
  573. </script>
  574. {% endblock %}