Procházet zdrojové kódy

feat : 支持医院列表

master
leiyun před 2 týdny
rodič
revize
395d63c8a4
11 změnil soubory, kde provedl 445 přidání a 2 odebrání
  1. +11
    -0
      sql/hospital.sql
  2. +11
    -0
      src/config/router.js
  3. +110
    -0
      src/controller/admin/hospital.js
  4. +15
    -0
      src/controller/common.js
  5. +12
    -0
      src/controller/mp.js
  6. +14
    -0
      src/model/hospital.js
  7. +10
    -0
      view/admin/common/_sidebar.html
  8. +258
    -0
      view/admin/hospital_index.html
  9. +1
    -1
      view/admin/sample_type_index.html
  10. +1
    -1
      view/admin/tag_index.html
  11. +2
    -0
      www/static/lib/sortablejs/Sortable.min.js

+ 11
- 0
sql/hospital.sql Zobrazit soubor

@@ -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='医院列表';

+ 11
- 0
src/config/router.js Zobrazit soubor

@@ -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'],


+ 110
- 0
src/controller/admin/hospital.js Zobrazit soubor

@@ -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 });
}
};

+ 15
- 0
src/controller/common.js Zobrazit soubor

@@ -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


+ 12
- 0
src/controller/mp.js Zobrazit soubor

@@ -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,


+ 14
- 0
src/model/hospital.js Zobrazit soubor

@@ -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();
}
};

+ 10
- 0
view/admin/common/_sidebar.html Zobrazit soubor

@@ -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">


+ 258
- 0
view/admin/hospital_index.html Zobrazit soubor

@@ -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 %}

+ 1
- 1
view/admin/sample_type_index.html Zobrazit soubor

@@ -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;


+ 1
- 1
view/admin/tag_index.html Zobrazit soubor

@@ -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 }};


+ 2
- 0
www/static/lib/sortablejs/Sortable.min.js
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


Načítá se…
Zrušit
Uložit