No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 
 

259 líneas
9.8 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-button type="primary" @click="onSearch">搜索</el-button>
  19. <el-button @click="resetQuery">重置</el-button>
  20. </div>
  21. <span class="text-xs text-gray-400">拖拽行可调整排序</span>
  22. </div>
  23. <div class="flex items-center gap-3">
  24. <el-button type="primary" :icon="Plus" @click="showAdd">新增医院</el-button>
  25. <el-button :icon="Upload" @click="showImport = true">导入医院</el-button>
  26. </div>
  27. </div>
  28. </template>
  29. <el-table :data="list" v-loading="loading" stripe border row-key="id">
  30. <el-table-column width="60" align="center">
  31. <template #header>排序</template>
  32. <template #default>
  33. <span class="drag-handle">⠿</span>
  34. </template>
  35. </el-table-column>
  36. <el-table-column prop="name" label="医院名称"></el-table-column>
  37. <el-table-column label="是否展示" width="120" align="center">
  38. <template #default="{ row }">
  39. <el-switch :model-value="row.is_show === 1" @change="(v) => handleToggle(row, v)" />
  40. </template>
  41. </el-table-column>
  42. <el-table-column label="操作" width="180" align="center">
  43. <template #default="{ row }">
  44. <el-button type="primary" link @click="showEdit(row)">编辑</el-button>
  45. <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
  46. </template>
  47. </el-table-column>
  48. </el-table>
  49. <div class="flex justify-end mt-4">
  50. <el-pagination background layout="total, sizes, prev, pager, next" :total="total" :page-sizes="[10, 20, 50, 100]"
  51. v-model:page-size="pageSize" v-model:current-page="page" @current-change="loadList" @size-change="onSizeChange"></el-pagination>
  52. </div>
  53. </el-card>
  54. <!-- 新增/编辑弹窗 -->
  55. <el-dialog v-model="dialogVisible" :title="dialogTitle" width="460px" destroy-on-close draggable :close-on-click-modal="false">
  56. <el-form :model="form" label-width="90px">
  57. <el-form-item label="医院名称" required>
  58. <el-input v-model="form.name" placeholder="请输入医院名称" maxlength="100" />
  59. </el-form-item>
  60. <el-form-item label="排序">
  61. <el-input-number v-model="form.sort" :min="0" :max="9999" />
  62. </el-form-item>
  63. <el-form-item label="是否展示">
  64. <el-switch v-model="form.is_show" :active-value="1" :inactive-value="0" />
  65. </el-form-item>
  66. </el-form>
  67. <template #footer>
  68. <el-button @click="dialogVisible = false">取消</el-button>
  69. <el-button type="primary" @click="handleSave" :loading="saving">确定</el-button>
  70. </template>
  71. </el-dialog>
  72. <!-- 批量导入弹窗 -->
  73. <el-dialog v-model="showImport" title="批量导入医院" width="500px" destroy-on-close :close-on-click-modal="false">
  74. <p style="font-size:13px;color:var(--el-color-primary);margin-bottom:12px;">每行输入一个医院名称,重复的将自动跳过。</p>
  75. <el-input v-model="importText" type="textarea" :rows="10" :placeholder="importPlaceholder"></el-input>
  76. <template #footer>
  77. <div style="text-align:right;">
  78. <el-button @click="showImport = false">取消</el-button>
  79. <el-button type="primary" @click="handleImport" :loading="importing">确认导入</el-button>
  80. </div>
  81. </template>
  82. </el-dialog>
  83. </div>
  84. {% endblock %}
  85. {% block js %}
  86. <script src="/static/lib/sortablejs/Sortable.min.js"></script>
  87. <script>
  88. const { createApp, ref, reactive, onMounted, nextTick } = Vue;
  89. const { Plus, Upload } = ElementPlusIconsVue;
  90. const app = createApp({
  91. delimiters: ['${', '}'],
  92. setup() {
  93. const loading = ref(false);
  94. const list = ref([]);
  95. const keyword = ref('');
  96. const total = ref(0);
  97. const page = ref(1);
  98. const pageSize = ref(10);
  99. const dialogVisible = ref(false);
  100. const dialogTitle = ref('新增医院');
  101. const saving = ref(false);
  102. const form = reactive({ id: null, name: '', sort: 0, is_show: 1 });
  103. const showImport = ref(false);
  104. const importText = ref('');
  105. const importing = ref(false);
  106. const importPlaceholder = '北京协和医院\n复旦大学附属肿瘤医院\n中山大学肿瘤防治中心';
  107. let sortableInstance = null;
  108. async function loadList() {
  109. loading.value = true;
  110. try {
  111. const params = new URLSearchParams({
  112. keyword: keyword.value, page: page.value, pageSize: pageSize.value
  113. });
  114. const res = await fetch('/admin/hospital/list?' + params).then(r => r.json());
  115. if (res.code === 0) {
  116. list.value = res.data.data || [];
  117. total.value = res.data.count || 0;
  118. nextTick(() => initSortable());
  119. }
  120. } finally { loading.value = false; }
  121. }
  122. function initSortable() {
  123. if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null; }
  124. const tbody = document.querySelector('#hospitalApp .el-table__body-wrapper tbody');
  125. if (!tbody) return;
  126. sortableInstance = Sortable.create(tbody, {
  127. handle: '.drag-handle',
  128. animation: 150,
  129. ghostClass: 'sortable-ghost',
  130. onEnd(evt) {
  131. if (evt.oldIndex === evt.newIndex) return;
  132. const arr = list.value.slice();
  133. const item = arr.splice(evt.oldIndex, 1)[0];
  134. arr.splice(evt.newIndex, 0, item);
  135. list.value = arr;
  136. fetch('/admin/hospital/sort', {
  137. method: 'POST',
  138. headers: { 'Content-Type': 'application/json' },
  139. body: JSON.stringify({ ids: arr.map(i => i.id) })
  140. }).then(r => r.json()).then(res => {
  141. if (res.code === 0) ElementPlus.ElMessage.success('排序已保存');
  142. else loadList();
  143. });
  144. }
  145. });
  146. }
  147. function showAdd() {
  148. dialogTitle.value = '新增医院';
  149. form.id = null; form.name = ''; form.sort = 0; form.is_show = 1;
  150. dialogVisible.value = true;
  151. }
  152. function onSearch() {
  153. page.value = 1;
  154. loadList();
  155. }
  156. function resetQuery() {
  157. keyword.value = '';
  158. page.value = 1;
  159. loadList();
  160. }
  161. function onSizeChange() {
  162. page.value = 1;
  163. loadList();
  164. }
  165. function showEdit(row) {
  166. dialogTitle.value = '编辑医院';
  167. form.id = row.id; form.name = row.name; form.sort = row.sort; form.is_show = row.is_show;
  168. dialogVisible.value = true;
  169. }
  170. async function handleSave() {
  171. if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入医院名称'); return; }
  172. saving.value = true;
  173. try {
  174. const url = form.id ? '/admin/hospital/edit' : '/admin/hospital/add';
  175. const res = await fetch(url, {
  176. method: 'POST',
  177. headers: { 'Content-Type': 'application/json' },
  178. body: JSON.stringify(form)
  179. }).then(r => r.json());
  180. if (res.code === 0) {
  181. ElementPlus.ElMessage.success('保存成功');
  182. dialogVisible.value = false;
  183. loadList();
  184. } else {
  185. ElementPlus.ElMessage.error(res.msg || '保存失败');
  186. }
  187. } finally { saving.value = false; }
  188. }
  189. async function handleDelete(row) {
  190. try {
  191. await ElementPlus.ElMessageBox.confirm(`确定要删除「${row.name}」吗?`, '提示', { type: 'warning' });
  192. const res = await fetch('/admin/hospital/delete', {
  193. method: 'POST',
  194. headers: { 'Content-Type': 'application/json' },
  195. body: JSON.stringify({ id: row.id })
  196. }).then(r => r.json());
  197. if (res.code === 0) { ElementPlus.ElMessage.success('删除成功'); loadList(); }
  198. else { ElementPlus.ElMessage.error(res.msg || '删除失败'); }
  199. } catch (e) {}
  200. }
  201. async function handleToggle(row, val) {
  202. const res = await fetch('/admin/hospital/toggleShow', {
  203. method: 'POST',
  204. headers: { 'Content-Type': 'application/json' },
  205. body: JSON.stringify({ id: row.id, is_show: val ? 1 : 0 })
  206. }).then(r => r.json());
  207. if (res.code === 0) { row.is_show = val ? 1 : 0; ElementPlus.ElMessage.success('已更新'); }
  208. else { ElementPlus.ElMessage.error(res.msg || '操作失败'); }
  209. }
  210. async function handleImport() {
  211. if (!importText.value.trim()) { ElementPlus.ElMessage.warning('请输入医院名称'); return; }
  212. importing.value = true;
  213. try {
  214. const res = await fetch('/admin/hospital/import', {
  215. method: 'POST',
  216. headers: { 'Content-Type': 'application/json' },
  217. body: JSON.stringify({ text: importText.value })
  218. }).then(r => r.json());
  219. if (res.code === 0) {
  220. ElementPlus.ElMessage.success(`导入成功 ${res.data.count} 条,跳过 ${res.data.skipped} 条`);
  221. showImport.value = false;
  222. importText.value = '';
  223. loadList();
  224. } else {
  225. ElementPlus.ElMessage.error(res.msg || '导入失败');
  226. }
  227. } finally { importing.value = false; }
  228. }
  229. onMounted(() => loadList());
  230. return { loading, list, keyword, total, page, pageSize, dialogVisible, dialogTitle, saving, form, showImport, importText, importing, importPlaceholder, loadList, onSearch, resetQuery, onSizeChange, showAdd, showEdit, handleSave, handleDelete, handleToggle, handleImport, Plus, Upload };
  231. }
  232. });
  233. app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
  234. app.mount('#hospitalApp');
  235. </script>
  236. {% endblock %}