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.
 
 
 
 
 

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