Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 
 

315 рядки
13 KiB

  1. {% extends "./layout.html" %}
  2. {% block title %}医院管理{% endblock %}
  3. {% block css %}
  4. <style>
  5. .drag-handle { cursor: move; color: #999; font-size: 18px; }
  6. .drag-handle:hover { color: #ff7800; }
  7. .sortable-ghost { opacity: 0.4; background: #fff7ed; }
  8. </style>
  9. {% endblock %}
  10. {% block content %}
  11. <div id="hospitalApp" v-cloak>
  12. <el-card shadow="never">
  13. <template #header>
  14. <div class="flex flex-col gap-3">
  15. <div class="flex items-center justify-between">
  16. <div class="flex items-center gap-3">
  17. <el-input v-model="keyword" placeholder="搜索医院名称" style="width:240px;" clearable @keyup.enter="onSearch"></el-input>
  18. <el-cascader v-model="regionFilter" :options="regionTree"
  19. :props="{ value: 'code', label: 'name', children: 'children', checkStrictly: true }"
  20. placeholder="按地区筛选" clearable filterable :filter-method="filterRegionNode" style="width:240px;"></el-cascader>
  21. <el-button type="primary" @click="onSearch">搜索</el-button>
  22. <el-button @click="resetQuery">重置</el-button>
  23. </div>
  24. <span class="text-xs text-gray-400">拖拽行可调整排序</span>
  25. </div>
  26. <div class="flex items-center gap-3">
  27. <el-button type="primary" :icon="Plus" @click="showAdd">新增医院</el-button>
  28. <el-button :icon="Upload" @click="showImport = true">导入医院</el-button>
  29. </div>
  30. </div>
  31. </template>
  32. <el-table :data="list" v-loading="loading" stripe border row-key="id">
  33. <el-table-column width="60" align="center">
  34. <template #header>排序</template>
  35. <template #default>
  36. <span class="drag-handle">⠿</span>
  37. </template>
  38. </el-table-column>
  39. <el-table-column prop="name" label="医院名称" min-width="200"></el-table-column>
  40. <el-table-column label="所在地区" min-width="180">
  41. <template #default="{ row }">
  42. <span v-if="row.province_name">${ row.province_name } ${ row.city_name } ${ row.district_name }</span>
  43. <span v-else style="color:#c0c4cc;">未设置</span>
  44. </template>
  45. </el-table-column>
  46. <el-table-column label="是否展示" width="100" align="center">
  47. <template #default="{ row }">
  48. <el-switch :model-value="row.is_show === 1" @change="(v) => handleToggle(row, v)" />
  49. </template>
  50. </el-table-column>
  51. <el-table-column label="操作" width="160" align="center">
  52. <template #default="{ row }">
  53. <el-button type="primary" link @click="showEdit(row)">编辑</el-button>
  54. <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
  55. </template>
  56. </el-table-column>
  57. </el-table>
  58. <div class="flex justify-end mt-4">
  59. <el-pagination background layout="total, sizes, prev, pager, next" :total="total" :page-sizes="[10, 20, 50, 100]"
  60. v-model:page-size="pageSize" v-model:current-page="page" @current-change="loadList" @size-change="onSizeChange"></el-pagination>
  61. </div>
  62. </el-card>
  63. <!-- 新增/编辑弹窗 -->
  64. <el-dialog v-model="dialogVisible" :title="dialogTitle" width="460px" destroy-on-close draggable :close-on-click-modal="false">
  65. <el-form :model="form" label-width="90px">
  66. <el-form-item label="医院名称" required>
  67. <el-input v-model="form.name" placeholder="请输入医院名称" maxlength="100"></el-input>
  68. </el-form-item>
  69. <el-form-item label="所在地区">
  70. <el-cascader v-model="form.regionCodes" :options="regionTree"
  71. :props="{ value: 'code', label: 'name', children: 'children' }"
  72. placeholder="请选择省/市/区" clearable filterable :filter-method="filterRegionNode" style="width:100%;"></el-cascader>
  73. </el-form-item>
  74. <el-form-item label="排序">
  75. <el-input-number v-model="form.sort" :min="0" :max="9999"></el-input-number>
  76. </el-form-item>
  77. <el-form-item label="是否展示">
  78. <el-switch v-model="form.is_show" :active-value="1" :inactive-value="0"></el-switch>
  79. </el-form-item>
  80. </el-form>
  81. <template #footer>
  82. <el-button @click="dialogVisible = false">取消</el-button>
  83. <el-button type="primary" @click="handleSave" :loading="saving">确定</el-button>
  84. </template>
  85. </el-dialog>
  86. <!-- Excel 导入弹窗 -->
  87. <el-dialog v-model="showImport" title="导入医院" width="500px" destroy-on-close :close-on-click-modal="false">
  88. <p style="font-size:13px;color:var(--el-color-primary);margin-bottom:12px;">
  89. 请上传 Excel 文件(.xlsx),表头依次为:关联机构名称、省份、城市、区县。重复医院将自动跳过。
  90. </p>
  91. <el-upload drag :auto-upload="false" :limit="1" :on-change="onFileChange" :on-remove="onFileRemove"
  92. accept=".xlsx" :file-list="importFileList">
  93. <el-icon class="el-icon--upload"><Upload /></el-icon>
  94. <div class="el-upload__text">将 Excel 文件拖到此处,或<em>点击上传</em></div>
  95. </el-upload>
  96. <template #footer>
  97. <div style="text-align:right;">
  98. <el-button @click="showImport = false">取消</el-button>
  99. <el-button type="primary" @click="handleImport" :loading="importing">确认导入</el-button>
  100. </div>
  101. </template>
  102. </el-dialog>
  103. </div>
  104. {% endblock %}
  105. {% block js %}
  106. <script src="/static/lib/sortablejs/Sortable.min.js"></script>
  107. <script>
  108. const { createApp, ref, reactive, onMounted, nextTick } = Vue;
  109. const { Plus, Upload } = ElementPlusIconsVue;
  110. const app = createApp({
  111. delimiters: ['${', '}'],
  112. setup() {
  113. const loading = ref(false);
  114. const list = ref([]);
  115. const keyword = ref('');
  116. const total = ref(0);
  117. const page = ref(1);
  118. const pageSize = ref(10);
  119. const regionTree = ref([]);
  120. const regionFilter = ref([]);
  121. const dialogVisible = ref(false);
  122. const dialogTitle = ref('新增医院');
  123. const saving = ref(false);
  124. const form = reactive({ id: null, name: '', regionCodes: [], sort: 0, is_show: 1 });
  125. const showImport = ref(false);
  126. const importing = ref(false);
  127. const importFileList = ref([]);
  128. let importFile = null;
  129. let sortableInstance = null;
  130. async function loadRegionTree() {
  131. const res = await fetch('/common/regions').then(r => r.json());
  132. if (res.code === 0) regionTree.value = res.data || [];
  133. }
  134. function filterRegionNode(node, keyword) {
  135. const kw = (keyword || '').trim().toLowerCase();
  136. if (!kw) return true;
  137. const text = String(node.text || '').toLowerCase();
  138. const pathText = (node.pathLabels || []).join('').toLowerCase();
  139. return text.includes(kw) || pathText.includes(kw);
  140. }
  141. async function loadList() {
  142. loading.value = true;
  143. try {
  144. const params = new URLSearchParams({
  145. keyword: keyword.value, page: page.value, pageSize: pageSize.value
  146. });
  147. if (regionFilter.value && regionFilter.value.length >= 1) params.set('province_code', regionFilter.value[0]);
  148. if (regionFilter.value && regionFilter.value.length >= 2) params.set('city_code', regionFilter.value[1]);
  149. if (regionFilter.value && regionFilter.value.length >= 3) params.set('district_code', regionFilter.value[2]);
  150. const res = await fetch('/admin/hospital/list?' + params).then(r => r.json());
  151. if (res.code === 0) {
  152. list.value = res.data.data || [];
  153. total.value = res.data.count || 0;
  154. nextTick(() => initSortable());
  155. }
  156. } finally { loading.value = false; }
  157. }
  158. function initSortable() {
  159. if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null; }
  160. const tbody = document.querySelector('#hospitalApp .el-table__body-wrapper tbody');
  161. if (!tbody) return;
  162. sortableInstance = Sortable.create(tbody, {
  163. handle: '.drag-handle',
  164. animation: 150,
  165. ghostClass: 'sortable-ghost',
  166. onEnd(evt) {
  167. if (evt.oldIndex === evt.newIndex) return;
  168. const arr = list.value.slice();
  169. const item = arr.splice(evt.oldIndex, 1)[0];
  170. arr.splice(evt.newIndex, 0, item);
  171. list.value = arr;
  172. fetch('/admin/hospital/sort', {
  173. method: 'POST',
  174. headers: { 'Content-Type': 'application/json' },
  175. body: JSON.stringify({ ids: arr.map(i => i.id) })
  176. }).then(r => r.json()).then(res => {
  177. if (res.code === 0) ElementPlus.ElMessage.success('排序已保存');
  178. else loadList();
  179. });
  180. }
  181. });
  182. }
  183. function showAdd() {
  184. dialogTitle.value = '新增医院';
  185. form.id = null; form.name = ''; form.regionCodes = []; form.sort = 0; form.is_show = 1;
  186. dialogVisible.value = true;
  187. }
  188. function onSearch() {
  189. page.value = 1;
  190. loadList();
  191. }
  192. function resetQuery() {
  193. keyword.value = '';
  194. regionFilter.value = [];
  195. page.value = 1;
  196. loadList();
  197. }
  198. function onSizeChange() {
  199. page.value = 1;
  200. loadList();
  201. }
  202. function showEdit(row) {
  203. dialogTitle.value = '编辑医院';
  204. form.id = row.id; form.name = row.name; form.sort = row.sort; form.is_show = row.is_show;
  205. form.regionCodes = [row.province_code, row.city_code, row.district_code].filter(Boolean);
  206. dialogVisible.value = true;
  207. }
  208. async function handleSave() {
  209. if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入医院名称'); return; }
  210. saving.value = true;
  211. try {
  212. const url = form.id ? '/admin/hospital/edit' : '/admin/hospital/add';
  213. const codes = form.regionCodes || [];
  214. const payload = {
  215. id: form.id, name: form.name, sort: form.sort, is_show: form.is_show,
  216. province_code: codes[0] || '', city_code: codes[1] || '', district_code: codes[2] || ''
  217. };
  218. const res = await fetch(url, {
  219. method: 'POST',
  220. headers: { 'Content-Type': 'application/json' },
  221. body: JSON.stringify(payload)
  222. }).then(r => r.json());
  223. if (res.code === 0) {
  224. ElementPlus.ElMessage.success('保存成功');
  225. dialogVisible.value = false;
  226. loadList();
  227. } else {
  228. ElementPlus.ElMessage.error(res.msg || '保存失败');
  229. }
  230. } finally { saving.value = false; }
  231. }
  232. async function handleDelete(row) {
  233. try {
  234. await ElementPlus.ElMessageBox.confirm(`确定要删除「${row.name}」吗?`, '提示', { type: 'warning' });
  235. const res = await fetch('/admin/hospital/delete', {
  236. method: 'POST',
  237. headers: { 'Content-Type': 'application/json' },
  238. body: JSON.stringify({ id: row.id })
  239. }).then(r => r.json());
  240. if (res.code === 0) { ElementPlus.ElMessage.success('删除成功'); loadList(); }
  241. else { ElementPlus.ElMessage.error(res.msg || '删除失败'); }
  242. } catch (e) {}
  243. }
  244. async function handleToggle(row, val) {
  245. const res = await fetch('/admin/hospital/toggleShow', {
  246. method: 'POST',
  247. headers: { 'Content-Type': 'application/json' },
  248. body: JSON.stringify({ id: row.id, is_show: val ? 1 : 0 })
  249. }).then(r => r.json());
  250. if (res.code === 0) { row.is_show = val ? 1 : 0; ElementPlus.ElMessage.success('已更新'); }
  251. else { ElementPlus.ElMessage.error(res.msg || '操作失败'); }
  252. }
  253. function onFileChange(file) {
  254. importFile = file.raw;
  255. importFileList.value = [file];
  256. }
  257. function onFileRemove() {
  258. importFile = null;
  259. importFileList.value = [];
  260. }
  261. async function handleImport() {
  262. if (!importFile) { ElementPlus.ElMessage.warning('请先选择 Excel 文件'); return; }
  263. importing.value = true;
  264. try {
  265. const fd = new FormData();
  266. fd.append('file', importFile);
  267. const res = await fetch('/admin/hospital/import', { method: 'POST', body: fd }).then(r => r.json());
  268. if (res.code === 0) {
  269. let msg = `导入成功 ${res.data.count} 条,跳过 ${res.data.skipped} 条`;
  270. if (res.data.unmatchedCount > 0) msg += `,${res.data.unmatchedCount} 条地区未匹配`;
  271. ElementPlus.ElMessage.success(msg);
  272. showImport.value = false;
  273. importFile = null;
  274. importFileList.value = [];
  275. loadList();
  276. } else {
  277. ElementPlus.ElMessage.error(res.msg || '导入失败');
  278. }
  279. } finally { importing.value = false; }
  280. }
  281. onMounted(() => { loadList(); loadRegionTree(); });
  282. return { loading, list, keyword, total, page, pageSize, regionTree, regionFilter, dialogVisible, dialogTitle, saving, form, showImport, importing, importFileList, loadList, onSearch, resetQuery, onSizeChange, showAdd, showEdit, handleSave, handleDelete, handleToggle, handleImport, onFileChange, onFileRemove, filterRegionNode, Plus, Upload };
  283. }
  284. });
  285. app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
  286. app.mount('#hospitalApp');
  287. </script>
  288. {% endblock %}