| @@ -0,0 +1,11 @@ | |||||
| -- 医院列表 | |||||
| CREATE TABLE `hospital` ( | |||||
| `id` int(11) NOT NULL AUTO_INCREMENT, | |||||
| `name` varchar(100) NOT NULL COMMENT '医院名称', | |||||
| `sort` int(11) DEFAULT 0 COMMENT '排序(越大越前)', | |||||
| `is_show` tinyint(1) DEFAULT 1 COMMENT '是否展示: 1是 0否', | |||||
| `is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除: 0正常 1已删除', | |||||
| `create_time` datetime DEFAULT CURRENT_TIMESTAMP, | |||||
| `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | |||||
| PRIMARY KEY (`id`) | |||||
| ) COMMENT='医院列表'; | |||||
| @@ -56,6 +56,7 @@ module.exports = [ | |||||
| ['/common/ocr/idcard', 'common/ocrIdcard', 'post'], | ['/common/ocr/idcard', 'common/ocrIdcard', 'post'], | ||||
| ['/common/ocr/hmt', 'common/ocrHmt', 'post'], | ['/common/ocr/hmt', 'common/ocrHmt', 'post'], | ||||
| ['/common/sampleTypes', 'common/sampleTypes'], | ['/common/sampleTypes', 'common/sampleTypes'], | ||||
| ['/common/hospitals', 'common/hospitals'], | |||||
| ['/api/content', 'common/content'], | ['/api/content', 'common/content'], | ||||
| // 小程序接口 | // 小程序接口 | ||||
| @@ -97,6 +98,16 @@ module.exports = [ | |||||
| ['/admin/sample_type/sort', 'admin/sample_type/sort', 'post'], | ['/admin/sample_type/sort', 'admin/sample_type/sort', 'post'], | ||||
| ['/admin/sample_type/setRequired', 'admin/sample_type/setRequired', 'post'], | ['/admin/sample_type/setRequired', 'admin/sample_type/setRequired', 'post'], | ||||
| // 医院管理 | |||||
| ['/admin/hospital', 'admin/hospital/index'], | |||||
| ['/admin/hospital/list', 'admin/hospital/list'], | |||||
| ['/admin/hospital/add', 'admin/hospital/add', 'post'], | |||||
| ['/admin/hospital/edit', 'admin/hospital/edit', 'post'], | |||||
| ['/admin/hospital/delete', 'admin/hospital/delete', 'post'], | |||||
| ['/admin/hospital/toggleShow', 'admin/hospital/toggleShow', 'post'], | |||||
| ['/admin/hospital/sort', 'admin/hospital/sort', 'post'], | |||||
| ['/admin/hospital/import', 'admin/hospital/import', 'post'], | |||||
| // 内容管理 | // 内容管理 | ||||
| ['/admin/content', 'admin/content/index'], | ['/admin/content', 'admin/content/index'], | ||||
| ['/admin/content/list', 'admin/content/list'], | ['/admin/content/list', 'admin/content/list'], | ||||
| @@ -0,0 +1,110 @@ | |||||
| const Base = require('../base'); | |||||
| module.exports = class extends Base { | |||||
| async indexAction() { | |||||
| this.assign('currentPage', 'hospital'); | |||||
| this.assign('pageTitle', '医院管理'); | |||||
| this.assign('breadcrumb', [{ name: '医院管理' }]); | |||||
| return this.display(); | |||||
| } | |||||
| // 列表 | |||||
| async listAction() { | |||||
| const keyword = this.get('keyword') || ''; | |||||
| const page = this.get('page') || 1; | |||||
| const pageSize = this.get('pageSize') || 10; | |||||
| const where = { is_deleted: 0 }; | |||||
| if (keyword) where.name = ['like', `%${keyword}%`]; | |||||
| const data = await this.model('hospital') | |||||
| .where(where) | |||||
| .order('sort DESC, id ASC') | |||||
| .page(page, pageSize) | |||||
| .countSelect(); | |||||
| return this.json({ code: 0, data }); | |||||
| } | |||||
| // 新增 | |||||
| async addAction() { | |||||
| const { name, sort, is_show } = this.post(); | |||||
| if (!name || !name.trim()) return this.fail('请输入医院名称'); | |||||
| const exists = await this.model('hospital').where({ name: name.trim(), is_deleted: 0 }).find(); | |||||
| if (!think.isEmpty(exists)) return this.fail('该医院已存在'); | |||||
| await this.model('hospital').add({ | |||||
| name: name.trim(), | |||||
| sort: sort || 0, | |||||
| is_show: is_show === undefined ? 1 : (is_show ? 1 : 0) | |||||
| }); | |||||
| await this.log('add', '医院管理', `新增医院「${name.trim()}」`); | |||||
| return this.success(); | |||||
| } | |||||
| // 编辑 | |||||
| async editAction() { | |||||
| const { id, name, sort, is_show } = this.post(); | |||||
| if (!id) return this.fail('参数错误'); | |||||
| if (!name || !name.trim()) return this.fail('请输入医院名称'); | |||||
| const exists = await this.model('hospital').where({ name: name.trim(), id: ['!=', id], is_deleted: 0 }).find(); | |||||
| if (!think.isEmpty(exists)) return this.fail('该医院已存在'); | |||||
| await this.model('hospital').where({ id }).update({ | |||||
| name: name.trim(), | |||||
| sort: sort || 0, | |||||
| is_show: is_show ? 1 : 0 | |||||
| }); | |||||
| await this.log('edit', '医院管理', `编辑医院(ID:${id})「${name.trim()}」`); | |||||
| return this.success(); | |||||
| } | |||||
| // 删除 | |||||
| async deleteAction() { | |||||
| const { id } = this.post(); | |||||
| if (!id) return this.fail('参数错误'); | |||||
| await this.model('hospital').where({ id }).update({ is_deleted: 1 }); | |||||
| await this.log('delete', '医院管理', `删除医院(ID:${id})`); | |||||
| return this.success(); | |||||
| } | |||||
| // 切换展示状态 | |||||
| async toggleShowAction() { | |||||
| const { id, is_show } = this.post(); | |||||
| if (!id) return this.fail('参数错误'); | |||||
| await this.model('hospital').where({ id }).update({ is_show: is_show ? 1 : 0 }); | |||||
| await this.log('edit', '医院管理', `${is_show ? '展示' : '隐藏'}医院(ID:${id})`); | |||||
| return this.success(); | |||||
| } | |||||
| // 排序 | |||||
| async sortAction() { | |||||
| const { ids } = this.post(); | |||||
| if (!ids || !ids.length) return this.fail('参数错误'); | |||||
| for (let i = 0; i < ids.length; i++) { | |||||
| await this.model('hospital').where({ id: ids[i] }).update({ sort: ids.length - i }); | |||||
| } | |||||
| return this.success(); | |||||
| } | |||||
| // 批量导入(粘贴文本,每行一个) | |||||
| async importAction() { | |||||
| const { text } = this.post(); | |||||
| if (!text || !text.trim()) return this.fail('请输入医院名称'); | |||||
| const names = text.split('\n').map(s => s.trim()).filter(Boolean); | |||||
| if (!names.length) return this.fail('没有有效的医院名称'); | |||||
| // 查已存在的,去重 | |||||
| const existRows = await this.model('hospital').where({ is_deleted: 0 }).field('name').select(); | |||||
| const existNames = new Set(existRows.map(r => r.name)); | |||||
| const toInsert = []; | |||||
| const seen = new Set(); | |||||
| names.forEach(name => { | |||||
| if (!existNames.has(name) && !seen.has(name)) { | |||||
| seen.add(name); | |||||
| toInsert.push({ name, sort: 0, is_show: 1 }); | |||||
| } | |||||
| }); | |||||
| if (!toInsert.length) return this.fail('没有新增医院(均已存在)'); | |||||
| await this.model('hospital').addMany(toInsert); | |||||
| await this.log('import', '医院管理', `批量导入医院 ${toInsert.length} 条`); | |||||
| return this.success({ count: toInsert.length, skipped: names.length - toInsert.length }); | |||||
| } | |||||
| }; | |||||
| @@ -164,6 +164,21 @@ module.exports = class extends think.Controller { | |||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * 获取医院列表(展示中的) | |||||
| * GET /common/hospitals | |||||
| */ | |||||
| async hospitalsAction() { | |||||
| try { | |||||
| const keyword = this.get('keyword') || ''; | |||||
| const list = await this.model('hospital').getShowList(keyword); | |||||
| return this.json({ code: 0, data: list }); | |||||
| } catch (error) { | |||||
| think.logger.error('获取医院列表失败:', error); | |||||
| return this.json({ code: 0, data: [] }); | |||||
| } | |||||
| } | |||||
| /** | /** | ||||
| * 获取省市区树形数据(一次返回) | * 获取省市区树形数据(一次返回) | ||||
| * GET /common/regions | * GET /common/regions | ||||
| @@ -715,6 +715,18 @@ module.exports = class extends Base { | |||||
| if (patient.status !== 1) return this.json({ code: 1, msg: '请先通过审核' }); | if (patient.status !== 1) return this.json({ code: 1, msg: '请先通过审核' }); | ||||
| const hasSample = sample_types && sample_types.length > 0; | const hasSample = sample_types && sample_types.length > 0; | ||||
| // 邮箱格式与重复校验 | |||||
| if (hasSample && report_email) { | |||||
| if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(report_email)) { | |||||
| return this.json({ code: 1, msg: '邮箱格式不正确' }); | |||||
| } | |||||
| const dup = await this.model('patient') | |||||
| .where({ report_email, is_deleted: 0, id: ['!=', user.patient_id] }) | |||||
| .find(); | |||||
| if (!think.isEmpty(dup)) return this.json({ code: 1, msg: '该邮箱已被其他患者使用' }); | |||||
| } | |||||
| await this.model('patient').where({ id: user.patient_id }).update({ | await this.model('patient').where({ id: user.patient_id }).update({ | ||||
| sample_types: JSON.stringify(sample_types || []), | sample_types: JSON.stringify(sample_types || []), | ||||
| wax_return: (hasSample && wax_return) ? 1 : 0, | wax_return: (hasSample && wax_return) ? 1 : 0, | ||||
| @@ -0,0 +1,14 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'hospital'; | |||||
| } | |||||
| /** | |||||
| * 获取展示中的医院列表(小程序用) | |||||
| */ | |||||
| async getShowList(keyword = '') { | |||||
| const where = { is_show: 1, is_deleted: 0 }; | |||||
| if (keyword) where.name = ['like', `%${keyword}%`]; | |||||
| return this.where(where).order('sort DESC, id ASC').select(); | |||||
| } | |||||
| }; | |||||
| @@ -42,6 +42,16 @@ | |||||
| </div> | </div> | ||||
| {% endif %} | {% endif %} | ||||
| {# 医院管理 #} | |||||
| {% if isSuperAdmin or (userPermissions and 'tag' in userPermissions) %} | |||||
| <div class="menu-group"> | |||||
| <a class="menu-item {% if currentPage == 'hospital' %}active{% endif %}" href="/admin/hospital.html"> | |||||
| <span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M832 312H696V176c0-13.3-10.7-24-24-24H352c-13.3 0-24 10.7-24 24v136H192c-13.3 0-24 10.7-24 24v512c0 13.3 10.7 24 24 24h640c13.3 0 24-10.7 24-24V336c0-13.3-10.7-24-24-24zM400 224h224v88H400v-88zM784 824H240V384h544v440z" fill="currentColor"/><path d="M548 488h-72v76h-76v72h76v76h72v-76h76v-72h-76z" fill="currentColor"/></svg></span> | |||||
| <span>医院管理</span> | |||||
| </a> | |||||
| </div> | |||||
| {% endif %} | |||||
| {# 内容管理 #} | {# 内容管理 #} | ||||
| {% if isSuperAdmin or (userPermissions and 'content' in userPermissions) %} | {% if isSuperAdmin or (userPermissions and 'content' in userPermissions) %} | ||||
| <div class="menu-group"> | <div class="menu-group"> | ||||
| @@ -0,0 +1,258 @@ | |||||
| {% extends "./layout.html" %} | |||||
| {% block title %}医院管理{% endblock %} | |||||
| {% block css %} | |||||
| <style> | |||||
| .drag-handle { cursor: move; color: #999; font-size: 18px; } | |||||
| .drag-handle:hover { color: #ff7800; } | |||||
| .sortable-ghost { opacity: 0.4; background: #fff7ed; } | |||||
| </style> | |||||
| {% endblock %} | |||||
| {% block content %} | |||||
| <div id="hospitalApp" v-cloak> | |||||
| <el-card shadow="never"> | |||||
| <template #header> | |||||
| <div class="flex flex-col gap-3"> | |||||
| <div class="flex items-center justify-between"> | |||||
| <div class="flex items-center gap-3"> | |||||
| <el-input v-model="keyword" placeholder="搜索医院名称" style="width:240px;" clearable @keyup.enter="onSearch"></el-input> | |||||
| <el-button type="primary" @click="onSearch">搜索</el-button> | |||||
| <el-button @click="resetQuery">重置</el-button> | |||||
| </div> | |||||
| <span class="text-xs text-gray-400">拖拽行可调整排序</span> | |||||
| </div> | |||||
| <div class="flex items-center gap-3"> | |||||
| <el-button type="primary" :icon="Plus" @click="showAdd">新增医院</el-button> | |||||
| <el-button :icon="Upload" @click="showImport = true">导入医院</el-button> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <el-table :data="list" v-loading="loading" stripe border row-key="id"> | |||||
| <el-table-column width="60" align="center"> | |||||
| <template #header>排序</template> | |||||
| <template #default> | |||||
| <span class="drag-handle">⠿</span> | |||||
| </template> | |||||
| </el-table-column> | |||||
| <el-table-column prop="name" label="医院名称"></el-table-column> | |||||
| <el-table-column label="是否展示" width="120" align="center"> | |||||
| <template #default="{ row }"> | |||||
| <el-switch :model-value="row.is_show === 1" @change="(v) => handleToggle(row, v)" /> | |||||
| </template> | |||||
| </el-table-column> | |||||
| <el-table-column label="操作" width="180" align="center"> | |||||
| <template #default="{ row }"> | |||||
| <el-button type="primary" link @click="showEdit(row)">编辑</el-button> | |||||
| <el-button type="danger" link @click="handleDelete(row)">删除</el-button> | |||||
| </template> | |||||
| </el-table-column> | |||||
| </el-table> | |||||
| <div class="flex justify-end mt-4"> | |||||
| <el-pagination background layout="total, sizes, prev, pager, next" :total="total" :page-sizes="[10, 20, 50, 100]" | |||||
| v-model:page-size="pageSize" v-model:current-page="page" @current-change="loadList" @size-change="onSizeChange"></el-pagination> | |||||
| </div> | |||||
| </el-card> | |||||
| <!-- 新增/编辑弹窗 --> | |||||
| <el-dialog v-model="dialogVisible" :title="dialogTitle" width="460px" destroy-on-close draggable :close-on-click-modal="false"> | |||||
| <el-form :model="form" label-width="90px"> | |||||
| <el-form-item label="医院名称" required> | |||||
| <el-input v-model="form.name" placeholder="请输入医院名称" maxlength="100" /> | |||||
| </el-form-item> | |||||
| <el-form-item label="排序"> | |||||
| <el-input-number v-model="form.sort" :min="0" :max="9999" /> | |||||
| </el-form-item> | |||||
| <el-form-item label="是否展示"> | |||||
| <el-switch v-model="form.is_show" :active-value="1" :inactive-value="0" /> | |||||
| </el-form-item> | |||||
| </el-form> | |||||
| <template #footer> | |||||
| <el-button @click="dialogVisible = false">取消</el-button> | |||||
| <el-button type="primary" @click="handleSave" :loading="saving">确定</el-button> | |||||
| </template> | |||||
| </el-dialog> | |||||
| <!-- 批量导入弹窗 --> | |||||
| <el-dialog v-model="showImport" title="批量导入医院" width="500px" destroy-on-close :close-on-click-modal="false"> | |||||
| <p style="font-size:13px;color:var(--el-color-primary);margin-bottom:12px;">每行输入一个医院名称,重复的将自动跳过。</p> | |||||
| <el-input v-model="importText" type="textarea" :rows="10" :placeholder="importPlaceholder"></el-input> | |||||
| <template #footer> | |||||
| <div style="text-align:right;"> | |||||
| <el-button @click="showImport = false">取消</el-button> | |||||
| <el-button type="primary" @click="handleImport" :loading="importing">确认导入</el-button> | |||||
| </div> | |||||
| </template> | |||||
| </el-dialog> | |||||
| </div> | |||||
| {% endblock %} | |||||
| {% block js %} | |||||
| <script src="/static/lib/sortablejs/Sortable.min.js"></script> | |||||
| <script> | |||||
| const { createApp, ref, reactive, onMounted, nextTick } = Vue; | |||||
| const { Plus, Upload } = ElementPlusIconsVue; | |||||
| const app = createApp({ | |||||
| delimiters: ['${', '}'], | |||||
| setup() { | |||||
| const loading = ref(false); | |||||
| const list = ref([]); | |||||
| const keyword = ref(''); | |||||
| const total = ref(0); | |||||
| const page = ref(1); | |||||
| const pageSize = ref(10); | |||||
| const dialogVisible = ref(false); | |||||
| const dialogTitle = ref('新增医院'); | |||||
| const saving = ref(false); | |||||
| const form = reactive({ id: null, name: '', sort: 0, is_show: 1 }); | |||||
| const showImport = ref(false); | |||||
| const importText = ref(''); | |||||
| const importing = ref(false); | |||||
| const importPlaceholder = '北京协和医院\n复旦大学附属肿瘤医院\n中山大学肿瘤防治中心'; | |||||
| let sortableInstance = null; | |||||
| async function loadList() { | |||||
| loading.value = true; | |||||
| try { | |||||
| const params = new URLSearchParams({ | |||||
| keyword: keyword.value, page: page.value, pageSize: pageSize.value | |||||
| }); | |||||
| const res = await fetch('/admin/hospital/list?' + params).then(r => r.json()); | |||||
| if (res.code === 0) { | |||||
| list.value = res.data.data || []; | |||||
| total.value = res.data.count || 0; | |||||
| nextTick(() => initSortable()); | |||||
| } | |||||
| } finally { loading.value = false; } | |||||
| } | |||||
| function initSortable() { | |||||
| if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null; } | |||||
| const tbody = document.querySelector('#hospitalApp .el-table__body-wrapper tbody'); | |||||
| if (!tbody) return; | |||||
| sortableInstance = Sortable.create(tbody, { | |||||
| handle: '.drag-handle', | |||||
| animation: 150, | |||||
| ghostClass: 'sortable-ghost', | |||||
| onEnd(evt) { | |||||
| if (evt.oldIndex === evt.newIndex) return; | |||||
| const arr = list.value.slice(); | |||||
| const item = arr.splice(evt.oldIndex, 1)[0]; | |||||
| arr.splice(evt.newIndex, 0, item); | |||||
| list.value = arr; | |||||
| fetch('/admin/hospital/sort', { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify({ ids: arr.map(i => i.id) }) | |||||
| }).then(r => r.json()).then(res => { | |||||
| if (res.code === 0) ElementPlus.ElMessage.success('排序已保存'); | |||||
| else loadList(); | |||||
| }); | |||||
| } | |||||
| }); | |||||
| } | |||||
| function showAdd() { | |||||
| dialogTitle.value = '新增医院'; | |||||
| form.id = null; form.name = ''; form.sort = 0; form.is_show = 1; | |||||
| dialogVisible.value = true; | |||||
| } | |||||
| function onSearch() { | |||||
| page.value = 1; | |||||
| loadList(); | |||||
| } | |||||
| function resetQuery() { | |||||
| keyword.value = ''; | |||||
| page.value = 1; | |||||
| loadList(); | |||||
| } | |||||
| function onSizeChange() { | |||||
| page.value = 1; | |||||
| loadList(); | |||||
| } | |||||
| function showEdit(row) { | |||||
| dialogTitle.value = '编辑医院'; | |||||
| form.id = row.id; form.name = row.name; form.sort = row.sort; form.is_show = row.is_show; | |||||
| dialogVisible.value = true; | |||||
| } | |||||
| async function handleSave() { | |||||
| if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入医院名称'); return; } | |||||
| saving.value = true; | |||||
| try { | |||||
| const url = form.id ? '/admin/hospital/edit' : '/admin/hospital/add'; | |||||
| const res = await fetch(url, { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify(form) | |||||
| }).then(r => r.json()); | |||||
| if (res.code === 0) { | |||||
| ElementPlus.ElMessage.success('保存成功'); | |||||
| dialogVisible.value = false; | |||||
| loadList(); | |||||
| } else { | |||||
| ElementPlus.ElMessage.error(res.msg || '保存失败'); | |||||
| } | |||||
| } finally { saving.value = false; } | |||||
| } | |||||
| async function handleDelete(row) { | |||||
| try { | |||||
| await ElementPlus.ElMessageBox.confirm(`确定要删除「${row.name}」吗?`, '提示', { type: 'warning' }); | |||||
| const res = await fetch('/admin/hospital/delete', { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify({ id: row.id }) | |||||
| }).then(r => r.json()); | |||||
| if (res.code === 0) { ElementPlus.ElMessage.success('删除成功'); loadList(); } | |||||
| else { ElementPlus.ElMessage.error(res.msg || '删除失败'); } | |||||
| } catch (e) {} | |||||
| } | |||||
| async function handleToggle(row, val) { | |||||
| const res = await fetch('/admin/hospital/toggleShow', { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify({ id: row.id, is_show: val ? 1 : 0 }) | |||||
| }).then(r => r.json()); | |||||
| if (res.code === 0) { row.is_show = val ? 1 : 0; ElementPlus.ElMessage.success('已更新'); } | |||||
| else { ElementPlus.ElMessage.error(res.msg || '操作失败'); } | |||||
| } | |||||
| async function handleImport() { | |||||
| if (!importText.value.trim()) { ElementPlus.ElMessage.warning('请输入医院名称'); return; } | |||||
| importing.value = true; | |||||
| try { | |||||
| const res = await fetch('/admin/hospital/import', { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify({ text: importText.value }) | |||||
| }).then(r => r.json()); | |||||
| if (res.code === 0) { | |||||
| ElementPlus.ElMessage.success(`导入成功 ${res.data.count} 条,跳过 ${res.data.skipped} 条`); | |||||
| showImport.value = false; | |||||
| importText.value = ''; | |||||
| loadList(); | |||||
| } else { | |||||
| ElementPlus.ElMessage.error(res.msg || '导入失败'); | |||||
| } | |||||
| } finally { importing.value = false; } | |||||
| } | |||||
| onMounted(() => loadList()); | |||||
| 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 }; | |||||
| } | |||||
| }); | |||||
| app.use(ElementPlus, { locale: ElementPlusLocaleZhCn }); | |||||
| app.mount('#hospitalApp'); | |||||
| </script> | |||||
| {% endblock %} | |||||
| @@ -78,7 +78,7 @@ | |||||
| {% endblock %} | {% endblock %} | ||||
| {% block js %} | {% block js %} | ||||
| <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script> | |||||
| <script src="/static/lib/sortablejs/Sortable.min.js"></script> | |||||
| <script> | <script> | ||||
| const initRequired = '{{ sampleRequired }}'; | const initRequired = '{{ sampleRequired }}'; | ||||
| const { createApp, ref, reactive, onMounted, nextTick } = Vue; | const { createApp, ref, reactive, onMounted, nextTick } = Vue; | ||||
| @@ -56,7 +56,7 @@ | |||||
| {% endblock %} | {% endblock %} | ||||
| {% block js %} | {% block js %} | ||||
| <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script> | |||||
| <script src="/static/lib/sortablejs/Sortable.min.js"></script> | |||||
| <script> | <script> | ||||
| const canAdd = {{ canAdd | dump | safe }}; | const canAdd = {{ canAdd | dump | safe }}; | ||||
| const canEdit = {{ canEdit | dump | safe }}; | const canEdit = {{ canEdit | dump | safe }}; | ||||