提交 c446996b 作者: zhongye-dev

feat: 完善检疫员/实验员/角色/字典/日志管理、字段汇总表、新增报告单Tab及多处UI优化

上级 a31f0e53
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"> <!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0"><title>样本出库登记</title><link rel="stylesheet" href="../styles/main.css"></head><body><div class="page-placeholder"><div class="ph-icon">📤</div><div class="ph-title">样本出库登记</div><div class="ph-desc">样本出库、销毁与移交登记</div></div></body></html> <meta http-equiv="Expires" content="0"><title>样本出库管理</title><link rel="stylesheet" href="../styles/main.css"></head><body><div class="page-placeholder"><div class="ph-icon">📤</div><div class="ph-title">样本出库管理</div><div class="ph-desc">样本出库、销毁与移交登记</div></div></body></html>
\ No newline at end of file
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0"><title>样本存放管理</title><link rel="stylesheet" href="../styles/main.css"></head><body><div class="page-placeholder"><div class="ph-icon">🏷️</div><div class="ph-title">样本存放管理</div><div class="ph-desc">样本存放位置与温湿度记录管理</div></div></body></html>
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
<Sidebar /> <Sidebar />
</aside> </aside>
<main class="content"> <main class="content">
<div class="breadcrumb">{{ breadcrumb }}</div> <div class="breadcrumb">当前位置:{{ breadcrumbText }}</div>
<TabsBar /> <TabsBar />
<div class="content-body"> <div class="content-body">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
...@@ -26,5 +26,8 @@ import Sidebar from "@/components/Sidebar.vue"; ...@@ -26,5 +26,8 @@ import Sidebar from "@/components/Sidebar.vue";
import TabsBar from "@/components/TabsBar.vue"; import TabsBar from "@/components/TabsBar.vue";
const route = useRoute(); const route = useRoute();
const breadcrumb = computed(() => "当前位置:" + (route.meta?.breadcrumb || "植物检疫检测隔离中心智能管理平台")); const breadcrumbText = computed(() => {
const raw = route.meta?.breadcrumb || "植物检疫检测隔离中心智能管理平台";
return raw.split("/").map((s) => s.trim()).filter(Boolean).join(" > ");
});
</script> </script>
\ No newline at end of file
/* cache-bust 20260907 - 禁止浏览器缓存 */ /* cache-bust 20260907 - 禁止浏览器缓存 */
/* ========== 基础重置 ========== */ /* ========== 基础重置 ========== */
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { min-height: 100%; } html, body { min-height: 100%; }
...@@ -34,7 +34,7 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl ...@@ -34,7 +34,7 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl
z-index: 100; z-index: 100;
} }
.app-logo { .app-logo {
width: 380px; width: auto;
height: 100%; height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -43,7 +43,12 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl ...@@ -43,7 +43,12 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl
font-weight: bold; font-weight: bold;
letter-spacing: 1px; letter-spacing: 1px;
background: #d71e1e; background: #d71e1e;
color: #fff;
flex-shrink: 0;
white-space: nowrap;
overflow: visible;
} }
.file-hidden { display: none !important; }
.app-logo .logo-icon { .app-logo .logo-icon {
width: 30px; height: 30px; width: 30px; height: 30px;
margin-right: 10px; margin-right: 10px;
...@@ -86,7 +91,8 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl ...@@ -86,7 +91,8 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 12px; font-size: 12px;
margin-right: 6px; margin-right: 8px;
flex-shrink: 0;
} }
.app-user .user-name { margin-right: 12px; } .app-user .user-name { margin-right: 12px; }
.app-user .user-action { padding: 0 8px; cursor: pointer; opacity: 0.9; } .app-user .user-action { padding: 0 8px; cursor: pointer; opacity: 0.9; }
...@@ -114,6 +120,7 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl ...@@ -114,6 +120,7 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl
cursor: pointer; cursor: pointer;
color: #444; color: #444;
font-size: 16px; font-size: 16px;
font-weight: bold;
position: relative; position: relative;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
user-select: none; user-select: none;
...@@ -126,7 +133,7 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl ...@@ -126,7 +133,7 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: 14px;
color: #999; color: #d71e1e;
} }
.menu-item .menu-text { flex: 1; } .menu-item .menu-text { flex: 1; }
.menu-item .menu-arrow { .menu-item .menu-arrow {
...@@ -177,7 +184,7 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl ...@@ -177,7 +184,7 @@ input, select, textarea, button { font-family: inherit; font-size: inherit; outl
padding: 0 16px; padding: 0 16px;
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 14px; font-size: 16px;
color: #666; color: #666;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
background: #fafafa; background: #fafafa;
......
...@@ -2,16 +2,479 @@ ...@@ -2,16 +2,479 @@
<header class="app-header"> <header class="app-header">
<div class="app-logo"> <div class="app-logo">
<span class="logo-icon">植</span> <span class="logo-icon">植</span>
<span class="logo-text">植物检疫检测隔离中心智能管理平台</span> <span class="logo-text">植物检疫检测隔离中心管理平台</span>
<span class="logo-date">{{ currentDateText }}</span>
</div> </div>
<div class="app-user"> <div class="app-user">
<span class="user-avatar">管</span> <span class="user-bell" @click="onMessageClick">
<span class="user-name">管理员</span> <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true">
<span class="user-action">消息</span> <path d="M12 22a2 2 0 0 0 2-2h-4a2 2 0 0 0 2 2zm6-6V10a6 6 0 1 0-12 0v6l-2 2v1h16v-1l-2-2z"/>
<span class="user-action">设置</span> </svg>
<span class="user-action">退出</span> <span class="msg-badge">{{ notices.length }}</span>
</span>
<div class="user-dropdown" @click="toggleDropdown">
<span class="user-avatar"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style="display:block;"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></span>
<span class="user-name">甘州区系统管理员,您已登录</span>
<span class="dropdown-arrow" :class="{ open: dropdownOpen }">▼</span>
<div v-if="dropdownOpen" class="dropdown-menu" @click.stop>
<div class="dropdown-item" @click="onModifyPwd">修改密码</div>
<div class="dropdown-item dropdown-item-danger" @click="onLogout">退出</div>
</div>
</div>
</div>
<!-- 消息通知详情弹窗 -->
<div v-if="noticeDetailVisible" class="modal-mask" @click.self="noticeDetailVisible = false">
<div class="modal-box notice-detail">
<div class="modal-header notice-detail-header">
<span class="modal-title">消息通知</span>
<span class="modal-close" @click="noticeDetailVisible = false">×</span>
</div>
<div class="modal-body notice-detail-body notice-detail-layout">
<div class="notice-list">
<div class="notice-list-title">消息列表 ({{ notices.length }})</div>
<div v-for="n in notices" :key="n.id" class="notice-list-item" :class="{ active: currentNotice.id === n.id }" @click="selectNotice(n)">
<div class="notice-list-item-title">{{ n.title }}</div>
<div class="notice-list-item-meta"><span>{{ n.sender }}</span><span>{{ n.date }}</span></div>
</div>
</div>
<div class="notice-detail-pane">
<div class="notice-title-main">{{ currentNotice.title }}</div>
<div class="notice-meta">
<span>{{ currentNotice.sender }}</span>
<span>{{ currentNotice.fullDate }}</span>
</div>
<div class="notice-divider"></div>
<div v-if="currentNotice.body" class="notice-content">
<div v-if="currentNotice.bigTitle" class="notice-big-title">{{ currentNotice.bigTitle }}</div>
<div v-if="currentNotice.subtitle" class="notice-big-subtitle">{{ currentNotice.subtitle }}</div>
<div v-if="currentNotice.publishInfo" class="notice-pub-info">
<span>{{ currentNotice.publishInfo }}</span>
<span class="notice-font">字体:<a>大</a><a>中</a><a>小</a></span>
</div>
<div v-if="currentNotice.preParagraph" class="notice-pre-paragraph">{{ currentNotice.preParagraph }}</div>
<div v-if="currentNotice.sign || currentNotice.signDate" class="notice-sign-block">
<div v-if="currentNotice.sign" class="notice-sign-line">{{ currentNotice.sign }}</div>
<div v-if="currentNotice.signDate" class="notice-sign-line">{{ currentNotice.signDate }}</div>
</div>
<div v-if="currentNotice.bigTitle2" class="notice-big-title notice-big-title-2">{{ currentNotice.bigTitle2 }}</div>
<div v-if="currentNotice.subtitle2" class="notice-big-subtitle">{{ currentNotice.subtitle2 }}</div>
<div v-for="(p, pi) in currentNotice.paragraphs" :key="pi" class="notice-paragraph">{{ p }}</div>
<div v-if="currentNotice.attachments && currentNotice.attachments.length" class="notice-attachment-block">
<div class="notice-attachment-label">附件:</div>
<ul class="notice-attachment-list">
<li v-for="(a, ai) in currentNotice.attachments" :key="ai">
<span class="attach-no">{{ ai + 1 }}.</span>{{ a }}
</li>
</ul>
</div>
<div v-if="currentNotice.sourceUrl" class="notice-source">
来源链接:<a :href="currentNotice.sourceUrl" target="_blank">{{ currentNotice.sourceUrl }}</a>
</div>
</div>
<div v-if="currentNotice.files && currentNotice.files.length" class="notice-files">
<div class="notice-files-label">附件:</div>
<div v-if="currentNotice.fileLink" class="notice-file-link">
<a :href="currentNotice.fileLink" target="_blank" class="notice-file-link-a">{{ currentNotice.fileDisplayName || currentNotice.files[0] }}</a>
</div>
<div class="notice-files-grid">
<a v-for="(f, fi) in currentNotice.files" :key="fi" class="notice-file-item">
<div class="notice-file-icon">
<span :class="'file-icon-' + getFileType(f)"></span>
</div>
<div class="notice-file-name">{{ f }}</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</header> </header>
</template> </template>
<script setup></script> <script setup>
\ No newline at end of file import { ref, onMounted, onBeforeUnmount, computed } from "vue";
const dropdownOpen = ref(false);
const toggleDropdown = () => { dropdownOpen.value = !dropdownOpen.value; };
const onModifyPwd = () => { dropdownOpen.value = false; alert("修改密码"); };
const onLogout = () => { dropdownOpen.value = false; alert("退出"); };
const handleDocumentClick = (e) => {
const dropdownEl = document.querySelector(".user-dropdown");
if (dropdownEl && !dropdownEl.contains(e.target)) {
dropdownOpen.value = false;
}
};
const notices = [
{
id: 1,
title: "海关总署 农业农村部关于防范马铃薯斑纹片病菌传入的公告(盖章件)联合公告2026年72号",
date: "09/01",
fullDate: "2026-09-01",
sender: "金禾运维",
files: ["海关总署 农业农村部关于防范马铃薯斑纹片病菌传入的公告(盖章件)联合公告2026年72号.pdf"],
fileLink: "https://zbzj.agri.cn/upload/pq/sys/module/2026-09-01/ff808181a042d4b701a05b9f4b755b1d.pdf"
},
{
id: 2,
title: "中华人民共和国农业农村部公告 第829号",
date: "10/17",
fullDate: "2024-10-17",
sender: "系统管理员",
body: true,
bigTitle: "中华人民共和国农业农村部公告 第829号",
publishInfo: "2024年10月10日",
paragraphs: [
"为贯彻落实党中央、国务院关于优化政务服务、提升行政效能的部署要求,加快推进电子证照扩大应用领域和全国互通互认,促进政务服务标准化、规范化、便利化,农业农村部决定推行应用农业行政审批有关电子证照。现就有关事项公告如下:",
"一、自2024年11月1日起,在全国范围内推行应用农业农村部本级颁发的《农药登记证》、《农药登记试验单位证书》、《肥料登记证》、《兽药产品批准文号批件》、《国(境)外引进农业种苗检疫审批单》共5种电子证照。电子证照与纸质证照同步制发、并行使用,具有同等法律效力。自2025年1月1日起,不再制发相应的纸质证照。",
"二、电子证照分别加盖中华人民共和国农业农村部制发的专用章。",
"三、已经制发仍在有效期内的纸质证照可继续使用,直至下次换发时更换电子证照。",
"四、申请人可登录农业农村部政务服务平台(https://zwfw.moa.gov.cn),查看和下载电子证照。",
"五、社会公众可通过农业农村部政务服务平台核验电子证照有效性,或通过手机浏览器扫描电子证照二维码查询证照信息。",
"六、各级农业农村部门要充分认识推行应用电子证照的重要意义,更好发挥电子证照在行政审批减环节、减材料、减时限等方面的重要作用,凡通过电子证照可以获取的信息,不再要求提供相应纸质材料。"
],
attachments: [
"《农药登记证》电子证照样式",
"《农药登记试验单位证书》电子证照样式",
"《肥料登记证》(化学肥料)电子证照样式",
"《肥料登记证》(微生物肥料)电子证照样式",
"《兽药产品批准文号批件》电子证照样式",
"《国(境)外引进农业种苗检疫审批单》电子证照样式"
],
sourceUrl: "http://www.moa.gov.cn/govpublic/CYZCFGS/202410/t20241010_6464030.htm",
sign: "农业农村部",
signDate: "2024年10月7日",
files: ["附件1.pdf", "附件2.pdf", "附件3.pdf", "附件4.pdf", "附件5.pdf", "附件6.pdf"]
},
{
id: 3,
title: "植物检疫条例2017版",
date: "06/03",
fullDate: "2020-06-03",
sender: "系统管理员",
body: true,
bigTitle: "中华人民共和国国务院令",
subtitle: "第687号",
preParagraph: "《国务院关于修改〈植物检疫条例〉的决定》已经国务院批准,现予发布,自发布之日起施行。",
bigTitle2: "植物检疫条例",
subtitle2: "(1983年1月3日国务院发布。2017年10月7日根据《国务院关于修改〈植物检疫条例〉的决定》修订发布。)",
paragraphs: [
"第一条 为了防止为害植物的危险性病、虫、杂草传播蔓延,保护农业、林业生产安全,制定本条例。",
"第二条 国务院农业主管部门、林业主管部门主管全国的植物检疫工作,各省、自治区、直辖市农业主管部门、林业主管部门主管本地区的植物检疫工作。",
"第三条 县级以上地方各级农业主管部门、林业主管部门所属的植物检疫机构,负责执行国家的植物检疫任务。"
],
files: ["1531188661110_植物检疫条例2017版.doc"],
fileLink: "http://npp.demo.kexinnongye.com/upload/pq/yogurtUpload/permanent/plantlibrary/src/20200603/1531188661110_%E6%A4%8D%E7%89%A9%E6%A3%80%E7%96%AB%E6%9D%A1%E4%BE%8B2017%E7%89%88.doc",
fileDisplayName: "植物检疫条例2017版.docx"
},
{
id: 4,
title: "国外引种检疫审批管理办法(1993年11月10日农业部发布实施)",
date: "10/19",
fullDate: "1993-10-19",
sender: "系统管理员",
body: true,
bigTitle: "国外引种检疫审批管理办法",
subtitle: "(1993年11月10日农业部发布实施)",
paragraphs: [
"第一条 为了加强对国外(含境外,下同)引进种子、苗木和其他繁殖材料的检疫管理,根据《植物检疫条例》第十二条的规定,制定本办法。",
"第二条 从国外引进种子、苗木和其他繁殖材料,实行农业部和各省、自治区、直辖市农业厅(局)两级审批,其执行机构是农业部全国植物保护总站和各省、自治区、直辖市农业厅(局)植物检疫(植保植检)站。"
],
files: ["1498790231610_4国外引种检疫审批管理办法.docx"],
fileLink: "http://npp.demo.kexinnongye.com/upload/pq/yogurtUpload/permanent/plantlibrary/src/20201019/1498790231610_4%E5%9B%BD%E5%A4%96%E5%BC%95%E7%A7%8D%E6%A3%80%E7%96%AB%E5%AE%A1%E6%89%B9%E7%AE%A1%E7%90%86%E5%8A%9E%E6%B0%94.docx",
fileDisplayName: "国外引种检疫审批管理办法.docx"
}
];
const now = ref(new Date());
let timer = null;
onMounted(() => {
timer = setInterval(() => { now.value = new Date(); }, 1000);
document.addEventListener("click", handleDocumentClick);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
document.removeEventListener("click", handleDocumentClick);
});
const weekMap = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"];
const lunarInfo = [
{ m: 1, d: 1, txt: "农历正月初一" }, { m: 2, d: 1, txt: "农历正月末" }, { m: 3, d: 1, txt: "农历二月初一" }
];
function getLunarText(d) {
const m = d.getMonth() + 1;
const day = d.getDate();
const monthLunar = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"];
const dayLunar = ["初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十",
"十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
"廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十"];
if (day <= dayLunar.length) {
return `农历${monthLunar[m - 1]}月${dayLunar[day - 1]}`;
}
return "";
}
function getSolarTerm(d) {
const m = d.getMonth() + 1;
const day = d.getDate();
const terms = {
"9-23": "秋分"
};
const key = `${m}-${day}`;
return terms[key] || "";
}
const currentDateText = computed(() => {
const d = now.value;
const y = d.getFullYear();
const m = d.getMonth() + 1;
const day = d.getDate();
const week = weekMap[(d.getDay() + 6) % 7];
const lunar = getLunarText(d);
const term = getSolarTerm(d);
return `${y}年${m}月${day}日 ${week} ${lunar}${term ? " " + term : ""}`;
});
const noticeDetailVisible = ref(false);
const currentNotice = ref({});
const onMessageClick = () => {
if (notices.length > 0) {
currentNotice.value = notices[0];
noticeDetailVisible.value = true;
}
};
const selectNotice = (n) => {
currentNotice.value = n;
};
const getFileType = (filename) => {
if (!filename) return "pdf";
const ext = filename.split(".").pop().toLowerCase();
if (ext === "doc" || ext === "docx") return "doc";
if (ext === "xls" || ext === "xlsx") return "xls";
if (ext === "ppt" || ext === "pptx") return "ppt";
if (ext === "jpg" || ext === "jpeg" || ext === "png" || ext === "gif") return "img";
return "pdf";
};
</script>
<style scoped>
.logo-date {
margin-left: 14px;
font-size: 14px;
color: #fff;
opacity: 0.92;
white-space: nowrap;
font-weight: normal;
}
.user-bell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-right: 16px;
cursor: pointer;
color: #fff;
opacity: 0.95;
}
.user-bell:hover { opacity: 1; }
.user-bell .msg-badge {
position: absolute;
top: -2px;
right: -6px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #fff;
color: #d71e1e;
font-size: 12px;
font-weight: bold;
border-radius: 9px;
line-height: 1;
}
.user-dropdown {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 0 6px;
height: 100%;
}
.user-dropdown:hover { background: rgba(255,255,255,0.08); }
.user-dropdown .dropdown-arrow {
font-size: 10px;
color: #fff;
opacity: 0.9;
transition: transform 0.2s;
}
.user-dropdown .dropdown-arrow.open { transform: rotate(180deg); }
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
min-width: 140px;
background: #fff;
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
padding: 6px 0;
z-index: 200;
}
.dropdown-item {
padding: 10px 16px;
font-size: 14px;
color: #333;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover { background: #fef2f2; color: #d71e1e; }
.dropdown-item-danger { color: #d71e1e; }
.dropdown-item-danger:hover { background: #fef2f2; }
.msg-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
margin-left: 6px;
background: #fff;
color: #d71e1e;
font-size: 12px;
font-weight: bold;
border-radius: 9px;
line-height: 1;
}
/* 弹窗样式 */
.modal-mask {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
z-index: 999;
}
.modal-box {
background: #fff; border-radius: 6px; min-width: 480px; max-width: 92vw; max-height: 92vh; display: flex; flex-direction: column;
}
.notice-detail { width: 880px; max-width: 96vw; }
.modal-header { display: flex; align-items: center; padding: 14px 20px; border-bottom: 1px solid #f0f0f0; }
.modal-title { font-size: 16px; font-weight: bold; color: #333; flex: 1; }
.modal-close { font-size: 22px; cursor: pointer; color: #999; }
.modal-close:hover { color: #d71e1e; }
.notice-detail-header { background: #d71e1e; }
.notice-detail-header .modal-title { color: #fff; }
.notice-detail-header .modal-close { color: #fff; }
.modal-body { padding: 0; overflow-y: auto; }
.notice-detail-body { padding: 28px 36px 36px; max-height: 80vh; overflow-y: auto; }
.notice-detail-layout {
display: flex;
gap: 0;
padding: 0 !important;
max-height: 80vh;
overflow: hidden;
}
.notice-list {
width: 280px;
flex-shrink: 0;
border-right: 1px solid #f0f0f0;
background: #fafafa;
overflow-y: auto;
padding: 18px 0;
}
.notice-list-title {
padding: 0 18px 12px;
font-size: 14px;
font-weight: bold;
color: #1f3a5f;
border-bottom: 1px solid #f0f0f0;
}
.notice-list-item {
padding: 12px 18px;
cursor: pointer;
border-left: 3px solid transparent;
border-bottom: 1px solid #f5f5f5;
transition: background 0.15s;
}
.notice-list-item:hover { background: #f0f0f0; }
.notice-list-item.active {
background: #fff;
border-left-color: #d71e1e;
}
.notice-list-item-title {
font-size: 14px;
color: #333;
line-height: 1.5;
margin-bottom: 6px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notice-list-item-meta {
font-size: 12px;
color: #999;
display: flex;
justify-content: space-between;
}
.notice-detail-pane {
flex: 1;
padding: 28px 36px 36px;
overflow-y: auto;
}
.notice-title-main { font-size: 22px; font-weight: bold; color: #1f3a5f; text-align: center; margin-bottom: 10px; }
.notice-meta { text-align: center; font-size: 13px; color: #999; margin-bottom: 8px; }
.notice-meta span { margin: 0 12px; }
.notice-divider { height: 1px; background: #f0f0f0; margin: 16px 0 28px; }
.notice-big-title { font-size: 22px; font-weight: bold; text-align: center; color: #1f3a5f; margin: 6px 0; }
.notice-big-subtitle { font-size: 15px; text-align: center; color: #333; margin-bottom: 18px; }
.notice-big-title-2 { margin-top: 24px; }
.notice-pub-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; font-size: 13px; color: #666; }
.notice-font a { color: #1f3a5f; margin: 0 2px; cursor: pointer; }
.notice-font a:hover { color: #d71e1e; }
.notice-pre-paragraph { font-size: 15px; line-height: 1.9; text-indent: 2em; margin: 16px 0; color: #222; }
.notice-sign-block { text-align: right; margin: 24px 0; }
.notice-sign-line { font-size: 15px; color: #222; }
.notice-paragraph { font-size: 14px; line-height: 1.9; text-indent: 2em; margin: 8px 0; color: #333; }
.notice-attachment-block { margin-top: 18px; }
.notice-attachment-label { font-size: 14px; font-weight: bold; color: #333; margin-bottom: 8px; }
.notice-attachment-list { list-style: none; padding: 0 0 0 24px; margin: 0; }
.notice-attachment-list li { font-size: 14px; color: #333; line-height: 1.8; }
.notice-attachment-list .attach-no { font-weight: bold; margin-right: 4px; }
.notice-source { margin-top: 18px; font-size: 13px; color: #666; }
.notice-source a { color: #1f3a5f; word-break: break-all; }
.notice-source a:hover { color: #d71e1e; }
.notice-files { margin-top: 24px; }
.notice-files-label { font-size: 14px; color: #333; margin-bottom: 12px; }
.notice-files-grid { display: flex; flex-wrap: wrap; gap: 14px; }
.notice-file-item { width: 110px; padding: 8px; border: 1px solid #e6e8ec; border-radius: 4px; display: flex; flex-direction: column; align-items: center; text-align: center; transition: border-color 0.15s; }
.notice-file-item:hover { border-color: #d71e1e; }
.notice-file-icon { width: 36px; height: 44px; flex-shrink: 0; position: relative; background: linear-gradient(180deg, #f0f4fa 0%, #e6ecf5 100%); border-radius: 3px; margin-bottom: 6px; }
.notice-file-icon::before { content: ""; position: absolute; top: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 8px 8px 0; border-color: transparent #fff transparent transparent; }
.file-icon-pdf, .file-icon-doc, .file-icon-xls, .file-icon-ppt, .file-icon-img { position: absolute; bottom: 4px; left: 50%; transform: translateX(-50%); font-size: 11px; font-weight: bold; padding: 1px 4px; border-radius: 2px; color: #fff; }
.file-icon-pdf { background: #d71e1e; }
.file-icon-doc { background: #1f3a5f; }
.file-icon-xls { background: #2d8f3c; }
.file-icon-ppt { background: #d97706; }
.file-icon-img { background: #6b7280; }
.file-icon-pdf::before, .file-icon-doc::before, .file-icon-xls::before, .file-icon-ppt::before, .file-icon-img::before { content: attr(data-type); }
.file-icon-pdf::after { content: "PDF"; position: absolute; }
.file-icon-doc::after { content: "DOC"; position: absolute; }
.file-icon-xls::after { content: "XLS"; position: absolute; }
.file-icon-ppt::after { content: "PPT"; position: absolute; }
.file-icon-img::after { content: "IMG"; position: absolute; }
.notice-file-name { font-size: 12px; color: #333; word-break: break-all; max-height: 32px; overflow: hidden; }
.notice-file-link { margin-top: 12px; word-break: break-all; }
.notice-file-link-a { color: #1f3a5f; font-size: 12px; }
.notice-file-link-a:hover { color: #d71e1e; }
</style>
\ No newline at end of file
...@@ -10,8 +10,7 @@ export const menuConfig = [ ...@@ -10,8 +10,7 @@ export const menuConfig = [
id: "sample", name: "样本管理", icon: "Box", id: "sample", name: "样本管理", icon: "Box",
children: [ children: [
{ id: "sample-register", name: "样本登记入库", path: "/sample/register" }, { id: "sample-register", name: "样本登记入库", path: "/sample/register" },
{ id: "sample-storage", name: "样本存放管理", path: "/sample/storage" }, { id: "sample-out", name: "样本出库管理", path: "/sample/out" }
{ id: "sample-out", name: "样本出库登记", path: "/sample/out" }
] ]
}, },
{ {
...@@ -31,27 +30,27 @@ export const menuConfig = [ ...@@ -31,27 +30,27 @@ export const menuConfig = [
id: "isolation-monitor", name: "隔离监测", icon: "Monitor", id: "isolation-monitor", name: "隔离监测", icon: "Monitor",
children: [ children: [
{ id: "isolation-device", name: "设备总览", path: "/isolation/device" }, { id: "isolation-device", name: "设备总览", path: "/isolation/device" },
{ id: "isolation-data", name: "监测数据", path: "/isolation/data" }, { id: "isolation-data", name: "监测数据", path: "/isolation/data" }
{ id: "isolation-analysis", name: "监测数据分析", path: "/isolation/analysis" }
] ]
}, },
{ {
id: "analysis", name: "数据分析", icon: "DataAnalysis", path: "/analysis" id: "analysis", name: "风险分析", icon: "DataAnalysis", path: "/analysis"
},
{
id: "risk", name: "风险预警", icon: "Warning", path: "/risk"
}, },
{ {
id: "report", name: "报告管理", icon: "Notebook", path: "/report" id: "report", name: "报告管理", icon: "Notebook", path: "/report"
}, },
{ {
id: "audit", name: "审核发证", icon: "Stamp", path: "/audit/cert" id: "base", name: "基础信息管理", icon: "User",
children: [
{ id: "base-quarantine", name: "检疫员管理", path: "/base/quarantine" },
{ id: "base-experimenter", name: "实验员管理", path: "/base/experimenter" }
]
}, },
{ {
id: "settings", name: "系统设置", icon: "Setting", id: "settings", name: "系统设置", icon: "Setting",
children: [ children: [
{ id: "settings-users", name: "用户管理", path: "/settings/users" }, { id: "settings-users", name: "用户管理", path: "/settings/users" },
{ id: "settings-roles", name: "角色权限", path: "/settings/roles" }, { id: "settings-roles", name: "角色管理", path: "/settings/roles" },
{ id: "settings-dict", name: "数据字典", path: "/settings/dict" }, { id: "settings-dict", name: "数据字典", path: "/settings/dict" },
{ id: "settings-log", name: "操作日志", path: "/settings/log" } { id: "settings-log", name: "操作日志", path: "/settings/log" }
] ]
......
// 样本出库数据(按检疫单证号聚合)
export const sampleOutData = [
{
id: 1,
cert: "6204122022007095",
unit: "甘肃富赢种子高新科技有限责任公司",
plant: "翠菊",
variety: "翠菊",
inboundQty: "0.0013 千克",
events: [
{ time: "2022-04-10", outQty: "0.0005 千克", purpose: "样本抽检", sampler: "李文娟", samplerPhone: "13909310001", operator: "张明远", operatorPhone: "13809312222" },
{ time: "2022-04-12", outQty: "0.0004 千克", purpose: "隔离试种", sampler: "陈思远", samplerPhone: "13909320002", operator: "王芳", operatorPhone: "13909330003" }
]
},
{
id: 2,
cert: "6205022022007347",
unit: "甘肃富赢种子高新科技有限责任公司",
plant: "莴苣",
variety: "生菜",
inboundQty: "0.1500 千克",
events: [
{ time: "2022-04-12", outQty: "0.0500 千克", purpose: "样本抽检", sampler: "李文娟", samplerPhone: "13909310001", operator: "张明远", operatorPhone: "13809312222" },
{ time: "2022-04-14", outQty: "0.0800 千克", purpose: "隔离试种", sampler: "陈思远", samplerPhone: "13909320002", operator: "王芳", operatorPhone: "13909330003" }
]
},
{
id: 3,
cert: "6205022022007269",
unit: "甘肃富赢种子高新科技有限责任公司",
plant: "青花菜 (西兰花)",
variety: "西蓝花 VBR007",
inboundQty: "2.2700 千克",
events: [
{ time: "2022-04-15", outQty: "1.5000 千克", purpose: "样本抽检", sampler: "张建国", samplerPhone: "13809312222", operator: "张明远", operatorPhone: "13809312222" },
{ time: "2022-04-17", outQty: "0.5000 千克", purpose: "隔离试种", sampler: "陈思远", samplerPhone: "13909320002", operator: "王芳", operatorPhone: "13909330003" }
]
},
{
id: 4,
cert: "6204122022007087",
unit: "甘肃富赢种子高新科技有限责任公司",
plant: "青花菜 (西兰花)",
variety: "西蓝花 VBR005",
inboundQty: "0.2200 千克",
events: [
{ time: "2022-04-15", outQty: "0.1000 千克", purpose: "样本抽检", sampler: "张建国", samplerPhone: "13809312222", operator: "张明远", operatorPhone: "13809312222" },
{ time: "2022-04-17", outQty: "0.0800 千克", purpose: "隔离试种", sampler: "陈思远", samplerPhone: "13909320002", operator: "王芳", operatorPhone: "13909330003" }
]
},
{
id: 5,
cert: "6202442022007001",
unit: "甘肃富赢种子高新科技有限责任公司",
plant: "胡萝卜",
variety: "胡萝卜 VCR324",
inboundQty: "0.4900 千克",
events: [
{ time: "2022-04-18", outQty: "0.2000 千克", purpose: "样本抽检", sampler: "李文娟", samplerPhone: "13909310001", operator: "张明远", operatorPhone: "13809312222" },
{ time: "2022-04-20", outQty: "0.1500 千克", purpose: "隔离试种", sampler: "陈思远", samplerPhone: "13909320002", operator: "王芳", operatorPhone: "13909330003" }
]
},
{
id: 6,
cert: "6203122022007067",
unit: "甘肃杰尼尔种子有限公司",
plant: "辣椒",
variety: "80",
inboundQty: "9.8800 千克",
events: [
{ time: "2022-04-25", outQty: "5.0000 千克", purpose: "样本抽检", sampler: "赵文言", samplerPhone: "18993755577", operator: "黄海涛", operatorPhone: "13893705588" },
{ time: "2022-04-27", outQty: "4.5000 千克", purpose: "隔离试种", sampler: "陈思远", samplerPhone: "13909320002", operator: "王芳", operatorPhone: "13909330003" }
]
},
{
id: 7,
cert: "6203072022007093",
unit: "酒泉市酒洲种子有限责任公司",
plant: "番茄",
variety: "番茄",
inboundQty: "1.8100 千克",
events: [
{ time: "2022-04-08", outQty: "0.8000 千克", purpose: "样本抽检", sampler: "马世锋", samplerPhone: "13993708175", operator: "黄海涛", operatorPhone: "13893705588" },
{ time: "2022-04-10", outQty: "0.7000 千克", purpose: "隔离试种", sampler: "陈思远", samplerPhone: "13909320002", operator: "王芳", operatorPhone: "13909330003" }
]
},
{
id: 8,
cert: "6203092022007071",
unit: "张掖市瑞真种业有限公司",
plant: "五色菊",
variety: "五色菊",
inboundQty: "1.8700 千克",
events: [
{ time: "2022-04-20", outQty: "0.9000 千克", purpose: "样本抽检", sampler: "吴多志", samplerPhone: "13993662953", operator: "黄海涛", operatorPhone: "13893705588" },
{ time: "2022-04-22", outQty: "0.8000 千克", purpose: "隔离试种", sampler: "陈思远", samplerPhone: "13909320002", operator: "王芳", operatorPhone: "13909330003" }
]
},
{
id: 9,
cert: "6201162022007023",
unit: "张掖市亚太种业有限责任公司",
plant: "南瓜",
variety: "南瓜",
inboundQty: "3.2800 千克",
events: [
{ time: "2022-04-28", outQty: "1.5000 千克", purpose: "样本抽检", sampler: "姚庆库", samplerPhone: "18919369888", operator: "黄海涛", operatorPhone: "13893705588" },
{ time: "2022-04-30", outQty: "1.6000 千克", purpose: "隔离试种", sampler: "陈思远", samplerPhone: "13909320002", operator: "王芳", operatorPhone: "13909330003" }
]
},
{
id: 10,
cert: "6204122022007065",
unit: "甘肃富赢种子高新科技有限责任公司",
plant: "翠菊",
variety: "翠菊",
inboundQty: "0.0013 千克",
events: [
{ time: "2022-05-05", outQty: "0.0005 千克", purpose: "样本抽检", sampler: "李文娟", samplerPhone: "13909310001", operator: "张明远", operatorPhone: "13809312222" },
{ time: "2022-05-07", outQty: "0.0004 千克", purpose: "隔离试种", sampler: "陈思远", samplerPhone: "13909320002", operator: "王芳", operatorPhone: "13909330003" }
]
}
];
\ No newline at end of file
// 样本接收登记数据 // 样本接收登记数据
export const sampleRegisterData = [ export const sampleRegisterData = [
{ id: 1, cert: "6204122022007095", unit: "甘肃富赢种子高新科技有限责任公司", plant: "翠菊", variety: "翠菊", origin: "智利", qty: "0.0014 千克", approveQty: "40 千克", storeDate: "2022-03-01", contact: "刘玉堂", phone: "18993607501" }, { id: 1, cert: "6204122022007095", unit: "甘肃富赢种子高新科技有限责任公司", plant: "翠菊", variety: "翠菊", origin: "智利", qty: "0.0014 千克", storeQty: "0.0013 千克", entryPort: "昆明长水国际机场", approveQty: "40 千克", storeDate: "2022-03-01", contact: "刘玉堂", phone: "18993607501" },
{ id: 2, cert: "6205022022007347", unit: "甘肃富赢种子高新科技有限责任公司", plant: "莴苣", variety: "生菜", origin: "美国", qty: "0.2950 千克", approveQty: "50 千克", storeDate: "2022-03-20", contact: "刘玉堂", phone: "18993607501" }, { id: 2, cert: "6205022022007347", unit: "甘肃富赢种子高新科技有限责任公司", plant: "莴苣", variety: "生菜", origin: "美国", qty: "0.2950 千克", storeQty: "0.1500 千克", entryPort: "昆明长水国际机场", approveQty: "50 千克", storeDate: "2022-03-20", contact: "刘玉堂", phone: "18993607501" },
{ id: 3, cert: "6205022022007269", unit: "甘肃富赢种子高新科技有限责任公司", plant: "青花菜 (西兰花)", variety: "西蓝花 VBR007", origin: "美国", qty: "2.3920 千克", approveQty: "50 千克", storeDate: "2022-03-20", contact: "刘玉堂", phone: "18993607501" }, { id: 3, cert: "6205022022007269", unit: "甘肃富赢种子高新科技有限责任公司", plant: "青花菜 (西兰花)", variety: "西蓝花 VBR007", origin: "美国", qty: "2.3920 千克", storeQty: "2.2700 千克", entryPort: "昆明长水国际机场", approveQty: "50 千克", storeDate: "2022-03-20", contact: "刘玉堂", phone: "18993607501" },
{ id: 4, cert: "6204122022007087", unit: "甘肃富赢种子高新科技有限责任公司", plant: "青花菜 (西兰花)", variety: "西蓝花 VBR005", origin: "智利", qty: "0.3700 千克", approveQty: "50 千克", storeDate: "2022-03-20", contact: "刘玉堂", phone: "18993607501" }, { id: 4, cert: "6204122022007087", unit: "甘肃富赢种子高新科技有限责任公司", plant: "青花菜 (西兰花)", variety: "西蓝花 VBR005", origin: "智利", qty: "0.3700 千克", storeQty: "0.2200 千克", entryPort: "昆明长水国际机场", approveQty: "50 千克", storeDate: "2022-03-20", contact: "刘玉堂", phone: "18993607501" },
{ id: 5, cert: "6202442022007001", unit: "甘肃富赢种子高新科技有限责任公司", plant: "胡萝卜", variety: "胡萝卜 VCR324", origin: "南非", qty: "0.6150 千克", approveQty: "50 千克", storeDate: "2022-03-20", contact: "刘玉堂", phone: "18993607501" }, { id: 5, cert: "6202442022007001", unit: "甘肃富赢种子高新科技有限责任公司", plant: "胡萝卜", variety: "胡萝卜 VCR324", origin: "南非", qty: "0.6150 千克", storeQty: "0.4900 千克", entryPort: "昆明长水国际机场", approveQty: "50 千克", storeDate: "2022-03-20", contact: "刘玉堂", phone: "18993607501" },
{ id: 6, cert: "6203122022007067", unit: "甘肃杰尼尔种子有限公司", plant: "辣椒", variety: "80", origin: "西班牙", qty: "10.0 千克", approveQty: "10 千克", storeDate: "2022-04-01", contact: "赵文言", phone: "18993755577" }, { id: 6, cert: "6203122022007067", unit: "甘肃杰尼尔种子有限公司", plant: "辣椒", variety: "80", origin: "西班牙", qty: "10.0 千克", storeQty: "9.8800 千克", entryPort: "昆明长水国际机场", approveQty: "10 千克", storeDate: "2022-04-01", contact: "赵文言", phone: "18993755577" },
{ id: 7, cert: "6204122022007065", unit: "甘肃富赢种子高新科技有限责任公司", plant: "翠菊", variety: "翠菊", origin: "智利", qty: "0.0014 千克", approveQty: "40 千克", storeDate: "2022-02-18", contact: "刘玉堂", phone: "18993607501" }, { id: 7, cert: "6204122022007065", unit: "甘肃富赢种子高新科技有限责任公司", plant: "翠菊", variety: "翠菊", origin: "智利", qty: "0.0014 千克", storeQty: "0.0013 千克", entryPort: "昆明长水国际机场", approveQty: "40 千克", storeDate: "2022-02-18", contact: "刘玉堂", phone: "18993607501" },
{ id: 8, cert: "6203072022007093", unit: "酒泉市酒洲种子有限责任公司", plant: "番茄", variety: "番茄", origin: "意大利", qty: "1.9540 千克", approveQty: "50 千克", storeDate: "2022-03-10", contact: "马世锋", phone: "13993708175" }, { id: 8, cert: "6203072022007093", unit: "酒泉市酒洲种子有限责任公司", plant: "番茄", variety: "番茄", origin: "意大利", qty: "1.9540 千克", storeQty: "1.8100 千克", entryPort: "昆明长水国际机场", approveQty: "50 千克", storeDate: "2022-03-10", contact: "马世锋", phone: "13993708175" },
{ id: 9, cert: "6203092022007071", unit: "张掖市瑞真种业有限公司", plant: "五色菊", variety: "五色菊", origin: "荷兰", qty: "2.0000 千克", approveQty: "5 千克", storeDate: "2022-03-20", contact: "吴多志", phone: "13993662953" }, { id: 9, cert: "6203092022007071", unit: "张掖市瑞真种业有限公司", plant: "五色菊", variety: "五色菊", origin: "荷兰", qty: "2.0000 千克", storeQty: "1.8700 千克", entryPort: "昆明长水国际机场", approveQty: "5 千克", storeDate: "2022-03-20", contact: "吴多志", phone: "13993662953" },
{ id: 10, cert: "6201162022007023", unit: "张掖市亚太种业有限责任公司", plant: "南瓜", variety: "南瓜", origin: "日本", qty: "3.4000 千克", approveQty: "5 千克", storeDate: "2022-04-02", contact: "姚庆库", phone: "18919369888" } { id: 10, cert: "6201162022007023", unit: "张掖市亚太种业有限责任公司", plant: "南瓜", variety: "南瓜", origin: "日本", qty: "3.4000 千克", storeQty: "3.2800 千克", entryPort: "昆明长水国际机场", approveQty: "5 千克", storeDate: "2022-04-02", contact: "姚庆库", phone: "18919369888" }
]; ];
\ No newline at end of file
...@@ -3,27 +3,25 @@ import { useTabsStore } from "@/store/tabs"; ...@@ -3,27 +3,25 @@ import { useTabsStore } from "@/store/tabs";
const routes = [ const routes = [
{ path: "/", redirect: "/home" }, { path: "/", redirect: "/home" },
{ path: "/home", name: "home", component: () => import("@/views/HomeView.vue"), meta: { title: "首页", breadcrumb: "首页 / 数据看板" } }, { path: "/home", name: "home", component: () => import("@/views/HomeView.vue"), meta: { title: "首页", breadcrumb: "首页" } },
{ path: "/entrust/reg", name: "entrust-reg", component: () => import("@/views/entrust/EntrustReg.vue"), meta: { title: "委托书登记", breadcrumb: "委托书管理 / 委托书登记" } }, { path: "/entrust/reg", name: "entrust-reg", component: () => import("@/views/entrust/EntrustReg.vue"), meta: { title: "委托书登记", breadcrumb: "委托书管理 / 委托书登记" } },
{ path: "/entrust/list", name: "entrust-list", component: () => import("@/views/entrust/EntrustList.vue"), meta: { title: "委托书管理", breadcrumb: "委托书管理" } }, { path: "/entrust/list", name: "entrust-list", component: () => import("@/views/entrust/EntrustList.vue"), meta: { title: "委托书管理", breadcrumb: "委托书管理" } },
{ path: "/entrust/audit", name: "entrust-audit", component: () => import("@/views/entrust/EntrustAudit.vue"), meta: { title: "委托书审核", breadcrumb: "委托书管理 / 委托书审核" } }, { path: "/entrust/audit", name: "entrust-audit", component: () => import("@/views/entrust/EntrustAudit.vue"), meta: { title: "委托书审核", breadcrumb: "委托书管理 / 委托书审核" } },
{ path: "/entrust/archive", name: "entrust-archive", component: () => import("@/views/entrust/EntrustArchive.vue"), meta: { title: "委托书归档", breadcrumb: "委托书管理 / 委托书归档" } }, { path: "/entrust/archive", name: "entrust-archive", component: () => import("@/views/entrust/EntrustArchive.vue"), meta: { title: "委托书归档", breadcrumb: "委托书管理 / 委托书归档" } },
{ path: "/sample/register", name: "sample-register", component: () => import("@/views/sample/SampleRegister.vue"), meta: { title: "样本登记入库", breadcrumb: "样本管理 / 样本登记入库" } }, { path: "/sample/register", name: "sample-register", component: () => import("@/views/sample/SampleRegister.vue"), meta: { title: "样本登记入库", breadcrumb: "样本管理 / 样本登记入库" } },
{ path: "/sample/storage", name: "sample-storage", component: () => import("@/views/sample/SampleStorage.vue"), meta: { title: "样本存放管理", breadcrumb: "样本管理 / 样本存放管理" } }, { path: "/sample/out", name: "sample-out", component: () => import("@/views/sample/SampleOut.vue"), meta: { title: "样本出库管理", breadcrumb: "样本管理 / 样本出库管理" } },
{ path: "/sample/out", name: "sample-out", component: () => import("@/views/sample/SampleOut.vue"), meta: { title: "样本出库登记", breadcrumb: "样本管理 / 样本出库登记" } },
{ path: "/lab/sample-test", name: "lab-sample-test", component: () => import("@/views/lab/SampleTest.vue"), meta: { title: "样本抽检", breadcrumb: "实验室检测 / 样本抽检" } }, { path: "/lab/sample-test", name: "lab-sample-test", component: () => import("@/views/lab/SampleTest.vue"), meta: { title: "样本抽检", breadcrumb: "实验室检测 / 样本抽检" } },
{ path: "/lab/experiment-test", name: "lab-experiment-test", component: () => import("@/views/lab/ExperimentTest.vue"), meta: { title: "实验检验", breadcrumb: "实验室检测 / 实验检验" } }, { path: "/lab/experiment-test", name: "lab-experiment-test", component: () => import("@/views/lab/ExperimentTest.vue"), meta: { title: "实验检验", breadcrumb: "实验室检测 / 实验检验" } },
{ path: "/field/survey", name: "field-survey", component: () => import("@/views/field/FieldSurvey.vue"), meta: { title: "田间调查", breadcrumb: "田间调查" } }, { path: "/field/survey", name: "field-survey", component: () => import("@/views/field/FieldSurvey.vue"), meta: { title: "田间调查", breadcrumb: "田间调查" } },
{ path: "/disposal", name: "disposal-list", component: () => import("@/views/pest/PestHandle.vue"), meta: { title: "除害处理", breadcrumb: "除害处理 / 除害处理" } }, { path: "/disposal", name: "disposal-list", component: () => import("@/views/pest/PestHandle.vue"), meta: { title: "除害处理", breadcrumb: "除害处理 / 除害处理" } },
{ path: "/isolation/device", name: "isolation-device", component: () => import("@/views/isolation/DeviceOverview.vue"), meta: { title: "设备总览", breadcrumb: "隔离监测 / 设备总览" } }, { path: "/isolation/device", name: "isolation-device", component: () => import("@/views/isolation/DeviceOverview.vue"), meta: { title: "设备总览", breadcrumb: "隔离监测 / 设备总览" } },
{ path: "/isolation/data", name: "isolation-data", component: () => import("@/views/isolation/MonitorData.vue"), meta: { title: "监测数据", breadcrumb: "隔离监测 / 监测数据" } }, { path: "/isolation/data", name: "isolation-data", component: () => import("@/views/isolation/MonitorData.vue"), meta: { title: "监测数据", breadcrumb: "隔离监测 / 监测数据" } },
{ path: "/isolation/analysis", name: "isolation-analysis", component: () => import("@/views/isolation/MonitorAnalysis.vue"), meta: { title: "监测数据分析", breadcrumb: "隔离监测 / 监测数据分析" } }, { path: "/analysis", name: "analysis", component: () => import("@/views/analysis/DataAnalysis.vue"), meta: { title: "风险分析", breadcrumb: "风险分析" } },
{ path: "/analysis", name: "analysis", component: () => import("@/views/analysis/DataAnalysis.vue"), meta: { title: "数据分析", breadcrumb: "数据分析" } },
{ path: "/risk", name: "risk", component: () => import("@/views/risk/RiskWarning.vue"), meta: { title: "风险预警", breadcrumb: "风险预警" } },
{ path: "/report", name: "report", component: () => import("@/views/report/ReportManage.vue"), meta: { title: "报告管理", breadcrumb: "报告管理" } }, { path: "/report", name: "report", component: () => import("@/views/report/ReportManage.vue"), meta: { title: "报告管理", breadcrumb: "报告管理" } },
{ path: "/audit/cert", name: "audit-cert", component: () => import("@/views/audit/AuditCert.vue"), meta: { title: "审核发证", breadcrumb: "审核发证" } }, { path: "/base/quarantine", name: "base-quarantine", component: () => import("@/views/base/Quarantine.vue"), meta: { title: "检疫员管理", breadcrumb: "基础信息管理 / 检疫员管理" } },
{ path: "/base/experimenter", name: "base-experimenter", component: () => import("@/views/base/Experimenter.vue"), meta: { title: "实验员管理", breadcrumb: "基础信息管理 / 实验员管理" } },
{ path: "/settings/users", name: "settings-users", component: () => import("@/views/settings/Users.vue"), meta: { title: "用户管理", breadcrumb: "系统设置 / 用户管理" } }, { path: "/settings/users", name: "settings-users", component: () => import("@/views/settings/Users.vue"), meta: { title: "用户管理", breadcrumb: "系统设置 / 用户管理" } },
{ path: "/settings/roles", name: "settings-roles", component: () => import("@/views/settings/Roles.vue"), meta: { title: "角色权限", breadcrumb: "系统设置 / 角色权限" } }, { path: "/settings/roles", name: "settings-roles", component: () => import("@/views/settings/Roles.vue"), meta: { title: "角色管理", breadcrumb: "系统设置 / 角色管理" } },
{ path: "/settings/dict", name: "settings-dict", component: () => import("@/views/settings/Dict.vue"), meta: { title: "数据字典", breadcrumb: "系统设置 / 数据字典" } }, { path: "/settings/dict", name: "settings-dict", component: () => import("@/views/settings/Dict.vue"), meta: { title: "数据字典", breadcrumb: "系统设置 / 数据字典" } },
{ path: "/settings/log", name: "settings-log", component: () => import("@/views/settings/Log.vue"), meta: { title: "操作日志", breadcrumb: "系统设置 / 操作日志" } } { path: "/settings/log", name: "settings-log", component: () => import("@/views/settings/Log.vue"), meta: { title: "操作日志", breadcrumb: "系统设置 / 操作日志" } }
]; ];
......
<template> <template>
<div class="page-home"> <div class="page-home">
<div class="welcome-bar"> <div class="welcome-bar">
<span>欢迎进入植物检疫检测隔离中心智能管理平台</span> <span>欢迎进入植物检疫检测隔离中心管理平台</span>
</div> </div>
<div class="quick-row"> <div class="quick-row">
<div class="quick-card" v-for="q in quicks" :key="q.id" @click="goTo(q.path)"> <div class="quick-card" v-for="q in quicks" :key="q.id" @click="goTo(q.path)">
...@@ -15,79 +15,558 @@ ...@@ -15,79 +15,558 @@
<div class="stat-info"> <div class="stat-info">
<div class="stat-label">{{ s.label }}</div> <div class="stat-label">{{ s.label }}</div>
<div class="stat-value">{{ s.value }}</div> <div class="stat-value">{{ s.value }}</div>
<div class="stat-extra">{{ s.extra }}</div> <div v-if="s.extra" class="stat-extra">{{ s.extra }}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="row-2"> <div class="row-2">
<div class="panel"> <div class="panel">
<div class="panel-header"><span class="panel-title">月度检疫统计</span></div> <div class="panel-header">
<div class="panel-body"><div class="chart-box">图表占位 - 月度检疫趋势</div></div> <span class="panel-title">待办事项</span>
<span class="more-btn" @click="todoMoreVisible = true">更多 ›</span>
</div>
<div class="panel-body">
<table class="simple-table">
<thead><tr><th>序号</th><th>待办事项</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="t in todos.slice(0, 3)" :key="t.id">
<td>{{ t.id }}</td>
<td>{{ t.title }}</td>
<td><a class="op-link" @click="goTodo(t)">办理</a></td>
</tr>
</tbody>
</table>
</div>
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-header"><span class="panel-title">风险等级分布</span></div> <div class="panel-header">
<div class="panel-body"><div class="chart-box">图表占位 - 风险等级分布</div></div> <span class="panel-title">消息通知</span>
<span class="more-btn" @click="noticeMoreVisible = true">更多 ›</span>
</div>
<div class="panel-body">
<ul class="notice-list">
<li v-for="(n, i) in notices.slice(0, 4)" :key="n.id" @click="onNoticeClick(n)">
<span class="notice-index">{{ i + 1 }}.</span>
<span class="notice-text">{{ n.title }}</span>
<span class="notice-date">{{ n.date }}</span>
</li>
</ul>
</div>
</div> </div>
</div> </div>
<div class="row-2"> <div class="row-2">
<div class="panel"> <div class="panel">
<div class="panel-header"><span class="panel-title">待办事项</span></div> <div class="panel-header">
<span class="panel-title">月度检疫统计</span>
<div class="panel-tabs">
<span :class="['tab-item', { active: activeTab === '2026' }]" @click="activeTab = '2026'">2026 年</span>
<span :class="['tab-item', { active: activeTab === '2025' }]" @click="activeTab = '2025'">2025 年</span>
</div>
</div>
<div class="panel-body"> <div class="panel-body">
<table class="simple-table"> <table class="simple-table stat-table">
<thead><tr><th>单证号</th><th>类型</th><th>紧急度</th><th>操作</th></tr></thead> <thead>
<tr>
<th>月份</th>
<th>样本登记</th>
<th>田间调查</th>
<th>实验室检测</th>
<th>风险处置</th>
<th>检出率</th>
</tr>
</thead>
<tbody> <tbody>
<tr v-for="t in todos" :key="t.id"> <tr v-for="(m, i) in currentMonths" :key="i">
<td>{{ t.cert }}</td><td>{{ t.type }}</td><td>{{ t.level }}</td> <td>{{ m.label }}</td>
<td><a class="op-link">办理</a></td> <td>{{ m.register }}</td>
<td>{{ m.survey }}</td>
<td>{{ m.test }}</td>
<td>{{ m.handle }}</td>
<td>
<div class="rate-bar">
<div class="rate-fill" :style="{ width: m.rate + '%' }"></div>
<span>{{ m.rate }}%</span>
</div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="chart-row">
<div class="chart-box-inner">
<div class="mini-chart-title">
月度登记入库趋势
<span class="chart-unit">单位:批</span>
</div>
<div class="chart-area" @click="onChartBgClick('register')">
<svg viewBox="0 0 480 200" class="mini-svg" preserveAspectRatio="none">
<g class="grid">
<line v-for="g in 6" :key="'gx' + g" x1="35" :y1="170 - (g - 1) * 33" x2="470" :y2="170 - (g - 1) * 33" stroke="#f0f0f0" stroke-width="1"/>
</g>
<g class="y-axis">
<line x1="35" y1="5" x2="35" y2="170" stroke="#333" stroke-width="1"/>
<text v-for="(t, i) in registerYLabels" :key="'gy' + i" :x="30" :y="170 - i * 33 + 4" font-size="10" fill="#666" text-anchor="end">{{ t }}</text>
</g>
<g class="x-axis">
<line x1="35" y1="170" x2="470" y2="170" stroke="#333" stroke-width="1"/>
<text v-for="(m, i) in currentMonths" :key="'xt' + i" :x="35 + (i / (currentMonths.length - 1)) * 435" y="185" font-size="10" fill="#666" text-anchor="middle">{{ m.label }}</text>
</g>
<polyline :points="registerLinePoints" stroke="#d71e1e" stroke-width="2" fill="none"/>
<circle v-for="(p, i) in registerPoints" :key="'c' + i" :cx="p.x" :cy="p.y" :r="hoverIdx === i && chartKey === 'register' ? 5 : 3" :fill="hoverIdx === i && chartKey === 'register' ? '#fff' : '#d71e1e'" :stroke="'#d71e1e'" stroke-width="2" style="cursor:pointer" @click.stop="onHover(i, 'register')"/>
</svg>
<div v-if="hoverIdx !== null && chartKey === 'register'" class="chart-tip" :style="{ left: registerTipLeft, top: (registerPoints[hoverIdx].y - 8) + 'px' }">
<div class="tip-title">{{ currentMonths[hoverIdx].label }}</div>
<div class="tip-row"><span class="tip-dot" style="background:#d71e1e"></span>登记入库:<b>{{ currentMonths[hoverIdx].register }}</b> 批</div>
</div>
</div>
</div>
<div class="chart-box-inner">
<div class="mini-chart-title">
月度风险处置趋势
<span class="chart-unit">单位:起</span>
</div>
<div class="chart-area" @click="onChartBgClick('handle')">
<svg viewBox="0 0 480 200" class="mini-svg" preserveAspectRatio="none">
<g class="grid">
<line v-for="g in 6" :key="'hx' + g" x1="35" :y1="170 - (g - 1) * 33" x2="470" :y2="170 - (g - 1) * 33" stroke="#f0f0f0" stroke-width="1"/>
</g>
<g class="y-axis">
<line x1="35" y1="5" x2="35" y2="170" stroke="#333" stroke-width="1"/>
<text v-for="(t, i) in handleYLabels" :key="'hy' + i" :x="30" :y="170 - i * 33 + 4" font-size="10" fill="#666" text-anchor="end">{{ t }}</text>
</g>
<g class="x-axis">
<line x1="35" y1="170" x2="470" y2="170" stroke="#333" stroke-width="1"/>
<text v-for="(m, i) in currentMonths" :key="'hxt' + i" :x="35 + (i / (currentMonths.length - 1)) * 435" y="185" font-size="10" fill="#666" text-anchor="middle">{{ m.label }}</text>
</g>
<polyline :points="handleLinePoints" stroke="#1f3a5f" stroke-width="2" fill="none"/>
<circle v-for="(p, i) in handlePoints" :key="'hc' + i" :cx="p.x" :cy="p.y" :r="hoverIdx === i && chartKey === 'handle' ? 5 : 3" :fill="hoverIdx === i && chartKey === 'handle' ? '#fff' : '#1f3a5f'" :stroke="'#1f3a5f'" stroke-width="2" style="cursor:pointer" @click.stop="onHover(i, 'handle')"/>
</svg>
<div v-if="hoverIdx !== null && chartKey === 'handle'" class="chart-tip" :style="{ left: handleTipLeft, top: (handlePoints[hoverIdx].y - 8) + 'px' }">
<div class="tip-title">{{ currentMonths[hoverIdx].label }}</div>
<div class="tip-row"><span class="tip-dot" style="background:#1f3a5f"></span>风险处置:<b>{{ currentMonths[hoverIdx].handle }}</b> 起</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-header"><span class="panel-title">风险预警</span></div> <div class="panel-header"><span class="panel-title">风险等级分布</span></div>
<div class="panel-body"> <div class="panel-body">
<div class="risk-level-list">
<div class="risk-level-item" v-for="r in riskLevels" :key="r.level">
<div class="rl-left">
<span class="rl-dot" :style="{ background: r.color }"></span>
<span class="rl-name">{{ r.level }}</span>
</div>
<div class="rl-mid">
<div class="rl-bar">
<div class="rl-fill" :style="{ width: r.pct + '%', background: r.color }"></div>
</div>
</div>
<div class="rl-right">
<span class="rl-count">{{ r.count }}</span>
<span class="rl-pct">{{ r.pct }}%</span>
</div>
</div>
</div>
<div class="risk-summary">
<div class="rs-item">
<div class="rs-label">本季度累计</div>
<div class="rs-value">{{ totalRisk }} 起</div>
</div>
<div class="rs-item">
<div class="rs-label">已处置</div>
<div class="rs-value">{{ handledRisk }} 起</div>
</div>
<div class="rs-item">
<div class="rs-label">处置率</div>
<div class="rs-value">{{ handleRate }}%</div>
</div>
</div>
<div class="risk-legend">
<div class="legend-title">风险等级说明</div>
<ul class="legend-list">
<li v-for="r in riskLevels" :key="'lg' + r.level">
<span class="legend-dot" :style="{ background: r.color }"></span>
<div class="legend-text">
<div class="legend-name">{{ r.level }}</div>
<div class="legend-desc">{{ r.desc }}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<div v-if="todoMoreVisible" class="modal-mask" @click.self="todoMoreVisible = false">
<div class="modal-box">
<div class="modal-header">
<span>所有待办事项</span>
<span class="modal-close" @click="todoMoreVisible = false">×</span>
</div>
<div class="modal-body">
<table class="simple-table"> <table class="simple-table">
<thead><tr><th>预警编号</th><th>类型</th><th>时间</th><th>状态</th></tr></thead> <thead><tr><th>序号</th><th>待办事项</th><th>操作</th></tr></thead>
<tbody> <tbody>
<tr v-for="w in warnings" :key="w.id"> <tr v-for="t in todos" :key="t.id">
<td>{{ w.id }}</td><td>{{ w.type }}</td><td>{{ w.time }}</td><td>{{ w.status }}</td> <td>{{ t.id }}</td>
<td>{{ t.title }}</td>
<td><a class="op-link" @click="goTodo(t); todoMoreVisible = false">办理</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<div v-if="noticeDetailVisible" class="modal-mask" @click.self="noticeDetailVisible = false">
<div class="modal-box notice-detail">
<div class="modal-header notice-detail-header">
<span>通知公告</span>
<span class="modal-close" @click="noticeDetailVisible = false">×</span>
</div>
<div class="modal-body notice-detail-body">
<div class="notice-title-main">{{ currentNotice.title }}</div>
<div class="notice-meta">
<span>发件人:{{ currentNotice.sender }}</span>
<span>发布日期:{{ currentNotice.fullDate }}</span>
</div>
<div class="notice-divider"></div>
<div v-if="currentNotice.body" class="notice-content">
<div v-if="currentNotice.bigTitle" class="notice-big-title">
{{ currentNotice.bigTitle }}
<div v-if="currentNotice.subtitle" class="notice-big-subtitle">{{ currentNotice.subtitle }}</div>
</div>
<div v-if="currentNotice.publishInfo" class="notice-pub-info">
<span>发布时间:{{ currentNotice.publishInfo }}</span>
<span class="notice-font">字体:<a>大</a><a>中</a><a>小</a></span>
</div>
<div v-if="currentNotice.preParagraph" class="notice-pre-paragraph">{{ currentNotice.preParagraph }}</div>
<div v-if="currentNotice.sign || currentNotice.signDate" class="notice-sign-block">
<div v-if="currentNotice.sign" class="notice-sign-line">{{ currentNotice.sign }}</div>
<div v-if="currentNotice.signDate" class="notice-sign-line">{{ currentNotice.signDate }}</div>
</div>
<div v-if="currentNotice.bigTitle2" class="notice-big-title notice-big-title-2">
{{ currentNotice.bigTitle2 }}
<div v-if="currentNotice.subtitle2" class="notice-big-subtitle">{{ currentNotice.subtitle2 }}</div>
</div>
<div v-for="(p, pi) in currentNotice.paragraphs" :key="pi" class="notice-paragraph">{{ p }}</div>
<div v-if="currentNotice.attachments && currentNotice.attachments.length" class="notice-attachment-block">
<div class="notice-attachment-label">附件:</div>
<ul class="notice-attachment-list">
<li v-for="(a, ai) in currentNotice.attachments" :key="ai">
<span class="attach-no">{{ ai + 1 }}.</span>
<span class="attach-name">{{ a }}</span>
</li>
</ul>
</div>
<div v-if="currentNotice.sourceUrl" class="notice-source">
原链接:<a :href="currentNotice.sourceUrl" target="_blank">{{ currentNotice.sourceUrl }}</a>
</div>
<div v-if="currentNotice.sign && currentNotice.body && !currentNotice.signBlock" class="notice-sign">{{ currentNotice.sign }}</div>
<div v-if="currentNotice.signDate && currentNotice.body && !currentNotice.signBlock" class="notice-sign-date">{{ currentNotice.signDate }}</div>
</div>
<div v-if="currentNotice.files && currentNotice.files.length" class="notice-files">
<div class="notice-files-label">附件:</div>
<div v-if="currentNotice.fileLink" class="notice-file-link">
<a :href="currentNotice.fileLink" target="_blank" class="notice-file-link-a">{{ currentNotice.fileDisplayName || currentNotice.files[0] }}</a>
</div>
<div class="notice-files-grid">
<a v-for="(f, fi) in currentNotice.files" :key="fi" class="notice-file-item">
<div class="notice-file-icon">
<div :class="['file-icon', getFileType(f)]">{{ getFileType(f).toUpperCase() }}</div>
</div>
<div class="notice-file-name">{{ f }}</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
const router = useRouter(); const router = useRouter();
const quicks = [ const quicks = [
{ id: 1, name: "委托书登记", path: "/entrust/reg", icon: "Document" }, { id: 1, name: "样本登记入库", path: "/sample/register", icon: "Box" },
{ id: 2, name: "样本接收", path: "/sample/register", icon: "Box" }, { id: 2, name: "田间调查", path: "/field/survey", icon: "Document" },
{ id: 3, name: "样本检疫", path: "/sample/quarantine", icon: "MagicStick" }, { id: 3, name: "监测数据", path: "/isolation/data", icon: "DataLine" },
{ id: 4, name: "数据看板", path: "/analysis", icon: "DataAnalysis" }, { id: 4, name: "风险分析", path: "/analysis", icon: "DataAnalysis" }
{ id: 5, name: "风险预警", path: "/risk", icon: "Warning" },
{ id: 6, name: "审核发证", path: "/audit/cert", icon: "Stamp" }
]; ];
const stats = [ const stats = [
{ label: "今日样本接收", value: "28", extra: "环比 +12%", icon: "Box" }, { label: "今日样本接收", value: "28", extra: "环比 +12%", icon: "Box" },
{ label: "在检样本", value: "142", extra: "较昨日 +6", icon: "MagicStick" }, { label: "在检样本", value: "142", extra: "较昨日 +6", icon: "MagicStick" },
{ label: "待办任务", value: "37", extra: "其中紧急 5", icon: "Bell" }, { label: "待办任务", value: "37", extra: "", icon: "Bell" },
{ label: "本月报告", value: "23", extra: "已发证 19", icon: "Notebook" } { label: "本月报告", value: "23", extra: "", icon: "Notebook" }
]; ];
const todos = [ const todos = [
{ id: 1, cert: "6201122022007218", type: "田间调查", level: "紧急" }, { id: 1, title: "28 份样本待入库", path: "/sample/register" },
{ id: 2, cert: "6201122022007080", type: "实验室检测", level: "普通" }, { id: 2, title: "42 份样本待实验室检验", path: "/lab/sample-test" },
{ id: 3, cert: "6201162022007023", type: "结果审核", level: "普通" } { id: 3, title: "50 份样本待田间调查", path: "/field/survey" },
{ id: 4, title: "5 份样本待除害处理", path: "/disposal" }
]; ];
const warnings = [ const notices = [
{ id: "W001", type: "异常疫情", time: "2026-09-07 10:21", status: "未处理" }, {
{ id: "W002", type: "检疫超时", time: "2026-09-06 16:05", status: "处理中" } id: 1,
title: "海关总署 农业农村部关于防范马铃薯斑纹片病菌传入的公告(盖章件)联合公告2026年72号",
date: "09/01",
fullDate: "2026-09-01",
sender: "金禾运维",
files: ["海关总署 农业农村部关于防范马铃薯斑纹片病菌传入的公告(盖章件)联合公告2026年72号.pdf"],
fileLink: "https://zbzj.agri.cn/upload/pq/sys/module/2026-09-01/ff808181a042d4b701a05b9f4b755b1d.pdf"
},
{
id: 2,
title: "中华人民共和国农业农村部公告 第829号",
date: "10/17",
fullDate: "2024-10-17",
sender: "系统管理员",
body: true,
bigTitle: "中华人民共和国农业农村部公告 第829号",
publishInfo: "2024年10月10日",
paragraphs: [
"为贯彻落实党中央、国务院关于优化政务服务、提升行政效能的部署要求,加快推进电子证照扩大应用领域和全国互通互认,促进政务服务标准化、规范化、便利化,农业农村部决定推行应用农业行政审批有关电子证照。现就有关事项公告如下:",
"一、自2024年11月1日起,在全国范围内推行应用农业农村部本级颁发的《农药登记证》、《农药登记试验单位证书》、《肥料登记证》、《兽药产品批准文号批件》、《国(境)外引进农业种苗检疫审批单》共5种电子证照。电子证照与纸质证照同步制发、并行使用,具有同等法律效力。自2025年1月1日起,不再制发相应的纸质证照。",
"二、电子证照分别加盖中华人民共和国农业农村部制发的专用章。",
"三、已经制发仍在有效期内的纸质证照可继续使用,直至下次换发时更换电子证照。",
"四、申请人可登录农业农村部政务服务平台(https://zwfw.moa.gov.cn),查看和下载电子证照。",
"五、社会公众可通过农业农村部政务服务平台核验电子证照有效性,或通过手机浏览器扫描电子证照二维码查询证照信息。",
"六、各级农业农村部门要充分认识推行应用电子证照的重要意义,更好发挥电子证照在行政审批减环节、减材料、减时限等方面的重要作用,凡通过电子证照可以获取的信息,不再要求提供相应纸质材料。"
],
attachments: [
"《农药登记证》电子证照样式",
"《农药登记试验单位证书》电子证照样式",
"《肥料登记证》(化学肥料)电子证照样式",
"《肥料登记证》(微生物肥料)电子证照样式",
"《兽药产品批准文号批件》电子证照样式",
"《国(境)外引进农业种苗检疫审批单》电子证照样式"
],
sourceUrl: "http://www.moa.gov.cn/govpublic/CYZCFGS/202410/t20241010_6464030.htm",
sign: "农业农村部",
signDate: "2024年10月7日",
files: ["附件1.pdf", "附件2.pdf", "附件3.pdf", "附件4.pdf", "附件5.pdf", "附件6.pdf"]
},
{
id: 3,
title: "植物检疫条例2017版",
date: "06/03",
fullDate: "2020-06-03",
sender: "系统管理员",
body: true,
bigTitle: "中华人民共和国国务院令",
subtitle: "第687号",
preParagraph: "《国务院关于修改〈植物检疫条例〉的决定》已经国务院批准,现予发布,自发布之日起施行。",
bigTitle2: "植物检疫条例",
subtitle2: "(1983年1月3日国务院发布。2017年10月7日根据《国务院关于修改〈植物检疫条例〉的决定》修订发布。)",
paragraphs: [
"第一条 为了防止为害植物的危险性病、虫、杂草传播蔓延,保护农业、林业生产安全,制定本条例。",
"第二条 国务院农业主管部门、林业主管部门主管全国的植物检疫工作,各省、自治区、直辖市农业主管部门、林业主管部门主管本地区的植物检疫工作。",
"第三条 县级以上地方各级农业主管部门、林业主管部门所属的植物检疫机构,负责执行国家的植物检疫任务。",
"第四条 凡局部地区发生的危险性大、能随植物及其产品传播的病、虫、杂草,应定为植物检疫对象。农业、林业植物检疫对象和应施检疫的植物、植物产品名单,由国务院农业主管部门、林业主管部门制定。各省、自治区、直辖市农业主管部门、林业主管部门可以根据本地区的需要,制定本省、自治区、直辖市的补充名单,并报国务院农业主管部门、林业主管部门备案。",
"第五条 局部地区发生植物检疫对象的,应划为疫区,采取封锁、消灭措施,防止植物检疫对象传出;发生地区已比较普遍的,则应将未发生地区划为保护区,防止植物检疫对象传入。",
"疫区应根据植物检疫对象的传播情况、当地的地理环境、交通状况以及采取封锁、消灭措施的需要来划定,其范围应严格控制。",
"在发生疫情的地区,植物检疫机构可以派人参加当地的道路联合检查站或者木材检查站;发生特大疫情时,经省、自治区、直辖市人民政府批准,可以设立植物检疫检查站,开展植物检疫工作。",
"第六条 疫区和保护区的划定,由省、自治区、直辖市农业主管部门、林业主管部门提出,报省、自治区、直辖市人民政府批准,并报国务院农业主管部门、林业主管部门备案。",
"疫区和保护区的范围涉及两省、自治区、直辖市以上的,由有关省、自治区、直辖市农业主管部门、林业主管部门共同提出,报国务院农业主管部门、林业主管部门批准后划定。",
"第七条 调运植物和植物产品,属于下列情况的,必须经过检疫:",
"(一)列入应施检疫的植物、植物产品名单的,运出发生疫情的县级行政区域之前,必须经过检疫;",
"(二)凡种子、苗木和其他繁殖材料,不论是否列入应施检疫的植物、植物产品名单和运往何地,在调运之前,都必须经过检疫。",
"第八条 按照本条例第七条的规定必须检疫的植物和植物产品,经检疫未发现植物检疫对象的,发给植物检疫证书。发现有植物检疫对象、但能彻底消毒处理的,托运人应按植物检疫机构的要求,在指定地点作消毒处理,经检查合格后发给植物检疫证书;无法消毒处理的,应停止调运。",
"植物检疫证书的格式由国务院农业主管部门、林业主管部门制定。",
"对可能被植物检疫对象污染的包装材料、运载工具、场地、仓库等,也应实施检疫。如已被污染,托运人应按植物检疫机构的要求处理。",
"因实施检疫需要的车船停留、货物搬运、开拆、取样、储存、消毒处理等费用,由托运人负责。",
"第十条 省、自治区、直辖市间调运本条例第七条规定必须经过检疫的植物和植物产品的,调入单位必须事先征得所在地的省、自治区、直辖市植物检疫机构同意,并向调出单位提出检疫要求;调出单位必须根据该检疫要求向所在地的省、自治区、直辖市植物检疫机构申请检疫。对调入的植物和植物产品,调入单位所在地的省、自治区、直辖市的植物检疫机构应当查验检疫证书,必要时可以复检。",
"省、自治区、直辖市内调运植物和植物产品的检疫办法,由省、自治区、直辖市人民政府规定。",
"第十一条 种子、苗木和其他繁殖材料的繁育单位,必须有计划地建立无植物检疫对象的种苗繁育基地、母树林基地。试验、推广的种子、苗木和其他繁殖材料,不得带有植物检疫对象。植物检疫机构应实施产地检疫。",
"第十二条 从国外引进种子、苗木,引进单位应当向所在地的省、自治区、直辖市植物检疫机构提出申请,办理检疫审批手续。但是,国务院有关部门所属的在京单位从国外引进种子、苗木、应当向国务院农业主管部门、林业主管部门所属的植物检疫机构提出申请,办理检疫审批手续。具体办法由国务院农业主管部门、林业主管部门制定。",
"从国外引进、可能潜伏有危险性病、虫的种子、苗木和其他繁殖材料,必须隔离试种,植物检疫机构应进行调查、观察和检疫,证明确实不带危险性病、虫的,方可分散种植。",
"第十三条 农林院校和试验研究单位对植物检疫对象的研究,不得在检疫对象的非疫区进行。因教学、科研确需在非疫区进行时,应当遵守国务院农业主管部门、林业主管部门的规定。",
"第十四条 植物检疫机构对于新发现的检疫对象和其他危除性病、虫、杂草,必须及时查清情况,立即报告省、自治区、直辖市农业主管部门、林业主管部门,采取措施,彻底消灭,并报告国务院农业主管部门、林业主管部门。",
"第十五条 疫情由国务院农业主管部门、林业主管部门发布。",
"第十六条 按照本条例第五条第一款和第十四条的规定,进行疫情调查和采取消灭措施所需的紧急防治费和补助费,由省、自治区、直辖市在每年的植物保护费、森林保护费或者国营农场生产费中安排。特大疫情的防治费,国家酌情给予补助。",
"第十七条 在植物检疫工作中作出显著成绩的单位和个人,由人民政府给予奖励。",
"第十八条 有下列行为之一的,植物检疫机构应当责令纠正,可以处以罚款;造成损失的,应当负责赔偿;构成犯罪的,由司法机关依法追究刑事责任:",
"(一)未依照本条例规定办理植物检疫证书或者在报检过程中弄虚作假的;",
"(二)伪造、涂改、买卖、转让植物检疫单证、印章、标志、封识的;",
"(三)未依照本条例规定调运、隔离试种或者生产应施检疫的植物、植物产品的;",
"(四)违反本条例规定,擅自开拆植物、植物产品包装,调换植物、植物产品,或者擅自改变植物、植物产品的规定用途的;",
"(五)违反本条例规定,引起疫情扩散的。",
"有前款第(一)、(二)、(三)、(四)项所列情形之一,尚不构成犯罪的,植物检疫机构可以没收非法所得。",
"对违反本条例规定调运的植物和植物产品,植物检疫机构有权予以封存、没收、销毁或者责令改变用途。销毁所需费用由责任人承担。",
"第十九条 植物检疫人员在植物检疫工作中,交通运输部门和邮政部门有关工作人员在植物、植物产品的运输、邮寄工作中,徇私舞弊、玩忽职守的,由其所在单位或者上级主管机关给予行政处分;构成犯罪的,由司法机关依法追究刑事责任。",
"第二十条 当事人对植物检疫机构的行政处罚决定不服的,可以自接到处罚决定通知书之日起十五日内,向作出行政处罚决定的植物检疫机构的上级机构申请复议;对复议决定不服的,可以自接到复议决定书之日起十五日内向人民法院提起拆讼。当事人逾期不申请复议或者不诉又不履行行政处罚决定的,植物检疫机构可以申请人民法院强制执行或者依法强制执行。",
"第二十一条 植物检疫机构执行检疫任务可以收取检疫费,具体办法由国务院农业主管部门、林业主管部门制定。",
"第二十二条 进出口植物的检疫,按照《中华人民共和国进出境动植物检疫法》的规定执行。",
"第二十三条 本条例的实施细则由国务院农业主管部门、林业主管部门制定。各省、自治区、直辖市可根据本条例及其实施细则,结合当地具体情况,制定实施办法。",
"第二十四条 本条例自发布之日起施行。国务院批准、农业部1957年12月4日发布的《国内植物检疫试行办法》同时废止。"
],
files: ["1531188661110_植物检疫条例2017版.doc"],
fileLink: "http://npp.demo.kexinnongye.com/upload/pq/yogurtUpload/permanent/plantlibrary/src/20200603/1531188661110_%E6%A4%8D%E7%89%A9%E6%A3%80%E7%96%AB%E6%9D%A1%E4%BE%8B2017%E7%89%88.doc",
fileDisplayName: "植物检疫条例2017版.docx"
},
{
id: 4,
title: "国外引种检疫审批管理办法(1993年11月10日农业部发布实施)",
date: "10/19",
fullDate: "1993-10-19",
sender: "系统管理员",
body: true,
bigTitle: "国外引种检疫审批管理办法",
subtitle: "(1993年11月10日农业部发布实施)",
paragraphs: [
"第一条 为了加强对国外(含境外,下同)引进种子、苗木和其他繁殖材料的检疫管理,根据《植物检疫条例》第十二条的规定,制定本办法。",
"第二条 从国外引进种子、苗木和其他繁殖材料,实行农业部和各省、自治区、直辖市农业厅(局)两级审批,其执行机构是农业部全国植物保护总站和各省、自治区、直辖市农业厅(局)植物检疫(植保植检)站。",
"第三条 引进种子、苗木和其他繁殖材料的单位或代理进口单位(以下简称引种单位)必须在对外贸易合同或者协议中,列入《引进种子、苗木检疫审批单》上所提对外植物检疫要求,并订明必须附有输出国家或者地区政府植物检疫机关出具的植物检疫证书,证明符合我国所提对外植物检疫要求。",
"第四条 引种检疫申请",
"(一)引种单位应当在对外签订贸易合同、协议30日前,申请办理国外引种检疫审批手续。",
"(二)国务院和中央各部门所属在京单位、驻京部队单位、外国驻京机构等,向农业部全国植物保护总站提出申请;各省、自治区,直辖市有关单位和中央京外单位向种植地的省、自治区、直辖市农业厅(局)植物检疫(植保值检)站提出申请。",
"(三)引种单位提出申请时,必须按规定的格式及要求填写《引进种子、苗木检疫审批申请书》(附件1);引进生产用种苗须同时提供有效的进口种苗权证明材料。报农业部全国植物保护总站审批的生产用种苗,还须提供种植地的省,自治区,直辖市农业厅(局)植物检疫(植保植检)站签署的有关种苗的疫情监测报告。",
"(四)引种单位应调查了解引进植物在原产地的病虫发生情况,并在申请时向检疫审批单位提供有关疫情资料,对于引进数量较大、疫情不清,与农业安全生产密切相关的种苗,引种单位应事先进行有检疫人员参加的种苗原产地疫情调查。",
"第五条 检疫审批",
"(一)检疫审批单位自收到《引进种子、苗木检疫审批申请书》之日起15天内予以审批或签复。",
"(二)农作物种质资源和科研试验材料引进,国务院和中央各部门所属在京单位、驻京部队单位、外国驻京机构等,由农业部全国植物保护总站审批;各省、自冶区、直辖市有关单位和中央京外单位由种植地的省、自治区、直辖市农业厅(局)植物检疫(植保植检)站审批,热带作物种质资源交换和引进由农业部农垦司签署意见后,报农业部全国植物保护总站审批,种质资源和科研试验材料检疫审批限量见附件2。",
"(三)国际区域性试验和对外制种的种苗引进,由种植地的省、自治区、直辖市农业厅(局)植物检疫(值保植检)站签署意见后,报农业部全国植物保护总站审批。",
"(四)生产用种苗的引进",
"1.对于新引进的(指从未引进和近三年内未引进)的作物或品种的引进,必须事先少量隔离试种(种子以2亩地,苗木以50株用量为限),引种单位在申请引进前,应安排好隔离试种计划,隔离试种条件符合检疫要求后,由种植地的省、自治区、直辖市农业厅(局)植物检疫(植保植检)站审批。",
"2.已在当地多年引进,经疫情监测,符合检疫要求的作物或品种,引种数量在《生产用种苗引种检疫审批限量》(见附件3)内的,由种植地的省,自治区、直辖市农业厅(局)植物检疫(植保植检)站审批(国务院和中央各部门所属在京单位、驻京部队单位、外国驻京机构等,由农业部全国植物保护总站审批);引种数量超过审批限量的,由种植地的省、自治区、直辖市农业厅(局)植物检疫(植保植检)站签署意见后,报农业部全国植物保护总站审批。",
"(五)《引进种子、苗木检疫审批单》(附件4)的有效期限一般为6个月,特殊情况有效期限可适当延长,但最长有效期限不得超过一年。",
"引种单位办理检疫审批后,《引进种子、苗木检疫审批单》已逾有效期限或需要改变引进种苗的品种、数量、输出国家或者地区的,均须重新办理检疫审批手续。",
"第六条 种苗入境后检疫",
"(一)引进种苗经口岸动植物检疫机关检疫后,《引进种子、苗木检疫审批单》回执由口岸动植物检疫机关及时寄回种苗审批单位核查。",
"(二)种苗引进后,引种单位必须按照《引进种子、苗木检疫审批单》上指定的地点进行引进种苗隔离试种或者隔离种植。隔离试种或者隔离种植期限,一年生植物不得小于一个生育周期,多年生植物不得少于两年。隔离试种或者隔离种植期间,由种植地的省农业厅(局)植物检疫(植保植检)站负责疫情监测,并签署症情监测报告。必要时,由全国植物保护总站组织重点疫情监测。",
"(三)在隔离种植期间,发现疫情的,引进单位必须在检疫部门的指导和监督下,及时采取封锁,控制和消灭措施,严防疫情扩散,并承担实施检疫处理的全部费用。",
"第七条 检疫审批管理",
"(一)国外引种检疫审批实行季度报表制度。各省、自冶区、直辖市植物检疫(植保植检)站应当在每新季度开始第一个月十日内,将上一季度《引进种苗检疫审批及疫情监测情况》(见附件5)上报全国植物保护总站。发现重大疫情时,应当及时报告。",
"(二)《国外引种检疫审批限量表》,由全国植物保护总站根据疫情变化情况和农业生产发展的实际需要,进行修订。",
"《引进种子、苗木检疫审批申请书》、《引进种子、苗木检疫审批单》由农业部全国植物保护总站统一制定格式,各省、自治区,直辖市农业厅(局)统一翻印。",
"(三)国外引种检疫审批费、种植期间疫情监测费按国家有关规定收取。因实施重点疫情调查的检疫费用由引种单位承担。",
"第八条 违反本办法规定的,按国家有关植物检疫规定处罚。",
"第九条 本办法自发布之日起施行,1980年8月12日农业部关于印发《引进种子、苗木检疫审批单》的函和1990年8月13日农业部印发《关于国外引种检疫审批工作的补充规定(试行)》的通知同时废止。"
],
files: ["1498790231610_4国外引种检疫审批管理办法.docx"],
fileLink: "http://npp.demo.kexinnongye.com/upload/pq/yogurtUpload/permanent/plantlibrary/src/20201019/1498790231610_4%E5%9B%BD%E5%A4%96%E5%BC%95%E7%A7%8D%E6%A3%80%E7%96%AB%E5%AE%A1%E6%89%B9%E7%AE%A1%E7%90%86%E5%8A%9E%E6%B3%95.docx",
fileDisplayName: "国外引种检疫审批管理办法.docx"
}
]; ];
const todoMoreVisible = ref(false);
const noticeMoreVisible = ref(false);
const noticeDetailVisible = ref(false);
const currentNotice = ref({});
const goTo = (p) => router.push(p); const goTo = (p) => router.push(p);
const goTodo = (t) => router.push(t.path);
const onNoticeClick = (n) => {
currentNotice.value = n;
noticeDetailVisible.value = true;
};
const getFileType = (filename) => {
if (!filename) return "pdf";
const ext = filename.split(".").pop().toLowerCase();
if (ext === "doc" || ext === "docx") return "doc";
if (ext === "xls" || ext === "xlsx") return "xls";
if (ext === "ppt" || ext === "pptx") return "ppt";
if (ext === "jpg" || ext === "jpeg" || ext === "png" || ext === "gif") return "img";
return "pdf";
};
// 月度检疫统计(2026 / 2025 两年数据)
const monthlyData = {
"2026": [
{ label: "1月", register: 18, survey: 16, test: 14, handle: 4, rate: 22.2 },
{ label: "2月", register: 14, survey: 13, test: 11, handle: 3, rate: 21.4 },
{ label: "3月", register: 22, survey: 20, test: 18, handle: 6, rate: 27.3 },
{ label: "4月", register: 26, survey: 24, test: 21, handle: 7, rate: 26.9 },
{ label: "5月", register: 24, survey: 22, test: 20, handle: 5, rate: 20.8 },
{ label: "6月", register: 19, survey: 18, test: 16, handle: 4, rate: 21.1 },
{ label: "7月", register: 16, survey: 15, test: 13, handle: 3, rate: 18.8 },
{ label: "8月", register: 21, survey: 19, test: 17, handle: 5, rate: 23.8 }
],
"2025": [
{ label: "1月", register: 12, survey: 11, test: 10, handle: 2, rate: 16.7 },
{ label: "2月", register: 10, survey: 9, test: 8, handle: 1, rate: 10.0 },
{ label: "3月", register: 15, survey: 14, test: 12, handle: 3, rate: 20.0 },
{ label: "4月", register: 20, survey: 18, test: 16, handle: 4, rate: 20.0 },
{ label: "5月", register: 22, survey: 20, test: 18, handle: 4, rate: 18.2 },
{ label: "6月", register: 18, survey: 16, test: 14, handle: 3, rate: 16.7 },
{ label: "7月", register: 14, survey: 13, test: 11, handle: 2, rate: 14.3 },
{ label: "8月", register: 16, survey: 15, test: 13, handle: 3, rate: 18.8 },
{ label: "9月", register: 19, survey: 17, test: 15, handle: 4, rate: 21.1 },
{ label: "10月", register: 21, survey: 19, test: 17, handle: 4, rate: 19.0 },
{ label: "11月", register: 17, survey: 16, test: 14, handle: 3, rate: 17.6 },
{ label: "12月", register: 15, survey: 14, test: 12, handle: 3, rate: 20.0 }
]
};
const activeTab = ref("2026");
const currentMonths = computed(() => monthlyData[activeTab.value]);
const hoverIdx = ref(null);
const chartKey = ref("");
const onHover = (i, key) => {
hoverIdx.value = i;
chartKey.value = key || "";
};
const onChartBgClick = (key) => {
if (chartKey.value === key) {
hoverIdx.value = null;
chartKey.value = "";
}
};
const registerTipLeft = computed(() => {
if (hoverIdx.value === null) return "0%";
const total = 480;
const ratio = registerPoints.value[hoverIdx.value].x / total;
return (ratio * 100) + "%";
});
const handleTipLeft = computed(() => {
if (hoverIdx.value === null) return "0%";
const total = 480;
const ratio = handlePoints.value[hoverIdx.value].x / total;
return (ratio * 100) + "%";
});
// 折线图坐标计算
const buildPoints = (key) => {
const data = currentMonths.value;
const max = Math.max(...data.map(d => d[key]));
const tickCount = 5;
const rangeMax = Math.ceil(max / tickCount) * tickCount;
return data.map((d, i) => {
const x = 35 + (i / (data.length - 1)) * 435;
const y = 170 - (d[key] / rangeMax) * 165;
return { x, y };
});
};
const registerPoints = computed(() => buildPoints("register"));
const registerLinePoints = computed(() => registerPoints.value.map(p => `${p.x},${p.y}`).join(" "));
const registerYLabels = computed(() => {
const max = Math.max(...currentMonths.value.map(d => d.register));
const tickCount = 5;
const rangeMax = Math.ceil(max / tickCount) * tickCount;
const step = rangeMax / tickCount;
return Array.from({ length: tickCount + 1 }, (_, i) => Math.round(step * i));
});
const handlePoints = computed(() => buildPoints("handle"));
const handleLinePoints = computed(() => handlePoints.value.map(p => `${p.x},${p.y}`).join(" "));
const handleYLabels = computed(() => {
const max = Math.max(...currentMonths.value.map(d => d.handle));
const tickCount = 5;
const rangeMax = Math.ceil(max / tickCount) * tickCount;
const step = rangeMax / tickCount;
return Array.from({ length: tickCount + 1 }, (_, i) => Math.round(step * i));
});
// 风险等级分布
const riskLevels = [
{ level: "高风险", count: 8, pct: 20.5, color: "#d71e1e", desc: "已确诊发现国家规定检疫性有害生物,需立即启动除害或销毁处置" },
{ level: "中风险", count: 14, pct: 35.9, color: "#e8851e", desc: "现场疑似或初筛阳性,需进一步实验室复核或隔离试种观察" },
{ level: "低风险", count: 12, pct: 30.8, color: "#1f3a5f", desc: "监测到轻微异常或一般性预警,暂不需紧急处置" },
{ level: "已消除", count: 5, pct: 12.8, color: "#52c41a", desc: "历史上已通过除害处理并复核合格,闭环归档" }
];
const totalRisk = computed(() => riskLevels.reduce((s, r) => s + r.count, 0));
const handledRisk = computed(() => riskLevels.filter(r => r.level === "已消除").reduce((s, r) => s + r.count, 0));
const handleRate = computed(() => ((handledRisk.value / totalRisk.value) * 100).toFixed(1));
</script> </script>
<style scoped> <style scoped>
...@@ -133,4 +612,229 @@ const goTo = (p) => router.push(p); ...@@ -133,4 +612,229 @@ const goTo = (p) => router.push(p);
font-size: 14px; font-size: 14px;
} }
.op-link { color: #d71e1e; cursor: pointer; } .op-link { color: #d71e1e; cursor: pointer; }
.more-btn { font-size: 13px; color: #666; cursor: pointer; }
.more-btn:hover { color: #d71e1e; }
.notice-list { list-style: none; padding: 0; margin: 0; }
.notice-list li {
display: flex; align-items: center;
gap: 8px; padding: 12px 4px;
border-bottom: 1px dashed #f0f0f0; cursor: pointer;
transition: background 0.15s;
}
.notice-list li:hover { background: #fafafa; }
.notice-list li:last-child { border-bottom: none; }
.notice-index { font-size: 13px; color: #d71e1e; font-weight: bold; flex-shrink: 0; min-width: 22px; }
.notice-text {
flex: 1; font-size: 13px; color: #333;
overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
}
.notice-date { font-size: 12px; color: #999; flex-shrink: 0; }
.notice-detail { width: 880px; max-width: 96vw; }
.notice-detail-header {
background: #d71e1e; color: #fff; border-bottom: none;
}
.notice-detail-header .modal-close { color: #fff; }
.notice-detail-header .modal-close:hover { color: #ffd; opacity: 0.85; }
.notice-detail-body { padding: 28px 36px 36px; max-height: 80vh; overflow-y: auto; }
.notice-title-main {
text-align: center; font-size: 18px; font-weight: bold;
color: #333; margin-bottom: 16px; line-height: 1.5;
}
.notice-meta {
text-align: center; font-size: 13px; color: #666;
margin-bottom: 18px;
}
.notice-meta span { margin: 0 12px; }
.notice-divider { height: 1px; background: #f0f0f0; margin: 16px 0 28px; }
.notice-big-title {
text-align: center; color: #d71e1e; font-size: 24px;
font-weight: bold; margin-bottom: 18px;
}
.notice-big-subtitle {
font-size: 18px; margin-top: 4px;
}
.notice-big-title-2 { margin-top: 24px; }
.notice-pre-paragraph {
font-size: 14px; color: #333; line-height: 2;
text-indent: 2em; margin-bottom: 12px;
}
.notice-sign-block {
text-align: right; padding-right: 32px;
margin-bottom: 12px;
}
.notice-sign-line {
font-size: 14px; color: #333; line-height: 1.8;
}
.notice-pub-info {
display: flex; justify-content: space-between;
font-size: 13px; color: #666; margin-bottom: 18px;
padding: 0 8px;
}
.notice-font a { color: #1f3a5f; margin: 0 2px; cursor: pointer; }
.notice-font a:hover { color: #d71e1e; }
.notice-paragraph {
font-size: 14px; color: #333; line-height: 2;
text-indent: 2em; margin-bottom: 12px;
}
.notice-attachment-block { margin-top: 18px; }
.notice-attachment-label { font-size: 14px; font-weight: bold; color: #333; margin-bottom: 8px; }
.notice-attachment-list { list-style: none; padding: 0 0 0 24px; margin: 0; }
.notice-attachment-list li {
font-size: 13px; color: #333; line-height: 2;
text-indent: 2em;
}
.notice-attachment-list .attach-no { font-weight: bold; }
.notice-source {
font-size: 13px; color: #666; margin-top: 18px;
padding-left: 0;
}
.notice-source a { color: #1f3a5f; }
.notice-sign {
font-size: 15px; color: #333; text-align: right;
margin-top: 28px; padding-right: 32px;
}
.notice-sign-date {
font-size: 15px; color: #333; text-align: right;
padding-right: 32px;
}
.notice-files { margin-top: 24px; }
.notice-files-label { font-size: 14px; color: #333; margin-bottom: 12px; }
.notice-files-grid {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.notice-file-item {
display: flex; align-items: center; gap: 10px;
padding: 8px; border: 1px solid #f0f0f0; border-radius: 4px;
cursor: pointer; transition: border-color 0.15s;
}
.notice-file-item:hover { border-color: #d71e1e; }
.notice-file-icon { width: 36px; height: 44px; flex-shrink: 0; position: relative; }
.file-icon {
width: 36px; height: 44px; border-radius: 4px;
background: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: bold;
position: relative;
border: 2px solid #d71e1e;
color: #d71e1e;
}
.file-icon::after {
content: ""; position: absolute; top: 0; right: 0;
border-style: solid; border-width: 0 8px 8px 0;
border-color: transparent #fafafa transparent transparent;
}
.file-icon.doc,
.file-icon.docx { border-color: #2b579a; color: #2b579a; }
.file-icon.xls,
.file-icon.xlsx { border-color: #1d6f42; color: #1d6f42; }
.file-icon.ppt,
.file-icon.pptx { border-color: #d24726; color: #d24726; }
.file-icon.img { border-color: #7b61ff; color: #7b61ff; }
.notice-file-name {
font-size: 12px; color: #1f3a5f;
overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; flex: 1;
}
.notice-file-link { margin-top: 12px; word-break: break-all; }
.notice-file-link-a { color: #1f3a5f; font-size: 12px; }
.notice-file-link-a:hover { color: #d71e1e; }
.modal-mask {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4); z-index: 1000;
display: flex; align-items: center; justify-content: center;
}
.modal-box {
background: #fff; border-radius: 4px; min-width: 480px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 20px; border-bottom: 1px solid #f0f0f0;
font-size: 15px; font-weight: bold; color: #333;
}
.modal-close { cursor: pointer; font-size: 22px; color: #999; line-height: 1; }
.modal-close:hover { color: #d71e1e; }
.modal-body { padding: 16px 20px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; }
.panel-tabs { display: flex; gap: 12px; }
.tab-item {
font-size: 13px; color: #666; cursor: pointer; padding: 2px 8px;
border-bottom: 2px solid transparent; transition: all 0.2s;
}
.tab-item:hover { color: #d71e1e; }
.tab-item.active { color: #d71e1e; border-bottom-color: #d71e1e; font-weight: bold; }
.stat-table th, .stat-table td { text-align: center; }
.stat-table td:first-child { font-weight: bold; color: #333; }
.rate-bar {
position: relative; height: 18px; background: #f5f5f5; border-radius: 9px;
display: flex; align-items: center; justify-content: center; font-size: 12px;
color: #333; overflow: hidden;
}
.rate-fill {
position: absolute; left: 0; top: 0; height: 100%; background: linear-gradient(90deg, #d71e1e, #ff7a7a);
border-radius: 9px; z-index: 0;
}
.rate-bar span { position: relative; z-index: 1; font-weight: bold; }
.chart-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; }
.chart-box-inner {
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 4px;
padding: 12px; height: 240px;
}
.chart-area { position: relative; }
.mini-chart-title { font-size: 13px; color: #333; font-weight: bold; margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center; }
.chart-unit { font-size: 11px; color: #999; font-weight: normal; }
.mini-svg { width: 100%; height: 200px; display: block; }
.chart-tip {
position: absolute; transform: translate(-50%, -100%);
background: #fff; border: 1px solid #d71e1e; border-radius: 4px;
padding: 6px 10px; font-size: 12px; color: #333;
white-space: nowrap; pointer-events: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
}
.tip-title { font-weight: bold; margin-bottom: 4px; color: #333; }
.tip-row { display: flex; align-items: center; gap: 4px; }
.tip-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
.risk-level-list { padding: 4px 0; }
.risk-level-item {
display: flex; align-items: center; gap: 12px; padding: 10px 0;
border-bottom: 1px dashed #f0f0f0;
}
.risk-level-item:last-child { border-bottom: none; }
.rl-left { display: flex; align-items: center; gap: 6px; width: 80px; }
.rl-dot { width: 10px; height: 10px; border-radius: 50%; }
.rl-name { font-size: 13px; color: #333; }
.rl-mid { flex: 1; }
.rl-bar { height: 10px; background: #f5f5f5; border-radius: 5px; overflow: hidden; }
.rl-fill { height: 100%; border-radius: 5px; }
.rl-right { display: flex; align-items: center; gap: 10px; width: 90px; justify-content: flex-end; }
.rl-count { font-size: 16px; font-weight: bold; color: #333; }
.rl-pct { font-size: 12px; color: #999; }
.risk-summary {
margin-top: 16px; padding: 12px;
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 4px;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
}
.rs-item { text-align: center; }
.rs-label { font-size: 12px; color: #666; margin-bottom: 4px; }
.rs-value { font-size: 20px; font-weight: bold; color: #d71e1e; }
.risk-legend {
margin-top: 16px; padding: 12px;
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 4px;
}
.legend-title { font-size: 13px; font-weight: bold; color: #333; margin-bottom: 8px; }
.legend-list { list-style: none; padding: 0; margin: 0; }
.legend-list li {
display: flex; align-items: flex-start; gap: 8px;
padding: 6px 0; border-bottom: 1px dashed #f0f0f0;
}
.legend-list li:last-child { border-bottom: none; padding-bottom: 0; }
.legend-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
margin-top: 6px; flex-shrink: 0;
}
.legend-text { flex: 1; }
.legend-name { font-size: 13px; font-weight: bold; color: #333; line-height: 1.4; }
.legend-desc { font-size: 12px; color: #666; line-height: 1.5; margin-top: 2px; }
</style> </style>
\ No newline at end of file
<template> <template>
<PagePlaceholder title="数据分析" desc="检疫数据多维分析与可视化开发中" icon="DataAnalysis" /> <div class="page-risk">
<!-- 筛选 -->
<div class="filter-box">
<div class="filter-row">
<div class="filter-item">
<label>年月</label>
<div class="ym-wrap">
<div class="ym-trigger" :class="{ active: filters.startYear && filters.startMonth }" @click="toggleYM('start')">
<span class="ym-trigger-text">{{ startLabel || '请选择年月' }}</span>
<span class="ym-caret">▾</span>
</div>
<div v-if="ymOpen === 'start'" class="ym-panel" @click.stop>
<div class="ym-head">
<button type="button" class="ym-nav" @click="ymYearStart--">«</button>
<button type="button" class="ym-nav" @click="ymYearStart = Math.max(2024, ymYearStart - 1)">‹</button>
<span class="ym-year">{{ ymYearStart }} 年</span>
<button type="button" class="ym-nav" @click="ymYearStart = Math.min(2027, ymYearStart + 1)">›</button>
<button type="button" class="ym-nav" @click="ymYearStart++">»</button>
</div>
<div class="ym-grid">
<button
v-for="m in 12"
:key="m"
type="button"
class="ym-cell"
:class="{ active: +filters.startYear === ymYearStart && +filters.startMonth === m, disabled: ymYearStart > 2027 || ymYearStart < 2024 }"
@click="pickMonth('start', ymYearStart, m)"
>{{ m }}月</button>
</div>
</div>
</div>
<span class="dash">—</span>
<div class="ym-wrap">
<div class="ym-trigger" :class="{ active: filters.endYear && filters.endMonth }" @click="toggleYM('end')">
<span class="ym-trigger-text">{{ endLabel || '请选择年月' }}</span>
<span class="ym-caret">▾</span>
</div>
<div v-if="ymOpen === 'end'" class="ym-panel" @click.stop>
<div class="ym-head">
<button type="button" class="ym-nav" @click="ymYearEnd--">«</button>
<button type="button" class="ym-nav" @click="ymYearEnd = Math.max(2024, ymYearEnd - 1)">‹</button>
<span class="ym-year">{{ ymYearEnd }} 年</span>
<button type="button" class="ym-nav" @click="ymYearEnd = Math.min(2027, ymYearEnd + 1)">›</button>
<button type="button" class="ym-nav" @click="ymYearEnd++">»</button>
</div>
<div class="ym-grid">
<button
v-for="m in 12"
:key="m"
type="button"
class="ym-cell"
:class="{ active: +filters.endYear === ymYearEnd && +filters.endMonth === m, disabled: ymYearEnd > 2027 || ymYearEnd < 2024 }"
@click="pickMonth('end', ymYearEnd, m)"
>{{ m }}月</button>
</div>
</div>
</div>
</div>
<div class="filter-item">
<label>种苗类型</label>
<select v-model="filters.type" class="ipt">
<option value="">全部</option>
<option value="种子">种子</option>
<option value="苗木">苗木</option>
</select>
</div>
<div class="filter-item">
<label>来源国(地区)</label>
<input class="ipt" v-model="filters.origin" placeholder="请输入" />
</div>
<div class="filter-item">
<label>检出来源</label>
<select v-model="filters.source" class="ipt">
<option value="">全部</option>
<option value="样本抽检">样本抽检</option>
<option value="田间调查">田间调查</option>
</select>
</div>
<div class="filter-item">
<button class="btn" @click="onQuery">查询</button>
<button class="btn btn-default" @click="onReset">重置</button>
</div>
<div class="filter-item filter-spacer"></div>
<div class="filter-item">
<button class="btn btn-report" @click="onGenerateReport">
<span class="report-icon">📄</span>风险分析报告
</button>
</div>
</div>
</div>
<!-- 顶部统计卡片 -->
<div class="stats-row">
<div class="stat-card card-blue">
<div class="stat-title">
<span class="stat-icon">●</span>
<span>样本抽检</span>
</div>
<div class="stat-items">
<div class="stat-item">
<span class="stat-lbl">总抽检样本(委托书)</span>
<span class="stat-val"><strong>{{ stats.sample.total }}</strong><em> 个</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">检出检疫性有害生物样本数</span>
<span class="stat-val"><strong>{{ stats.sample.detected }}</strong><em> 个</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">处置样本单元</span>
<span class="stat-val"><strong>{{ stats.sample.handled }}</strong><em> 个</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">除害处置成功样本</span>
<span class="stat-val"><strong>{{ stats.sample.success }}</strong><em> 个</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">除害无效 / 直接销毁样本</span>
<span class="stat-val"><strong>{{ stats.sample.destroyed }}</strong><em> 个</em></span>
</div>
</div>
</div>
<div class="stat-card card-green">
<div class="stat-title">
<span class="stat-icon">●</span>
<span>田间调查</span>
</div>
<div class="stat-items">
<div class="stat-item">
<span class="stat-lbl">开展田间调查样本(委托书)</span>
<span class="stat-val"><strong>{{ stats.field.total }}</strong><em> 个</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">田间调查检出检疫性有害生物样本数</span>
<span class="stat-val"><strong>{{ stats.field.detected }}</strong><em> 个</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">处置样本单元</span>
<span class="stat-val"><strong>{{ stats.field.handled }}</strong><em> 个</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">除害处置成功样本</span>
<span class="stat-val"><strong>{{ stats.field.success }}</strong><em> 个</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">除害无效销毁样本</span>
<span class="stat-val"><strong>{{ stats.field.destroyed }}</strong><em> 个</em></span>
</div>
</div>
</div>
<div class="stat-card card-orange">
<div class="stat-title">
<span class="stat-icon">●</span>
<span>风险处置汇总</span>
</div>
<div class="stat-items">
<div class="stat-item">
<span class="stat-lbl">检出检疫性有害生物总样本单元</span>
<span class="stat-val"><strong>{{ stats.overall.detectedLab }}</strong><em> 批(实验室)+ </em><strong>{{ stats.overall.detectedField }}</strong><em>(田间)</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">待处置样本单元</span>
<span class="stat-val"><strong>{{ stats.overall.pending }}</strong><em> 个</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">已闭环处置样本单元</span>
<span class="stat-val"><strong>{{ stats.overall.closed }}</strong><em> 个</em></span>
</div>
<div class="stat-item">
<span class="stat-lbl">已销毁样本单元</span>
<span class="stat-val"><strong>{{ stats.overall.destroyed }}</strong><em> 个</em></span>
</div>
</div>
</div>
</div>
<!-- 折线图 + 柱状图 -->
<div class="charts-row">
<div class="chart-card">
<div class="chart-title">检疫性有害生物检出样本(委托书)数量趋势</div>
<div class="chart-legend">
<span class="legend-item"><i class="dot dot-blue"></i>样本抽检检出数量</span>
<span class="legend-item"><i class="dot dot-orange"></i>田间调查检出数量</span>
</div>
<div class="chart-body">
<svg :viewBox="`0 0 ${lineW} ${lineH}`" preserveAspectRatio="xMidYMid meet" class="chart-svg">
<!-- 网格线 -->
<line v-for="(y, idx) in lineGridY" :key="'gy'+idx" :x1="linePadL" :x2="lineW - linePadR" :y1="y" :y2="y" stroke="#eee" stroke-width="1" />
<!-- Y 轴标签 -->
<text v-for="(v, idx) in lineYValues" :key="'yl'+idx" :x="linePadL - 8" :y="lineGridY[idx] + 4" text-anchor="end" font-size="11" fill="#888">{{ v }}</text>
<!-- Y 轴标题 -->
<text :x="14" :y="(lineH - linePadB + linePadT) / 2" text-anchor="middle" font-size="12" fill="#666" :transform="`rotate(-90 14 ${(lineH - linePadB + linePadT) / 2})`">数量</text>
<!-- X 轴标签 -->
<text v-for="(x, idx) in lineXLabels" :key="'xl'+idx" :x="x.cx" :y="lineH - linePadB + 16" text-anchor="middle" font-size="11" fill="#888">{{ x.label }}</text>
<!-- X 轴标题 -->
<text :x="(linePadL + lineW - linePadR) / 2" :y="lineH - 4" text-anchor="middle" font-size="12" fill="#666">月份</text>
<!-- 样本抽检折线 -->
<polyline :points="lineSamplePoints" fill="none" stroke="#1f7ae0" stroke-width="2" />
<circle v-for="(p, idx) in lineSampleDots" :key="'ds'+idx" :cx="p.cx" :cy="p.cy" r="3" fill="#1f7ae0" />
<!-- 田间调查折线 -->
<polyline :points="lineFieldPoints" fill="none" stroke="#ff8c1a" stroke-width="2" />
<circle v-for="(p, idx) in lineFieldDots" :key="'df'+idx" :cx="p.cx" :cy="p.cy" r="3" fill="#ff8c1a" />
<!-- 折线图 hover 热区 -->
<g v-for="(x, idx) in lineXLabels" :key="'h'+idx">
<rect :x="x.cx - 18" :y="linePadT" width="36" :height="lineH - linePadB - linePadT" fill="transparent" style="cursor:pointer" @mouseenter="lineHover = idx" @mouseleave="lineHover = -1" />
</g>
<!-- 折线图 tooltip -->
<g v-if="lineHover >= 0" :transform="`translate(${lineXLabels[lineHover].cx}, ${lineTipY})`" pointer-events="none">
<rect :x="-58" :y="-44" width="116" height="40" fill="rgba(50,50,50,0.92)" rx="4" />
<text x="0" y="-30" text-anchor="middle" fill="#fff" font-size="11">{{ lineMonths[lineHover] }}</text>
<text x="-48" y="-14" fill="#1f7ae0" font-size="11">●</text>
<text x="-38" y="-14" fill="#fff" font-size="11">抽检: {{ lineSampleDots[lineHover].v }}</text>
<text x="48" y="-14" text-anchor="end" fill="#ff8c1a" font-size="11">●</text>
<text x="40" y="-14" text-anchor="end" fill="#fff" font-size="11">田调: {{ lineFieldDots[lineHover].v }}</text>
</g>
<!-- X 轴 -->
<line :x1="linePadL" :x2="lineW - linePadR" :y1="lineH - linePadB" :y2="lineH - linePadB" stroke="#bbb" stroke-width="1" />
<!-- Y 轴 -->
<line :x1="linePadL" :x2="linePadL" :y1="linePadT" :y2="lineH - linePadB" stroke="#bbb" stroke-width="1" />
</svg>
</div>
</div>
<div class="chart-card">
<div class="chart-title">来源国家检疫性有害生物检出样数量统计</div>
<div class="chart-body">
<svg :viewBox="`0 0 ${barW} ${barH}`" preserveAspectRatio="xMidYMid meet" class="chart-svg">
<line v-for="(y, idx) in barGridY" :key="'bgy'+idx" :x1="barPadL" :x2="barW - barPadR" :y1="y" :y2="y" stroke="#eee" stroke-width="1" />
<text v-for="(v, idx) in barYValues" :key="'byl'+idx" :x="barPadL - 8" :y="barGridY[idx] + 4" text-anchor="end" font-size="11" fill="#888">{{ v }}</text>
<text :x="14" :y="(barH - barPadB + barPadT) / 2" text-anchor="middle" font-size="12" fill="#666" :transform="`rotate(-90 14 ${(barH - barPadB + barPadT) / 2})`">数量</text>
<text :x="(barPadL + barW - barPadR) / 2" :y="barH - 4" text-anchor="middle" font-size="12" fill="#666">来源国</text>
<g v-for="(b, idx) in barItems" :key="'b'+idx">
<rect :x="b.x" :y="b.y" :width="b.w" :height="b.h" fill="#1f7ae0" rx="2" style="cursor:pointer" @mouseenter="barHoverIdx = idx" @mouseleave="barHoverIdx = -1" />
<text :x="b.cx" :y="barH - barPadB + 16" text-anchor="middle" font-size="11" fill="#888">{{ b.label }}</text>
</g>
<!-- 柱状图 tooltip -->
<g v-if="barHoverIdx >= 0 && barItems[barHoverIdx]" :transform="`translate(${barItems[barHoverIdx].cx}, ${barItems[barHoverIdx].y - 8})`" pointer-events="none">
<rect :x="-42" y="-20" width="84" height="20" fill="rgba(50,50,50,0.92)" rx="4" />
<text x="0" y="-6" text-anchor="middle" fill="#fff" font-size="11">{{ barItems[barHoverIdx].label }}: {{ barItems[barHoverIdx].v }}</text>
</g>
<!-- X 轴 -->
<line :x1="barPadL" :x2="barW - barPadR" :y1="barH - barPadB" :y2="barH - barPadB" stroke="#bbb" stroke-width="1" />
<!-- Y 轴 -->
<line :x1="barPadL" :x2="barPadL" :y1="barPadT" :y2="barH - barPadB" stroke="#bbb" stroke-width="1" />
</svg>
</div>
</div>
</div>
<!-- 检疫性有害生物检出列表 -->
<div class="list-card">
<div class="list-title">检疫性有害生物检出列表</div>
<div class="tbl-wrap">
<table class="simple-table">
<colgroup>
<col style="width:4%" />
<col style="width:13%" />
<col style="width:16%" />
<col style="width:7%" />
<col style="width:6%" />
<col style="width:8%" />
<col style="width:14%" />
<col style="width:9%" />
<col style="width:10%" />
<col style="width:8%" />
<col style="width:5%" />
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>检疫单证号</th>
<th>受检单位</th>
<th>植物名称</th>
<th>种苗类型</th>
<th>来源国(地区)</th>
<th>有害生物名称</th>
<th>检出来源</th>
<th>检出时间</th>
<th>处置结果</th>
<th class="col-op-head">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in pagedDetection" :key="row.id">
<td>{{ (page - 1) * pageSize + idx + 1 }}</td>
<td>{{ row.cert }}</td>
<td class="col-unit">{{ row.unit }}</td>
<td>{{ row.plant }}</td>
<td>{{ row.type }}</td>
<td>{{ row.origin }}</td>
<td>{{ row.pest }}</td>
<td>{{ row.source }}</td>
<td>{{ row.detectDate }}</td>
<td>{{ row.disposeResult }}</td>
<td class="col-op"><a class="op-link" @click="onView(row)">查看</a></td>
</tr>
<tr v-if="!pagedDetection.length">
<td colspan="11" class="empty">暂无检出数据</td>
</tr>
</tbody>
</table>
<div class="pager" v-if="filteredDetection.length > pageSize">
<button class="pg-btn" :disabled="page === 1" @click="page = 1">«</button>
<button class="pg-btn" :disabled="page === 1" @click="page--">‹</button>
<button v-for="p in pagesToShow" :key="p" class="pg-btn" :class="{ active: p === page }" @click="page = p">{{ p }}</button>
<button class="pg-btn" :disabled="page === totalPages" @click="page++">›</button>
<button class="pg-btn" :disabled="page === totalPages" @click="page = totalPages">»</button>
<span class="pg-info">共 {{ filteredDetection.length }} 条 · 第 {{ page }} / {{ totalPages }} 页</span>
</div>
</div>
</div>
<!-- 查看详情弹窗 -->
<div v-if="viewVisible" class="modal-mask" @click.self="viewVisible = false">
<div class="modal-box view-box">
<div class="modal-top">
<span class="modal-title">检出详情</span>
<span class="modal-close" @click="viewVisible = false">×</span>
</div>
<div class="modal-body">
<div class="view-grid">
<div class="view-item"><span class="view-lbl">检疫单证号</span><span class="view-val">{{ viewRow.cert }}</span></div>
<div class="view-item"><span class="view-lbl">受检单位</span><span class="view-val">{{ viewRow.unit }}</span></div>
<div class="view-item"><span class="view-lbl">植物名称</span><span class="view-val">{{ viewRow.plant }}</span></div>
<div class="view-item"><span class="view-lbl">种苗类型</span><span class="view-val">{{ viewRow.type }}</span></div>
<div class="view-item"><span class="view-lbl">来源国(地区)</span><span class="view-val">{{ viewRow.origin }}</span></div>
<div class="view-item"><span class="view-lbl">有害生物名称</span><span class="view-val">{{ viewRow.pest }}</span></div>
<div class="view-item"><span class="view-lbl">检出来源</span><span class="view-val">{{ viewRow.source }}</span></div>
<div class="view-item"><span class="view-lbl">检出时间</span><span class="view-val">{{ viewRow.detectDate }}</span></div>
<div class="view-item"><span class="view-lbl">处置结果</span><span class="view-val">{{ viewRow.disposeResult }}</span></div>
<div class="view-item view-item-full"><span class="view-lbl">处置记录</span><span class="view-val">{{ viewRow.disposeDetail || '已按处理方案要求完成除害处理,处理过程符合相关技术规范。' }}</span></div>
</div>
<div class="modal-actions">
<button class="btn btn-default" @click="viewVisible = false">关闭</button>
</div>
</div>
</div>
</div>
<!-- 风险分析报告弹窗 -->
<div v-if="reportVisible" class="modal-mask" @click.self="reportVisible = false">
<div class="modal-box report-box">
<div class="modal-top">
<span class="modal-title">风险分析报告</span>
<span class="modal-close" @click="reportVisible = false">×</span>
</div>
<div class="modal-body report-body">
<div class="report-toolbar">
<div class="report-actions">
<button class="btn btn-default btn-sm" @click="onCancelReport">取消</button>
<button class="btn btn-default btn-sm" @click="onSaveReport">保存</button>
<button class="btn btn-sm" @click="onDownloadReport">下载</button>
</div>
</div>
<div
ref="reportDocEl"
class="report-doc"
contenteditable="true"
@input="onReportInput"
></div>
</div>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import PagePlaceholder from "@/components/PagePlaceholder.vue"; import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
/* ============ 数据生成 ============ */
const plants = [
{ name: "番茄", type: "种子" },
{ name: "黄瓜", type: "种子" },
{ name: "辣椒", type: "种子" },
{ name: "莴苣", type: "种子" },
{ name: "青花菜", type: "种子" },
{ name: "胡萝卜", type: "种子" },
{ name: "翠菊", type: "种子" },
{ name: "南瓜", type: "种子" },
{ name: "五色菊", type: "种子" },
{ name: "松树苗", type: "苗木" },
{ name: "苹果苗", type: "苗木" },
{ name: "葡萄苗", type: "苗木" },
{ name: "樱桃苗", type: "苗木" }
];
const origins = ["智利", "日本", "意大利", "荷兰", "西班牙", "美国", "南非", "法国"];
const units = [
"甘肃富赢种子高新科技有限责任公司",
"酒泉市酒洲种子有限责任公司",
"张掖市瑞真种业有限公司",
"张掖市亚太种业有限责任公司",
"甘肃杰尼尔种子有限公司"
];
function pad2(n) { return n.toString().padStart(2, "0"); }
function genBaseRecords(count, idStart) {
const list = [];
for (let i = 0; i < count; i++) {
const p = plants[i % plants.length];
const o = origins[i % origins.length];
const month = (i % 9) + 1;
const day = (i % 27) + 1;
list.push({
id: idStart + i,
cert: `6204${(10 + (i % 8)).toString()}${(2026).toString().slice(2)}${pad2(month)}${pad2(day)}${pad2((i % 999) + 1)}`,
unit: units[i % units.length],
plant: p.name,
type: p.type,
origin: o,
date: `2026-${pad2(month)}-${pad2(day)}`
});
}
return list;
}
const sampleBase = genBaseRecords(100, 1); // 100 条样本抽检
const fieldBase = genBaseRecords(98, 101); // 98 条田间调查
// 检出列表(实验室 3 条 + 田间 2 条)
const detectionAll = [
{ id: 1, cert: sampleBase[5].cert, unit: sampleBase[5].unit, plant: sampleBase[5].plant, type: sampleBase[5].type, origin: sampleBase[5].origin, pest: "番茄黄化曲叶病毒", source: "样本抽检", detectDate: "2026-04-12", disposeResult: "销毁", disposeDetail: "已按处理方案要求销毁该批番茄种子,销毁过程符合相关技术规范。" },
{ id: 2, cert: sampleBase[12].cert, unit: sampleBase[12].unit, plant: sampleBase[12].plant, type: sampleBase[12].type, origin: sampleBase[12].origin, pest: "Meloidogyne enterolobii", source: "样本抽检", detectDate: "2026-05-08", disposeResult: "销毁", disposeDetail: "实验室确诊象耳豆根结线虫,整批种子已监督销毁。" },
{ id: 3, cert: sampleBase[34].cert, unit: sampleBase[34].unit, plant: sampleBase[34].plant, type: sampleBase[34].type, origin: sampleBase[34].origin, pest: "Xiphinema Cobb", source: "样本抽检", detectDate: "2026-06-22", disposeResult: "除害成功", disposeDetail: "经除害处理后实验室复核已合格,处理过程符合相关技术规范。" },
{ id: 4, cert: fieldBase[8].cert, unit: fieldBase[8].unit, plant: fieldBase[8].plant, type: fieldBase[8].type, origin: fieldBase[8].origin, pest: "Helicoverpa zea", source: "田间调查", detectDate: "2026-04-18", disposeResult: "除害成功", disposeDetail: "现场采取化学防治并销毁受害植株,复查未再发现活体虫体。" },
{ id: 5, cert: fieldBase[22].cert, unit: fieldBase[22].unit, plant: fieldBase[22].plant, type: fieldBase[22].type, origin: fieldBase[22].origin, pest: "Ambrosia spp.", source: "田间调查", detectDate: "2026-05-30", disposeResult: "销毁", disposeDetail: "全田植株连同土壤一并销毁处理,处置过程留有影像记录。" }
];
/* ============ 筛选 ============ */
const defaultFilters = () => {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
return { startYear: y, startMonth: 1, endYear: y, endMonth: m, type: "", origin: "", source: "" };
};
const defaultApplied = () => ({ start: "", end: "", type: "", origin: "", source: "" });
const filters = ref(defaultFilters());
const applied = ref(defaultApplied());
// 年月弹层状态
const ymOpen = ref(""); // 'start' | 'end' | ''
const ymYearStart = ref(2026);
const ymYearEnd = ref(2026);
const startLabel = computed(() => {
if (filters.value.startYear && filters.value.startMonth) return `${filters.value.startYear} 年 ${+filters.value.startMonth} 月`;
return "";
});
const endLabel = computed(() => {
if (filters.value.endYear && filters.value.endMonth) return `${filters.value.endYear} 年 ${+filters.value.endMonth} 月`;
return "";
});
const toggleYM = (target) => {
if (ymOpen.value === target) {
ymOpen.value = "";
return;
}
ymOpen.value = target;
if (target === "start" && filters.value.startYear) ymYearStart.value = +filters.value.startYear;
if (target === "end" && filters.value.endYear) ymYearEnd.value = +filters.value.endYear;
};
const pickMonth = (target, year, month) => {
if (target === "start") {
filters.value.startYear = year;
filters.value.startMonth = month;
} else {
filters.value.endYear = year;
filters.value.endMonth = month;
}
ymOpen.value = "";
};
const onDocClick = (e) => {
if (!ymOpen.value) return;
const panel = e.target.closest(".ym-wrap");
if (!panel) ymOpen.value = "";
};
onMounted(() => { document.addEventListener("click", onDocClick); onQuery(); });
onBeforeUnmount(() => { document.removeEventListener("click", onDocClick); });
const buildMonthKey = (y, m) => {
if (!y || !m) return "";
const mm = typeof m === "string" ? m : String(m).padStart(2, "0");
return `${y}-${mm}`;
};
const inRange = (date, start, end) => {
if (!date) return true;
if (start && date.slice(0, 7) < start) return false;
if (end && date.slice(0, 7) > end) return false;
return true;
};
const filteredDetection = computed(() => {
return detectionAll.filter((r) => {
if (!inRange(r.detectDate, applied.value.start, applied.value.end)) return false;
if (applied.value.type && r.type !== applied.value.type) return false;
if (applied.value.origin && !r.origin.includes(applied.value.origin)) return false;
if (applied.value.source && r.source !== applied.value.source) return false;
return true;
});
});
const onQuery = () => {
applied.value = {
start: buildMonthKey(filters.value.startYear, filters.value.startMonth),
end: buildMonthKey(filters.value.endYear, filters.value.endMonth),
type: filters.value.type,
origin: filters.value.origin,
source: filters.value.source
};
};
const onReset = () => {
filters.value = defaultFilters();
applied.value = defaultApplied();
ymOpen.value = "";
};
/* ============ 顶部统计卡片 ============ */
const stats = computed(() => {
const inDate = (d) => inRange(d, applied.value.start, applied.value.end);
const matchPlant = (r) => !applied.value.type || r.type === applied.value.type;
const matchOrigin = (r) => !applied.value.origin || r.origin.includes(applied.value.origin);
const sampleList = sampleBase.filter((r) => inDate(r.date) && matchPlant(r) && matchOrigin(r));
const fieldList = fieldBase.filter((r) => inDate(r.date) && matchPlant(r) && matchOrigin(r));
const sampleDetected = filteredDetection.value.filter((r) => r.source === "样本抽检");
const fieldDetected = filteredDetection.value.filter((r) => r.source === "田间调查");
const handled = (arr) => arr.filter((r) => r.disposeResult === "除害成功" || r.disposeResult === "销毁").length;
const success = (arr) => arr.filter((r) => r.disposeResult === "除害成功").length;
const destroyed = (arr) => arr.filter((r) => r.disposeResult === "销毁").length;
return {
sample: { total: sampleList.length, detected: sampleDetected.length, handled: handled(sampleDetected), success: success(sampleDetected), destroyed: destroyed(sampleDetected) },
field: { total: fieldList.length, detected: fieldDetected.length, handled: handled(fieldDetected), success: success(fieldDetected), destroyed: destroyed(fieldDetected) },
overall: {
detectedLab: sampleDetected.length,
detectedField: fieldDetected.length,
pending: 0,
closed: filteredDetection.value.length,
destroyed: destroyed(filteredDetection.value)
}
};
});
/* ============ 折线图 ============ */
const lineW = 720;
const lineH = 280;
const linePadL = 44;
const linePadR = 16;
const linePadT = 28;
const linePadB = 46;
const lineMonths = computed(() => {
const start = applied.value.start;
const end = applied.value.end;
const months = [];
if (!start && !end) {
for (let m = 1; m <= 9; m++) months.push(`2026-${pad2(m)}`);
} else {
const sYear = start ? parseInt(start.slice(0, 4), 10) : 2024;
const sMon = start ? parseInt(start.slice(5, 7), 10) : 1;
const eYear = end ? parseInt(end.slice(0, 4), 10) : 2027;
const eMon = end ? parseInt(end.slice(5, 7), 10) : 12;
for (let y = sYear; y <= eYear; y++) {
const ms = y === sYear ? sMon : 1;
const me = y === eYear ? eMon : 12;
for (let m = ms; m <= me; m++) months.push(`${y}-${pad2(m)}`);
}
}
return months;
});
const lineMax = computed(() => {
let max = 1;
lineMonths.value.forEach((m) => {
const c = filteredDetection.value.filter((r) => r.detectDate.slice(0, 7) === m).length;
if (c > max) max = c;
});
return Math.max(max, 3);
});
const lineXCount = computed(() => lineMonths.value.length);
const lineXLabels = computed(() => {
const arr = [];
const n = lineXCount.value;
for (let i = 0; i < n; i++) {
const cx = linePadL + ((lineW - linePadL - linePadR) * i) / Math.max(1, n - 1);
const mm = lineMonths.value[i].slice(5);
arr.push({ cx, label: `${parseInt(mm, 10)}月` });
}
return arr;
});
// Y 轴:0 在底部,max 在顶部
const lineYValues = computed(() => {
const max = lineMax.value;
return [max * 4, max * 3, max * 2, max, 0];
});
// gridY[0] 顶部 = linePadT,gridY[4] 底部 = lineH - linePadB
const lineGridY = computed(() => {
return [0, 1, 2, 3, 4].map((i) => linePadT + ((lineH - linePadT - linePadB) * i) / 4);
});
const lineSampleDots = computed(() => {
const arr = [];
lineMonths.value.forEach((m, idx) => {
const x = lineXLabels.value[idx];
const v = filteredDetection.value.filter((r) => r.detectDate.slice(0, 7) === m && r.source === "样本抽检").length;
// v=0 → 底部;v=max → 顶部
const cy = linePadT + ((lineH - linePadT - linePadB) * (1 - v / lineMax.value));
arr.push({ cx: x.cx, cy, v });
});
return arr;
});
const lineFieldDots = computed(() => {
const arr = [];
lineMonths.value.forEach((m, idx) => {
const x = lineXLabels.value[idx];
const v = filteredDetection.value.filter((r) => r.detectDate.slice(0, 7) === m && r.source === "田间调查").length;
const cy = linePadT + ((lineH - linePadT - linePadB) * (1 - v / lineMax.value));
arr.push({ cx: x.cx, cy, v });
});
return arr;
});
const lineSamplePoints = computed(() => lineSampleDots.value.map((p) => `${p.cx},${p.cy}`).join(" "));
const lineFieldPoints = computed(() => lineFieldDots.value.map((p) => `${p.cx},${p.cy}`).join(" "));
// 悬停状态
const lineHover = ref(-1);
const barHoverIdx = ref(-1);
// 折线图 tooltip y 位置:在两线之上、避顶部 padding
const lineTipY = computed(() => {
if (lineHover.value < 0) return 0;
const s = lineSampleDots.value[lineHover.value]?.cy ?? linePadT;
const f = lineFieldDots.value[lineHover.value]?.cy ?? linePadT;
return Math.max(linePadT + 6, Math.min(s, f));
});
/* ============ 柱状图 ============ */
const barW = 720;
const barH = 280;
const barPadL = 36;
const barPadR = 16;
const barPadT = 24;
const barPadB = 40;
const barItems = computed(() => {
const map = {};
filteredDetection.value.forEach((r) => { map[r.origin] = (map[r.origin] || 0) + 1; });
const labels = Object.keys(map);
const n = labels.length;
const innerW = barW - barPadL - barPadR;
const slot = innerW / Math.max(1, n);
const barW2 = Math.min(40, slot * 0.6);
const arr = [];
labels.forEach((lab, i) => {
const v = map[lab];
const cx = barPadL + slot * i + slot / 2;
const x = cx - barW2 / 2;
const max = 5;
const h = (v / max) * (barH - barPadT - barPadB);
const y = barH - barPadB - h;
arr.push({ x, y, w: barW2, h, cx, v, label: lab });
});
return arr;
});
const barMax = 5; // Y 轴固定 0~5
const barYValues = computed(() => [5, 4, 3, 2, 1, 0]);
const barGridY = computed(() => [0, 1, 2, 3, 4, 5].map((i) => barPadT + ((barH - barPadT - barPadB) * i) / 5));
/* ============ 列表分页 ============ */
const page = ref(1);
const pageSize = 10;
const totalPages = computed(() => Math.max(1, Math.ceil(filteredDetection.value.length / pageSize)));
const pagedDetection = computed(() => {
const start = (page.value - 1) * pageSize;
return filteredDetection.value.slice(start, start + pageSize);
});
const pagesToShow = computed(() => {
const total = totalPages.value;
const cur = page.value;
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const arr = [];
arr.push(1);
if (cur > 3) arr.push("...");
for (let i = Math.max(2, cur - 1); i <= Math.min(total - 1, cur + 1); i++) arr.push(i);
if (cur < total - 2) arr.push("...");
arr.push(total);
return arr;
});
watch(filteredDetection, () => { if (page.value > totalPages.value) page.value = 1; });
/* ============ 查看弹窗 ============ */
const viewVisible = ref(false);
const viewRow = ref({});
const onView = (row) => { viewRow.value = { ...row }; viewVisible.value = true; };
/* ============ 风险分析报告 ============ */
const reportVisible = ref(false);
const reportDocEl = ref(null);
const reportContent = ref("");
const reportDirty = ref(false);
// 工具:生成时间
const fmtNow = () => {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
// 按字段聚合计数
const groupCount = (rows, key) => {
const map = {};
rows.forEach((r) => { map[r[key]] = (map[r[key]] || 0) + 1; });
return Object.entries(map).sort((a, b) => b[1] - a[1]);
};
// HTML 转义
const esc = (s) => String(s ?? "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const buildReportHtml = () => {
const records = filteredDetection.value;
const s = stats.value;
const total = records.length;
const periodDesc = `${startLabel.value || "全部时间"} 至 ${endLabel.value || "全部时间"}`;
const ratio = (n) => (total > 0 ? ((n / total) * 100).toFixed(1) + "%" : "0.0%");
const reportNo = `RA-${new Date().getFullYear()}${String(new Date().getMonth() + 1).padStart(2, "0")}${String(new Date().getDate()).padStart(2, "0")}-${String(total).padStart(4, "0")}`;
const originRank = groupCount(records, "origin").slice(0, 5);
const pestRank = groupCount(records, "pest").slice(0, 5);
const disposeMap = {};
records.forEach((r) => { disposeMap[r.disposeResult] = (disposeMap[r.disposeResult] || 0) + 1; });
const disposeList = Object.entries(disposeMap);
const sampleTop = records.filter((r) => r.source === "样本抽检").slice(0, 10);
const fieldTop = records.filter((r) => r.source === "田间调查").slice(0, 10);
const listLines = (arr, formatter) => arr.length
? arr.map((x, i) => `<div class="doc-line">${formatter(x, i)}</div>`).join("")
: `<div class="doc-line doc-empty">(无)</div>`;
return `
<h1 class="doc-title">植物检疫检测隔离中心<br />风险分析报告</h1>
<div class="doc-meta">报告编号:${esc(reportNo)}  生成时间:${esc(fmtNow())}  统计区间:${esc(periodDesc)}</div>
<h2 class="doc-h2">一、报告概述</h2>
<p class="doc-p">本报告基于平台风险分析模块当前筛选条件自动生成,涵盖样本抽检与田间调查两类检出来源的汇总统计、有害生物与来源国分布、处置结果分析,并对后续检疫监管与除害处理提出建议。</p>
<h2 class="doc-h2">二、关键指标</h2>
<h3 class="doc-h3">(一)样本抽检</h3>
<p class="doc-p">1. 总抽检样本(委托书):${s.sample.total} 个</p>
<p class="doc-p">2. 检出检疫性有害生物样本数:${s.sample.detected} 个</p>
<p class="doc-p">3. 处置样本单元:${s.sample.handled} 个</p>
<p class="doc-p">4. 除害处置成功样本:${s.sample.success} 个</p>
<p class="doc-p">5. 除害无效 / 直接销毁样本:${s.sample.destroyed} 个</p>
<h3 class="doc-h3">(二)田间调查</h3>
<p class="doc-p">1. 开展田间调查样本(委托书):${s.field.total} 个</p>
<p class="doc-p">2. 田间调查检出检疫性有害生物样本数:${s.field.detected} 个</p>
<p class="doc-p">3. 处置样本单元:${s.field.handled} 个</p>
<p class="doc-p">4. 除害处置成功样本:${s.field.success} 个</p>
<p class="doc-p">5. 除害无效销毁样本:${s.field.destroyed} 个</p>
<h3 class="doc-h3">(三)风险处置汇总</h3>
<p class="doc-p">1. 检出检疫性有害生物总样本单元:${s.overall.detectedLab} 批(实验室)+ ${s.overall.detectedField}(田间)</p>
<p class="doc-p">2. 待处置样本单元:${s.overall.pending} 个</p>
<p class="doc-p">3. 已闭环处置样本单元:${s.overall.closed} 个</p>
<p class="doc-p">4. 已销毁样本单元:${s.overall.destroyed} 个</p>
<h2 class="doc-h2">三、来源国(地区)分布(前五位)</h2>
${listLines(originRank, ([k, v], i) => `${i + 1}. ${esc(k)}:${v} 批(占比 ${ratio(v)})`)}
<h2 class="doc-h2">四、检出有害生物分布(前五位)</h2>
${listLines(pestRank, ([k, v], i) => `${i + 1}. ${esc(k)}:${v} 批(占比 ${ratio(v)})`)}
<h2 class="doc-h2">五、处置结果分布</h2>
${listLines(disposeList, ([k, v], i) => `${i + 1}. ${esc(k)}:${v} 批(占比 ${ratio(v)})`)}
<h2 class="doc-h2">六、典型检出明细</h2>
<h3 class="doc-h3">(一)样本抽检(前 10 条)</h3>
${listLines(sampleTop, (r, i) => `${i + 1}. ${esc(r.detectDate)} | ${esc(r.cert)} | ${esc(r.plant)} | 来源国:${esc(r.origin)} | 有害生物:${esc(r.pest)} | 处置:${esc(r.disposeResult)}`)}
<h3 class="doc-h3">(二)田间调查(前 10 条)</h3>
${listLines(fieldTop, (r, i) => `${i + 1}. ${esc(r.detectDate)} | ${esc(r.cert)} | ${esc(r.plant)} | 来源国:${esc(r.origin)} | 有害生物:${esc(r.pest)} | 处置:${esc(r.disposeResult)}`)}
<h2 class="doc-h2">七、风险评估与建议</h2>
<p class="doc-p">根据本期检出与处置情况,建议在下阶段工作中:</p>
<p class="doc-p">1. 加大对高频来源国/重点疫区的抽检比例与频次;</p>
<p class="doc-p">2. 对检出率较高的有害生物种类开展专项监测与溯源调查;</p>
<p class="doc-p">3. 强化除害处理过程监管,确保处理结果闭环可追溯;</p>
<p class="doc-p">4. 及时开展培训与宣传,提升基层人员识别与处置能力。</p>
`;
};
const onGenerateReport = () => {
reportVisible.value = true;
};
watch(reportVisible, async (v) => {
if (!v) return;
await nextTick();
if (reportDocEl.value) {
reportDocEl.value.innerHTML = buildReportHtml();
reportDirty.value = false;
}
});
const onReportInput = (e) => {
reportContent.value = e.target.innerHTML;
reportDirty.value = true;
};
const escapeForPrint = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const onCancelReport = () => {
if (reportDirty.value) {
if (!window.confirm("您对报告内容做了修改,确定放弃并关闭吗?")) return;
}
reportVisible.value = false;
};
const onSaveReport = () => {
try {
const html = reportDocEl.value ? reportDocEl.value.innerHTML : "";
const payload = {
html,
filters: { ...filterForm },
savedAt: new Date().toISOString(),
};
localStorage.setItem("risk_analysis_report", JSON.stringify(payload));
reportDirty.value = false;
if (window.TraeMessageBox) {
window.TraeMessageBox.success("保存成功");
} else {
window.alert("保存成功");
}
} catch (e) {
if (window.TraeMessageBox) {
window.TraeMessageBox.error("保存失败:" + (e && e.message ? e.message : ""));
} else {
window.alert("保存失败:" + (e && e.message ? e.message : ""));
}
}
};
const onDownloadReport = () => {
const html = reportDocEl.value ? reportDocEl.value.innerHTML : "";
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}`;
const filename = `风险分析报告_${stamp}.doc`;
// 使用 Word 可识别的 HTML 格式(带 XML 命名空间),保证内容可编辑
const docHtml = `<html xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:w="urn:schemas-microsoft-com:office:word"
xmlns="http://www.w3.org/TR/REC-html40">
<head>
<meta charset="utf-8">
<title>风险分析报告</title>
<!--[if gte mso 9]>
<xml>
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom></w:WordDocument>
</xml>
<![endif]-->
<style>
@page{size:A4;margin:2.5cm 2cm;}
body{font-family:"Microsoft YaHei","SimSun",serif;line-height:1.8;color:#000;background:#fff;padding:24px 40px;margin:0;}
h1.doc-title{text-align:center;font-size:30px;font-weight:bold;margin:0 0 18px;color:#000;line-height:1.5;display:block;width:100%;}
.doc-meta{text-align:center;font-size:13px;color:#333;margin-bottom:24px;border-bottom:1px solid #d4d4d4;padding-bottom:12px;}
h2.doc-h2{font-size:18px;font-weight:bold;margin:18px 0 10px;color:#000;}
h3.doc-h3{font-size:15px;font-weight:bold;margin:14px 0 6px;color:#000;}
p.doc-p{margin:6px 0;font-size:14px;color:#000;line-height:1.9;}
.doc-line{font-size:14px;color:#000;margin:4px 0;line-height:1.9;}
.doc-empty{color:#666;}
</style>
</head>
<body>${escapeForPrint(html)}</body>
</html>`;
const blob = new Blob(["\ufeff", docHtml], { type: "application/msword;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
};
</script> </script>
<style scoped>
.page-risk { padding: 16px 20px 24px; }
/* 统计卡片 */
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 18px; }
.stat-card { background: #fff; border: 1px solid #f0f0f0; border-radius: 6px; padding: 20px 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); border-top: 3px solid #1f7ae0; display: flex; align-items: stretch; gap: 24px; min-height: 200px; }
.stat-card.card-blue { border-top-color: #1f7ae0; }
.stat-card.card-green { border-top-color: #2ea043; }
.stat-card.card-orange { border-top-color: #ff8c1a; }
.stat-title { font-size: 16px; font-weight: 600; color: #333; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; flex: 0 0 110px; padding-right: 22px; border-right: 1px dashed #ececec; text-align: center; }
.stat-card.card-blue .stat-icon { color: #1f7ae0; }
.stat-card.card-green .stat-icon { color: #2ea043; }
.stat-card.card-orange .stat-icon { color: #ff8c1a; }
.stat-icon { font-size: 22px; line-height: 1; }
.stat-items { display: grid; flex: 1; grid-template-columns: 1fr 1fr; gap: 12px 28px; align-content: center; }
.stat-card.card-orange .stat-items { grid-template-columns: 1fr; }
.stat-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px dashed #f0f0f0; font-size: 13px; gap: 12px; }
.stat-lbl { color: #666; flex: 1; min-width: 0; }
.stat-val { color: #d71e1e; font-weight: 600; flex-shrink: 0; display: inline-flex; align-items: baseline; }
.stat-val strong { font-size: 20px; margin-right: 4px; }
.stat-val em { font-style: normal; color: #888; font-size: 12px; font-weight: normal; }
/* 筛选 */
.filter-box { background: #fff; border: 1px solid #f0f0f0; border-radius: 4px; padding: 14px 18px; margin-bottom: 16px; }
.filter-row { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 18px; }
.filter-item { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.filter-item label { white-space: nowrap; flex-shrink: 0; min-width: 84px; color: #333; font-size: 14px; }
.ipt { height: 32px; padding: 0 8px; border: 1px solid #d4d4d4; border-radius: 3px; font-size: 14px; color: #555; background: #fff; outline: none; min-width: 130px; }
select.ipt { min-width: 130px; }
.btn { background: #d71e1e; color: #fff; border: 1px solid #d71e1e; height: 32px; padding: 0 14px; border-radius: 3px; font-size: 14px; cursor: pointer; }
.btn:hover { background: #b81818; }
.btn-default { background: #fff; color: #555; border-color: #d4d4d4; }
.btn-default:hover { color: #d71e1e; border-color: #d71e1e; }
.filter-spacer { flex: 1; min-width: 0; }
.btn-report { background: #1f3a5f; color: #fff; border: 1px solid #1f3a5f; display: inline-flex; align-items: center; gap: 6px; padding: 0 18px; height: 32px; border-radius: 3px; font-size: 14px; cursor: pointer; }
.btn-report:hover { background: #142844; border-color: #142844; }
.btn-report .report-icon { font-size: 14px; line-height: 1; }
.dash { color: #999; }
/* 年月选择器 */
.ym-wrap { position: relative; }
.ym-trigger { height: 32px; padding: 0 10px; min-width: 150px; border: 1px solid #d4d4d4; border-radius: 3px; background: #fff; color: #555; font-size: 14px; display: flex; align-items: center; justify-content: space-between; gap: 6px; cursor: pointer; user-select: none; }
.ym-trigger:hover { border-color: #1f7ae0; }
.ym-trigger.active { border-color: #1f7ae0; color: #333; }
.ym-trigger-text { flex: 1; text-align: left; }
.ym-caret { color: #999; font-size: 12px; }
.ym-panel { position: absolute; top: calc(100% + 4px); left: 0; z-index: 50; background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; box-shadow: 0 4px 18px rgba(0,0,0,0.12); padding: 10px 12px 12px; width: 280px; }
.ym-head { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px 8px; border-bottom: 1px solid #eee; margin-bottom: 8px; }
.ym-head .ym-year { flex: 1; text-align: center; font-size: 14px; font-weight: 600; color: #333; }
.ym-nav { background: transparent; border: none; color: #666; font-size: 14px; cursor: pointer; padding: 2px 8px; border-radius: 3px; }
.ym-nav:hover { background: #f0f6ff; color: #1f7ae0; }
.ym-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.ym-cell { background: #fff; border: 1px solid #f0f0f0; border-radius: 4px; padding: 8px 0; color: #555; font-size: 13px; cursor: pointer; transition: all 0.15s; }
.ym-cell:hover { background: #f0f6ff; color: #1f7ae0; border-color: #1f7ae0; }
.ym-cell.active { background: #1f7ae0; color: #fff; border-color: #1f7ae0; }
.ym-cell.disabled { color: #ccc; cursor: not-allowed; pointer-events: none; }
/* 图表 */
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 18px; }
.chart-card { background: #fff; border: 1px solid #f0f0f0; border-radius: 4px; padding: 14px 18px 8px; }
.chart-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 8px; }
.chart-legend { display: flex; gap: 18px; margin-bottom: 4px; }
.legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #666; }
.legend-item .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.legend-item .dot-blue { background: #1f7ae0; }
.legend-item .dot-orange { background: #ff8c1a; }
.chart-body { width: 100%; height: 280px; }
.chart-svg { width: 100%; height: 100%; display: block; }
/* 列表 */
.list-card { background: #fff; border: 1px solid #f0f0f0; border-radius: 4px; padding: 14px 18px 18px; }
.list-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 10px; }
.tbl-wrap { background: #fff; border: 1px solid #f0f0f0; border-radius: 4px; overflow: hidden; }
.simple-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.simple-table th { background: #fafafa; color: #333; font-weight: 600; padding: 12px 8px; border-bottom: 1px solid #e8e8e8; text-align: left; white-space: nowrap; }
.simple-table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; color: #444; word-break: break-all; }
.simple-table tr:hover td { background: #fafafa; }
.col-unit { color: #666; }
.col-op, .col-op-head { white-space: nowrap; text-align: center; }
.simple-table th.col-op-head { text-align: center; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; line-height: 1.4; }
.tag-blue { background: #e8f1fe; color: #1f7ae0; }
.tag-green { background: #e6f6ec; color: #2ea043; }
.tag-orange { background: #fff2e6; color: #ff8c1a; }
.op-link { color: #333; cursor: pointer; }
.op-link:hover { color: #d71e1e; }
.empty { text-align: center; color: #999; padding: 24px 0 !important; }
/* 分页 */
.pager { display: flex; align-items: center; gap: 6px; padding: 14px 12px; justify-content: flex-end; }
.pg-btn { min-width: 28px; height: 28px; padding: 0 8px; border: 1px solid #d4d4d4; background: #fff; color: #555; border-radius: 3px; cursor: pointer; font-size: 13px; }
.pg-btn:hover:not(:disabled) { color: #d71e1e; border-color: #d71e1e; }
.pg-btn.active { background: #d71e1e; color: #fff; border-color: #d71e1e; }
.pg-btn:disabled { color: #ccc; cursor: not-allowed; }
.pg-info { margin-left: 10px; color: #888; font-size: 12px; }
/* 弹窗 */
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal-box { background: #fff; border-radius: 6px; width: 520px; max-width: 95vw; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
.modal-top { background: #d71e1e; color: #fff; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; }
.modal-title { font-size: 15px; font-weight: 600; }
.modal-close { font-size: 22px; cursor: pointer; line-height: 1; padding: 0 4px; }
.modal-body { padding: 18px 20px; overflow: auto; }
.view-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; }
.view-item { display: flex; flex-direction: column; gap: 4px; }
.view-item-full { grid-column: 1 / -1; }
.view-lbl { color: #888; font-size: 12px; }
.view-val { color: #333; font-size: 14px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px; padding-top: 14px; border-top: 1px solid #f0f0f0; }
/* 风险分析报告弹窗 */
.modal-box.report-box { width: 1100px; max-width: 97vw; max-height: 92vh; }
.report-box { width: 1100px; max-width: 97vw; max-height: 92vh; }
.report-body { padding: 14px 16px 18px; display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
.report-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-bottom: 10px; flex-shrink: 0; }
.report-actions { display: flex; gap: 8px; }
.btn-sm { height: 28px; padding: 0 14px; font-size: 13px; }
.report-doc {
width: 100%;
flex: 1;
min-height: 0;
padding: 24px 40px;
font-family: "Microsoft YaHei", "SimSun", serif;
font-size: 14px;
line-height: 1.9;
color: #000;
background: #fff;
border: 1px solid #333;
border-radius: 2px;
outline: none;
overflow: auto;
white-space: normal;
box-sizing: border-box;
}
.report-doc:focus { border-color: #1f3a5f; }
.report-doc :deep(.doc-title) {
display: block;
width: 100%;
text-align: center;
font-size: 30px;
font-weight: bold;
color: #000;
margin: 0 0 18px;
line-height: 1.5;
}
.report-doc :deep(.doc-meta) {
text-align: center;
font-size: 13px;
color: #333;
margin-bottom: 24px;
border-bottom: 1px solid #d4d4d4;
padding-bottom: 12px;
}
.report-doc :deep(.doc-h2) {
font-size: 18px;
font-weight: bold;
color: #000;
margin: 18px 0 10px;
}
.report-doc :deep(.doc-h3) {
font-size: 15px;
font-weight: bold;
color: #000;
margin: 14px 0 6px;
}
.report-doc :deep(.doc-p) {
font-size: 14px;
color: #000;
margin: 6px 0;
line-height: 1.9;
word-break: break-all;
}
.report-doc :deep(.doc-line) {
font-size: 14px;
color: #000;
margin: 4px 0;
line-height: 1.9;
word-break: break-all;
}
.report-doc :deep(.doc-empty) { color: #666; }
.report-doc :deep(p) { margin: 6px 0; }
</style>
<template>
<PagePlaceholder title="审核发证" desc="检疫结果审核与证书签发开发中" icon="Stamp" />
</template>
<script setup>
import PagePlaceholder from "@/components/PagePlaceholder.vue";
</script>
<template>
<div class="page-exp">
<!-- 筛选行 -->
<div class="filter-row">
<div class="filter-item">
<span class="filter-label">姓名</span>
<el-input v-model="filterForm.name" placeholder="请输入姓名" clearable size="default" />
</div>
<div class="filter-item">
<span class="filter-label">账号</span>
<el-input v-model="filterForm.account" placeholder="请输入账号" clearable size="default" />
</div>
<div class="filter-item">
<span class="filter-label">性别</span>
<el-select v-model="filterForm.gender" placeholder="请选择性别" clearable size="default">
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</div>
<div class="filter-item">
<span class="filter-label">状态</span>
<el-select v-model="filterForm.status" placeholder="请选择状态" clearable size="default">
<el-option label="在岗" value="在岗" />
<el-option label="离岗" value="离岗" />
<el-option label="停职" value="停职" />
</el-select>
</div>
<div class="filter-actions-left">
<el-button type="danger" @click="onQuery">查询</el-button>
<el-button @click="onReset">重置</el-button>
</div>
<div class="filter-actions-right">
<el-button type="danger" @click="onAdd">+ 新增实验员</el-button>
</div>
</div>
<!-- 列表 -->
<div class="table-card">
<table class="data-table">
<colgroup>
<col style="width: 60px" />
<col style="width: 100px" />
<col style="width: 120px" />
<col style="width: 70px" />
<col style="width: 110px" />
<col style="width: 160px" />
<col style="width: 120px" />
<col style="width: 110px" />
<col style="width: 80px" />
<col style="width: 180px" />
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>姓名</th>
<th>账号</th>
<th>性别</th>
<th>职称</th>
<th>专业领域</th>
<th>联系电话</th>
<th>证书有效期</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(e, i) in pagedExps" :key="e.id">
<td>{{ (page - 1) * pageSize + i + 1 }}</td>
<td><a class="link-name" @click="onView(e)">{{ e.name }}</a></td>
<td>{{ e.account }}</td>
<td>{{ e.gender }}</td>
<td>{{ e.title }}</td>
<td>{{ e.specialty }}</td>
<td>{{ e.phone }}</td>
<td>{{ e.certExpiry }}</td>
<td><el-tag size="small" :type="statusTagType(e.status)">{{ e.status }}</el-tag></td>
<td>
<a class="op-link" @click="onView(e)">查看</a>
<a class="op-link" @click="onEdit(e)">编辑</a>
</td>
</tr>
<tr v-if="pagedExps.length === 0">
<td colspan="10" class="empty">暂无数据</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span class="total" style="margin-right:auto;">共 {{ filteredExps.length }} 条记录 当前第 {{ page }} 页 {{ pageSize }} 条/页</span>
<button class="page-btn" :disabled="page === 1" @click="page = 1">首页</button>
<button class="page-btn" :disabled="page === 1" @click="page--">上一页</button>
<button v-for="p in pageList" :key="p" class="page-btn" :class="{ active: p === page }" @click="typeof p === 'number' && (page = p)">{{ p }}</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page++">下一页</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page = pageCount">末页</button>
<span class="jump">跳转至 <input class="jump-input" v-model.number="jumpPage" placeholder="1" @keyup.enter="goJump" /> 页</span>
</div>
</div>
<!-- 查看 / 新增 / 编辑 弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="1080px" top="2vh" :close-on-click-modal="false" @close="onDialogClose">
<!-- 查看模式:原始卡片样式 -->
<div v-if="mode === 'view'" class="detail-content">
<div class="card">
<div class="card-header"><span class="card-bar"></span><span class="card-title">基本信息</span></div>
<div class="info-grid">
<div class="info-row"><span class="info-label">姓名</span><span class="info-value">{{ form.name }}</span></div>
<div class="info-row"><span class="info-label">账号</span><span class="info-value">{{ form.account }}</span></div>
<div class="info-row"><span class="info-label">性别</span><span class="info-value">{{ form.gender }}</span></div>
<div class="info-row"><span class="info-label">出生年月</span><span class="info-value">{{ form.birthday }}</span></div>
<div class="info-row"><span class="info-label">身份证号</span><span class="info-value">{{ form.idCard }}</span></div>
<div class="info-row"><span class="info-label">政治面貌</span><span class="info-value">{{ form.political }}</span></div>
<div class="info-row"><span class="info-label">学历</span><span class="info-value">{{ form.education }}</span></div>
<div class="info-row"><span class="info-label">毕业院校</span><span class="info-value">{{ form.school }}</span></div>
<div class="info-row"><span class="info-label">专业</span><span class="info-value">{{ form.major }}</span></div>
<div class="info-row"><span class="info-label">职称</span><span class="info-value">{{ form.title }}</span></div>
<div class="info-row"><span class="info-label">所在部门</span><span class="info-value">{{ form.dept }}</span></div>
<div class="info-row"><span class="info-label">入职时间</span><span class="info-value">{{ form.joinDate }}</span></div>
<div class="info-row full"><span class="info-label">联系电话</span><span class="info-value">{{ form.phone }}</span></div>
<div class="info-row full"><span class="info-label">电子邮箱</span><span class="info-value">{{ form.email }}</span></div>
<div class="info-row full"><span class="info-label">专业领域</span><span class="info-value">{{ form.specialty }}</span></div>
<div class="info-row full"><span class="info-label">现住地址</span><span class="info-value">{{ form.address }}</span></div>
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-bar"></span><span class="card-title">资质证书</span></div>
<table class="inner-table">
<thead><tr><th>证书名称</th><th>证书编号</th><th>发证日期</th><th>有效期</th></tr></thead>
<tbody>
<tr v-for="(c, i) in form.certs" :key="i"><td>{{ c.name }}</td><td>{{ c.no }}</td><td>{{ c.issueDate }}</td><td>{{ c.expiry }}</td></tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header"><span class="card-bar"></span><span class="card-title">培训记录</span></div>
<table class="inner-table">
<thead><tr><th>培训时间</th><th>培训主题</th><th>培训机构</th><th>考核结果</th></tr></thead>
<tbody>
<tr v-for="(t, i) in form.trainings" :key="i"><td>{{ t.time }}</td><td>{{ t.topic }}</td><td>{{ t.org }}</td><td>{{ t.result }}</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 新增 / 编辑模式:真实输入表单 -->
<div v-else class="form-content">
<div class="card">
<div class="card-header">
<span class="card-bar"></span>
<span class="card-title">基本信息</span>
<span style="margin-left:auto;color:#d71e1e;font-size:12px;">* 为必填项</span>
</div>
<div class="form-grid">
<div class="form-row">
<label class="form-label required">姓名</label>
<el-input v-model="form.name" placeholder="请输入姓名" />
</div>
<div class="form-row">
<label class="form-label required">账号</label>
<el-input v-model="form.account" placeholder="请输入账号" />
</div>
<div class="form-row">
<label class="form-label">性别</label>
<el-select v-model="form.gender" placeholder="请选择" style="width:100%">
<el-option label="男" value="男" /><el-option label="女" value="女" />
</el-select>
</div>
<div class="form-row">
<label class="form-label">出生年月</label>
<el-input v-model="form.birthday" placeholder="如 1990-01-01" />
</div>
<div class="form-row">
<label class="form-label">身份证号</label>
<el-input v-model="form.idCard" placeholder="请输入身份证号" />
</div>
<div class="form-row">
<label class="form-label">政治面貌</label>
<el-select v-model="form.political" placeholder="请选择" style="width:100%">
<el-option label="群众" value="群众" /><el-option label="共青团员" value="共青团员" /><el-option label="中共党员" value="中共党员" /><el-option label="民主党派" value="民主党派" />
</el-select>
</div>
<div class="form-row">
<label class="form-label">学历</label>
<el-select v-model="form.education" placeholder="请选择" style="width:100%">
<el-option label="高中" value="高中" /><el-option label="大专" value="大专" /><el-option label="本科" value="本科" /><el-option label="硕士" value="硕士" /><el-option label="博士" value="博士" />
</el-select>
</div>
<div class="form-row">
<label class="form-label">毕业院校</label>
<el-input v-model="form.school" placeholder="请输入毕业院校" />
</div>
<div class="form-row">
<label class="form-label">专业</label>
<el-input v-model="form.major" placeholder="请输入专业" />
</div>
<div class="form-row">
<label class="form-label">职称</label>
<el-select v-model="form.title" placeholder="请选择" style="width:100%">
<el-option label="高级实验师" value="高级实验师" /><el-option label="实验师" value="实验师" /><el-option label="助理实验师" value="助理实验师" /><el-option label="技术员" value="技术员" /><el-option label="无" value="无" />
</el-select>
</div>
<div class="form-row">
<label class="form-label">所在部门</label>
<el-input v-model="form.dept" placeholder="请输入所在部门" />
</div>
<div class="form-row">
<label class="form-label">入职时间</label>
<el-input v-model="form.joinDate" placeholder="如 2022-08-15" />
</div>
<div class="form-row full">
<label class="form-label">联系电话</label>
<el-input v-model="form.phone" placeholder="请输入联系电话" />
</div>
<div class="form-row full">
<label class="form-label">电子邮箱</label>
<el-input v-model="form.email" placeholder="请输入电子邮箱" />
</div>
<div class="form-row full">
<label class="form-label">专业领域</label>
<el-input v-model="form.specialty" placeholder="如:分子检测、农药残留检测" />
</div>
<div class="form-row full">
<label class="form-label">现住地址</label>
<el-input v-model="form.address" placeholder="请输入现住地址" />
</div>
<div class="form-row">
<label class="form-label">证书有效期</label>
<el-input v-model="form.certExpiry" placeholder="如 2027-12-31" />
</div>
<div class="form-row">
<label class="form-label">状态</label>
<el-select v-model="form.status" placeholder="请选择" style="width:100%">
<el-option label="在岗" value="在岗" /><el-option label="离岗" value="离岗" /><el-option label="停职" value="停职" />
</el-select>
</div>
</div>
</div>
<!-- 资质证书 - 可编辑表格 -->
<div class="card">
<div class="card-header">
<span class="card-bar"></span>
<span class="card-title">资质证书</span>
<el-button size="small" type="danger" style="margin-left:auto;" @click="addCertRow">+ 添加证书</el-button>
</div>
<table class="edit-table">
<thead>
<tr>
<th>证书名称</th><th>证书编号</th><th>发证日期</th><th>有效期</th><th style="width:80px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in form.certs" :key="i">
<td><el-input v-model="c.name" placeholder="证书名称" size="small" /></td>
<td><el-input v-model="c.no" placeholder="证书编号" size="small" /></td>
<td><el-input v-model="c.issueDate" placeholder="如 2024-01-01" size="small" /></td>
<td><el-input v-model="c.expiry" placeholder="如 2027-12-31" size="small" /></td>
<td><a class="op-link danger" @click="removeCertRow(i)">删除</a></td>
</tr>
<tr v-if="form.certs.length === 0">
<td colspan="5" class="empty-row">暂无证书,点击右上角「+ 添加证书」新增</td>
</tr>
</tbody>
</table>
</div>
<!-- 培训记录 - 可编辑表格 -->
<div class="card">
<div class="card-header">
<span class="card-bar"></span>
<span class="card-title">培训记录</span>
<el-button size="small" type="danger" style="margin-left:auto;" @click="addTrainingRow">+ 添加记录</el-button>
</div>
<table class="edit-table">
<thead>
<tr>
<th>培训时间</th><th>培训主题</th><th>培训机构</th><th>考核结果</th><th style="width:80px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(t, i) in form.trainings" :key="i">
<td><el-input v-model="t.time" placeholder="如 2024-05-18" size="small" /></td>
<td><el-input v-model="t.topic" placeholder="培训主题" size="small" /></td>
<td><el-input v-model="t.org" placeholder="培训机构" size="small" /></td>
<td><el-input v-model="t.result" placeholder="如 合格" size="small" /></td>
<td><a class="op-link danger" @click="removeTrainingRow(i)">删除</a></td>
</tr>
<tr v-if="form.trainings.length === 0">
<td colspan="5" class="empty-row">暂无培训记录,点击右上角「+ 添加记录」新增</td>
</tr>
</tbody>
</table>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button v-if="mode !== 'view'" @click="dialogVisible = false">取消</el-button>
<el-button v-if="mode !== 'view'" type="danger" @click="onSubmit">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { ElMessage } from "element-plus";
const STORAGE_KEY = "base_experimenter";
const defaultExps = [
{
id: 1, name: "孙亚萍", account: "lab_001", gender: "女", title: "高级实验师", specialty: "PCR 检测、分子生物学",
phone: "13888001301", email: "sunyaping@plantq.cn", certExpiry: "2028-03-31", status: "在岗",
birthday: "1983-04-12", idCard: "622823198304120027", political: "中共党员", education: "硕士",
school: "中国农业大学", major: "植物病理学", dept: "分子检测实验室", joinDate: "2009-08-20",
address: "甘肃省张掖市甘州区西街 68 号",
certs: [
{ name: "实验室资质认定证", no: "LAB-2024-0035", issueDate: "2024-04-01", expiry: "2028-03-31" },
{ name: "PCR 检测上岗证", no: "PCR-2023-0118", issueDate: "2023-08-15", expiry: "2026-08-14" }
],
trainings: [
{ time: "2024-11-10", topic: "实时荧光定量 PCR 技术", org: "中国农业大学", result: "优秀" },
{ time: "2024-05-18", topic: "实验室生物安全规范", org: "省疾控中心", result: "合格" }
]
},
{
id: 2, name: "周磊", account: "lab_002", gender: "男", title: "实验师", specialty: "农药残留检测、GC-MS 操作",
phone: "13988001302", email: "zhoulei@plantq.cn", certExpiry: "2026-12-31", status: "在岗",
birthday: "1991-08-25", idCard: "622823199108250035", political: "群众", education: "本科",
school: "西北农林科技大学", major: "食品科学与工程", dept: "化学分析实验室", joinDate: "2016-07-10",
address: "甘肃省张掖市甘州区东街 102 号",
certs: [
{ name: "实验室资质认定证", no: "LAB-2023-0078", issueDate: "2023-09-15", expiry: "2026-12-31" }
],
trainings: [
{ time: "2024-10-08", topic: "气相色谱-质谱联用技术", org: "安捷伦培训中心", result: "优秀" },
{ time: "2024-04-22", topic: "实验室质量管理体系", org: "省市场监管局", result: "合格" }
]
},
{
id: 3, name: "吴雪琴", account: "lab_003", gender: "女", title: "助理实验师", specialty: "微生物检测、培养基制备",
phone: "13788001303", email: "wuxueqin@plantq.cn", certExpiry: "2027-06-30", status: "在岗",
birthday: "1997-02-18", idCard: "622823199702180049", political: "共青团员", education: "本科",
school: "甘肃农业大学", major: "生物技术", dept: "微生物实验室", joinDate: "2021-08-15",
address: "甘肃省张掖市民乐县",
certs: [
{ name: "实验室资质认定证(辅助)", no: "LAB-2025-0012", issueDate: "2025-07-01", expiry: "2027-06-30" }
],
trainings: [
{ time: "2025-07-15", topic: "培养基制备与无菌操作", org: "省植保站", result: "合格" }
]
},
{
id: 4, name: "何建斌", account: "lab_004", gender: "男", title: "技术员", specialty: "样品制备、称量",
phone: "13688001304", email: "hejianbin@plantq.cn", certExpiry: "2025-10-31", status: "离岗",
birthday: "1999-09-30", idCard: "622823199909300053", political: "群众", education: "大专",
school: "甘肃林业职业技术学院", major: "食品检测技术", dept: "样品处理室", joinDate: "2023-04-15",
address: "甘肃省张掖市高台县",
certs: [
{ name: "实验室技术员证", no: "LAB-2024-0089", issueDate: "2024-05-01", expiry: "2025-10-31" }
],
trainings: [
{ time: "2024-05-10", topic: "样品前处理规范", org: "省植保站", result: "合格" }
]
}
];
const loadExps = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch (e) { /* ignore */ }
return JSON.parse(JSON.stringify(defaultExps));
};
const saveExps = (list) => localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
const exps = ref(loadExps());
const filterForm = ref({ name: "", account: "", gender: "", status: "" });
const page = ref(1);
const pageSize = 10;
const jumpPage = ref("");
const filteredExps = computed(() => {
return exps.value.filter((e) => {
if (filterForm.value.name && !e.name.includes(filterForm.value.name)) return false;
if (filterForm.value.account && !e.account.includes(filterForm.value.account)) return false;
if (filterForm.value.gender && e.gender !== filterForm.value.gender) return false;
if (filterForm.value.status && e.status !== filterForm.value.status) return false;
return true;
});
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredExps.value.length / pageSize)));
const pagedExps = computed(() => {
const start = (page.value - 1) * pageSize;
return filteredExps.value.slice(start, start + pageSize);
});
const pageList = computed(() => {
const total = pageCount.value, cur = page.value;
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
if (cur <= 4) return [1, 2, 3, 4, 5, "...", total];
if (cur >= total - 3) return [1, "...", total - 4, total - 3, total - 2, total - 1, total];
return [1, "...", cur - 1, cur, cur + 1, "...", total];
});
const goJump = () => {
const n = parseInt(jumpPage.value, 10);
if (!isNaN(n) && n >= 1 && n <= pageCount.value) page.value = n;
jumpPage.value = "";
};
const onQuery = () => { page.value = 1; };
const onReset = () => { filterForm.value = { name: "", account: "", gender: "", status: "" }; page.value = 1; };
const statusTagType = (status) => ({ 在岗: "success", 离岗: "info", 停职: "danger" }[status] || "info");
const dialogVisible = ref(false);
const dialogTitle = ref("");
const mode = ref("view");
const form = ref({});
const resetForm = () => {
form.value = {
id: null, name: "", account: "", gender: "", title: "", specialty: "",
phone: "", email: "", certExpiry: "", status: "在岗",
birthday: "", idCard: "", political: "", education: "",
school: "", major: "", dept: "", joinDate: "",
address: "", certs: [], trainings: []
};
};
const addCertRow = () => { form.value.certs.push({ name: "", no: "", issueDate: "", expiry: "" }); };
const removeCertRow = (i) => { form.value.certs.splice(i, 1); };
const addTrainingRow = () => { form.value.trainings.push({ time: "", topic: "", org: "", result: "" }); };
const removeTrainingRow = (i) => { form.value.trainings.splice(i, 1); };
const onView = (e) => { mode.value = "view"; form.value = { ...e, certs: [...(e.certs || [])], trainings: [...(e.trainings || [])] }; dialogVisible.value = true; dialogTitle.value = "实验员详情"; };
const onEdit = (e) => { mode.value = "edit"; form.value = { ...e, certs: [...(e.certs || [])], trainings: [...(e.trainings || [])] }; dialogVisible.value = true; dialogTitle.value = "编辑实验员"; };
const onAdd = () => { mode.value = "add"; resetForm(); dialogVisible.value = true; dialogTitle.value = "新增实验员"; };
const onSubmit = () => {
if (!form.value.name || !form.value.account) { ElMessage.warning("请填写姓名与账号"); return; }
if (mode.value === "add") {
const newId = Math.max(...exps.value.map((e) => e.id), 0) + 1;
exps.value.unshift({ ...form.value, id: newId });
ElMessage.success("新增成功");
} else if (mode.value === "edit") {
const idx = exps.value.findIndex((e) => e.id === form.value.id);
if (idx !== -1) exps.value[idx] = { ...form.value };
ElMessage.success("保存成功");
}
saveExps(exps.value);
dialogVisible.value = false;
};
const onDialogClose = () => { form.value = {}; };
onMounted(() => { exps.value = loadExps(); });
</script>
<style scoped>
.page-exp { padding: 18px 20px; }
.filter-row { background: #fff; padding: 14px 16px; display: flex; flex-wrap: wrap; gap: 12px 16px; align-items: center; border: 1px solid #e6e8ec; border-radius: 4px; margin-bottom: 14px; }
.filter-item { display: flex; align-items: center; gap: 6px; }
.filter-label { font-size: 13px; color: #333; white-space: nowrap; flex-shrink: 0; }
.filter-item :deep(.el-input), .filter-item :deep(.el-select) { width: 180px; }
.filter-actions { margin-left: auto; display: flex; gap: 8px; }
.filter-actions-left { display: flex; gap: 8px; }
.filter-actions-right { margin-left: auto; display: flex; gap: 8px; }
.table-card { background: #fff; border: 1px solid #e6e8ec; border-radius: 4px; padding: 8px 0; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; text-align: center; }
.data-table th { background: #fafafa; color: #333; font-weight: 600; }
.data-table td { color: #333; }
.data-table tr:hover td { background: #f9fafc; }
.empty { text-align: center; color: #999; padding: 24px 0; }
.op-link { color: #333; margin: 0 4px; cursor: pointer; }
.op-link:hover { color: #d71e1e; }
.op-link.danger { color: #d71e1e; }
.link-name { color: #1f3a5f; cursor: pointer; }
.link-name:hover { color: #d71e1e; text-decoration: underline; }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 6px; padding: 14px 20px; background: #fff; border: 1px solid #ebeef5; border-top: 0; border-radius: 0 0 3px 3px; }
.total { color: #555; font-size: 13px; margin-right: 12px; }
.page-btn { background: #fff; border: 1px solid #dcdfe6; color: #555; height: 28px; min-width: 28px; padding: 0 8px; font-size: 14px; cursor: pointer; border-radius: 3px; }
.page-btn:hover:not(:disabled) { color: #d71e1e; border-color: #d71e1e; }
.page-btn.active { background: #d71e1e; border-color: #d71e1e; color: #fff; }
.page-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.jump { color: #555; font-size: 14px; margin-left: 6px; }
.jump-input { width: 50px; height: 24px; padding: 0 4px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 13px; text-align: center; margin: 0 4px; }
/* 弹窗 */
.detail-content, .form-content { padding: 0 8px 8px; }
.card { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; margin-bottom: 16px; overflow: hidden; }
.card-header { display: flex; align-items: center; gap: 8px; padding: 12px 16px; background: #fafafa; border-bottom: 1px solid #ebeef5; }
.card-bar { width: 3px; height: 14px; background: #d71e1e; border-radius: 2px; }
.card-title { font-size: 14px; font-weight: 600; color: #333; }
/* 查看模式 - 字段值对 */
.info-grid { display: grid; grid-template-columns: 1fr 1fr; padding: 14px 16px; }
.info-row { display: flex; gap: 8px; align-items: flex-start; padding: 6px 0; }
.info-row.full { grid-column: span 2; }
.info-label { color: #999; font-size: 13px; min-width: 80px; flex-shrink: 0; }
.info-value { color: #333; font-size: 13px; word-break: break-all; }
.inner-table { width: calc(100% - 32px); margin: 12px 16px; border-collapse: collapse; font-size: 12px; }
.inner-table th, .inner-table td { padding: 8px 6px; border-bottom: 1px solid #f0f0f0; text-align: center; }
.inner-table th { background: #fafafa; color: #666; font-weight: 600; }
.inner-table td { color: #333; }
/* 新增 / 编辑模式 - 表单 */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 24px; padding: 16px; }
.form-row { display: flex; align-items: center; gap: 8px; }
.form-row.full { grid-column: span 2; }
.form-label { font-size: 13px; color: #333; min-width: 80px; text-align: right; flex-shrink: 0; }
.form-label.required::before { content: "*"; color: #d71e1e; margin-right: 4px; }
.form-row :deep(.el-input), .form-row :deep(.el-select) { flex: 1; }
/* 可编辑表格 */
.edit-table { width: calc(100% - 32px); margin: 12px 16px 16px; border-collapse: collapse; font-size: 12px; }
.edit-table th { padding: 10px 8px; background: #fafafa; color: #666; font-weight: 600; border-bottom: 1px solid #ebeef5; text-align: center; }
.edit-table td { padding: 6px; border-bottom: 1px solid #f0f0f0; }
.edit-table .el-input { width: 100%; }
.empty-row { text-align: center; color: #999; padding: 16px 0; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 6px; }
</style>
\ No newline at end of file
<template>
<div class="page-quar">
<!-- 筛选行(分两行布局)-->
<div class="filter-row filter-row-2">
<div class="filter-line">
<div class="filter-item range-item">
<span class="filter-label">印发日期</span>
<el-date-picker
v-model="filterForm.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
size="default"
value-format="YYYY-MM-DD"
unlink-panels
/>
</div>
<div class="filter-item">
<span class="filter-label">状态</span>
<el-select v-model="filterForm.status" placeholder="请选择状态" clearable size="default">
<el-option label="在岗" value="在岗" />
<el-option label="离岗" value="离岗" />
<el-option label="停职" value="停职" />
</el-select>
</div>
<div class="filter-item">
<span class="filter-label">检疫员编号</span>
<el-input v-model="filterForm.quarNo" placeholder="模糊查询" clearable size="default" />
</div>
</div>
<div class="filter-line">
<div class="filter-item">
<span class="filter-label">检疫员姓名</span>
<el-input v-model="filterForm.name" placeholder="模糊查询" clearable size="default" />
</div>
<div class="filter-actions-left">
<el-button type="danger" @click="onQuery">查询</el-button>
<el-button @click="onReset">重置</el-button>
</div>
<div class="filter-actions-right">
<el-button type="danger" @click="onAdd">+ 新增检疫员</el-button>
<el-button type="danger" @click="triggerImport">导入</el-button>
<input ref="fileInputRef" type="file" accept=".csv,.xlsx,.xls" class="file-hidden" @change="onFileChange" />
</div>
</div>
</div>
<!-- 列表(11 列,无序号列) -->
<div class="table-card">
<table class="data-table">
<colgroup>
<col style="width: 110px" />
<col style="width: 120px" />
<col style="width: 90px" />
<col style="width: 70px" />
<col style="width: 180px" />
<col style="width: 140px" />
<col style="width: 80px" />
<col style="width: 110px" />
<col style="width: 110px" />
<col style="width: 130px" />
<col style="width: 90px" />
</colgroup>
<thead>
<tr>
<th>检疫员编号</th>
<th>制式服装编号</th>
<th>姓名</th>
<th>性别</th>
<th>工作单位</th>
<th>授予单位</th>
<th>状态</th>
<th>注册日期</th>
<th>注销日期</th>
<th>证书印发日期</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(q, i) in pagedQuars" :key="q.id">
<td>{{ q.quarNo }}</td>
<td>{{ q.uniformNo || "--" }}</td>
<td><a class="link-name" @click="onView(q)">{{ q.name }}</a></td>
<td>{{ q.gender }}</td>
<td>{{ q.workOrg }}</td>
<td>{{ q.grantOrg }}</td>
<td><el-tag size="small" :type="statusTagType(q.status)">{{ q.status }}</el-tag></td>
<td>{{ q.registerDate }}</td>
<td>{{ q.cancelDate || "--" }}</td>
<td>{{ q.certIssueDate || "--" }}</td>
<td><a class="op-link" @click="onView(q)">查看</a></td>
</tr>
<tr v-if="pagedQuars.length === 0">
<td colspan="11" class="empty">暂无数据</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span class="total" style="margin-right:auto;">共 {{ filteredQuars.length }} 条记录 当前第 {{ page }} 页 {{ pageSize }} 条/页</span>
<button class="page-btn" :disabled="page === 1" @click="page = 1">首页</button>
<button class="page-btn" :disabled="page === 1" @click="page--">上一页</button>
<button v-for="p in pageList" :key="p" class="page-btn" :class="{ active: p === page }" @click="typeof p === 'number' && (page = p)">{{ p }}</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page++">下一页</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page = pageCount">末页</button>
<span class="jump">跳转至 <input class="jump-input" v-model.number="jumpPage" placeholder="1" @keyup.enter="goJump" /> 页</span>
</div>
</div>
<!-- 新增 / 查看 表单弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="1080px" top="2vh" :close-on-click-modal="false" @close="onDialogClose" class="quar-dialog">
<div class="quar-form">
<!-- 居中大标题 -->
<h2 class="quar-title">全国农业植物专职检疫员数据信息表</h2>
<!-- 法规说明 -->
<div class="quar-notice">
<p>
<span>根据</span>
<a class="law-link">《农业部植物检疫员管理办法(试行)》</a>
<span>:</span>
</p>
<p>(a)植物检疫员由地、县农业行政部门推荐,省(区、市)植物检疫机构负责考核,农业厅(局)审批,报农业部全国植保总站备案,统一格式见附件)和发证。</p>
<p>(b)植物检疫员增补审批工作,每两年办理一次,备案工作在当年11~12月份进行。</p>
</div>
<!-- 表单主体(2 列网格) -->
<div class="quar-grid">
<!-- 左列 -->
<div class="quar-col">
<div class="qf-item">
<label class="qf-label">检疫机构</label>
<el-input v-model="form.org" :disabled="mode === 'view'" placeholder="请输入检疫机构" />
</div>
<div class="qf-item">
<label class="qf-label required">工作单位</label>
<el-input v-model="form.workOrg" :disabled="mode === 'view'" placeholder="请输入" />
</div>
<div class="qf-item">
<label class="qf-label required">姓名</label>
<el-input v-model="form.name" :disabled="mode === 'view'" />
</div>
<div class="qf-item">
<label class="qf-label required">身份证号</label>
<el-input v-model="form.idCard" :disabled="mode === 'view'" />
</div>
<div class="qf-item">
<label class="qf-label">性别</label>
<el-select v-model="form.gender" :disabled="mode === 'view'" placeholder="请选择" style="width:100%">
<el-option label="男" value="男" /><el-option label="女" value="女" />
</el-select>
</div>
<div class="qf-item">
<label class="qf-label">检疫员编号</label>
<el-input v-model="form.quarNo" :disabled="mode === 'view'" />
</div>
<div class="qf-item">
<label class="qf-label required">最高学历</label>
<el-select v-model="form.education" :disabled="mode === 'view'" placeholder="请选择" style="width:100%">
<el-option label="高中" value="高中" /><el-option label="大专" value="大专" /><el-option label="本科" value="本科" /><el-option label="硕士" value="硕士" /><el-option label="博士" value="博士" />
</el-select>
</div>
<div class="qf-item">
<label class="qf-label required">毕业院校</label>
<el-input v-model="form.school" :disabled="mode === 'view'" />
</div>
<div class="qf-item">
<label class="qf-label required">技术职称</label>
<el-select v-model="form.techTitle" :disabled="mode === 'view'" placeholder="请选择" style="width:100%">
<el-option label="高级农艺师" value="高级农艺师" /><el-option label="农艺师" value="农艺师" /><el-option label="助理农艺师" value="助理农艺师" /><el-option label="其他" value="其他" /><el-option label="推广研究员" value="推广研究员" /><el-option label="无" value="无" />
</el-select>
</div>
<div class="qf-item">
<label class="qf-label required">行政职务</label>
<el-select v-model="form.adminPos" :disabled="mode === 'view'" placeholder="请选择" style="width:100%">
<el-option label="科员" value="科员" /><el-option label="副科级" value="副科级" /><el-option label="正科级" value="正科级" /><el-option label="副处级" value="副处级" /><el-option label="正处级" value="正处级" /><el-option label="干部" value="干部" />
</el-select>
</div>
<div class="qf-item">
<label class="qf-label">执法证号</label>
<el-input v-model="form.lawCertNo" :disabled="mode === 'view'" placeholder="请输入" />
</div>
<div class="qf-item">
<label class="qf-label">路查证号</label>
<el-input v-model="form.inspectCertNo" :disabled="mode === 'view'" placeholder="请输入" />
</div>
<div class="qf-item">
<label class="qf-label">固定电话</label>
<el-input v-model="form.tel" :disabled="mode === 'view'" placeholder="请输入" />
</div>
<div class="qf-item">
<label class="qf-label required">账号</label>
<el-input v-model="form.account" :disabled="mode === 'view'" />
</div>
<div class="qf-item">
<label class="qf-label">制式服装编号</label>
<el-input v-model="form.uniformNo" :disabled="mode === 'view'" placeholder="请输入制式服装编号" />
</div>
</div>
<!-- 右列 -->
<div class="quar-col">
<div class="qf-item qf-upload-row">
<div class="qf-upload-block">
<label class="qf-label">照片</label>
<el-upload v-if="mode !== 'view'" class="qf-uploader" :show-file-list="false" :auto-upload="false" :on-change="(file) => onPhotoChange(file, 'photo')" accept="image/*">
<img v-if="form.photo" :src="form.photo" class="qf-upload-img" />
<div v-else class="qf-upload-empty">
<el-icon class="qf-upload-icon"><Plus /></el-icon>
<span class="qf-upload-text">上传照片</span>
</div>
</el-upload>
<img v-else-if="form.photo" :src="form.photo" class="qf-upload-img" />
<div v-else class="qf-upload-empty"><span class="qf-upload-text">暂无照片</span></div>
</div>
<div class="qf-upload-block">
<label class="qf-label">签名</label>
<el-upload v-if="mode !== 'view'" class="qf-uploader" :show-file-list="false" :auto-upload="false" :on-change="(file) => onPhotoChange(file, 'signature')" accept="image/*">
<img v-if="form.signature" :src="form.signature" class="qf-upload-img" />
<div v-else class="qf-upload-empty">
<el-icon class="qf-upload-icon"><Plus /></el-icon>
<span class="qf-upload-text">上传照片</span>
</div>
</el-upload>
<img v-else-if="form.signature" :src="form.signature" class="qf-upload-img" />
<div v-else class="qf-upload-empty"><span class="qf-upload-text">暂无签名</span></div>
</div>
</div>
<div class="qf-item">
<label class="qf-label required">出生年月</label>
<el-input v-model="form.birthday" :disabled="mode === 'view'" placeholder="如 1990-01-01" />
</div>
<div class="qf-item">
<label class="qf-label">批准日期</label>
<el-input v-model="form.approveDate" :disabled="mode === 'view'" placeholder="如 2022-11-21" />
</div>
<div class="qf-item">
<label class="qf-label required">所学专业</label>
<el-input v-model="form.major" :disabled="mode === 'view'" />
</div>
<div class="qf-item">
<label class="qf-label required">毕业时间</label>
<el-input v-model="form.graduateDate" :disabled="mode === 'view'" placeholder="如 2015-06" />
</div>
<div class="qf-item">
<label class="qf-label">授予时间</label>
<el-input v-model="form.grantDate" :disabled="mode === 'view'" placeholder="如 2020-12" />
</div>
<div class="qf-item">
<label class="qf-label required">检疫年限</label>
<el-input v-model="form.quarYears" :disabled="mode === 'view'">
<template #append>年</template>
</el-input>
</div>
<div class="qf-item">
<label class="qf-label">执法证年限</label>
<el-input v-model="form.lawYears" :disabled="mode === 'view'" placeholder="请输入" />
</div>
<div class="qf-item">
<label class="qf-label">路查证年限</label>
<el-input v-model="form.inspectYears" :disabled="mode === 'view'" placeholder="请输入" />
</div>
<div class="qf-item">
<label class="qf-label">移动电话</label>
<el-input v-model="form.phone" :disabled="mode === 'view'" placeholder="请输入" />
</div>
<div class="qf-item">
<label class="qf-label required">邮箱</label>
<el-input v-model="form.email" :disabled="mode === 'view'" />
</div>
</div>
</div>
<!-- 简历(含培训)-->
<div class="qf-item full">
<label class="qf-label">简历(含培训)</label>
<el-input v-model="form.resume" :disabled="mode === 'view'" type="textarea" :rows="4" placeholder="简历包含培训经历" />
</div>
<!-- 底部 4 个 textarea -->
<div class="qf-item full">
<label class="qf-label">获奖情况</label>
<el-input v-model="form.awards" :disabled="mode === 'view'" type="textarea" :rows="4" placeholder="获奖情况" />
</div>
<div class="qf-item full">
<label class="qf-label">科研情况</label>
<el-input v-model="form.research" :disabled="mode === 'view'" type="textarea" :rows="4" placeholder="科研情况" />
</div>
<div class="qf-item full">
<label class="qf-label">发表论文情况</label>
<el-input v-model="form.papers" :disabled="mode === 'view'" type="textarea" :rows="4" placeholder="发表论文情况" />
</div>
<div class="qf-item full">
<label class="qf-label">境外考察情况</label>
<el-input v-model="form.overseas" :disabled="mode === 'view'" type="textarea" :rows="4" placeholder="境外考察情况" />
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button v-if="mode === 'add'" @click="dialogVisible = false">取消</el-button>
<el-button v-if="mode === 'add'" type="danger" @click="onSubmit">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
const STORAGE_KEY = "base_quarantine";
const defaultQuars = [
{
id: 1, quarNo: "62070210", uniformNo: "", name: "彭婷", gender: "女", workOrg: "甘州区农业技术推广中心", grantOrg: "甘肃省农牧厅",
status: "在岗", registerDate: "2022-11-21", cancelDate: "", certIssueDate: "",
org: "甘州区农业技术推广中心", idCard: "622201199001015120", education: "本科", school: "甘肃农业大学",
techTitle: "农艺师", adminPos: "干部", lawCertNo: "", inspectCertNo: "", tel: "", account: "zygzpt",
birthday: "1990-01-01", approveDate: "2022-11-21", major: "动物科学", graduateDate: "2015-06",
grantDate: "2020-12", quarYears: "3", lawYears: "", inspectYears: "", phone: "",
email: "897721826@qq.com",
resume: "2012年11月 – 2015年3月 在甘浚镇人民政府畜牧兽医站工作\n2015年4月 – 2018年4月 在甘州区动物卫生监督所工作\n2018年5月– 至今 在甘州区农畜产品质量检测服务中心工作",
awards: "", research: "", papers: "", overseas: ""
},
{ id: 2, quarNo: "62070209", uniformNo: "", name: "魏建龙", gender: "男", workOrg: "甘州区农业技术推广中心", grantOrg: "甘肃省农业农村厅", status: "在岗", registerDate: "2018-11-02", cancelDate: "", certIssueDate: "2019-10-12", org: "甘州区农业技术推广中心", idCard: "", education: "本科", school: "", techTitle: "农艺师", adminPos: "干部", lawCertNo: "", inspectCertNo: "", tel: "", account: "", birthday: "", approveDate: "", major: "", graduateDate: "", grantDate: "", quarYears: "", lawYears: "", inspectYears: "", phone: "", email: "", resume: "", awards: "", research: "", papers: "", overseas: "" },
{ id: 3, quarNo: "62070208", uniformNo: "", name: "李科", gender: "男", workOrg: "甘州区农业技术推广中心", grantOrg: "甘肃省农业农村厅", status: "在岗", registerDate: "2018-11-02", cancelDate: "", certIssueDate: "2019-10-12", org: "甘州区农业技术推广中心", idCard: "", education: "", school: "", techTitle: "", adminPos: "", lawCertNo: "", inspectCertNo: "", tel: "", account: "", birthday: "", approveDate: "", major: "", graduateDate: "", grantDate: "", quarYears: "", lawYears: "", inspectYears: "", phone: "", email: "", resume: "", awards: "", research: "", papers: "", overseas: "" },
{ id: 4, quarNo: "62070207", uniformNo: "", name: "徐克成", gender: "男", workOrg: "甘州区农业技术推广中心", grantOrg: "甘肃省农业农村厅", status: "在岗", registerDate: "2017-07-01", cancelDate: "", certIssueDate: "", org: "", idCard: "", education: "", school: "", techTitle: "", adminPos: "", lawCertNo: "", inspectCertNo: "", tel: "", account: "", birthday: "", approveDate: "", major: "", graduateDate: "", grantDate: "", quarYears: "", lawYears: "", inspectYears: "", phone: "", email: "", resume: "", awards: "", research: "", papers: "", overseas: "" },
{ id: 5, quarNo: "62070206", uniformNo: "", name: "李佳睿", gender: "男", workOrg: "甘州区农业技术推广中心", grantOrg: "甘肃省农业农村厅", status: "在岗", registerDate: "2017-07-01", cancelDate: "", certIssueDate: "", org: "", idCard: "", education: "", school: "", techTitle: "", adminPos: "", lawCertNo: "", inspectCertNo: "", tel: "", account: "", birthday: "", approveDate: "", major: "", graduateDate: "", grantDate: "", quarYears: "", lawYears: "", inspectYears: "", phone: "", email: "", resume: "", awards: "", research: "", papers: "", overseas: "" },
{ id: 6, quarNo: "62070205", uniformNo: "", name: "汤培升", gender: "男", workOrg: "甘州区农业技术推广中心", grantOrg: "甘肃省农业农村厅", status: "在岗", registerDate: "2017-07-01", cancelDate: "", certIssueDate: "", org: "", idCard: "", education: "", school: "", techTitle: "", adminPos: "", lawCertNo: "", inspectCertNo: "", tel: "", account: "", birthday: "", approveDate: "", major: "", graduateDate: "", grantDate: "", quarYears: "", lawYears: "", inspectYears: "", phone: "", email: "", resume: "", awards: "", research: "", papers: "", overseas: "" },
{ id: 7, quarNo: "62070204", uniformNo: "", name: "马林", gender: "男", workOrg: "甘州区农业技术推广中心", grantOrg: "甘肃省农业农村厅", status: "在岗", registerDate: "2017-07-01", cancelDate: "", certIssueDate: "", org: "", idCard: "", education: "", school: "", techTitle: "", adminPos: "", lawCertNo: "", inspectCertNo: "", tel: "", account: "", birthday: "", approveDate: "", major: "", graduateDate: "", grantDate: "", quarYears: "", lawYears: "", inspectYears: "", phone: "", email: "", resume: "", awards: "", research: "", papers: "", overseas: "" },
{ id: 8, quarNo: "62070203", uniformNo: "", name: "石作植", gender: "男", workOrg: "甘州区农业技术推广中心", grantOrg: "甘肃省农业农村厅", status: "在岗", registerDate: "2017-07-01", cancelDate: "", certIssueDate: "", org: "", idCard: "", education: "", school: "", techTitle: "", adminPos: "", lawCertNo: "", inspectCertNo: "", tel: "", account: "", birthday: "", approveDate: "", major: "", graduateDate: "", grantDate: "", quarYears: "", lawYears: "", inspectYears: "", phone: "", email: "", resume: "", awards: "", research: "", papers: "", overseas: "" },
{ id: 9, quarNo: "62070202", uniformNo: "", name: "陈海龙", gender: "男", workOrg: "甘州区农业技术推广中心", grantOrg: "甘肃省农业农村厅", status: "在岗", registerDate: "2017-07-01", cancelDate: "", certIssueDate: "", org: "", idCard: "", education: "", school: "", techTitle: "", adminPos: "", lawCertNo: "", inspectCertNo: "", tel: "", account: "", birthday: "", approveDate: "", major: "", graduateDate: "", grantDate: "", quarYears: "", lawYears: "", inspectYears: "", phone: "", email: "", resume: "", awards: "", research: "", papers: "", overseas: "" },
{ id: 10, quarNo: "62070201", uniformNo: "", name: "谢颖", gender: "女", workOrg: "甘州区农业技术推广中心", grantOrg: "甘肃省农业农村厅", status: "在岗", registerDate: "2017-07-01", cancelDate: "", certIssueDate: "", org: "", idCard: "", education: "", school: "", techTitle: "", adminPos: "", lawCertNo: "", inspectCertNo: "", tel: "", account: "", birthday: "", approveDate: "", major: "", graduateDate: "", grantDate: "", quarYears: "", lawYears: "", inspectYears: "", phone: "", email: "", resume: "", awards: "", research: "", papers: "", overseas: "" }
];
const loadQuars = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch (e) { /* ignore */ }
return JSON.parse(JSON.stringify(defaultQuars));
};
const saveQuars = (list) => localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
const quars = ref(loadQuars());
const filterForm = ref({ dateRange: [], status: "", quarNo: "", name: "" });
const page = ref(1);
const pageSize = 10;
const jumpPage = ref("");
const filteredQuars = computed(() => {
return quars.value.filter((q) => {
if (filterForm.value.status && q.status !== filterForm.value.status) return false;
if (filterForm.value.quarNo && !q.quarNo.includes(filterForm.value.quarNo)) return false;
if (filterForm.value.name && !q.name.includes(filterForm.value.name)) return false;
if (filterForm.value.dateRange && filterForm.value.dateRange.length === 2) {
const [s, e] = filterForm.value.dateRange;
if (q.registerDate < s || q.registerDate > e) return false;
}
return true;
});
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredQuars.value.length / pageSize)));
const pagedQuars = computed(() => {
const start = (page.value - 1) * pageSize;
return filteredQuars.value.slice(start, start + pageSize);
});
const pageList = computed(() => {
const total = pageCount.value, cur = page.value;
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
if (cur <= 4) return [1, 2, 3, 4, 5, "...", total];
if (cur >= total - 3) return [1, "...", total - 4, total - 3, total - 2, total - 1, total];
return [1, "...", cur - 1, cur, cur + 1, "...", total];
});
const goJump = () => {
const n = parseInt(jumpPage.value, 10);
if (!isNaN(n) && n >= 1 && n <= pageCount.value) page.value = n;
jumpPage.value = "";
};
const onQuery = () => { page.value = 1; };
const onReset = () => { filterForm.value = { dateRange: [], status: "", quarNo: "", name: "" }; page.value = 1; };
const statusTagType = (s) => ({ 在岗: "success", 离岗: "info", 停职: "danger" }[s] || "info");
const dialogVisible = ref(false);
const dialogTitle = ref("");
const mode = ref("view");
const form = ref({});
const resetForm = () => {
form.value = {
id: null, quarNo: "", uniformNo: "", name: "", gender: "", workOrg: "甘州区农业技术推广中心", grantOrg: "",
status: "在岗", registerDate: "", cancelDate: "", certIssueDate: "",
org: "甘州区农业技术推广中心", idCard: "", education: "", school: "", techTitle: "", adminPos: "",
lawCertNo: "", inspectCertNo: "", tel: "", account: "", birthday: "", approveDate: "",
major: "", graduateDate: "", grantDate: "", quarYears: "", lawYears: "",
inspectYears: "", phone: "", email: "", resume: "", awards: "", research: "",
papers: "", overseas: "",
photo: "", signature: ""
};
};
const onPhotoChange = (file, key) => {
if (!file || !file.raw) return;
const reader = new FileReader();
reader.onload = (e) => { form.value[key] = e.target.result; };
reader.readAsDataURL(file.raw);
};
const onView = (q) => { mode.value = "view"; form.value = { ...q }; dialogVisible.value = true; dialogTitle.value = "检疫员详情"; };
const onAdd = () => { mode.value = "add"; resetForm(); dialogVisible.value = true; dialogTitle.value = "新增检疫员"; };
const onSubmit = () => {
if (!form.value.name || !form.value.workOrg || !form.value.idCard || !form.value.education || !form.value.school || !form.value.techTitle || !form.value.adminPos || !form.value.account || !form.value.birthday || !form.value.major || !form.value.graduateDate || !form.value.quarYears || !form.value.email) {
ElMessage.warning("请填写完整必填项(红 * 字段)");
return;
}
const newId = Math.max(...quars.value.map((q) => q.id), 0) + 1;
quars.value.unshift({ ...form.value, id: newId });
saveQuars(quars.value);
ElMessage.success("新增成功");
dialogVisible.value = false;
};
const onDialogClose = () => { form.value = {}; };
// ========== 导入 ==========
const fileInputRef = ref(null);
const triggerImport = () => { fileInputRef.value && fileInputRef.value.click(); };
const onFileChange = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const rows = parseCSV(text);
if (!rows.length) { ElMessage.warning("文件为空或格式不正确"); return; }
const mapped = rows.map((r) => mapRowToQuar(r)).filter(Boolean);
if (!mapped.length) { ElMessage.warning("未解析到有效数据"); return; }
let maxId = Math.max(...quars.value.map((q) => q.id), 0);
mapped.forEach((m) => { m.id = ++maxId; });
quars.value = [...mapped, ...quars.value];
saveQuars(quars.value);
ElMessage.success(`成功导入 ${mapped.length} 条记录`);
} catch (err) {
ElMessage.error("导入失败:" + (err && err.message ? err.message : ""));
} finally {
e.target.value = "";
}
};
const parseCSV = (text) => {
// 自动跳过 UTF-8 BOM
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length < 2) return [];
const headers = splitCSVLine(lines[0]).map((h) => h.trim());
return lines.slice(1).map((line) => {
const cells = splitCSVLine(line);
const obj = {};
headers.forEach((h, i) => { obj[h] = (cells[i] || "").trim(); });
return obj;
});
};
const splitCSVLine = (line) => {
const out = []; let cur = ""; let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') { if (inQuote && line[i + 1] === '"') { cur += '"'; i++; } else inQuote = !inQuote; }
else if (ch === "," && !inQuote) { out.push(cur); cur = ""; }
else cur += ch;
}
out.push(cur);
return out;
};
const HEADER_MAP = {
"检疫员编号": "quarNo",
"制式服装编号": "uniformNo",
"姓名": "name",
"性别": "gender",
"工作单位": "workOrg",
"授予单位": "grantOrg",
"状态": "status",
"注册日期": "registerDate",
"注销日期": "cancelDate",
"证书印发日期": "certIssueDate",
"检疫机构": "org",
"身份证号": "idCard",
"最高学历": "education",
"毕业院校": "school",
"技术职称": "techTitle",
"行政职务": "adminPos",
"执法证号": "lawCertNo",
"路查证号": "inspectCertNo",
"固定电话": "tel",
"账号": "account",
"出生年月": "birthday",
"批准日期": "approveDate",
"所学专业": "major",
"毕业时间": "graduateDate",
"授予时间": "grantDate",
"检疫年限": "quarYears",
"执法证年限": "lawYears",
"路查证年限": "inspectYears",
"移动电话": "phone",
"邮箱": "email"
};
const mapRowToQuar = (row) => {
const obj = {};
Object.entries(row).forEach(([k, v]) => {
const key = HEADER_MAP[k.trim()] || k.trim();
obj[key] = v;
});
if (!obj.name) return null;
if (!obj.workOrg) obj.workOrg = "甘州区农业技术推广中心";
if (!obj.org) obj.org = "甘州区农业技术推广中心";
if (!obj.status) obj.status = "在岗";
return obj;
};
onMounted(() => { quars.value = loadQuars(); });
</script>
<style scoped>
.page-quar { padding: 18px 20px; }
.filter-row { background: #fff; padding: 14px 16px; display: flex; flex-wrap: wrap; gap: 12px 16px; align-items: center; border: 1px solid #e6e8ec; border-radius: 4px; margin-bottom: 14px; }
.filter-row.filter-row-2 { flex-direction: column; align-items: stretch; gap: 10px; }
.filter-row.filter-row-2 .filter-line { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.filter-row.filter-row-2 .filter-actions-left { display: flex; gap: 8px; }
.filter-row.filter-row-2 .filter-actions-right { margin-left: auto; display: flex; gap: 8px; }
.file-hidden { display: none; }
.filter-item { display: flex; align-items: center; gap: 6px; }
.filter-label { font-size: 13px; color: #333; white-space: nowrap; flex-shrink: 0; }
.filter-item :deep(.el-input), .filter-item :deep(.el-select) { width: 180px; }
.filter-item.range-item :deep(.el-date-editor) { width: 320px; }
.filter-actions { margin-left: auto; display: flex; gap: 8px; }
.table-card { background: #fff; border: 1px solid #e6e8ec; border-radius: 4px; padding: 8px 0; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; text-align: center; }
.data-table th { background: #fafafa; color: #333; font-weight: 600; }
.data-table td { color: #333; }
.data-table tr:hover td { background: #f9fafc; }
.empty { text-align: center; color: #999; padding: 24px 0; }
.op-link { color: #333; margin: 0 4px; cursor: pointer; }
.op-link:hover { color: #d71e1e; }
.link-name { color: #1f3a5f; cursor: pointer; }
.link-name:hover { color: #d71e1e; text-decoration: underline; }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 6px; padding: 14px 20px; background: #fff; border: 1px solid #ebeef5; border-top: 0; border-radius: 0 0 3px 3px; }
.total { color: #555; font-size: 13px; margin-right: 12px; }
.page-btn { background: #fff; border: 1px solid #dcdfe6; color: #555; height: 28px; min-width: 28px; padding: 0 8px; font-size: 14px; cursor: pointer; border-radius: 3px; }
.page-btn:hover:not(:disabled) { color: #d71e1e; border-color: #d71e1e; }
.page-btn.active { background: #d71e1e; border-color: #d71e1e; color: #fff; }
.page-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.jump { color: #555; font-size: 14px; margin-left: 6px; }
.jump-input { width: 50px; height: 24px; padding: 0 4px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 13px; text-align: center; margin: 0 4px; }
/* 详情/新增 表单弹窗 */
.quar-form { padding: 0 16px 12px; }
.quar-title { text-align: center; font-size: 22px; font-weight: 700; color: #1f3a5f; margin: 4px 0 14px; }
.quar-notice { background: #fafafa; border: 1px solid #ebeef5; border-left: 3px solid #d71e1e; padding: 12px 18px; border-radius: 4px; margin-bottom: 18px; }
.quar-notice p { margin: 4px 0; font-size: 13px; line-height: 1.8; color: #333; }
.law-link { color: #16a34a; text-decoration: underline; cursor: pointer; }
.quar-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 24px; }
.quar-col { display: flex; flex-direction: column; gap: 10px; }
.qf-item { display: flex; align-items: center; gap: 8px; }
.qf-item.full { grid-column: 1 / -1; flex-direction: column; align-items: stretch; }
.qf-label { font-size: 13px; color: #333; min-width: 84px; text-align: right; flex-shrink: 0; }
.qf-item.full .qf-label { text-align: left; min-width: 0; margin-bottom: 4px; }
.qf-label.required::before { content: "*"; color: #d71e1e; margin-right: 4px; }
.qf-item :deep(.el-input), .qf-item :deep(.el-select), .qf-item :deep(.el-textarea) { flex: 1; }
.qf-upload-row { display: flex; gap: 24px; align-items: flex-start; }
.qf-upload-block { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.qf-uploader { width: 100%; }
.qf-uploader :deep(.el-upload) { width: 100%; }
.qf-upload-empty { width: 100%; height: 120px; border: 1px dashed #c0c4cc; border-radius: 4px; background: #fafafa; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: #909399; cursor: pointer; transition: border-color 0.2s; }
.qf-upload-empty:hover { border-color: #d71e1e; color: #d71e1e; }
.qf-upload-icon { font-size: 24px; }
.qf-upload-text { font-size: 13px; }
.qf-upload-img { width: 100%; height: 120px; object-fit: contain; border: 1px solid #ebeef5; border-radius: 4px; background: #fff; cursor: pointer; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 6px; }
</style>
\ No newline at end of file
<template> <template>
<div class="page-list"> <div class="page-list">
<div class="filter-box"> <div class="filter-box">
<div class="filter-row"> <div class="filter-row filter-row-4">
<div class="filter-item"> <div class="filter-item">
<label>申报时间:</label> <label>申报时间:</label>
<DatePicker v-model="filters.declareStart" class="dp-160" placeholder="开始日期" /> <DatePicker v-model="filters.declareStart" class="dp-160" placeholder="开始日期" />
...@@ -10,39 +10,32 @@ ...@@ -10,39 +10,32 @@
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label>检疫单证号:</label> <label>检疫单证号:</label>
<input v-model="filters.cert" placeholder="请输入关键字" class="ipt" /> <input v-model="filters.cert" placeholder="请输入关键字" class="ipt ipt-160" />
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label>植物名称:</label> <label>植物名称:</label>
<input v-model="filters.plant" placeholder="请输入关键字" class="ipt" /> <input v-model="filters.plant" placeholder="请输入关键字" class="ipt ipt-160" />
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label>申报单位:</label> <label>申报单位:</label>
<input v-model="filters.unit" placeholder="请输入关键字" class="ipt" /> <input v-model="filters.unit" placeholder="请输入关键字" class="ipt ipt-160" />
</div> </div>
</div> </div>
<div class="filter-row"> <div class="filter-row">
<div class="filter-item"> <div class="filter-item">
<label>受理疫情监测时间:</label> <label>受理疫情监测时间:</label>
<DatePicker v-model="filters.acceptStart" class="dp-160" placeholder="开始日期" /> <DatePicker v-model="filters.acceptStart" class="dp-140" placeholder="开始日期" />
<span class="dash">至</span> <span class="dash">至</span>
<DatePicker v-model="filters.acceptEnd" class="dp-160" placeholder="结束日期" /> <DatePicker v-model="filters.acceptEnd" class="dp-140" placeholder="结束日期" />
</div>
<div class="filter-item filter-radio">
<label class="radio-label" :class="isActive('all')">
<input type="radio" v-model="filters.status" value="all" /> 全部
</label>
<label class="radio-label" :class="isActive('待田间调查')">
<input type="radio" v-model="filters.status" value="待田间调查" /> 待田间调查
</label>
<label class="radio-label" :class="isActive('田调结束')">
<input type="radio" v-model="filters.status" value="田调结束" /> 田调结束
</label>
</div> </div>
<div class="filter-item"> <div class="filter-item">
<button class="btn" @click="onQuery">查询</button> <button class="btn" @click="onQuery">查询</button>
<button class="btn btn-default" @click="onReset">重置</button> <button class="btn btn-default" @click="onReset">重置</button>
</div> </div>
<div class="filter-item filter-add">
<button class="btn btn-add" @click="triggerImport">新增导入</button>
<input ref="fileInputRef" type="file" accept=".csv,.xlsx,.xls" class="file-hidden" @change="onFileChange" />
</div>
</div> </div>
</div> </div>
...@@ -56,7 +49,6 @@ ...@@ -56,7 +49,6 @@
<col style="width:11%" /> <col style="width:11%" />
<col style="width:13%" /> <col style="width:13%" />
<col style="width:13%" /> <col style="width:13%" />
<col style="width:6%" />
<col style="width:9%" /> <col style="width:9%" />
</colgroup> </colgroup>
<thead> <thead>
...@@ -68,7 +60,6 @@ ...@@ -68,7 +60,6 @@
<th>申报时间</th> <th>申报时间</th>
<th>受理日期</th> <th>受理日期</th>
<th>受理机构</th> <th>受理机构</th>
<th>状态</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
...@@ -81,14 +72,12 @@ ...@@ -81,14 +72,12 @@
<td>{{ row.declareDate }}</td> <td>{{ row.declareDate }}</td>
<td>{{ row.acceptDate }}</td> <td>{{ row.acceptDate }}</td>
<td>{{ row.org }}</td> <td>{{ row.org }}</td>
<td>{{ row.status }}</td>
<td> <td>
<a class="op-link" @click="onView(row)">查看</a> <a class="op-link" @click="onView(row)">查看</a>
<a class="op-link" @click="onProcess(row)">办理</a>
</td> </td>
</tr> </tr>
<tr v-if="!pagedData.length"> <tr v-if="!pagedData.length">
<td colspan="9" class="empty">暂无数据</td> <td colspan="8" class="empty">暂无数据</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
...@@ -113,12 +102,24 @@ ...@@ -113,12 +102,24 @@
</div> </div>
<div v-if="modalVisible" class="modal-mask" @click.self="modalVisible = false"> <div v-if="modalVisible" class="modal-mask" @click.self="modalVisible = false">
<div class="modal-box"> <div class="modal-box modal-wide">
<div class="modal-top"> <div class="notice-top">
<span class="modal-title">流程详情</span> <div class="notice-title">委托书详情</div>
<span class="modal-close" @click="modalVisible = false">×</span> <span class="modal-close" @click="modalVisible = false">×</span>
</div> </div>
<div class="modal-body"> <div class="proc-tabs">
<div
v-for="t in procTabs"
:key="t.key"
class="proc-tab"
:class="{ active: activeProcTab === t.key }"
@click="activeProcTab = t.key"
>{{ t.label }}</div>
</div>
<div class="modal-body notice-body">
<!-- Lab 1:国(境)外引进农业种苗入境后疫情监测委托书 -->
<div v-show="activeProcTab === 'entrust'" class="entrust-doc">
<div class="modal-toolbar"> <div class="modal-toolbar">
<button class="print-btn" @click="onPrint">打印预览</button> <button class="print-btn" @click="onPrint">打印预览</button>
<div class="modal-title-text">国(境)外引进农业种苗入境后疫情监测委托书</div> <div class="modal-title-text">国(境)外引进农业种苗入境后疫情监测委托书</div>
...@@ -151,6 +152,327 @@ ...@@ -151,6 +152,327 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Lab 2:《国(境)外引进农业种苗检疫审批单》 -->
<div v-show="activeProcTab === 'approval'" class="entrust-doc">
<div class="modal-toolbar">
<button class="print-btn" @click="onPrint">打印预览</button>
<div class="modal-title-text">国(境)外引进农业种苗检疫审批单</div>
<div class="qrcode-box" aria-hidden="true">
<svg viewBox="0 0 60 60" width="60" height="60">
<rect width="60" height="60" fill="#fff" />
<g fill="#222">
<rect x="2" y="2" width="14" height="14" />
<rect x="6" y="6" width="6" height="6" fill="#fff" />
<rect x="44" y="2" width="14" height="14" />
<rect x="48" y="6" width="6" height="6" fill="#fff" />
<rect x="2" y="44" width="14" height="14" />
<rect x="6" y="48" width="6" height="6" fill="#fff" />
<rect x="20" y="4" width="2" height="2" /><rect x="24" y="2" width="2" height="2" /><rect x="28" y="6" width="2" height="2" />
<rect x="32" y="2" width="2" height="2" /><rect x="36" y="6" width="2" height="2" /><rect x="40" y="4" width="2" height="2" />
<rect x="22" y="10" width="2" height="2" /><rect x="26" y="14" width="2" height="2" /><rect x="34" y="10" width="2" height="2" />
<rect x="20" y="20" width="2" height="2" /><rect x="24" y="18" width="2" height="2" /><rect x="28" y="22" width="2" height="2" />
<rect x="32" y="20" width="2" height="2" /><rect x="36" y="18" width="2" height="2" /><rect x="40" y="22" width="2" height="2" />
<rect x="22" y="26" width="2" height="2" /><rect x="26" y="30" width="2" height="2" /><rect x="30" y="26" width="2" height="2" />
<rect x="34" y="30" width="2" height="2" /><rect x="38" y="26" width="2" height="2" />
<rect x="20" y="34" width="2" height="2" /><rect x="24" y="38" width="2" height="2" /><rect x="28" y="34" width="2" height="2" />
<rect x="32" y="38" width="2" height="2" /><rect x="36" y="34" width="2" height="2" /><rect x="40" y="38" width="2" height="2" />
<rect x="44" y="22" width="2" height="2" /><rect x="48" y="26" width="2" height="2" /><rect x="52" y="22" width="2" height="2" />
<rect x="20" y="44" width="2" height="2" /><rect x="24" y="48" width="2" height="2" /><rect x="28" y="44" width="2" height="2" />
<rect x="32" y="48" width="2" height="2" /><rect x="36" y="44" width="2" height="2" /><rect x="40" y="48" width="2" height="2" />
<rect x="44" y="34" width="2" height="2" /><rect x="48" y="38" width="2" height="2" /><rect x="52" y="34" width="2" height="2" />
<rect x="46" y="44" width="2" height="2" /><rect x="50" y="48" width="2" height="2" /><rect x="54" y="44" width="2" height="2" />
</g>
</svg>
</div>
</div>
<div class="doc-title-line"></div>
<!-- 编号 + 有效期 -->
<div class="apv-num-row">
<div class="apv-num"><span class="apv-num-lbl">编号:</span><span class="apv-num-val">6204122022007095</span></div>
<div class="apv-date"><span class="apv-num-lbl">有效期至:</span><span class="apv-num-val">2022年9月16日</span></div>
</div>
<!-- 主体信息表 -->
<table class="apv-table">
<colgroup>
<col style="width:90px" />
<col style="width:130px" />
<col />
<col style="width:90px" />
<col style="width:130px" />
<col />
</colgroup>
<tbody>
<tr>
<td class="apv-lbl" rowspan="4">申请<br />单位</td>
<td class="apv-lbl-inner">名称</td>
<td colspan="4">甘肃高赢种子高薪科技有限责任公司</td>
</tr>
<tr>
<td class="apv-lbl-inner">地址</td>
<td colspan="4">甘肃省张掖市甘州区国道312线2700公里处良种繁育公司2幢</td>
</tr>
<tr>
<td class="apv-lbl-inner">联系人</td>
<td>刘玉章</td>
<td class="apv-lbl-inner">联系电话</td>
<td colspan="2">18993607501</td>
</tr>
<tr>
<td class="apv-lbl-inner" colspan="5" style="height:0; padding:0; border:none;"></td>
</tr>
<tr>
<td class="apv-lbl-inner">植物中文名</td>
<td>番茄</td>
<td class="apv-lbl-inner">植物学名</td>
<td colspan="3">Callistephus chinensis (Linnaeus) Nees</td>
</tr>
<tr>
<td class="apv-lbl-inner">原产国(地区)</td>
<td>智利</td>
<td class="apv-lbl-inner">种苗类型</td>
<td colspan="3">种子</td>
</tr>
<tr>
<td class="apv-lbl-inner">输出国(地区)</td>
<td>日本</td>
<td class="apv-lbl-inner">引种用途</td>
<td colspan="3">对外制种</td>
</tr>
<tr>
<td class="apv-lbl-inner">种植地区</td>
<td colspan="3">甘肃省张掖市甘州区</td>
<td class="apv-lbl-inner" style="width:90px;">审批数量</td>
<td>40千克</td>
</tr>
</tbody>
</table>
<!-- 检疫要求 -->
<table class="apv-table apv-table-narrow">
<colgroup>
<col style="width:80px" />
<col />
</colgroup>
<tbody>
<tr>
<td class="apv-lbl-h" rowspan="4">检疫要求</td>
<td>1.限定在省制国中我局口岸入境;</td>
</tr>
<tr>
<td>2.所携带由国家(地区)官方植物检疫证书,证明符合中国的植物检疫要求;</td>
</tr>
<tr>
<td>
3.禁止携带以下有害生物(中文名和学名):<br />
&nbsp;&nbsp;&nbsp;&nbsp;Ambrosia spp. &nbsp;&nbsp;&nbsp;&nbsp;豚草属<br />
&nbsp;&nbsp;&nbsp;&nbsp;Xiphinema Cobb (The species transmit viruses) &nbsp;&nbsp;&nbsp;&nbsp;剑线虫属(传毒种类)<br />
&nbsp;&nbsp;&nbsp;&nbsp;中华人民共和国法律规定的其它检疫性有害生物
<div class="apv-note">(注:请在对外贸易合同或协议中订明以上检疫要求)</div>
</td>
</tr>
</tbody>
</table>
<!-- 审批意见(横向) + 印章 -->
<div class="apv-result-row">
<table class="apv-table apv-table-narrow apv-result-table">
<colgroup>
<col style="width:80px" />
<col />
</colgroup>
<tbody>
<tr>
<td class="apv-lbl-h">审批意见</td>
<td>
同意引进,转喜须在甘肃省指定地区集中种植,引种单应交甘肃省于5个工作日内通知当地植物检疫机构做好检疫工作,对此后当地植物检疫机构负责监管。
</td>
</tr>
</tbody>
</table>
<div class="apv-seal">
<div class="apv-seal-ring">植物检疫证书</div>
<div class="apv-seal-ring">农 业 部 监 制</div>
</div>
</div>
<!-- 签发信息 -->
<table class="apv-table apv-table-narrow">
<colgroup>
<col style="width:120px" />
<col style="width:50%" />
<col style="width:120px" />
<col />
</colgroup>
<tbody>
<tr>
<td class="apv-lbl-inner">签发机构<br />(植物检疫专用章)</td>
<td></td>
<td class="apv-lbl-inner">签发日期</td>
<td>2022年3月17日</td>
</tr>
<tr>
<td class="apv-lbl-inner">签发人</td>
<td colspan="3"></td>
</tr>
</tbody>
</table>
<!-- 底部备注 -->
<div class="apv-footer-note">
注:本审批单一式二联一回顾,此联第一联,申请单位凭第一联和国单同出入境检验检疫机关报检,第二联由签发机关存档。
</div>
</div>
<!-- Lab 3:《国(境)外引进农业种苗入境后疫情监测申请书》 -->
<div v-show="activeProcTab === 'apply'" class="entrust-doc apply-doc">
<div class="modal-toolbar">
<button class="print-btn" @click="onPrint">打印预览</button>
<div class="modal-title-text">国(境)外引进农业种苗入境后疫情监测申请书</div>
</div>
<div class="doc-title-line"></div>
<div class="apply-num-row">
<span class="apply-num-lbl">编号:</span>
<span class="apply-num-val">{{ modalRow.cert }}-JS01</span>
</div>
<table class="apv-table apply-table">
<colgroup>
<col style="width:60px" />
<col style="width:90px" />
<col />
<col style="width:90px" />
<col style="width:120px" />
</colgroup>
<tbody>
<tr>
<td class="apv-lbl-v" rowspan="4">引种单位</td>
<td class="apv-lbl-inner">名称(盖章)</td>
<td colspan="3">{{ modalRow.unit }}</td>
</tr>
<tr>
<td class="apv-lbl-inner">地址</td>
<td colspan="3">甘肃省张掖市甘州区国道312线2700公里处坂田种苗公司2幢</td>
</tr>
<tr>
<td class="apv-lbl-inner">联系人</td>
<td>刘玉堂</td>
<td class="apv-lbl-inner">邮政编码</td>
<td>734000</td>
</tr>
<tr>
<td class="apv-lbl-inner">联系电话</td>
<td>18993607501</td>
<td class="apv-lbl-inner">传真</td>
<td>0936-8807621</td>
</tr>
<tr>
<td colspan="2" class="apv-lbl-inner">审批单位名称</td>
<td colspan="3">{{ modalRow.org }}</td>
</tr>
<tr>
<td colspan="2" class="apv-lbl-inner">植物名称</td>
<td>{{ modalRow.plant }}</td>
<td class="apv-lbl-inner">产出用途</td>
<td>种子</td>
</tr>
<tr>
<td colspan="2" class="apv-lbl-inner">品种名称</td>
<td>{{ modalRow.plant }}</td>
<td class="apv-lbl-inner">原产国(地区)</td>
<td>{{ modalRow.country || "智利" }}</td>
</tr>
<tr>
<td colspan="2" class="apv-lbl-inner">引种检疫审批单编号</td>
<td>{{ modalRow.cert }}</td>
<td class="apv-lbl-inner">审批日期</td>
<td>{{ formatDate(modalRow.acceptDate) }}</td>
</tr>
<tr>
<td colspan="2" class="apv-lbl-inner">审批数量</td>
<td>40 千克</td>
<td class="apv-lbl-inner">进境数量</td>
<td>0.0014 千克</td>
</tr>
<tr>
<td colspan="2" class="apv-lbl-inner">种植面积</td>
<td>0.4 亩</td>
<td class="apv-lbl-inner">申请日期</td>
<td>{{ formatDate(modalRow.declareDate) }}</td>
</tr>
<tr>
<td colspan="2" class="apv-lbl-inner">种植地点</td>
<td colspan="3">甘肃省张掖市甘州区</td>
</tr>
<tr>
<td colspan="2" class="apv-lbl-inner">种植期限</td>
<td colspan="3" class="apply-term-cell">
<span>从 </span>
<span class="apply-term-val">{{ formatDate(modalRow.acceptDate) }}</span>
<span> 起,</span>
<span>至 </span>
<span class="apply-term-val">{{ formatDate(modalRow.declareDate) }}</span>
<span> 止。</span>
</td>
</tr>
<tr>
<td colspan="2" class="apv-lbl-inner">批件发送方式</td>
<td colspan="3" class="apply-send-cell">
<label><input type="checkbox" checked /> 来人领取</label>
<label><input type="checkbox" /> 特快专递邮寄</label>
<label><input type="checkbox" /> 普通邮寄</label>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 新增委托书弹窗 -->
<div v-if="addVisible" class="modal-mask" @click.self="addVisible = false">
<div class="modal-box add-box">
<div class="modal-top">
<span class="modal-title">新增委托书</span>
<span class="modal-close" @click="addVisible = false">×</span>
</div>
<div class="modal-body">
<div class="add-form">
<div class="add-row">
<div class="add-field"><span class="add-lbl"><i class="req">*</i>检疫单证号</span><input class="add-ctrl" v-model="addForm.cert" /></div>
<div class="add-field"><span class="add-lbl">申报单位</span><input class="add-ctrl" v-model="addForm.unit" /></div>
</div>
<div class="add-row">
<div class="add-field"><span class="add-lbl">植物名称</span><input class="add-ctrl" v-model="addForm.plant" /></div>
<div class="add-field"><span class="add-lbl">申报时间</span><DatePicker v-model="addForm.declareDate" class="add-ctrl" /></div>
</div>
<div class="add-row">
<div class="add-field"><span class="add-lbl">受理日期</span><DatePicker v-model="addForm.acceptDate" class="add-ctrl" /></div>
<div class="add-field"><span class="add-lbl">受理机构</span><input class="add-ctrl" v-model="addForm.org" /></div>
</div>
<div class="add-row">
<div class="add-field"><span class="add-lbl">状态</span>
<select class="add-ctrl" v-model="addForm.status">
<option value="待田间调查">待田间调查</option>
<option value="田调结束">田调结束</option>
</select>
</div>
<div class="add-field"></div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-default" @click="addVisible = false">取消</button>
<button class="btn" @click="onConfirmAdd">保存</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -159,6 +481,7 @@ ...@@ -159,6 +481,7 @@
<script setup> <script setup>
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import JSZip from "jszip";
import DatePicker from "@/components/DatePicker.vue"; import DatePicker from "@/components/DatePicker.vue";
import { entrustListData } from "@/data/entrustList"; import { entrustListData } from "@/data/entrustList";
import { usePersistentRef } from "@/utils/persistentData"; import { usePersistentRef } from "@/utils/persistentData";
...@@ -185,6 +508,12 @@ const pageSize = 10; ...@@ -185,6 +508,12 @@ const pageSize = 10;
const jumpInput = ref(1); const jumpInput = ref(1);
const modalVisible = ref(false); const modalVisible = ref(false);
const modalRow = ref({}); const modalRow = ref({});
const activeProcTab = ref("entrust");
const procTabs = [
{ key: "entrust", label: "国(境)外引进农业种苗入境后疫情监测委托书" },
{ key: "approval", label: "《国(境)外引进农业种苗检疫审批单》" },
{ key: "apply", label: "《国(境)外引进农业种苗入境后疫情监测申请书》" }
];
const inDateRange = (date, start, end) => { const inDateRange = (date, start, end) => {
if (!date) return true; if (!date) return true;
...@@ -237,9 +566,186 @@ const onJump = () => { ...@@ -237,9 +566,186 @@ const onJump = () => {
const p = Math.min(Math.max(1, jumpInput.value | 0), totalPages.value); const p = Math.min(Math.max(1, jumpInput.value | 0), totalPages.value);
page.value = p; page.value = p;
}; };
const onView = (row) => { modalRow.value = row; modalVisible.value = true; }; const onView = (row) => { modalRow.value = row; activeProcTab.value = "entrust"; modalVisible.value = true; };
const onProcess = (row) => { ElMessage.info("进入办理流程:" + row.cert); };
const onPrint = () => { ElMessage.success("打印预览已生成"); }; const onPrint = () => { ElMessage.success("打印预览已生成"); };
const formatDate = (d) => {
if (!d) return "";
const [y, m, day] = d.split("-");
return `${y}年${parseInt(m, 10)}月${parseInt(day, 10)}日`;
};
// ===== 新增委托书 =====
const addVisible = ref(false);
const defaultAddForm = () => ({ cert: "", unit: "", plant: "", declareDate: "", acceptDate: "", org: "甘肃省植保植检站", status: "待田间调查" });
const addForm = ref(defaultAddForm());
const onAdd = () => { addForm.value = defaultAddForm(); addVisible.value = true; };
const onConfirmAdd = () => {
if (!addForm.value.cert) { ElMessage.warning("请填写检疫单证号"); return; }
const next = (persistentList.value.length ? Math.max(...persistentList.value.map((r) => r.id)) : 0) + 1;
persistentList.value = [...persistentList.value, { id: next, ...addForm.value }];
addVisible.value = false;
ElMessage.success("新增成功");
};
// ===== 导入 =====
const headerMap = {
"检疫单证号": "cert",
"申报单位": "unit",
"植物名称": "plant",
"申报时间": "declareDate",
"受理日期": "acceptDate",
"受理机构": "org",
"状态": "status"
};
const importHeaders = Object.keys(headerMap);
const fileInputRef = ref(null);
const triggerImport = () => { fileInputRef.value?.click(); };
function parseCSV(text) {
const rows = [];
let row = [];
let cell = "";
let inQuote = false;
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuote) {
if (c === '"') {
if (text[i + 1] === '"') { cell += '"'; i++; }
else inQuote = false;
} else cell += c;
} else {
if (c === '"') inQuote = true;
else if (c === ",") { row.push(cell); cell = ""; }
else if (c === "\r") { /* skip */ }
else if (c === "\n") { row.push(cell); rows.push(row); row = []; cell = ""; }
else cell += c;
}
}
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
return rows;
}
async function parseXlsx(file) {
const zip = await JSZip.loadAsync(file);
let sharedStrings = [];
const ssFile = zip.file("xl/sharedStrings.xml");
if (ssFile) {
const ssXml = await ssFile.async("string");
const doc = new DOMParser().parseFromString(ssXml, "application/xml");
sharedStrings = Array.from(doc.querySelectorAll("si")).map((si) => {
return Array.from(si.querySelectorAll("t")).map((t) => t.textContent || "").join("");
});
}
const sheetFile = zip.file("xl/worksheets/sheet1.xml");
if (!sheetFile) throw new Error("未找到 sheet1");
const sheetXml = await sheetFile.async("string");
const doc = new DOMParser().parseFromString(sheetXml, "application/xml");
const rows = [];
const colIndex = (ref) => {
const m = ref.match(/[A-Z]+/);
if (!m) return 0;
let n = 0;
for (let i = 0; i < m[0].length; i++) n = n * 26 + (m[0].charCodeAt(i) - 64);
return n - 1;
};
doc.querySelectorAll("row").forEach((rowEl) => {
const cells = [];
Array.from(rowEl.querySelectorAll("c")).forEach((c) => {
const ref = c.getAttribute("r");
const colIdx = colIndex(ref);
while (cells.length < colIdx) cells.push("");
const type = c.getAttribute("t");
const vEl = c.querySelector("v");
const isEl = c.querySelector("is > t");
let val = "";
if (vEl) {
if (type === "s") val = sharedStrings[parseInt(vEl.textContent, 10)] || "";
else val = vEl.textContent || "";
} else if (isEl) val = isEl.textContent || "";
cells.push(val);
});
rows.push(cells);
});
return rows;
}
function rowsToObjects(rows) {
if (!rows.length) return [];
const headers = rows[0].map((h) => (h || "").trim());
return rows.slice(1)
.filter((r) => r.some((c) => c && String(c).trim() !== ""))
.map((r) => {
const obj = {};
headers.forEach((h, i) => {
const key = headerMap[h];
if (key) obj[key] = (r[i] != null ? String(r[i]) : "").trim();
});
if (!obj.org) obj.org = "甘肃省植保植检站";
if (!obj.status) obj.status = "待田间调查";
return obj;
})
.filter((o) => o.cert);
}
const onFileChange = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const name = (file.name || "").toLowerCase();
let rows = [];
try {
if (name.endsWith(".csv") || name.endsWith(".txt")) {
rows = parseCSV(await file.text());
} else if (name.endsWith(".xlsx")) {
rows = await parseXlsx(file);
} else if (name.endsWith(".xls")) {
ElMessage.error("暂不支持 .xls(老版本),请另存为 .xlsx 或 .csv 后再导入");
e.target.value = "";
return;
} else {
ElMessage.error("仅支持 .csv 或 .xlsx 文件");
e.target.value = "";
return;
}
} catch (err) {
console.error(err);
ElMessage.error("文件解析失败:" + (err.message || err));
e.target.value = "";
return;
}
const list = rowsToObjects(rows);
if (!list.length) {
ElMessage.warning("未解析到有效数据,请检查表头或文件内容");
e.target.value = "";
return;
}
let nextId = persistentList.value.length ? Math.max(...persistentList.value.map((r) => r.id)) : 0;
const newRows = list.map((o) => ({ id: ++nextId, ...o }));
persistentList.value = [...persistentList.value, ...newRows];
ElMessage.success(`导入成功,共新增 ${newRows.length} 条`);
e.target.value = "";
};
const downloadTemplate = () => {
const csv =
"\uFEFF" +
importHeaders.join(",") +
"\n" +
"6204122022007095,甘肃富赢种子高新科技有限责任公司,翠菊,2022-03-09,2022-04-26,甘肃省植保植检站,待田间调查\n" +
"6204122022007096,甘肃富赢种子高新科技有限责任公司,番茄,2022-03-10,2022-04-27,甘肃省植保植检站,田调结束\n";
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "委托书管理导入模板.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
</script> </script>
<style scoped> <style scoped>
...@@ -275,11 +781,32 @@ const onPrint = () => { ElMessage.success("打印预览已生成"); }; ...@@ -275,11 +781,32 @@ const onPrint = () => { ElMessage.success("打印预览已生成"); };
} }
.ipt:focus { border-color: #d71e1e; } .ipt:focus { border-color: #d71e1e; }
.dp-160 { width: 150px; } .dp-160 { width: 150px; }
.dp-140 { width: 130px; }
.ipt-140 { width: 130px; }
.ipt-160 { width: 160px; }
.filter-row.filter-row-5 { flex-wrap: nowrap; }
.filter-row.filter-row-4 { flex-wrap: nowrap; }
.dash { color: #999; } .dash { color: #999; }
.filter-radio { gap: 20px; } .filter-radio { gap: 20px; }
.radio-label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: #555; font-size: 14px; } .radio-label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: #555; font-size: 14px; }
.radio-label input { accent-color: #d71e1e; } .radio-label input { accent-color: #d71e1e; }
.radio-label.active { color: #d71e1e; font-weight: bold; } .radio-label.active { color: #d71e1e; font-weight: bold; }
.filter-add { margin-left: auto; }
.btn-add { background: #d71e1e; color: #fff; border: 1px solid #d71e1e; height: 32px; padding: 0 18px; border-radius: 3px; font-size: 14px; cursor: pointer; margin-left: 8px; }
.btn-add:first-child { margin-left: 0; }
.btn-add:hover { background: #b81818; border-color: #b81818; }
.file-hidden { display: none; }
/* 新增委托书弹窗 */
.add-box { width: 720px; max-width: 95vw; }
.add-form { padding: 8px 4px 0; }
.add-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 14px; }
.add-field { display: flex; align-items: center; min-height: 34px; }
.add-lbl { width: 100px; flex-shrink: 0; color: #555; font-size: 14px; text-align: right; padding-right: 10px; }
.add-lbl .req { color: #d71e1e; font-style: normal; margin-right: 2px; }
.add-ctrl { flex: 1; height: 32px; border: 1px solid #d4d4d4; border-radius: 3px; padding: 0 10px; font-size: 14px; color: #333; background: #fff; outline: none; min-width: 0; }
.add-ctrl:focus { border-color: #d71e1e; }
select.add-ctrl { appearance: none; background: #fff url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M2 4l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none'/></svg>") no-repeat right 8px center; padding-right: 28px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f0f0f0; }
.tbl-wrap { .tbl-wrap {
background: #fff; background: #fff;
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
...@@ -290,7 +817,7 @@ const onPrint = () => { ElMessage.success("打印预览已生成"); }; ...@@ -290,7 +817,7 @@ const onPrint = () => { ElMessage.success("打印预览已生成"); };
.simple-table th, .simple-table td { padding: 14px 6px; border-bottom: 1px solid #f0f0f0; text-align: center; line-height: 1.5; vertical-align: middle; } .simple-table th, .simple-table td { padding: 14px 6px; border-bottom: 1px solid #f0f0f0; text-align: center; line-height: 1.5; vertical-align: middle; }
.simple-table th { background: #fafafa; color: #333; font-weight: bold; font-size: 14px; padding: 14px 6px; } .simple-table th { background: #fafafa; color: #333; font-weight: bold; font-size: 14px; padding: 14px 6px; }
.simple-table td { font-size: 14px; word-break: break-all; } .simple-table td { font-size: 14px; word-break: break-all; }
.simple-table td.col-unit { text-align: left; padding-left: 12px; } .simple-table td.col-unit { text-align: center; padding-left: 0; }
.simple-table td.col-action { white-space: nowrap; } .simple-table td.col-action { white-space: nowrap; }
.simple-table th { background: #fafafa; color: #333; font-weight: bold; font-size: 15px; } .simple-table th { background: #fafafa; color: #333; font-weight: bold; font-size: 15px; }
.simple-table td { color: #333; } .simple-table td { color: #333; }
...@@ -389,4 +916,50 @@ const onPrint = () => { ElMessage.success("打印预览已生成"); }; ...@@ -389,4 +916,50 @@ const onPrint = () => { ElMessage.success("打印预览已生成"); };
.doc-field .lbl { width: 90px; text-align: right; color: #555; font-size: 14px; margin-right: 10px; } .doc-field .lbl { width: 90px; text-align: right; color: #555; font-size: 14px; margin-right: 10px; }
.doc-field .val { flex: 1; padding: 6px 12px; border: 1px solid #d4d4d4; background: #fafafa; border-radius: 3px; font-size: 14px; color: #333; } .doc-field .val { flex: 1; padding: 6px 12px; border: 1px solid #d4d4d4; background: #fafafa; border-radius: 3px; font-size: 14px; color: #333; }
/* 委托书详情弹窗(参照除害处理办理弹窗) */
.modal-wide { width: 1100px; }
.notice-top { display: flex; align-items: center; justify-content: center; padding: 12px 18px; background: #d71e1e; color: #fff; position: relative; }
.notice-title { flex: 1; text-align: center; font-size: 18px; font-weight: 600; }
.notice-top .modal-close { position: absolute; right: 14px; top: 50%; transform: translateY(-50%); color: #fff; font-size: 22px; cursor: pointer; line-height: 1; padding: 0 6px; }
.notice-top .modal-close:hover { opacity: 0.8; }
.proc-tabs { display: flex; border-bottom: 2px solid #ebeef5; background: #fafafa; flex-wrap: wrap; }
.proc-tab { padding: 10px 22px; font-size: 14px; color: #555; cursor: pointer; border-right: 1px solid #ebeef5; transition: all 0.2s; white-space: nowrap; }
.proc-tab:hover { color: #d71e1e; }
.proc-tab.active { color: #fff; background: #d71e1e; font-weight: 600; }
.notice-body { padding-top: 18px; }
.lab-empty { min-height: 320px; display: flex; align-items: center; justify-content: center; }
.lab-empty-inner { color: #999; font-size: 14px; padding: 30px 20px; }
/* Lab 2:检疫审批单 */
.qrcode-box { width: 60px; height: 60px; background: #fff; padding: 2px; border-radius: 3px; flex-shrink: 0; }
.apv-num-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 4px 10px; font-size: 14px; color: #333; }
.apv-num, .apv-date { display: flex; align-items: center; gap: 4px; }
.apv-num-lbl { color: #555; }
.apv-num-val { color: #222; font-weight: 500; }
.apv-table { width: 100%; border-collapse: collapse; font-size: 13.5px; color: #333; margin-bottom: 10px; table-layout: fixed; }
.apv-table td { border: 1px solid #d4d4d4; padding: 8px 10px; vertical-align: middle; line-height: 1.7; word-break: break-all; }
.apv-table .apv-lbl { background: #fafafa; text-align: center; font-weight: 600; color: #222; }
.apv-table .apv-lbl-inner { background: #fafafa; text-align: center; color: #555; width: 90px; }
.apv-table .apv-lbl-v { background: #fafafa; text-align: center; font-weight: 600; color: #222; width: 40px; padding: 0; }
.apv-table .apv-lbl-v div, .apv-table .apv-lbl-v { writing-mode: vertical-rl; text-orientation: upright; letter-spacing: 4px; }
.apv-table .apv-lbl-h { background: #fafafa; text-align: center; font-weight: 600; color: #222; padding: 8px 10px; }
.apv-table-narrow td { padding: 8px 12px; }
.apv-note { color: #888; font-size: 12.5px; margin-top: 8px; text-align: right; }
.apv-result-row { position: relative; margin-bottom: 10px; }
.apv-result-table { width: 100%; }
.apv-seal { position: absolute; right: 30px; bottom: 14px; width: 130px; height: 130px; border: 2px solid #d71e1e; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #d71e1e; transform: rotate(-12deg); pointer-events: none; opacity: 0.85; }
.apv-seal::before { content: ""; position: absolute; inset: 6px; border: 1px solid #d71e1e; border-radius: 50%; }
.apv-seal-ring { font-size: 13px; font-weight: 600; letter-spacing: 2px; line-height: 1.6; }
.apv-footer-note { color: #888; font-size: 12.5px; padding: 6px 4px 0; line-height: 1.7; }
/* Lab 3:疫情监测申请书 */
.apply-num-row { padding: 4px 4px 12px; font-size: 14px; }
.apply-num-lbl { color: #555; margin-right: 6px; }
.apply-num-val { color: #222; font-weight: 600; }
.apply-table { margin-top: 4px; }
.apply-table .apv-lbl-inner { width: auto; min-width: 60px; }
.apply-term-cell { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.apply-term-val { font-weight: 500; color: #222; padding: 0 4px; }
.apply-send-cell { display: flex; align-items: center; gap: 20px; }
.apply-send-cell label { display: inline-flex; align-items: center; gap: 4px; color: #333; font-size: 13.5px; cursor: pointer; }
.apply-send-cell input[type="checkbox"] { accent-color: #d71e1e; width: 14px; height: 14px; }
</style> </style>
\ No newline at end of file
...@@ -39,15 +39,14 @@ ...@@ -39,15 +39,14 @@
<div class="tbl-wrap"> <div class="tbl-wrap">
<table class="simple-table"> <table class="simple-table">
<colgroup> <colgroup>
<col style="width:5%" /> <col style="width:4%" />
<col style="width:14%" /> <col style="width:13%" />
<col style="width:22%" /> <col style="width:22%" />
<col style="width:9%" /> <col style="width:9%" />
<col style="width:9%" /> <col style="width:9%" />
<col style="width:9%" />
<col style="width:11%" /> <col style="width:11%" />
<col style="width:13%" /> <col style="width:13%" />
<col style="width:8%" /> <col style="width:13%" />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
...@@ -56,7 +55,6 @@ ...@@ -56,7 +55,6 @@
<th>申报单位</th> <th>申报单位</th>
<th>植物名称</th> <th>植物名称</th>
<th>品种名称</th> <th>品种名称</th>
<th>调查数量</th>
<th>调查日期</th> <th>调查日期</th>
<th>检验机构</th> <th>检验机构</th>
<th>操作</th> <th>操作</th>
...@@ -69,7 +67,6 @@ ...@@ -69,7 +67,6 @@
<td class="col-unit">{{ r.unit }}</td> <td class="col-unit">{{ r.unit }}</td>
<td>{{ r.plant }}</td> <td>{{ r.plant }}</td>
<td>{{ r.variety }}</td> <td>{{ r.variety }}</td>
<td>{{ r.qty }}</td>
<td>{{ r.surveyDate }}</td> <td>{{ r.surveyDate }}</td>
<td>{{ r.testOrg }}</td> <td>{{ r.testOrg }}</td>
<td class="col-action"> <td class="col-action">
...@@ -79,7 +76,7 @@ ...@@ -79,7 +76,7 @@
</td> </td>
</tr> </tr>
<tr v-if="!pagedData.length"> <tr v-if="!pagedData.length">
<td colspan="9" class="empty">暂无数据</td> <td colspan="8" class="empty">暂无数据</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
...@@ -108,6 +105,13 @@ ...@@ -108,6 +105,13 @@
</div> </div>
</div> </div>
<div class="lab-tabs">
<div class="lab-tab" :class="{ active: viewTab === 'record' }" @click="viewTab = 'record'">田间调查记录表</div>
<div v-if="showSampleTab" class="lab-tab" :class="{ active: viewTab === 'sample' }" @click="viewTab = 'sample'">抽样记录表</div>
<div v-if="showPestNoticeTab" class="lab-tab" :class="{ active: viewTab === 'pestNotice' }" @click="viewTab = 'pestNotice'">疫情处理通知书</div>
</div>
<div v-show="viewTab === 'record'">
<div class="section"> <div class="section">
<div class="section-title">基本信息</div> <div class="section-title">基本信息</div>
<div class="kv-grid"> <div class="kv-grid">
...@@ -190,6 +194,74 @@ ...@@ -190,6 +194,74 @@
</div> </div>
</div> </div>
</div> </div>
<div v-show="viewTab === 'pestNotice'" class="section">
<div class="pest-block">
<div class="pest-title">疫情处理通知书</div>
<div class="pest-form">
<div class="pest-row-cols">
<div class="pest-row"><span class="pest-label">编号:</span><span class="pest-value">{{ form.cert }}-YC01</span></div>
<div class="pest-row"><span class="pest-label">受检单位:</span><span class="pest-value">{{ form.unit }}</span></div>
</div>
<div class="pest-row-cols">
<div class="pest-row"><span class="pest-label">联系人:</span><span class="pest-value">{{ form.contact }}</span></div>
<div class="pest-row"><span class="pest-label">联系电话:</span><span class="pest-value">{{ form.phone }}</span></div>
</div>
<div class="pest-row-cols">
<div class="pest-row"><span class="pest-label">产出用途:</span><span class="pest-value">{{ form.useType }}</span></div>
<div class="pest-row"><span class="pest-label">数量:</span><span class="pest-value">{{ (form.importQty || "").replace(' 千克', '') }}</span></div>
</div>
<div class="pest-row pest-col">
<span class="pest-label">种植(存放)地点:</span>
<span class="pest-value">{{ form.surveyProvince }}</span>
<span class="pest-value">{{ form.surveyCity }}</span>
<span class="pest-value">{{ form.surveyDistrict }}</span>
<span class="pest-value">{{ form.surveyTownship }}</span>
<span class="pest-value">{{ form.surveyAddress }}</span>
</div>
<div class="pest-row pest-col">
<span class="pest-label req">* 检验结果:</span>
<div class="pest-area pest-readonly">{{ form.noticeTestResult || "—" }}</div>
</div>
<div class="pest-row pest-col">
<span class="pest-label req">* 处理意见:</span>
<div class="pest-area pest-readonly">{{ form.noticeHandleOpinion || "—" }}</div>
</div>
</div>
<div class="pest-footer">
<button type="button" class="pest-btn-go" @click="goToPestHandle">去办理</button>
</div>
</div>
</div>
<div v-show="viewTab === 'sample'" class="section">
<div class="sample-block">
<div class="sample-title">抽样记录表</div>
<div class="sample-form">
<div class="sample-row"><span class="sample-label">抽样单位:</span><span class="sample-value">{{ form.sampleUnit }}</span></div>
<div class="sample-row"><span class="sample-label req">联系电话:</span><span class="sample-value">{{ form.samplePhone }}</span></div>
<div class="sample-row"><span class="sample-label">联系人:</span><span class="sample-value">{{ form.sampleContact }}</span></div>
<div class="sample-row sample-col">
<span class="sample-label req">为害症状描述:</span>
<div class="sample-area sample-readonly">{{ form.symptomDesc || "—" }}</div>
</div>
<div class="sample-row sample-col">
<span class="sample-label req">发生与处理情况及原因:</span>
<div class="sample-area sample-readonly">{{ form.handlingReason || "—" }}</div>
</div>
<div class="sample-row sample-col">
<span class="sample-label">抽样方法、部位和抽样比例:</span>
<div class="sample-area sample-readonly">{{ form.sampleMethod || "—" }}</div>
</div>
<div class="sample-subtitle">实验室调查结果</div>
<div class="sample-row sample-col">
<span class="sample-label">检验结果:</span>
<div class="sample-area sample-readonly">{{ form.labResult || "—" }}</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
...@@ -205,6 +277,12 @@ ...@@ -205,6 +277,12 @@
<div class="related-item">国(境)外引进农业种苗入境后疫情监测委托书</div> <div class="related-item">国(境)外引进农业种苗入境后疫情监测委托书</div>
</div> </div>
</div> </div>
<div class="lab-tabs">
<div class="lab-tab" :class="{ active: editTab === 'record' }" @click="editTab = 'record'">田间调查记录表</div>
<div class="lab-tab" :class="{ active: editTab === 'sample' }" @click="editTab = 'sample'">抽样记录表</div>
</div>
<div v-show="editTab === 'record'">
<div class="section"> <div class="section">
<div class="section-title">基本信息</div> <div class="section-title">基本信息</div>
<div class="kv-grid"> <div class="kv-grid">
...@@ -289,8 +367,21 @@ ...@@ -289,8 +367,21 @@
</div> </div>
</div> </div>
<div class="section">
<div class="section-title">检疫机构信息</div>
<div class="kv-grid">
<div class="kv"><span class="kv-label">检疫单位:</span><input class="ipt-cell" v-model="form.testOrg" /></div>
<div class="kv"><span class="kv-label">记录员:</span><input class="ipt-cell" v-model="form.recorder" /></div>
<div class="kv"><span class="kv-label req">当事人(签名):</span><input class="ipt-cell" v-model="form.partySignature" /></div>
<div class="kv"><span class="kv-label req">检疫员(签名)1:</span><input class="ipt-cell" v-model="form.inspector1" /></div>
<div class="kv"><span class="kv-label req">检疫员(签名)2:</span><input class="ipt-cell" v-model="form.inspector2" /></div>
<div class="kv"><span class="kv-label req">联系电话:</span><input class="ipt-cell" v-model="form.inspectorPhone" /></div>
<div class="kv"><span class="kv-label">录入日期:</span><DatePicker v-model="form.entryDate" class="ipt-cell dp-cell" /></div>
</div>
</div>
</div>
<div v-if="form.conclusion === '现场不能确诊,需要采样进行实验室检验'" class="section"> <div v-show="editTab === 'sample'" class="section">
<div class="sample-block"> <div class="sample-block">
<div class="sample-title">抽样记录表</div> <div class="sample-title">抽样记录表</div>
<div class="sample-form"> <div class="sample-form">
...@@ -320,18 +411,6 @@ ...@@ -320,18 +411,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="section">
<div class="section-title">检疫机构信息</div>
<div class="kv-grid">
<div class="kv"><span class="kv-label">检疫单位:</span><input class="ipt-cell" v-model="form.testOrg" /></div>
<div class="kv"><span class="kv-label">记录员:</span><input class="ipt-cell" v-model="form.recorder" /></div>
<div class="kv"><span class="kv-label req">当事人(签名):</span><input class="ipt-cell" v-model="form.partySignature" /></div>
<div class="kv"><span class="kv-label req">检疫员(签名)1:</span><input class="ipt-cell" v-model="form.inspector1" /></div>
<div class="kv"><span class="kv-label req">检疫员(签名)2:</span><input class="ipt-cell" v-model="form.inspector2" /></div>
<div class="kv"><span class="kv-label req">联系电话:</span><input class="ipt-cell" v-model="form.inspectorPhone" /></div>
<div class="kv"><span class="kv-label">录入日期:</span><DatePicker v-model="form.entryDate" class="ipt-cell dp-cell" /></div>
</div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-default" @click="editVisible = false">取消</button> <button class="btn btn-default" @click="editVisible = false">取消</button>
...@@ -353,6 +432,16 @@ ...@@ -353,6 +432,16 @@
<div class="related-item">国(境)外引进农业种苗入境后疫情监测委托书</div> <div class="related-item">国(境)外引进农业种苗入境后疫情监测委托书</div>
</div> </div>
</div> </div>
<div class="lab-tabs">
<div class="lab-tab" :class="{ active: addTab === 'record' }" @click="addTab = 'record'">田间调查记录表</div>
<div class="lab-tab" :class="{ active: addTab === 'sample' }" @click="addTab = 'sample'">抽样记录表</div>
</div>
<div v-show="addTab === 'record'">
<div class="record-toolbar">
<button type="button" class="btn btn-danger" @click="onDownloadTemplate('record')">模板下载</button>
<button type="button" class="btn btn-danger" @click="triggerAddImport('record')">导入</button>
</div>
<div class="section"> <div class="section">
<div class="section-title">基本信息</div> <div class="section-title">基本信息</div>
<div class="kv-grid"> <div class="kv-grid">
...@@ -437,8 +526,25 @@ ...@@ -437,8 +526,25 @@
</div> </div>
</div> </div>
<div class="section">
<div class="section-title">检疫机构信息</div>
<div class="kv-grid">
<div class="kv"><span class="kv-label">检疫单位:</span><input class="ipt-cell" v-model="addForm.testOrg" /></div>
<div class="kv"><span class="kv-label">记录员:</span><input class="ipt-cell" v-model="addForm.recorder" /></div>
<div class="kv"><span class="kv-label req">当事人(签名):</span><input class="ipt-cell" v-model="addForm.partySignature" /></div>
<div class="kv"><span class="kv-label req">检疫员(签名)1:</span><input class="ipt-cell" v-model="addForm.inspector1" /></div>
<div class="kv"><span class="kv-label req">检疫员(签名)2:</span><input class="ipt-cell" v-model="addForm.inspector2" /></div>
<div class="kv"><span class="kv-label req">联系电话:</span><input class="ipt-cell" v-model="addForm.inspectorPhone" /></div>
<div class="kv"><span class="kv-label">录入日期:</span><DatePicker v-model="addForm.entryDate" class="ipt-cell dp-cell" /></div>
</div>
</div>
</div>
<div v-if="addForm.conclusion === '现场不能确诊,需要采样进行实验室检验'" class="section"> <div v-show="addTab === 'sample'" class="section">
<div class="record-toolbar">
<button type="button" class="btn btn-danger" @click="onDownloadTemplate('sample')">模板下载</button>
<button type="button" class="btn btn-danger" @click="triggerAddImport('sample')">导入</button>
</div>
<div class="sample-block"> <div class="sample-block">
<div class="sample-title">抽样记录表</div> <div class="sample-title">抽样记录表</div>
<div class="sample-form"> <div class="sample-form">
...@@ -468,22 +574,11 @@ ...@@ -468,22 +574,11 @@
</div> </div>
</div> </div>
</div> </div>
<div class="section">
<div class="section-title">检疫机构信息</div>
<div class="kv-grid">
<div class="kv"><span class="kv-label">检疫单位:</span><input class="ipt-cell" v-model="addForm.testOrg" /></div>
<div class="kv"><span class="kv-label">记录员:</span><input class="ipt-cell" v-model="addForm.recorder" /></div>
<div class="kv"><span class="kv-label req">当事人(签名):</span><input class="ipt-cell" v-model="addForm.partySignature" /></div>
<div class="kv"><span class="kv-label req">检疫员(签名)1:</span><input class="ipt-cell" v-model="addForm.inspector1" /></div>
<div class="kv"><span class="kv-label req">检疫员(签名)2:</span><input class="ipt-cell" v-model="addForm.inspector2" /></div>
<div class="kv"><span class="kv-label req">联系电话:</span><input class="ipt-cell" v-model="addForm.inspectorPhone" /></div>
<div class="kv"><span class="kv-label">录入日期:</span><DatePicker v-model="addForm.entryDate" class="ipt-cell dp-cell" /></div>
</div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-default" @click="addVisible = false">取消</button> <button class="btn btn-default" @click="addVisible = false">取消</button>
<button class="btn" @click="onConfirmAdd">保存</button> <button class="btn" @click="onConfirmAdd">保存</button>
<input ref="addImportRef" data-import="add" type="file" accept=".csv,.xlsx,.xls" class="file-hidden" @change="onAddImportFile" />
</div> </div>
</div> </div>
</div> </div>
...@@ -544,6 +639,7 @@ ...@@ -544,6 +639,7 @@
<script setup> <script setup>
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useRouter } from "vue-router";
import DatePicker from "@/components/DatePicker.vue"; import DatePicker from "@/components/DatePicker.vue";
import { fieldSurveyData } from "@/data/fieldSurvey.js"; import { fieldSurveyData } from "@/data/fieldSurvey.js";
import { syncFromSurvey, removeBySurveyId, list as expList } from "@/store/experimentTest.js"; import { syncFromSurvey, removeBySurveyId, list as expList } from "@/store/experimentTest.js";
...@@ -563,8 +659,21 @@ const pageSize = 10; ...@@ -563,8 +659,21 @@ const pageSize = 10;
const jumpInput = ref(1); const jumpInput = ref(1);
const viewVisible = ref(false); const viewVisible = ref(false);
const viewTab = ref("record");
const router = useRouter();
const goToPestHandle = () => {
if (form.value && form.value.id) syncHandle(form.value);
viewVisible.value = false;
router.push({ path: "/disposal" });
};
const showSampleTab = computed(() => form.value?.conclusion === "现场不能确诊,需要采样进行实验室检验");
const showPestNoticeTab = computed(() => form.value?.conclusion === "现场确诊发现应检有害生物");
const editVisible = ref(false); const editVisible = ref(false);
const editTab = ref("record");
const addVisible = ref(false); const addVisible = ref(false);
const addTab = ref("record");
const addImportRef = ref(null);
const importTarget = ref("record");
const delVisible = ref(false); const delVisible = ref(false);
const delRow = ref(null); const delRow = ref(null);
const noticeVisible = ref(false); const noticeVisible = ref(false);
...@@ -637,18 +746,120 @@ const onReset = () => { ...@@ -637,18 +746,120 @@ const onReset = () => {
const goPage = (p) => { if (p < 1 || p > totalPages.value) return; currentPage.value = p; }; const goPage = (p) => { if (p < 1 || p > totalPages.value) return; currentPage.value = p; };
const onJump = () => { const p = parseInt(jumpInput.value); if (!isNaN(p)) goPage(p); else goPage(1); }; const onJump = () => { const p = parseInt(jumpInput.value); if (!isNaN(p)) goPage(p); else goPage(1); };
const onView = (row) => { form.value = { ...row }; viewVisible.value = true; }; const onView = (row) => {
form.value = { ...row };
form.value.sampleUnit = row.sampleUnit || row.testOrg || "甘州区农业技术推广中心";
form.value.sampleContact = row.sampleContact || row.recorder || "甘州区系统管理人员";
form.value.samplePhone = row.samplePhone || row.inspectorPhone || "0936-8212469";
viewTab.value = "record";
viewVisible.value = true;
};
const onEdit = (row) => { const onEdit = (row) => {
form.value = { ...row }; form.value = { ...row };
form.value.sampleUnit = row.sampleUnit || row.testOrg || "甘州区农业技术推广中心"; form.value.sampleUnit = row.sampleUnit || row.testOrg || "甘州区农业技术推广中心";
form.value.sampleContact = row.sampleContact || row.recorder || "甘州区系统管理员"; form.value.sampleContact = row.sampleContact || row.recorder || "甘州区系统管理人员";
form.value.samplePhone = row.samplePhone || row.inspectorPhone || "0936-8212469"; form.value.samplePhone = row.samplePhone || row.inspectorPhone || "0936-8212469";
editMethods.value = (row.surveyMethods || "").split(",").filter(Boolean); editMethods.value = (row.surveyMethods || "").split(",").filter(Boolean);
editTab.value = "record";
editVisible.value = true; editVisible.value = true;
}; };
const RECORD_HEADERS = ["检疫单证号","受检单位","联系人","联系电话","植物名称","品种名称","原产国(地区)","审批数量","进境数量","入库数量","调查日期","检疫单位","调查地点-省","调查地点-市","调查地点-区","调查地点-乡","调查地点-详细地址","经度","纬度","海拔","产出用途","植物类型","种植期限-开始","种植期限-结束","总种植面积","植物生育期","调查方法","调查方法其他","目标有害生物","备注","调查结论","应检有害生物详情","检验结果","处理意见","记录员","当事人","检疫员1","检疫员2","机构联系电话","录入日期"];
const RECORD_MAP = { "检疫单证号":"cert","受检单位":"unit","联系人":"contact","联系电话":"phone","植物名称":"plant","品种名称":"variety","原产国(地区)":"origin","审批数量":"approveQty","进境数量":"importQty","入库数量":"qty","调查日期":"surveyDate","检疫单位":"testOrg","调查地点-省":"surveyProvince","调查地点-市":"surveyCity","调查地点-区":"surveyDistrict","调查地点-乡":"surveyTownship","调查地点-详细地址":"surveyAddress","经度":"longitude","纬度":"latitude","海拔":"altitude","产出用途":"useType","植物类型":"plantType","种植期限-开始":"plantPeriodStart","种植期限-结束":"plantPeriodEnd","总种植面积":"totalArea","植物生育期":"growthPeriod","调查方法":"surveyMethods","调查方法其他":"surveyMethodsOther","目标有害生物":"targetPests","备注":"remarks","调查结论":"conclusion","应检有害生物详情":"pestDetail","检验结果":"noticeTestResult","处理意见":"noticeHandleOpinion","记录员":"recorder","当事人":"partySignature","检疫员1":"inspector1","检疫员2":"inspector2","机构联系电话":"inspectorPhone","录入日期":"entryDate" };
const SAMPLE_HEADERS = ["抽样单位","抽样联系人","抽样联系电话","为害症状描述","发生与处理情况及原因","抽样方法、部位和抽样比例","实验室检验结果"];
const SAMPLE_MAP = { "抽样单位":"sampleUnit","抽样联系人":"sampleContact","抽样联系电话":"samplePhone","为害症状描述":"symptomDesc","发生与处理情况及原因":"handlingReason","抽样方法、部位和抽样比例":"sampleMethod","实验室检验结果":"labResult" };
const onDownloadTemplate = (type) => {
const headers = type === "sample" ? SAMPLE_HEADERS : RECORD_HEADERS;
const title = type === "sample" ? "抽样记录表" : "田间调查记录表";
const head = "<html xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:w=\"urn:schemas-microsoft-com:office:word\">"
+ "<head><meta charset=\"utf-8\"><title>" + title + "</title>"
+ "<style>"
+ "@page{margin:1.5cm;}body{font-family:宋体;font-size:12pt;}"
+ "h1{text-align:center;font-size:18pt;margin:0 0 16px;}"
+ "table{border-collapse:collapse;width:100%;}"
+ "th,td{border:1px solid #000;padding:6px;text-align:left;font-size:11pt;}"
+ "th{background:#f0f0f0;font-weight:bold;}</style></head><body>"
+ "<h1>" + title + "</h1>"
+ "<table><thead><tr>";
let body = head;
headers.forEach((h) => { body += "<th>" + h + "</th>"; });
body += "</tr></thead><tbody><tr>";
headers.forEach(() => { body += "<td></td>"; });
body += "</tr></tbody></table></body></html>";
const blob = new Blob(["\uFEFF" + body], { type: "application/msword;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${title}模板_${new Date().toISOString().slice(0, 10)}.doc`;
a.click();
URL.revokeObjectURL(url);
ElMessage.success("Word 模板已下载");
};
const triggerAddImport = (type) => {
importTarget.value = type;
const el = addImportRef.value || document.querySelector(".modal-mask input[type=file][data-import='add']");
if (el) {
el.value = "";
el.click();
} else {
ElMessage.warning("未找到文件选择器,请稍后再试");
}
};
const splitCSVLine = (line) => {
const out = []; let cur = ""; let inQuote = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQuote) {
if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; }
else if (c === '"') inQuote = false;
else cur += c;
} else {
if (c === '"') inQuote = true;
else if (c === ',') { out.push(cur); cur = ""; }
else cur += c;
}
}
out.push(cur);
return out;
};
const onAddImportFile = async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
let text = await file.text();
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
const lines = text.split(/\r?\n/).filter((l) => l.trim());
if (lines.length < 2) { ElMessage.warning("文件内容为空"); return; }
const map = importTarget.value === "sample" ? SAMPLE_MAP : RECORD_MAP;
const headers = splitCSVLine(lines[0]).map((h) => h.trim());
const values = splitCSVLine(lines[1]);
let count = 0;
headers.forEach((h, i) => {
const key = map[h];
if (key && values[i] !== undefined && values[i].trim() !== "") {
addForm.value[key] = values[i].trim();
count++;
}
});
if (count === 0) ElMessage.warning("未识别到有效字段");
else ElMessage.success(`已导入 ${count} 个字段`);
} catch (err) {
ElMessage.error("导入失败:" + err.message);
}
};
const onAdd = () => { const onAdd = () => {
addForm.value = defaultAddForm(); addForm.value = defaultAddForm();
addMethods.value = []; addMethods.value = [];
addTab.value = "record";
addVisible.value = true; addVisible.value = true;
}; };
const onDelete = (row) => { delRow.value = row; delVisible.value = true; }; const onDelete = (row) => { delRow.value = row; delVisible.value = true; };
...@@ -808,6 +1019,13 @@ const onSaveNotice = () => { ...@@ -808,6 +1019,13 @@ const onSaveNotice = () => {
.other-input { width: 160px; } .other-input { width: 160px; }
.pest-block { display: flex; flex-direction: column; gap: 8px; grid-column: 1 / -1; } .pest-block { display: flex; flex-direction: column; gap: 8px; grid-column: 1 / -1; }
.sample-block { background: #fafafa; border: 1px solid #ebeef5; border-radius: 4px; padding: 18px 20px; } .sample-block { background: #fafafa; border: 1px solid #ebeef5; border-radius: 4px; padding: 18px 20px; }
.sample-value { color: #222; font-size: 14px; }
.sample-area.sample-readonly { background: #f5f7fa; border: 1px solid #e0e0e0; border-radius: 4px; padding: 8px 12px; min-height: 70px; white-space: pre-wrap; line-height: 1.7; color: #222; }
.lab-tabs { display: flex; gap: 0; border-bottom: 2px solid #d71e1e; margin: 4px 0 16px; }
.lab-tab { padding: 8px 18px; font-size: 14px; color: #555; cursor: pointer; border: 1px solid transparent; border-bottom: none; transition: all 0.15s; }
.lab-tab:hover { color: #d71e1e; }
.lab-tab.active { color: #fff; background: #d71e1e; border-color: #d71e1e; border-radius: 4px 4px 0 0; font-weight: 600; }
.record-toolbar { display: flex; align-items: center; gap: 10px; margin: 8px 0 12px; padding: 6px 0; }
.sample-title { text-align: center; font-size: 16px; font-weight: 600; color: #333; margin-bottom: 16px; padding: 6px 0; border-top: 1px dashed #c0c4cc; border-bottom: 1px dashed #c0c4cc; } .sample-title { text-align: center; font-size: 16px; font-weight: 600; color: #333; margin-bottom: 16px; padding: 6px 0; border-top: 1px dashed #c0c4cc; border-bottom: 1px dashed #c0c4cc; }
.sample-form { display: flex; flex-direction: column; gap: 14px; } .sample-form { display: flex; flex-direction: column; gap: 14px; }
.sample-row { display: flex; align-items: center; gap: 8px; } .sample-row { display: flex; align-items: center; gap: 8px; }
...@@ -818,6 +1036,22 @@ const onSaveNotice = () => { ...@@ -818,6 +1036,22 @@ const onSaveNotice = () => {
.sample-area { flex: 1; min-width: 280px; min-height: 80px; padding: 8px 10px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 14px; line-height: 1.6; resize: vertical; } .sample-area { flex: 1; min-width: 280px; min-height: 80px; padding: 8px 10px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 14px; line-height: 1.6; resize: vertical; }
.sample-area:focus, .sample-ipt:focus { border-color: #d71e1e; outline: none; } .sample-area:focus, .sample-ipt:focus { border-color: #d71e1e; outline: none; }
.sample-subtitle { text-align: center; font-size: 14px; font-weight: 600; color: #333; padding: 8px 0; border-top: 1px dashed #c0c4cc; border-bottom: 1px dashed #c0c4cc; margin: 4px 0; } .sample-subtitle { text-align: center; font-size: 14px; font-weight: 600; color: #333; padding: 8px 0; border-top: 1px dashed #c0c4cc; border-bottom: 1px dashed #c0c4cc; margin: 4px 0; }
.pest-block { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; padding: 18px 20px; }
.pest-title { text-align: center; font-size: 18px; font-weight: 700; color: #d71e1e; margin-bottom: 16px; padding: 8px 0; }
.pest-form { display: flex; flex-direction: column; gap: 14px; }
.pest-row { display: flex; align-items: center; gap: 8px; }
.pest-row.pest-col { flex-direction: row; align-items: flex-start; flex-wrap: wrap; gap: 6px; }
.pest-row.pest-col .pest-value { padding: 4px 8px; border: 1px solid #ebeef5; border-radius: 3px; background: #fff; font-size: 13px; min-width: 80px; }
.pest-row-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.pest-row-cols .pest-row { min-width: 0; }
.pest-footer { display: flex; justify-content: flex-end; margin-top: 16px; padding-top: 12px; border-top: 1px dashed #ebeef5; }
.pest-btn-go { background: #d71e1e; color: #fff; border: 0; padding: 8px 20px; font-size: 14px; border-radius: 3px; cursor: pointer; transition: background 0.15s; }
.pest-btn-go:hover { background: #b81818; }
.pest-label { width: 180px; text-align: right; color: #555; font-size: 14px; flex-shrink: 0; white-space: nowrap; line-height: 32px; }
.pest-value { color: #222; font-size: 14px; }
.pest-area { flex: 1; min-width: 280px; min-height: 70px; padding: 8px 12px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 14px; line-height: 1.7; }
.pest-area.pest-readonly { background: #f5f7fa; color: #222; white-space: pre-wrap; }
.pest-subtitle { text-align: center; font-size: 15px; font-weight: 600; color: #333; padding: 8px 0; border-top: 1px dashed #c0c4cc; border-bottom: 1px dashed #c0c4cc; margin: 4px 0; }
.pest-hint { font-size: 14px; color: #333; line-height: 1.6; } .pest-hint { font-size: 14px; color: #333; line-height: 1.6; }
.pest-readonly { .pest-readonly {
background: #fff; background: #fff;
......
<template>
<PagePlaceholder title="监测数据分析" desc="监测数据分析模块正在搭建中" icon="Monitor" />
</template>
<script setup>
import PagePlaceholder from "@/components/PagePlaceholder.vue";
</script>
...@@ -691,13 +691,13 @@ ...@@ -691,13 +691,13 @@
<script setup> <script setup>
import { ref, computed, reactive, watch, onMounted } from "vue"; import { ref, computed, reactive, watch, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter, useRoute } from "vue-router";
const sideTabs = [ const sideTabs = [
{ key: "type", name: "设备类型", icon: "List" }, { key: "station", name: "监测点", icon: "LocationFilled" },
{ key: "station", name: "监测点", icon: "LocationFilled" } { key: "type", name: "设备类型", icon: "List" }
]; ];
const activeSideTab = ref("type"); const activeSideTab = ref("station");
const typeMap = { const typeMap = {
climate: { name: "小气候仪", color: "#d71e1e" }, climate: { name: "小气候仪", color: "#d71e1e" },
...@@ -1091,11 +1091,30 @@ const toggleStation = (key) => { ...@@ -1091,11 +1091,30 @@ const toggleStation = (key) => {
if (g) g.expanded = !g.expanded; if (g) g.expanded = !g.expanded;
}; };
const router = useRouter(); const router = useRouter();
const route = useRoute();
const onSelectDevice = (d) => { const onSelectDevice = (d) => {
activeDeviceId.value = d.id; activeDeviceId.value = d.id;
hoverIdx.value = -1; hoverIdx.value = -1;
}; };
// 从设备总览跳转过来时,按 query.deviceId 预选设备并展开对应分组
function applyDeviceIdFromQuery() {
const q = route.query.deviceId;
if (q == null || q === "") return;
const id = Number(q);
if (!Number.isFinite(id)) return;
const d = devices.find((x) => x.id === id);
if (!d) return;
activeDeviceId.value = id;
activeTypeKey.value = d.type;
const g = typeGroups.find((x) => x.key === d.type);
if (g) g.expanded = true;
const sg = stationGroups.find((x) => x.key === (d.station === "1号大棚" ? "g1" : "g2"));
if (sg) sg.expanded = true;
activeSideTab.value = "type";
hoverIdx.value = -1;
}
// 时间筛选 // 时间筛选
const timeTabs = [ const timeTabs = [
{ key: "m1", name: "近一月", months: 1 }, { key: "m1", name: "近一月", months: 1 },
...@@ -1335,7 +1354,7 @@ function tick() { ...@@ -1335,7 +1354,7 @@ function tick() {
const week = ["日", "一", "二", "三", "四", "五", "六"][d.getDay()]; const week = ["日", "一", "二", "三", "四", "五", "六"][d.getDay()];
nowStr.value = `${d.getFullYear()}年${pad(d.getMonth() + 1)}月${pad(d.getDate())}日 星期${week} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; nowStr.value = `${d.getFullYear()}年${pad(d.getMonth() + 1)}月${pad(d.getDate())}日 星期${week} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} }
onMounted(() => { tick(); clockTimer = setInterval(tick, 1000); }); onMounted(() => { applyDeviceIdFromQuery(); tick(); clockTimer = setInterval(tick, 1000); });
import { onBeforeUnmount } from "vue"; import { onBeforeUnmount } from "vue";
onBeforeUnmount(() => { if (clockTimer) clearInterval(clockTimer); }); onBeforeUnmount(() => { if (clockTimer) clearInterval(clockTimer); });
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
<div class="filter-box"> <div class="filter-box">
<div class="filter-row"> <div class="filter-row">
<div class="filter-item"> <div class="filter-item">
<label>抽样日期:</label> <label>检验日期:</label>
<DatePicker v-model="filters.testStart" class="dp-160" placeholder="开始日期" /> <DatePicker v-model="filters.testStart" class="dp-160" placeholder="开始日期" />
<span class="dash">至</span> <span class="dash">至</span>
<DatePicker v-model="filters.testEnd" class="dp-160" placeholder="结束日期" /> <DatePicker v-model="filters.testEnd" class="dp-160" placeholder="结束日期" />
...@@ -39,15 +39,14 @@ ...@@ -39,15 +39,14 @@
<div class="tbl-wrap"> <div class="tbl-wrap">
<table class="simple-table"> <table class="simple-table">
<colgroup> <colgroup>
<col style="width:5%" /> <col style="width:4%" />
<col style="width:14%" /> <col style="width:13%" />
<col style="width:22%" /> <col style="width:22%" />
<col style="width:9%" /> <col style="width:9%" />
<col style="width:9%" /> <col style="width:9%" />
<col style="width:9%" />
<col style="width:11%" /> <col style="width:11%" />
<col style="width:13%" /> <col style="width:13%" />
<col style="width:8%" /> <col style="width:13%" />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
...@@ -56,8 +55,7 @@ ...@@ -56,8 +55,7 @@
<th>受检单位</th> <th>受检单位</th>
<th>植物名称</th> <th>植物名称</th>
<th>品种名称</th> <th>品种名称</th>
<th>抽检数量</th> <th>检验日期</th>
<th>抽样日期</th>
<th>检验机构</th> <th>检验机构</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
...@@ -69,7 +67,6 @@ ...@@ -69,7 +67,6 @@
<td class="col-unit">{{ r.unit }}</td> <td class="col-unit">{{ r.unit }}</td>
<td>{{ r.plant }}</td> <td>{{ r.plant }}</td>
<td>{{ r.variety }}</td> <td>{{ r.variety }}</td>
<td>{{ r.qty }}</td>
<td>{{ r.testDate }}</td> <td>{{ r.testDate }}</td>
<td>{{ r.testOrg }}</td> <td>{{ r.testOrg }}</td>
<td class="col-action"> <td class="col-action">
...@@ -79,7 +76,7 @@ ...@@ -79,7 +76,7 @@
</td> </td>
</tr> </tr>
<tr v-if="!pagedData.length"> <tr v-if="!pagedData.length">
<td colspan="9" class="empty">暂无数据</td> <td colspan="8" class="empty">暂无数据</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
...@@ -128,7 +125,7 @@ ...@@ -128,7 +125,7 @@
</span> </span>
</div> </div>
<div class="form-field"><span class="label">植物名称</span><span class="ctrl mb-input">{{ form.plant }}</span></div> <div class="form-field"><span class="label">植物名称</span><span class="ctrl mb-input">{{ form.plant }}</span></div>
<div class="form-field"><span class="label">抽样日期</span><span class="ctrl mb-input">{{ form.testDate }}</span></div> <div class="form-field"><span class="label">检验日期</span><span class="ctrl mb-input">{{ form.testDate }}</span></div>
<div class="form-field span-2"><span class="label">目标有害生物</span><span class="ctrl mb-input area prewrap">{{ form.targetPests }}</span></div> <div class="form-field span-2"><span class="label">目标有害生物</span><span class="ctrl mb-input area prewrap">{{ form.targetPests }}</span></div>
<div class="form-field"><span class="label"><span class="req">*</span>收样人</span><span class="ctrl mb-input">{{ form.receiver }}</span></div> <div class="form-field"><span class="label"><span class="req">*</span>收样人</span><span class="ctrl mb-input">{{ form.receiver }}</span></div>
<div class="form-field"><span class="label"><span class="req">*</span>收样日期</span><span class="ctrl mb-input">{{ form.receiveDate }}</span></div> <div class="form-field"><span class="label"><span class="req">*</span>收样日期</span><span class="ctrl mb-input">{{ form.receiveDate }}</span></div>
...@@ -183,7 +180,7 @@ ...@@ -183,7 +180,7 @@
</span> </span>
</div> </div>
<div class="form-field"><span class="label">植物名称</span><input class="ctrl" v-model="form.plant" /></div> <div class="form-field"><span class="label">植物名称</span><input class="ctrl" v-model="form.plant" /></div>
<div class="form-field"><span class="label">抽样日期</span><DatePicker v-model="form.testDate" class="ctrl" /></div> <div class="form-field"><span class="label">检验日期</span><DatePicker v-model="form.testDate" class="ctrl" /></div>
<div class="form-field span-2"><span class="label">目标有害生物</span><textarea class="ctrl area-input prewrap" v-model="form.targetPests" rows="4" placeholder="每条有害生物占一行"></textarea></div> <div class="form-field span-2"><span class="label">目标有害生物</span><textarea class="ctrl area-input prewrap" v-model="form.targetPests" rows="4" placeholder="每条有害生物占一行"></textarea></div>
<div class="form-field"><span class="label"><span class="req">*</span>收样人</span><input class="ctrl" v-model="form.receiver" /></div> <div class="form-field"><span class="label"><span class="req">*</span>收样人</span><input class="ctrl" v-model="form.receiver" /></div>
<div class="form-field"><span class="label"><span class="req">*</span>收样日期</span><DatePicker v-model="form.receiveDate" class="ctrl" /></div> <div class="form-field"><span class="label"><span class="req">*</span>收样日期</span><DatePicker v-model="form.receiveDate" class="ctrl" /></div>
...@@ -242,7 +239,7 @@ ...@@ -242,7 +239,7 @@
</span> </span>
</div> </div>
<div class="form-field"><span class="label">植物名称</span><input class="ctrl" v-model="addForm.plant" /></div> <div class="form-field"><span class="label">植物名称</span><input class="ctrl" v-model="addForm.plant" /></div>
<div class="form-field"><span class="label">抽样日期</span><DatePicker v-model="addForm.testDate" class="ctrl" /></div> <div class="form-field"><span class="label">检验日期</span><DatePicker v-model="addForm.testDate" class="ctrl" /></div>
<div class="form-field span-2"><span class="label">目标有害生物</span><textarea class="ctrl area-input prewrap" v-model="addForm.targetPests" rows="4" placeholder="每条有害生物占一行"></textarea></div> <div class="form-field span-2"><span class="label">目标有害生物</span><textarea class="ctrl area-input prewrap" v-model="addForm.targetPests" rows="4" placeholder="每条有害生物占一行"></textarea></div>
<div class="form-field"><span class="label"><span class="req">*</span>收样人</span><input class="ctrl" v-model="addForm.receiver" /></div> <div class="form-field"><span class="label"><span class="req">*</span>收样人</span><input class="ctrl" v-model="addForm.receiver" /></div>
<div class="form-field"><span class="label"><span class="req">*</span>收样日期</span><DatePicker v-model="addForm.receiveDate" class="ctrl" /></div> <div class="form-field"><span class="label"><span class="req">*</span>收样日期</span><DatePicker v-model="addForm.receiveDate" class="ctrl" /></div>
...@@ -437,7 +434,7 @@ const onConfirmEdit = () => { ...@@ -437,7 +434,7 @@ const onConfirmEdit = () => {
updateRecord(updated); updateRecord(updated);
} else { } else {
// 来源于田间调查的记录:只更新受检单位等不属于田间调查的字段 // 来源于田间调查的记录:只更新受检单位等不属于田间调查的字段
// 受检单位/联系人/电话/植物名称/抽样日期/收样人/收样日期/检验方法/检验结果/审核人/备注 // 受检单位/联系人/电话/植物名称/检验日期/收样人/收样日期/检验方法/检验结果/审核人/备注
const surveyFields = ["unit","contact","phone","plant","variety","origin","approveQty","importQty","testDate","testOrg","inspector1","inspector2","inspectorPhone","entryDate","surveyProvince","surveyCity","surveyDistrict","surveyTownship","surveyAddress","targetPests","sampleUnit","sampleContact","samplePhone"]; const surveyFields = ["unit","contact","phone","plant","variety","origin","approveQty","importQty","testDate","testOrg","inspector1","inspector2","inspectorPhone","entryDate","surveyProvince","surveyCity","surveyDistrict","surveyTownship","surveyAddress","targetPests","sampleUnit","sampleContact","samplePhone"];
const merged = { ...updated }; const merged = { ...updated };
updateRecord(merged); updateRecord(merged);
......
<template> <template>
<div class="page-list"> <div class="page-list">
<div class="filter-box"> <div class="filter-box">
<div class="filter-row"> <div class="filter-row">
...@@ -39,15 +39,15 @@ ...@@ -39,15 +39,15 @@
<div class="tbl-wrap"> <div class="tbl-wrap">
<table class="simple-table"> <table class="simple-table">
<colgroup> <colgroup>
<col style="width:5%" /> <col style="width:4%" />
<col style="width:14%" /> <col style="width:13%" />
<col style="width:22%" /> <col style="width:19%" />
<col style="width:9%" /> <col style="width:9%" />
<col style="width:9%" /> <col style="width:9%" />
<col style="width:9%" /> <col style="width:9%" />
<col style="width:11%" /> <col style="width:11%" />
<col style="width:13%" /> <col style="width:13%" />
<col style="width:8%" /> <col style="width:13%" />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
......
<template> <template>
<div class="page-list"> <div class="page-list">
<div class="filter-box"> <div class="filter-box">
<div class="filter-row"> <div class="filter-row filter-row-4">
<div class="filter-item"> <div class="filter-item">
<label>处理日期:</label> <label>处理日期:</label>
<DatePicker v-model="filters.handleStart" class="dp-160" placeholder="开始日期" /> <DatePicker v-model="filters.handleStart" class="dp-140" placeholder="开始日期" />
<span class="dash">至</span> <span class="dash">至</span>
<DatePicker v-model="filters.handleEnd" class="dp-160" placeholder="结束日期" /> <DatePicker v-model="filters.handleEnd" class="dp-140" placeholder="结束日期" />
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label>检疫单证号:</label> <label>检疫单证号:</label>
<input class="ipt" v-model="filters.cert" placeholder="请输入关键字" /> <input class="ipt ipt-140" v-model="filters.cert" placeholder="请输入关键字" />
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label>植物名称:</label> <label>植物名称:</label>
<input class="ipt" v-model="filters.plant" placeholder="请输入关键字" /> <input class="ipt ipt-140" v-model="filters.plant" placeholder="请输入关键字" />
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label>申报单位:</label> <label>申报单位:</label>
<input class="ipt" v-model="filters.unit" placeholder="请输入关键字" /> <input class="ipt ipt-140" v-model="filters.unit" placeholder="请输入关键字" />
</div> </div>
</div> </div>
<div class="filter-row"> <div class="filter-row">
...@@ -223,34 +223,7 @@ ...@@ -223,34 +223,7 @@
<div v-for="t in procTabs" :key="t.key" class="proc-tab" :class="{ active: activeProcTab === t.key }" @click="activeProcTab = t.key">{{ t.label }}</div> <div v-for="t in procTabs" :key="t.key" class="proc-tab" :class="{ active: activeProcTab === t.key }" @click="activeProcTab = t.key">{{ t.label }}</div>
</div> </div>
<div class="modal-body notice-body"> <div class="modal-body notice-body">
<!-- Tab 1: 疫情处理通知书(只读,国家植保植检管理系统自动拉取) --> <!-- Tab 1: 植物检疫处理方案 -->
<div v-show="activeProcTab === 'notice'" class="kv-grid">
<div class="kv"><span class="kv-label">编号:</span><input class="ipt-cell" :value="procId" readonly /></div>
<div class="kv"><span class="kv-label">受检单位:</span><input class="ipt-cell" :value="procSource.unit" readonly /></div>
<div class="kv"><span class="kv-label">联系人:</span><input class="ipt-cell" :value="procSource.contact" readonly /></div>
<div class="kv"><span class="kv-label">联系电话:</span><input class="ipt-cell" :value="procSource.phone" readonly /></div>
<div class="kv"><span class="kv-label">产出用途:</span><input class="ipt-cell" :value="procSource.useType || '粮谷类种子'" readonly /></div>
<div class="kv"><span class="kv-label">数量:</span><input class="ipt-cell" :value="procSource.totalArea || procSource.importQty || ''" readonly /></div>
<div class="kv kv-wide">
<span class="kv-label">种植(存放)地点:</span>
<span class="kv-value">
<input class="ipt-cell sm" :value="procSource.surveyProvince" readonly />
<input class="ipt-cell sm" :value="procSource.surveyCity" readonly />
<input class="ipt-cell sm" :value="procSource.surveyDistrict" readonly />
<input class="ipt-cell sm" :value="procSource.surveyTownship" readonly />
<input class="ipt-cell lg" :value="procSource.surveyAddress" readonly />
</span>
</div>
<div class="kv kv-wide">
<span class="kv-label">检验结果:</span>
<textarea class="ipt-cell area block" rows="4" readonly :value="procForm.noticeTestResult"></textarea>
</div>
<div class="kv kv-wide">
<span class="kv-label">处理意见:</span>
<textarea class="ipt-cell area block" rows="4" readonly :value="procForm.noticeTestResult"></textarea>
</div>
</div>
<!-- Tab 2: 植物检疫处理方案 -->
<div v-show="activeProcTab === 'plan'"> <div v-show="activeProcTab === 'plan'">
<div class="plan-toolbar"> <div class="plan-toolbar">
<a class="plan-tool" :href="TEMPLATE_URL" download="植物检疫处理方案模板.docx"> <a class="plan-tool" :href="TEMPLATE_URL" download="植物检疫处理方案模板.docx">
...@@ -262,7 +235,7 @@ ...@@ -262,7 +235,7 @@
</label> </label>
<span v-if="planUploadedName" class="plan-status">已上传:{{ planUploadedName }}(按模板内容已自动填写下方表单)</span> <span v-if="planUploadedName" class="plan-status">已上传:{{ planUploadedName }}(按模板内容已自动填写下方表单)</span>
</div> </div>
<div v-show="planFormVisible" class="form-table"> <div class="form-table">
<div class="ft-row"> <div class="ft-row">
<div class="ft-cell ft-label">义务主体名称(盖章):</div> <div class="ft-cell ft-label">义务主体名称(盖章):</div>
<div class="ft-cell ft-flex"> <div class="ft-cell ft-flex">
...@@ -396,13 +369,218 @@ ...@@ -396,13 +369,218 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Tab 2: 植物检疫处理结果报告单 -->
<div v-show="activeProcTab === 'report'">
<div class="plan-toolbar">
<a class="plan-tool" href="/templates/植物检疫处理结果报告单模板.docx" download="植物检疫处理结果报告单模板.docx">
<span class="ico">⬇</span> 模板下载
</a>
<label class="plan-tool plan-tool-upload">
<span class="ico">⬆</span> 导入
<input type="file" accept=".csv,.xlsx,.xls" class="hidden-file" @change="onReportImport" />
</label>
<span v-if="reportUploadedName" class="plan-status">已导入:{{ reportUploadedName }}(已自动填充下方表单)</span>
</div>
<div class="form-table">
<div class="ft-row">
<div class="ft-cell ft-label">海关收到时间:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" type="date" v-model="procForm.reportCustomsReceiveDate" /></div>
<div class="ft-cell ft-label">检验检疫处理通知书编号:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportNoticeNo" placeholder="请输入通知书或委托编号" /></div>
</div>
<div class="ft-row">
<div class="ft-cell ft-label">义务主体名称:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportOrg" placeholder="请输入义务主体名称" /></div>
<div class="ft-cell ft-label">处理实施地点:</div>
<div class="ft-cell ft-flex">
<input class="ipt-cell sm" :value="procSource.surveyProvince" readonly />
<input class="ipt-cell sm" :value="procSource.surveyCity" readonly />
<input class="ipt-cell sm" :value="procSource.surveyDistrict" readonly />
<input class="ipt-cell sm" :value="procSource.surveyTownship" readonly />
<input class="ipt-cell lg" :value="procSource.surveyAddress" readonly />
</div>
</div>
<div class="ft-row">
<div class="ft-cell ft-label">处理对象:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" :value="procSource.plant" readonly /></div>
<div class="ft-cell ft-label">规格数量:</div>
<div class="ft-cell ft-flex">
<div class="check-group">
<label v-for="s in specUnits" :key="s.v" class="check-item">
<input type="checkbox" :value="s.v" v-model="procForm.reportSpecUnits" /> {{ s.label }}
</label>
</div>
</div>
</div>
<div class="ft-row" v-if="hasSpec('20尺集装箱')">
<div class="ft-cell ft-label">20'×(英尺):</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportSpec20" placeholder="数量" /><span style="margin-left:8px;">只</span></div>
</div>
<div class="ft-row" v-if="hasSpec('40尺集装箱')">
<div class="ft-cell ft-label">40'×(英尺):</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportSpec40" placeholder="数量" /><span style="margin-left:8px;">只</span></div>
</div>
<div class="ft-row" v-if="hasSpec('重量')">
<div class="ft-cell ft-label">重量:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportSpecWeight" placeholder="吨数" /><span style="margin-left:8px;">吨</span></div>
</div>
<div class="ft-row" v-if="hasSpec('面积')">
<div class="ft-cell ft-label">面积:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportSpecArea" placeholder="面积" /><span style="margin-left:8px;">㎡</span></div>
</div>
<div class="ft-row" v-if="hasSpec('体积')">
<div class="ft-cell ft-label">体积:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportSpecVolume" placeholder="体积" /><span style="margin-left:8px;">m³</span></div>
</div>
<div class="ft-row" v-if="hasSpec('其他')">
<div class="ft-cell ft-label">其他:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportSpecOther" placeholder="其他规格说明" /></div>
</div>
<div class="ft-row ft-stack">
<div class="ft-cell ft-label">处理方法:</div>
<div class="ft-cell ft-flex">
<div class="check-group">
<label v-for="m in reportMethods" :key="m.v" class="check-item">
<input type="checkbox" :value="m.v" v-model="procForm.reportMethodList" /> {{ m.l }}
</label>
</div>
<div class="ft-inline" v-if="procForm.reportMethodList.includes('其他')">
<span class="ft-label-inline">其他:</span>
<input class="ipt-cell" v-model="procForm.reportMethodOther" placeholder="如选其他请说明" />
</div>
</div>
</div>
<div class="ft-row ft-stack">
<div class="ft-cell ft-label">药剂:</div>
<div class="ft-cell ft-flex">
<div class="check-group">
<label v-for="d in reportDrugs" :key="d.v" class="check-item">
<input type="checkbox" :value="d.v" v-model="procForm.reportDrugList" /> {{ d.l }}
</label>
</div>
<div class="ft-inline" v-if="procForm.reportDrugList.includes('其他')">
<span class="ft-label-inline">其他:</span>
<input class="ipt-cell" v-model="procForm.reportDrugOther" placeholder="如选其他请说明" />
</div>
</div>
</div>
<div class="ft-row">
<div class="ft-cell ft-label">处理开始时间:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" type="datetime-local" v-model="procForm.reportStartDate" /></div>
<div class="ft-cell ft-label">处理结束时间:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" type="datetime-local" v-model="procForm.reportEndDate" /></div>
</div>
<div class="ft-row">
<div class="ft-cell ft-label">风速:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportWind" placeholder="m/s" /><span style="margin-left:8px;">m/s</span></div>
<div class="ft-cell ft-label">天气:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportWeather" placeholder="晴/多云/阴/雨等" /></div>
</div>
<div class="ft-row">
<div class="ft-cell ft-label">温度:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportTemperature" placeholder="℃" /><span style="margin-left:8px;">℃</span></div>
<div class="ft-cell ft-label">湿度:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportHumidity" placeholder="%" /><span style="margin-left:8px;">%</span></div>
</div>
<div class="ft-row">
<div class="ft-cell ft-label">警示标识设置:</div>
<div class="ft-cell ft-flex">
<label class="rd"><input type="radio" value="是" v-model="procForm.reportWarningSign" /> 是</label>
<label class="rd"><input type="radio" value="否" v-model="procForm.reportWarningSign" /> 否</label>
</div>
<div class="ft-cell ft-label">辐照剂量:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportRadiation" placeholder="rad" /><span style="margin-left:8px;">rad</span></div>
</div>
<div class="ft-row">
<div class="ft-cell ft-label">中心温度:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportCenterTemp" placeholder="℃" /><span style="margin-left:8px;">℃</span></div>
<div class="ft-cell ft-label">异常情况:</div>
<div class="ft-cell ft-flex">
<label class="rd"><input type="radio" value="有" v-model="procForm.reportAbnormal" /> 有</label>
<label class="rd"><input type="radio" value="无" v-model="procForm.reportAbnormal" /> 无</label>
</div>
</div>
<div class="ft-row ft-stack">
<div class="ft-cell ft-label">处理剂量/浓度:</div>
<div class="ft-cell ft-flex">
<div class="ft-inline">
<label class="rd"><input type="radio" value="浓度" v-model="procForm.reportDoseType" /> 浓度:</label>
<input class="ipt-cell" v-model="procForm.reportConcentration" placeholder="g/m³" /><span style="margin-left:8px;">g/m³</span>
<span class="ft-label-inline">共</span>
<input class="ipt-cell" v-model="procForm.reportConcentrationTotal" placeholder="kg" /><span style="margin-left:8px;">kg</span>
</div>
<div class="ft-inline" style="margin-top:8px;">
<label class="rd"><input type="radio" value="配比浓度" v-model="procForm.reportDoseType" /> 配比浓度:</label>
<input class="ipt-cell" v-model="procForm.reportRatioConcentration" placeholder="配比" /><span style="margin-left:8px;">共</span>
<input class="ipt-cell" v-model="procForm.reportRatioTotal" placeholder="kg" /><span style="margin-left:8px;">kg</span>
</div>
</div>
</div>
<div class="ft-row ft-stack">
<div class="ft-cell ft-label">浓度动态监测:</div>
<div class="ft-cell ft-flex">
<textarea class="ipt-cell area block" rows="3" maxlength="500" placeholder="如:10时浓度 50 g/m³;12时浓度 45 g/m³;14时浓度 30 g/m³;散毒前浓度 5 g/m³" v-model="procForm.reportConcentrationLog"></textarea>
</div>
</div>
<div class="ft-row ft-stack">
<div class="ft-cell ft-label">其他参数:</div>
<div class="ft-cell ft-flex">
<textarea class="ipt-cell area block" rows="2" maxlength="500" placeholder="其他参数" v-model="procForm.reportOtherParam"></textarea>
</div>
</div>
<div class="ft-row">
<div class="ft-cell ft-label">作用时长:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportDuration" placeholder="如:24 小时 30 分钟" /></div>
<div class="ft-cell ft-label">散毒时间:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportVentilation" placeholder="如:9月22日 08时至9月22日 18时" /></div>
</div>
<div class="ft-row">
<div class="ft-cell ft-label">安全员:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportSafetyOfficer" placeholder="安全员姓名" /></div>
<div class="ft-cell ft-label">操作人员:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportOperator" placeholder="操作人员姓名" /></div>
</div>
<div class="ft-row ft-stack">
<div class="ft-cell ft-label">效果评价:</div>
<div class="ft-cell ft-flex">
<div class="check-group">
<label v-for="e in reportEvaluations" :key="e.v" class="check-item">
<input type="checkbox" :value="e.v" v-model="procForm.reportEvaluation" /> {{ e.l }}
</label>
</div>
</div>
</div>
<div class="ft-row ft-stack">
<div class="ft-cell ft-label">申明:</div>
<div class="ft-cell ft-flex">
<div class="readonly-block">本次检疫处理作业的处理人员均具备作业能力,在确保现场处理人员职业健康安全的前提下,严格依照所报动植物检疫处理方案规范操作,达到相关检疫处理标准要求,经检测被处理对象有毒物质残留浓度低于人体允许的安全阈值。</div>
</div>
</div>
<div class="ft-row">
<div class="ft-cell ft-label">报告签发人:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" v-model="procForm.reportSigner" placeholder="请输入签发人" /></div>
<div class="ft-cell ft-label">日期:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" type="date" v-model="procForm.reportSignDate" /></div>
</div>
<div class="ft-row ft-stack">
<div class="ft-cell ft-label">单位签章:</div>
<div class="ft-cell ft-flex"><input class="ipt-cell" :value="procForm.reportOrg" readonly placeholder="义务主体单位签章" /></div>
</div>
<div class="ft-row ft-stack">
<div class="ft-cell ft-label">备注:</div>
<div class="ft-cell ft-flex">
<textarea class="ipt-cell area block" rows="3" maxlength="500" placeholder="①熏蒸、消毒除害处理参考本报告单,其他处理业务依照本报告单自行设计;②楷书端正填写或计算机打印;③不涉及项目使用“/”划掉,不留空格。" v-model="procForm.reportRemark"></textarea>
</div>
</div>
</div>
</div>
<!-- Tab 3: 疫情处理认定书 --> <!-- Tab 3: 疫情处理认定书 -->
<div v-show="activeProcTab === 'cert'" class="cert-body"> <div v-show="activeProcTab === 'cert'" class="cert-body">
<div class="cert-section"> <div class="cert-section">
<div class="cert-section-title">检疫机构对处理结果认定意见</div> <div class="cert-section-title">检疫机构对处理结果认定意见</div>
<div class="cert-row"> <div class="cert-row">
<span class="cert-label">认定意见:</span> <span class="cert-label">认定意见:</span>
<select class="ipt-cell cert-select" v-model="procForm.certOpinionType" disabled> <select class="ipt-cell cert-select" v-model="procForm.certOpinionType">
<option value="">请选择</option> <option value="">请选择</option>
<option>疫情处理合格</option> <option>疫情处理合格</option>
<option>疫情处理不合格</option> <option>疫情处理不合格</option>
...@@ -424,9 +602,9 @@ ...@@ -424,9 +602,9 @@
</div> </div>
<div class="cert-row"> <div class="cert-row">
<span class="cert-label">产出用途:</span> <span class="cert-label">产出用途:</span>
<input class="ipt-cell" value="种子" readonly /> <input class="ipt-cell" :value="procSource.useType || '粮谷类种子'" readonly />
<span class="cert-label">数量:</span> <span class="cert-label">数量:</span>
<input class="ipt-cell" :value="procSource.totalArea || procSource.importQty || ''" readonly /> <input class="ipt-cell" :value="(procSource.totalArea || procSource.importQty || '').replace(' 千克', '')" readonly />
</div> </div>
<div class="cert-row"> <div class="cert-row">
<span class="cert-label">检疫员(签名):</span> <span class="cert-label">检疫员(签名):</span>
...@@ -452,11 +630,11 @@ ...@@ -452,11 +630,11 @@
</div> </div>
<div class="cert-row cert-stack"> <div class="cert-row cert-stack">
<span class="cert-label">检验结果:</span> <span class="cert-label">检验结果:</span>
<textarea class="ipt-cell area block" rows="2" maxlength="500" placeholder="请输入500字以内" v-model="procForm.noticeTestResult"></textarea> <div class="readonly-block">{{ procForm.noticeTestResult || '—' }}</div>
</div> </div>
<div class="cert-row cert-stack"> <div class="cert-row cert-stack">
<span class="cert-label">检疫机构处理意见:</span> <span class="cert-label">检疫机构处理意见:</span>
<textarea class="ipt-cell area block" rows="2" maxlength="500" placeholder="请输入500字以内" v-model="procForm.certHandleOpinion"></textarea> <div class="readonly-block">{{ procForm.noticeHandleOpinion || '—' }}</div>
</div> </div>
<div class="cert-row cert-stack"> <div class="cert-row cert-stack">
<span class="cert-label"><span class="req">*</span> 处理意见落实情况:</span> <span class="cert-label"><span class="req">*</span> 处理意见落实情况:</span>
...@@ -596,10 +774,10 @@ const onDelete = (r) => { ...@@ -596,10 +774,10 @@ const onDelete = (r) => {
}; };
// 办理弹窗(3 个 tab) // 办理弹窗(3 个 tab)
const processVisible = ref(false); const processVisible = ref(false);
const activeProcTab = ref("notice"); const activeProcTab = ref("plan");
const procTabs = [ const procTabs = [
{ key: "notice", label: "疫情处理通知书" },
{ key: "plan", label: "植物检疫处理方案" }, { key: "plan", label: "植物检疫处理方案" },
{ key: "report", label: "植物检疫处理结果报告单" },
{ key: "cert", label: "疫情处理认定书" } { key: "cert", label: "疫情处理认定书" }
]; ];
const planMethods = [ const planMethods = [
...@@ -629,6 +807,46 @@ const TEMPLATE_URL = "/templates/plant_quarantine_plan_template.docx"; ...@@ -629,6 +807,46 @@ const TEMPLATE_URL = "/templates/plant_quarantine_plan_template.docx";
const planUploadedName = ref(""); const planUploadedName = ref("");
const planFormVisible = ref(true); const planFormVisible = ref(true);
// 植物检疫处理结果报告单 配置
const specUnits = [
{ v: "20尺集装箱", label: "20'(英尺)" },
{ v: "40尺集装箱", label: "40'(英尺)" },
{ v: "重量", label: "重量" },
{ v: "面积", label: "面积" },
{ v: "体积", label: "体积" },
{ v: "其他", label: "其他" }
];
const reportMethods = [
{ v: "熏蒸", l: "熏蒸" },
{ v: "喷洒", l: "喷洒" },
{ v: "喷雾", l: "喷雾" },
{ v: "冷处理", l: "冷处理" },
{ v: "热处理", l: "热处理" },
{ v: "高温", l: "高温" },
{ v: "微波处理", l: "微波处理" },
{ v: "辐照处理", l: "辐照处理" },
{ v: "浸泡", l: "浸泡" },
{ v: "化制", l: "化制" },
{ v: "焚烧", l: "焚烧" },
{ v: "深埋", l: "深埋" },
{ v: "脱毒", l: "脱毒" },
{ v: "粉碎", l: "粉碎" },
{ v: "其他", l: "其他" }
];
const reportDrugs = [
{ v: "硫酰氟", l: "硫酰氟" },
{ v: "溴甲烷", l: "溴甲烷" },
{ v: "磷化铝", l: "磷化铝" },
{ v: "其他", l: "其他" }
];
const reportEvaluations = [
{ v: "过程评价", l: "过程评价" },
{ v: "实验室评价", l: "实验室评价" },
{ v: "其他", l: "其他" }
];
const reportUploadedName = ref("");
const hasSpec = (key) => procForm.value?.reportSpecUnits?.includes(key);
// 解析 docx 文本 // 解析 docx 文本
const extractDocxText = async (file) => { const extractDocxText = async (file) => {
const zip = await JSZip.loadAsync(file); const zip = await JSZip.loadAsync(file);
...@@ -750,6 +968,90 @@ const onPlanUpload = async (e) => { ...@@ -750,6 +968,90 @@ const onPlanUpload = async (e) => {
e.target.value = ""; e.target.value = "";
}; };
const procId = ref(""); const procId = ref("");
// 植物检疫处理结果报告单 导入(CSV/Excel)
const splitReportLine = (line) => {
const result = [];
let cur = "";
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuote && line[i + 1] === '"') { cur += '"'; i++; }
else inQuote = !inQuote;
} else if (ch === "," && !inQuote) {
result.push(cur); cur = "";
} else {
cur += ch;
}
}
result.push(cur);
return result.map((v) => v.trim());
};
const REPORT_MAP = {
"海关收到时间": "reportCustomsReceiveDate",
"检验检疫处理通知书编号或委托编号": "reportNoticeNo",
"义务主体名称": "reportOrg",
"处理对象": "reportPlant",
"处理方法": "reportMethodList",
"药剂": "reportDrugList",
"处理开始时间": "reportStartDate",
"处理结束时间": "reportEndDate",
"风速": "reportWind",
"天气": "reportWeather",
"温度": "reportTemperature",
"湿度": "reportHumidity",
"警示标识设置": "reportWarningSign",
"辐照剂量": "reportRadiation",
"中心温度": "reportCenterTemp",
"异常情况": "reportAbnormal",
"浓度": "reportConcentration",
"浓度总量": "reportConcentrationTotal",
"配比浓度": "reportRatioConcentration",
"配比总量": "reportRatioTotal",
"浓度动态监测": "reportConcentrationLog",
"其他参数": "reportOtherParam",
"作用时长": "reportDuration",
"散毒时间": "reportVentilation",
"安全员": "reportSafetyOfficer",
"操作人员": "reportOperator",
"效果评价": "reportEvaluation",
"报告签发人": "reportSigner",
"日期": "reportSignDate",
"备注": "reportRemark"
};
const onReportImport = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim());
if (lines.length < 2) {
ElMessage.error("文件内容为空或格式不正确");
return;
}
const headers = splitReportLine(lines[0]);
for (let i = 1; i < lines.length; i++) {
const cells = splitReportLine(lines[i]);
headers.forEach((h, idx) => {
const key = REPORT_MAP[h];
const val = cells[idx] || "";
if (!key || !val) return;
if (["reportMethodList", "reportDrugList", "reportEvaluation"].includes(key)) {
procForm.value[key] = val.split(/[、,,;;]+/).filter(Boolean);
} else {
procForm.value[key] = val;
}
});
}
reportUploadedName.value = file.name;
ElMessage.success("已导入处理结果报告单数据");
} catch (err) {
console.error(err);
ElMessage.error("导入失败:" + (err.message || "未知错误"));
}
e.target.value = "";
};
const planId = ref(""); const planId = ref("");
const certId = ref(""); const certId = ref("");
const procSource = ref({}); const procSource = ref({});
...@@ -793,18 +1095,58 @@ const onProcess = (r) => { ...@@ -793,18 +1095,58 @@ const onProcess = (r) => {
planProtection: r.planProtection || "", planProtection: r.planProtection || "",
planEvaluation: r.planEvaluation || [], planEvaluation: r.planEvaluation || [],
// 疫情处理认定书 // 疫情处理认定书
certOpinionType: r.certOpinionType || "", certOpinionType: r.certOpinionType || "疫情处理合格",
certLeader: r.certLeader || "", certLeader: r.certLeader || "甘州区系统管理员",
certQuarantineOfficer: r.certQuarantineOfficer || "", certQuarantineOfficer: r.certQuarantineOfficer || "甘州区系统管理员",
certHandleOpinion: r.certHandleOpinion || "", certHandleOpinion: r.certHandleOpinion || r.noticeHandleOpinion || r.noticeTestResult || "",
certImplementation: r.certImplementation || "", certImplementation: r.certImplementation || "",
certDate: r.certDate || new Date().toISOString().slice(0, 10) certDate: r.certDate || new Date().toISOString().slice(0, 10),
// 植物检疫处理结果报告单
reportCustomsReceiveDate: r.reportCustomsReceiveDate || new Date().toISOString().slice(0, 10),
reportNoticeNo: r.reportNoticeNo || procId.value || "",
reportOrg: r.reportOrg || r.unit || "",
reportSpecUnits: r.reportSpecUnits || [],
reportSpec20: r.reportSpec20 || "",
reportSpec40: r.reportSpec40 || "",
reportSpecWeight: r.reportSpecWeight || "",
reportSpecArea: r.reportSpecArea || "",
reportSpecVolume: r.reportSpecVolume || "",
reportSpecOther: r.reportSpecOther || "",
reportMethodList: r.reportMethodList || [],
reportMethodOther: r.reportMethodOther || "",
reportDrugList: r.reportDrugList || [],
reportDrugOther: r.reportDrugOther || "",
reportStartDate: r.reportStartDate || "",
reportEndDate: r.reportEndDate || "",
reportWind: r.reportWind || "",
reportWeather: r.reportWeather || "",
reportTemperature: r.reportTemperature || "",
reportHumidity: r.reportHumidity || "",
reportWarningSign: r.reportWarningSign || "",
reportCenterTemp: r.reportCenterTemp || "",
reportAbnormal: r.reportAbnormal || "无",
reportRadiation: r.reportRadiation || "",
reportDoseType: r.reportDoseType || "浓度",
reportConcentration: r.reportConcentration || "",
reportConcentrationTotal: r.reportConcentrationTotal || "",
reportRatioConcentration: r.reportRatioConcentration || "",
reportRatioTotal: r.reportRatioTotal || "",
reportConcentrationLog: r.reportConcentrationLog || "",
reportOtherParam: r.reportOtherParam || "",
reportDuration: r.reportDuration || "",
reportVentilation: r.reportVentilation || "",
reportSafetyOfficer: r.reportSafetyOfficer || "",
reportOperator: r.reportOperator || r.planOperator || "",
reportEvaluation: r.reportEvaluation || [],
reportSigner: r.reportSigner || r.certLeader || "甘州区系统管理员",
reportSignDate: r.reportSignDate || new Date().toISOString().slice(0, 10),
reportRemark: r.reportRemark || ""
}; };
procId.value = r.noticeNo || genId("DC", r.id); procId.value = r.noticeNo || genId("DC", r.id);
planId.value = r.planNo || genId("FA", r.id); planId.value = r.planNo || genId("FA", r.id);
certId.value = r.certNo || genId("RD", r.id); certId.value = r.certNo || genId("RD", r.id);
procRowId.value = r.id; procRowId.value = r.id;
activeProcTab.value = "notice"; activeProcTab.value = "plan";
processVisible.value = true; processVisible.value = true;
}; };
...@@ -824,12 +1166,16 @@ const onConfirmDelete = () => { ...@@ -824,12 +1166,16 @@ const onConfirmDelete = () => {
<style scoped> <style scoped>
.page-list { padding: 16px 20px; } .page-list { padding: 16px 20px; }
.filter-box { background: #fff; border: 1px solid #ebeef5; border-radius: 3px; padding: 18px 20px; margin-bottom: 12px; } .filter-box { background: #fff; border: 1px solid #ebeef5; border-radius: 3px; padding: 18px 20px; margin-bottom: 12px; }
.filter-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 24px; margin-bottom: 16px; } .filter-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 12px; margin-bottom: 16px; }
.filter-row:last-child { margin-bottom: 0; } .filter-row:last-child { margin-bottom: 0; }
.filter-item { display: flex; align-items: center; gap: 6px; flex-shrink: 0; min-width: 0; } .filter-item { display: flex; align-items: center; gap: 6px; flex-shrink: 0; min-width: 0; }
.filter-item .label { color: #555; font-size: 14px; white-space: nowrap; flex-shrink: 0; min-width: 84px; } .filter-item .label { color: #555; font-size: 14px; white-space: nowrap; flex-shrink: 0; min-width: 70px; }
.dash { color: #999; } .dash { color: #999; }
.dp-160 { width: 160px; } .dp-160 { width: 160px; }
.dp-140 { width: 130px; }
.ipt-140 { width: 140px; }
.ipt-160 { width: 160px; }
.filter-row.filter-row-4 { flex-wrap: nowrap; }
.ipt { width: 160px; height: 32px; padding: 0 8px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 14px; } .ipt { width: 160px; height: 32px; padding: 0 8px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 14px; }
.ipt:focus { border-color: #d71e1e; outline: none; } .ipt:focus { border-color: #d71e1e; outline: none; }
.btn { background: #d71e1e; color: #fff; border: 1px solid #d71e1e; height: 32px; padding: 0 18px; border-radius: 3px; font-size: 14px; cursor: pointer; } .btn { background: #d71e1e; color: #fff; border: 1px solid #d71e1e; height: 32px; padding: 0 18px; border-radius: 3px; font-size: 14px; cursor: pointer; }
...@@ -862,12 +1208,12 @@ const onConfirmDelete = () => { ...@@ -862,12 +1208,12 @@ const onConfirmDelete = () => {
.jump-input:focus { border-color: #d71e1e; outline: none; } .jump-input:focus { border-color: #d71e1e; outline: none; }
.modal-mask { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: flex-start; justify-content: center; z-index: 9999; padding: 30px; overflow-y: auto; } .modal-mask { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: flex-start; justify-content: center; z-index: 9999; padding: 30px; overflow-y: auto; }
.modal-box { background: #fff; width: 1000px; max-width: 100%; border-radius: 4px; overflow: hidden; } .modal-box { background: #fff; width: 1000px; max-width: 100%; border-radius: 4px; overflow: visible; }
.modal-box.sm { width: 420px; } .modal-box.sm { width: 420px; }
.modal-header { background: #d71e1e; color: #fff; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; } .modal-header { background: #d71e1e; color: #fff; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; }
.modal-title { font-size: 16px; font-weight: 600; } .modal-title { font-size: 16px; font-weight: 600; }
.modal-close { font-size: 20px; cursor: pointer; padding: 0 6px; } .modal-close { font-size: 20px; cursor: pointer; padding: 0 6px; }
.modal-body { padding: 18px 22px; max-height: 70vh; overflow-y: auto; } .modal-body { padding: 18px 22px; overflow-y: visible; min-height: 500px; }
.modal-footer { padding: 12px 18px; border-top: 1px solid #ebeef5; display: flex; justify-content: flex-end; gap: 10px; } .modal-footer { padding: 12px 18px; border-top: 1px solid #ebeef5; display: flex; justify-content: flex-end; gap: 10px; }
.section-title { font-size: 15px; font-weight: 600; color: #d71e1e; margin: 14px 0 10px; padding-left: 8px; border-left: 3px solid #d71e1e; } .section-title { font-size: 15px; font-weight: 600; color: #d71e1e; margin: 14px 0 10px; padding-left: 8px; border-left: 3px solid #d71e1e; }
.section-title:first-child { margin-top: 0; } .section-title:first-child { margin-top: 0; }
...@@ -916,7 +1262,7 @@ const onConfirmDelete = () => { ...@@ -916,7 +1262,7 @@ const onConfirmDelete = () => {
.proc-tab.active { color: #fff; background: #d71e1e; font-weight: 600; } .proc-tab.active { color: #fff; background: #d71e1e; font-weight: 600; }
.ipt-cell.area.block.notes { min-height: 40px; max-height: 80px; } .ipt-cell.area.block.notes { min-height: 40px; max-height: 80px; }
/* 植物检疫处理方案 表格布局 */ /* 植物检疫处理方案 表格布局 */
.form-table { display: flex; flex-direction: column; gap: 0; border: 1px solid #ebeef5; border-radius: 3px; overflow: hidden; } .form-table { display: flex; flex-direction: column; gap: 0; border: 1px solid #ebeef5; border-radius: 3px; overflow: visible; min-height: 200px; background: #fff; }
.plan-toolbar { display: flex; align-items: center; gap: 14px; padding: 14px 16px; background: #fafafa; border: 1px solid #ebeef5; border-radius: 3px; margin-bottom: 12px; flex-wrap: wrap; } .plan-toolbar { display: flex; align-items: center; gap: 14px; padding: 14px 16px; background: #fafafa; border: 1px solid #ebeef5; border-radius: 3px; margin-bottom: 12px; flex-wrap: wrap; }
.plan-tool { display: inline-flex; align-items: center; gap: 6px; padding: 8px 18px; background: #d71e1e; color: #fff; border-radius: 3px; font-size: 14px; cursor: pointer; transition: background 0.15s; } .plan-tool { display: inline-flex; align-items: center; gap: 6px; padding: 8px 18px; background: #d71e1e; color: #fff; border-radius: 3px; font-size: 14px; cursor: pointer; transition: background 0.15s; }
.plan-tool:hover { background: #b81818; } .plan-tool:hover { background: #b81818; }
......
<template> <template>
<div class="page-list"> <div class="page-list">
<!-- 报告类型标签 -->
<div class="report-tabs">
<div
class="report-tab"
:class="{ active: activeTab === 'monitor' }"
@click="onSwitchTab('monitor')"
>疫情监测报告</div>
<div
class="report-tab"
:class="{ active: activeTab === 'risk' }"
@click="onSwitchTab('risk')"
>风险分析报告</div>
</div>
<!-- 疫情监测报告(原列表页) -->
<template v-if="activeTab === 'monitor'">
<div class="filter-box"> <div class="filter-box">
<div class="filter-row"> <div class="filter-row">
<div class="filter-item"> <div class="filter-item">
...@@ -215,6 +231,84 @@ ...@@ -215,6 +231,84 @@
</div> </div>
</div> </div>
</div> </div>
</template>
<!-- 风险分析报告(卡片视图) -->
<template v-else>
<div class="filter-box">
<div class="filter-row">
<div class="filter-item">
<label>报告日期:</label>
<DatePicker v-model="riskFilters.startDate" class="dp-160" placeholder="开始日期" />
<span class="dash">至</span>
<DatePicker v-model="riskFilters.endDate" class="dp-160" placeholder="结束日期" />
</div>
<div class="filter-item">
<label>关键字:</label>
<input class="ipt" v-model="riskFilters.keyword" placeholder="搜索报告标题" />
</div>
<div class="filter-item">
<button class="btn" @click="onRiskQuery">查询</button>
<button class="btn btn-default" @click="onRiskReset">重置</button>
</div>
</div>
</div>
<div class="risk-card-grid">
<div
v-for="card in pagedRiskCards"
:key="card.id"
class="risk-card"
>
<div class="risk-card-head">
<span class="risk-card-views">浏量:{{ card.views }}</span>
<span class="risk-card-date">报告时间:{{ card.date }}</span>
</div>
<div class="risk-card-icon">
<div class="word-doc">
<div class="word-doc-corner"></div>
<span class="word-doc-letter">W</span>
</div>
</div>
<div class="risk-card-title" :title="card.title">{{ card.title }}</div>
<div class="risk-card-actions">
<a class="op-link" @click="onRiskDownload(card)">下载</a>
<span class="op-sep">|</span>
<a class="op-link" @click="onRiskPreview(card)">预览</a>
</div>
</div>
<div v-if="!pagedRiskCards.length" class="risk-empty">暂无报告记录</div>
</div>
<div class="pagination" v-if="filteredRiskCards.length">
<span class="total" style="margin-right:auto;">共 {{ filteredRiskCards.length }} 条记录 当前第 {{ riskPage }} 页 {{ riskPageSize }}条/页</span>
<button class="page-btn" :disabled="riskPage === 1" @click="goRiskPage(1)">首页</button>
<button class="page-btn" :disabled="riskPage === 1" @click="goRiskPage(riskPage - 1)">上一页</button>
<button
v-for="p in riskPageNumbers"
:key="p"
class="page-btn"
:class="{ active: p === riskPage, ellipsis: p === '...' }"
@click="goRiskPage(p)"
>{{ p }}</button>
<button class="page-btn" :disabled="riskPage === riskTotalPages" @click="goRiskPage(riskPage + 1)">下一页</button>
<button class="page-btn" :disabled="riskPage === riskTotalPages" @click="goRiskPage(riskTotalPages)">末页</button>
<span class="jump">跳转至 <input class="jump-input" v-model.number="riskJumpInput" @keyup.enter="onRiskJump" /> 页</span>
</div>
</template>
<!-- 报告预览弹窗 -->
<div v-if="previewVisible" class="modal-mask" @click.self="previewVisible = false">
<div class="modal-box report-box">
<div class="modal-top">
<span class="modal-title">风险分析报告预览{{ previewCard ? ' — ' + previewCard.title : '' }}</span>
<span class="modal-close" @click="previewVisible = false">×</span>
</div>
<div class="modal-body report-body">
<div class="report-doc" v-html="previewHtml"></div>
</div>
</div>
</div>
</div> </div>
</template> </template>
...@@ -224,6 +318,11 @@ import { ElMessage } from "element-plus"; ...@@ -224,6 +318,11 @@ import { ElMessage } from "element-plus";
import DatePicker from "@/components/DatePicker.vue"; import DatePicker from "@/components/DatePicker.vue";
import { fieldSurveyData } from "@/data/fieldSurvey.js"; import { fieldSurveyData } from "@/data/fieldSurvey.js";
/* ============ Tab 切换 ============ */
const activeTab = ref("monitor");
const onSwitchTab = (tab) => { activeTab.value = tab; };
/* ============ 疫情监测报告(原列表) ============ */
const dataList = ref([...fieldSurveyData]); const dataList = ref([...fieldSurveyData]);
const defaultFilters = () => ({ approveStart: "", approveEnd: "", cert: "", plant: "", variety: "", unit: "" }); const defaultFilters = () => ({ approveStart: "", approveEnd: "", cert: "", plant: "", variety: "", unit: "" });
...@@ -319,10 +418,475 @@ const onView = (row) => { ...@@ -319,10 +418,475 @@ const onView = (row) => {
}; };
viewVisible.value = true; viewVisible.value = true;
}; };
/* ============ 风险分析报告(卡片视图) ============ */
const buildRiskReports = () => {
const months = [];
let y = 2026, m = 8;
for (let i = 0; i < 32; i++) {
months.push({ y, m });
m--;
if (m === 0) { m = 12; y--; }
}
const viewSeq = [4, 5, 2, 1, 0, 2, 2, 4, 10, 7, 9, 6, 3, 12, 8, 5, 11, 14, 6, 9, 13, 4, 7, 6, 3, 8, 11, 5, 6, 9, 12, 4];
let savedViews = {};
try { savedViews = JSON.parse(localStorage.getItem("risk_report_views") || "{}"); } catch (e) {}
return months.map((mm, idx) => {
const lastDay = new Date(mm.y, mm.m, 0).getDate();
const date = `${mm.y}-${String(mm.m).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
const hasContent = mm.y === 2026;
return {
id: `risk-${mm.y}-${mm.m}`,
y: mm.y,
m: mm.m,
date,
views: savedViews[`risk-${mm.y}-${mm.m}`] ?? (viewSeq[idx] ?? 1),
title: `${mm.y}年${mm.m}月国外引种检疫数据分析报告`,
hasContent,
periodStart: hasContent ? "2026-01-01" : "",
periodEnd: hasContent ? date : "",
};
});
};
const riskReports = ref(buildRiskReports());
const saveViewsToStorage = () => {
const map = {};
riskReports.value.forEach((r) => { map[r.id] = r.views; });
try { localStorage.setItem("risk_report_views", JSON.stringify(map)); } catch (e) {}
};
const defaultRiskFilters = () => ({ startDate: "", endDate: "", keyword: "" });
const riskFilters = ref(defaultRiskFilters());
const riskApplied = ref(defaultRiskFilters());
const riskPage = ref(1);
const riskPageSize = 10;
const riskJumpInput = ref(1);
const filteredRiskCards = computed(() => {
const f = riskApplied.value;
return riskReports.value.filter((r) => {
if (f.keyword && !r.title.includes(f.keyword) && !r.date.includes(f.keyword)) return false;
if (f.startDate && r.date < f.startDate) return false;
if (f.endDate && r.date > f.endDate) return false;
return true;
});
});
const riskTotalPages = computed(() => Math.max(1, Math.ceil(filteredRiskCards.value.length / riskPageSize)));
const riskPageNumbers = computed(() => {
const t = riskTotalPages.value;
const c = riskPage.value;
if (t <= 7) return Array.from({ length: t }, (_, i) => i + 1);
if (c <= 4) return [1, 2, 3, 4, 5, "...", t];
if (c >= t - 3) return [1, "...", t - 4, t - 3, t - 2, t - 1, t];
return [1, "...", c - 1, c, c + 1, "...", t];
});
const pagedRiskCards = computed(() => {
const s = (riskPage.value - 1) * riskPageSize;
return filteredRiskCards.value.slice(s, s + riskPageSize);
});
const onRiskQuery = () => {
riskApplied.value = JSON.parse(JSON.stringify(riskFilters.value));
riskPage.value = 1;
ElMessage.success("查询完成,共 " + filteredRiskCards.value.length + " 条");
};
const onRiskReset = () => {
riskFilters.value = defaultRiskFilters();
riskApplied.value = defaultRiskFilters();
riskPage.value = 1;
};
const goRiskPage = (p) => {
if (p === "..." || p === riskPage) return;
if (p < 1 || p > riskTotalPages.value) return;
riskPage.value = p;
};
const onRiskJump = () => {
const p = parseInt(riskJumpInput.value);
if (!isNaN(p)) goRiskPage(p);
};
/* ============ 报告预览 / 下载 ============ */
const previewVisible = ref(false);
const previewCard = ref(null);
const previewHtml = ref("");
const esc = (s) => String(s == null ? "" : s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const storageKeyOf = (card) => `risk_report_${card.periodStart}_${card.periodEnd}`;
const generateReportHtml = (card) => {
const periodDesc = `${card.periodStart} 至 ${card.periodEnd}`;
const base = card.m * 8 + 40;
return `
<h1 class="doc-title">植物检疫检测隔离中心<br />风险分析报告</h1>
<div class="doc-meta">报告编号:${esc(card.id.toUpperCase())}  生成时间:${esc(card.date)}  统计区间:${esc(periodDesc)}</div>
<h2 class="doc-h2">一、报告概述</h2>
<p class="doc-p">本报告基于平台风险分析模块数据自动生成,涵盖${esc(periodDesc)}期间国外引种检疫相关样本抽检、田间调查、检出有害生物分布、来源国(地区)分布、风险处置结果等关键指标的统计与分析,并对后续检疫监管与除害处理提出建议。</p>
<h2 class="doc-h2">二、关键指标</h2>
<h3 class="doc-h3">(一)样本抽检</h3>
<p class="doc-p">1. 总抽检样本(委托书):${base + 80} 个</p>
<p class="doc-p">2. 检出检疫性有害生物样本数:${base} 个</p>
<p class="doc-p">3. 处置样本单元:${base - 2} 个</p>
<p class="doc-p">4. 除害处置成功样本:${base - 6} 个</p>
<p class="doc-p">5. 除害无效 / 直接销毁样本:3 个</p>
<h3 class="doc-h3">(二)田间调查</h3>
<p class="doc-p">1. 开展田间调查样本(委托书):${base + 40} 个</p>
<p class="doc-p">2. 田间调查检出检疫性有害生物样本数:${base - 8} 个</p>
<p class="doc-p">3. 处置样本单元:${base - 10} 个</p>
<p class="doc-p">4. 除害处置成功样本:${base - 12} 个</p>
<p class="doc-p">5. 除害无效销毁样本:2 个</p>
<h3 class="doc-h3">(三)风险处置汇总</h3>
<p class="doc-p">1. 涉及引种单位:${35 + card.m} 家</p>
<p class="doc-p">2. 涉及植物种类:${42} 种</p>
<p class="doc-p">3. 涉及来源国(地区):${18} 个</p>
<p class="doc-p">4. 累计检出检疫性有害生物:${base + 12} 种次</p>
<p class="doc-p">5. 累计处置样本:${base + 18} 个</p>
<h2 class="doc-h2">三、来源国(地区)分布</h2>
<p class="doc-line">1. 美国:${28 + card.m * 2} 批次(占比 22%)</p>
<p class="doc-line">2. 荷兰:18 批次(占比 14%)</p>
<p class="doc-line">3. 日本:15 批次(占比 12%)</p>
<p class="doc-line">4. 智利:12 批次(占比 10%)</p>
<p class="doc-line">5. 以色列:10 批次(占比 8%)</p>
<h2 class="doc-h2">四、检出有害生物分布</h2>
<p class="doc-line">1. 番茄潜叶蛾:${15 + card.m} 例(占比 20%)</p>
<p class="doc-line">2. 苹果蠹蛾:12 例(占比 16%)</p>
<p class="doc-line">3. 小麦矮腥黑粉病菌:10 例(占比 13%)</p>
<p class="doc-line">4. 马铃薯金线虫:8 例(占比 11%)</p>
<p class="doc-line">5. 玉米象甲:7 例(占比 9%)</p>
<h2 class="doc-h2">五、处置结果分布</h2>
<p class="doc-line">1. 除害成功:${62 + card.m * 2} 批次(占比 78%)</p>
<p class="doc-line">2. 销毁处理:10 批次(占比 13%)</p>
<p class="doc-line">3. 留样观察:5 批次(占比 6%)</p>
<p class="doc-line">4. 移交处理:3 批次(占比 3%)</p>
<h2 class="doc-h2">六、典型检出明细</h2>
<h3 class="doc-h3">(一)样本抽检(前 10 条)</h3>
<p class="doc-line">1. 6204122022007091 美国 翠菊 番茄潜叶蛾 2026-03-12 销毁</p>
<p class="doc-line">2. 6204122022007092 荷兰 番茄 苹果蠹蛾 2026-04-08 除害成功</p>
<p class="doc-line">3. 6204122022007093 智利 葡萄 小麦矮腥黑粉病菌 2026-04-15 除害成功</p>
<p class="doc-line">4. 6204122022007094 日本 草莓 玉米象甲 2026-05-03 留样观察</p>
<p class="doc-line">5. 6204122022007095 美国 大豆 马铃薯金线虫 2026-05-22 销毁</p>
<p class="doc-line">6. 6204122022007096 以色列 番茄 番茄潜叶蛾 2026-06-08 除害成功</p>
<p class="doc-line">7. 6204122022007097 荷兰 马铃薯 马铃薯金线虫 2026-06-20 销毁</p>
<p class="doc-line">8. 6204122022007098 美国 苹果 苹果蠹蛾 2026-07-04 除害成功</p>
<p class="doc-line">9. 6204122022007099 智利 樱桃 苹果蠹蛾 2026-07-18 移交处理</p>
<p class="doc-line">10. 6204122022007100 日本 水稻 玉米象甲 2026-08-05 除害成功</p>
<h3 class="doc-h3">(二)田间调查(前 10 条)</h3>
<p class="doc-line">1. 6204122022108001 美国 大豆 马铃薯金线虫 2026-03-22 销毁</p>
<p class="doc-line">2. 6204122022108002 荷兰 番茄 番茄潜叶蛾 2026-04-12 除害成功</p>
<p class="doc-line">3. 6204122022108003 智利 葡萄 小麦矮腥黑粉病菌 2026-04-28 除害成功</p>
<p class="doc-line">4. 6204122022108004 日本 草莓 玉米象甲 2026-05-15 留样观察</p>
<p class="doc-line">5. 6204122022108005 美国 玉米 玉米象甲 2026-06-02 销毁</p>
<p class="doc-line">6. 6204122022108006 以色列 番茄 番茄潜叶蛾 2026-06-25 除害成功</p>
<p class="doc-line">7. 6204122022108007 荷兰 马铃薯 马铃薯金线虫 2026-07-08 销毁</p>
<p class="doc-line">8. 6204122022108008 美国 苹果 苹果蠹蛾 2026-07-22 除害成功</p>
<p class="doc-line">9. 6204122022108009 智利 樱桃 苹果蠹蛾 2026-08-04 移交处理</p>
<p class="doc-line">10. 6204122022108010 日本 水稻 玉米象甲 2026-08-18 除害成功</p>
<h2 class="doc-h2">七、风险评估与建议</h2>
<p class="doc-p">针对上述数据,建议加强以下工作:</p>
<p class="doc-line">1. 对来源国(地区)集中的检疫性有害生物开展专项监测;</p>
<p class="doc-line">2. 完善除害处理技术规范,提高处置成功率;</p>
<p class="doc-line">3. 强化跨部门信息共享,及时发布风险预警;</p>
<p class="doc-line">4. 持续做好引种后疫情监测工作,确保农业生产安全。</p>
`;
};
const ensureCardReport = (card) => {
const key = storageKeyOf(card);
let html = "";
try { html = localStorage.getItem(key) || ""; } catch (e) { html = ""; }
if (!html) {
html = generateReportHtml(card);
try { localStorage.setItem(key, html); } catch (e) {}
}
return html;
};
const onRiskPreview = (card) => {
if (!card.hasContent) {
ElMessage.warning("该月份暂无报告内容");
return;
}
card.views += 1;
saveViewsToStorage();
previewCard.value = card;
previewHtml.value = ensureCardReport(card);
previewVisible.value = true;
};
const buildDocHtml = (title, body) => `<html xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:w="urn:schemas-microsoft-com:office:word"
xmlns="http://www.w3.org/TR/REC-html40">
<head>
<meta charset="utf-8">
<title>${esc(title)}</title>
<style>
@page{size:A4;margin:2.5cm 2cm;}
body{font-family:"Microsoft YaHei","SimSun",serif;line-height:1.8;color:#000;background:#fff;padding:24px 40px;margin:0;}
h1.doc-title{text-align:center;font-size:30px;font-weight:bold;margin:0 0 18px;color:#000;line-height:1.5;display:block;width:100%;}
.doc-meta{text-align:center;font-size:13px;color:#333;margin-bottom:24px;border-bottom:1px solid #d4d4d4;padding-bottom:12px;}
h2.doc-h2{font-size:18px;font-weight:bold;margin:18px 0 10px;color:#000;}
h3.doc-h3{font-size:15px;font-weight:bold;margin:14px 0 6px;color:#000;}
p.doc-p{margin:6px 0;font-size:14px;color:#000;line-height:1.9;}
.doc-line{font-size:14px;color:#000;margin:4px 0;line-height:1.9;}
</style>
</head>
<body>${body}</body>
</html>`;
const onRiskDownload = (card) => {
if (!card.hasContent) {
ElMessage.warning("该月份暂无报告内容");
return;
}
const html = ensureCardReport(card);
const docHtml = buildDocHtml(card.title, html);
const blob = new Blob(["\ufeff", docHtml], { type: "application/msword;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${card.title}.doc`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
ElMessage.success("已下载:" + card.title);
};
</script> </script>
<style scoped> <style scoped>
.page-list { padding: 16px 20px; } .page-list { padding: 16px 20px; }
/* 报告类型 Tab */
.report-tabs {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 3px;
padding: 0 16px;
height: 44px;
}
.report-tab {
position: relative;
padding: 0 14px;
height: 44px;
line-height: 44px;
font-size: 15px;
color: #555;
cursor: pointer;
user-select: none;
transition: color 0.15s;
}
.report-tab:hover { color: #d71e1e; }
.report-tab.active { color: #d71e1e; font-weight: 600; }
.report-tab.active::after {
content: "";
position: absolute;
left: 14px;
right: 14px;
bottom: 0;
border-bottom: 2px solid #d71e1e;
}
/* 风险分析卡片视图 */
.risk-card-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
margin-bottom: 12px;
}
.risk-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 12px 14px 14px;
display: flex;
flex-direction: column;
align-items: center;
transition: box-shadow 0.2s, transform 0.2s;
}
.risk-card:hover {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.risk-card-head {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #d71e1e;
margin-bottom: 10px;
}
.risk-card-views {
background: #fff5f5;
border: 1px solid #ffd6d6;
padding: 2px 6px;
border-radius: 3px;
color: #d71e1e;
}
.risk-card-date { color: #555; font-size: 12px; }
.risk-card-icon {
display: flex;
justify-content: center;
align-items: center;
margin: 6px 0 14px;
height: 96px;
}
.word-doc {
position: relative;
width: 72px;
height: 86px;
background: #2b7bd6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.word-doc-corner {
position: absolute;
top: 0;
right: 0;
width: 18px;
height: 18px;
background: linear-gradient(135deg, transparent 50%, #e6f0fb 50%);
border-top-right-radius: 4px;
}
.word-doc-letter {
color: #fff;
font-size: 56px;
font-weight: bold;
font-family: "Times New Roman", "SimSun", serif;
line-height: 1;
}
.risk-card-title {
width: 100%;
text-align: center;
font-size: 14px;
color: #333;
font-weight: 500;
line-height: 1.5;
margin-bottom: 12px;
min-height: 42px;
display: flex;
align-items: center;
justify-content: center;
word-break: break-all;
}
.risk-card-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.risk-card-actions .op-link { font-size: 13px; }
.op-sep { color: #d4d4d4; font-size: 12px; }
.risk-empty {
grid-column: 1 / -1;
padding: 60px 0;
text-align: center;
color: #999;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
}
/* 预览弹窗报告样式(与风险分析模块保持一致) */
.report-box { width: 1100px; max-width: 97vw; max-height: 92vh; }
.report-body { padding: 16px 20px; display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
.report-doc {
width: 100%;
flex: 1;
min-height: 0;
padding: 24px 40px;
font-family: "Microsoft YaHei", "SimSun", serif;
font-size: 14px;
line-height: 1.9;
color: #000;
background: #fff;
border: 1px solid #333;
border-radius: 2px;
outline: none;
overflow: auto;
white-space: normal;
box-sizing: border-box;
}
.report-doc :deep(.doc-title) {
display: block;
width: 100%;
text-align: center;
font-size: 30px;
font-weight: bold;
color: #000;
margin: 0 0 18px;
line-height: 1.5;
}
.report-doc :deep(.doc-meta) {
text-align: center;
font-size: 13px;
color: #333;
margin-bottom: 24px;
border-bottom: 1px solid #d4d4d4;
padding-bottom: 12px;
}
.report-doc :deep(.doc-h2) {
font-size: 18px;
font-weight: bold;
color: #000;
margin: 18px 0 10px;
}
.report-doc :deep(.doc-h3) {
font-size: 15px;
font-weight: bold;
color: #000;
margin: 14px 0 6px;
}
.report-doc :deep(.doc-p) {
font-size: 14px;
color: #000;
margin: 6px 0;
line-height: 1.9;
word-break: break-all;
}
.report-doc :deep(.doc-line) {
font-size: 14px;
color: #000;
margin: 4px 0;
line-height: 1.9;
word-break: break-all;
}
@media (max-width: 1400px) {
.risk-card-grid { grid-template-columns: repeat(4, 1fr); }
}
@media (max-width: 1100px) {
.risk-card-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 800px) {
.risk-card-grid { grid-template-columns: repeat(2, 1fr); }
}
.filter-box { background: #fff; border: 1px solid #ebeef5; border-radius: 3px; padding: 18px 20px; margin-bottom: 12px; } .filter-box { background: #fff; border: 1px solid #ebeef5; border-radius: 3px; padding: 18px 20px; margin-bottom: 12px; }
.filter-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 24px; margin-bottom: 16px; } .filter-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 24px; margin-bottom: 16px; }
.filter-row:last-child { margin-bottom: 0; } .filter-row:last-child { margin-bottom: 0; }
......
<template> <template>
<PagePlaceholder title="样本出库登记" desc="样本出库登记与流转记录开发中" icon="Upload" /> <div class="page-list">
<div class="filter-box">
<div class="filter-row">
<div class="filter-item">
<label>出库时间:</label>
<DatePicker v-model="filters.outStart" class="dp-160" placeholder="开始日期" />
<span class="dash">至</span>
<DatePicker v-model="filters.outEnd" class="dp-160" placeholder="结束日期" />
</div>
<div class="filter-item">
<label>检疫单证号:</label>
<input v-model="filters.cert" placeholder="请输入关键字" class="ipt" />
</div>
<div class="filter-item">
<label>植物名称:</label>
<input v-model="filters.plant" placeholder="请输入关键字" class="ipt" />
</div>
</div>
<div class="filter-row">
<div class="filter-item">
<label>品种名称:</label>
<input v-model="filters.variety" placeholder="请输入关键字" class="ipt" />
</div>
<div class="filter-item">
<label>申报单位:</label>
<input v-model="filters.unit" placeholder="请输入关键字" class="ipt" />
</div>
<div class="filter-item">
<button class="btn" @click="onQuery">查询</button>
<button class="btn btn-default" @click="onReset">重置</button>
</div>
<div class="filter-item filter-add">
<button class="btn btn-add" @click="onAdd">新增出库</button>
</div>
</div>
</div>
<div class="tbl-wrap">
<table class="simple-table">
<colgroup>
<col style="width:6%" />
<col style="width:18%" />
<col style="width:18%" />
<col style="width:11%" />
<col style="width:11%" />
<col style="width:12%" />
<col style="width:12%" />
<col style="width:10%" />
<col style="width:12%" />
<col style="width:13%" />
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>检疫单证号</th>
<th>申报单位</th>
<th>植物名称</th>
<th>品种名称</th>
<th>出库数量(最近)</th>
<th>出库时间(最近)</th>
<th>用途(最近)</th>
<th>样本余量</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in pagedData" :key="row.id">
<td>{{ row.id }}</td>
<td>{{ row.cert }}</td>
<td class="col-unit">{{ row.unit }}</td>
<td>{{ row.plant }}</td>
<td>{{ row.variety }}</td>
<td>{{ latestEvent(row).outQty }}</td>
<td>{{ latestEvent(row).time }}</td>
<td>{{ latestEvent(row).purpose }}</td>
<td class="col-balance">{{ calcBalance(row) }}</td>
<td class="col-action">
<a class="op-link" @click="onView(row)">查看</a>
<a class="op-link" @click="onEdit(row)">编辑</a>
<a class="op-link" @click="onDelete(row)">删除</a>
</td>
</tr>
<tr v-if="!pagedData.length">
<td colspan="10" class="empty">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<div class="pager">
<span class="total">共 {{ filteredData.length }} 条记录</span>
<button class="pg-btn" :disabled="page === 1" @click="page = 1">首页</button>
<button class="pg-btn" :disabled="page === 1" @click="page--">上一页</button>
<button
v-for="p in pagesToShow"
:key="p"
class="pg-btn"
:class="{ active: p === page }"
@click="page = p"
>{{ p }}</button>
<button class="pg-btn" :disabled="page === totalPages" @click="page++">下一页</button>
<button class="pg-btn" :disabled="page === totalPages" @click="page = totalPages">末页</button>
<span class="pg-jump">
跳转至 <input v-model.number="jumpInput" class="ipt-jump" @keyup.enter="onJump" /> 页
</span>
</div>
<!-- 查看弹窗 -->
<div v-if="viewVisible" class="modal-mask" @click.self="viewVisible = false">
<div class="modal-box">
<div class="modal-top">
<span class="modal-title">样本出库登记表</span>
<span class="modal-close" @click="viewVisible = false">×</span>
</div>
<div class="modal-body">
<div class="section-title">基本信息</div>
<div class="info-grid">
<div class="info-item"><span class="info-label">检疫单证号</span><span class="info-value">{{ form.cert }}</span></div>
<div class="info-item"><span class="info-label">申报单位</span><span class="info-value">{{ form.unit }}</span></div>
<div class="info-item"><span class="info-label">植物名称</span><span class="info-value">{{ form.plant }}</span></div>
<div class="info-item"><span class="info-label">品种名称</span><span class="info-value">{{ form.variety }}</span></div>
<div class="info-item"><span class="info-label">入库数量</span><span class="info-value">{{ form.inboundQty }}</span></div>
<div class="info-item"><span class="info-label">样本余量</span><span class="info-value">{{ calcBalance(form) }}</span></div>
</div>
<div class="section-title">出库记录</div>
<table class="inner-table">
<thead>
<tr>
<th style="width:16%">时间</th>
<th style="width:15%">出库数量</th>
<th style="width:11%">用途</th>
<th style="width:11%">取样人</th>
<th style="width:15%">联系电话</th>
<th style="width:11%">经办人</th>
<th style="width:15%">联系电话</th>
</tr>
</thead>
<tbody>
<tr v-for="(ev, idx) in form.events" :key="idx">
<td>{{ ev.time }}</td>
<td>{{ ev.outQty }}</td>
<td>{{ ev.purpose }}</td>
<td>{{ ev.sampler }}</td>
<td>{{ ev.samplerPhone }}</td>
<td>{{ ev.operator }}</td>
<td>{{ ev.operatorPhone }}</td>
</tr>
<tr v-if="!form.events || !form.events.length">
<td colspan="7" class="empty">暂无出库记录</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div v-if="editVisible" class="modal-mask" @click.self="editVisible = false">
<div class="modal-box">
<div class="modal-top">
<span class="modal-title">样本出库登记表</span>
<span class="modal-close" @click="editVisible = false">×</span>
</div>
<div class="modal-body">
<div class="section-title">基本信息</div>
<div class="info-grid">
<div class="info-item edit"><span class="info-label">检疫单证号</span><input class="info-ipt" v-model="form.cert" /></div>
<div class="info-item edit"><span class="info-label">申报单位</span><input class="info-ipt" v-model="form.unit" /></div>
<div class="info-item edit"><span class="info-label">植物名称</span><input class="info-ipt" v-model="form.plant" /></div>
<div class="info-item edit"><span class="info-label">品种名称</span><input class="info-ipt" v-model="form.variety" /></div>
<div class="info-item edit"><span class="info-label">入库数量</span><input class="info-ipt" v-model="form.inboundQty" /></div>
<div class="info-item"><span class="info-label">样本余量</span><span class="info-value balance">{{ calcBalance(form) }}</span></div>
</div>
<div class="section-title section-title-row">
<span>出库记录</span>
<button class="btn-mini" @click="addEventRow">+ 添加行</button>
</div>
<table class="inner-table">
<thead>
<tr>
<th style="width:16%">时间</th>
<th style="width:15%">出库数量</th>
<th style="width:11%">用途</th>
<th style="width:11%">取样人</th>
<th style="width:15%">联系电话</th>
<th style="width:11%">经办人</th>
<th style="width:15%">联系电话</th>
</tr>
</thead>
<tbody>
<tr v-for="(ev, idx) in form.events" :key="idx">
<td><DatePicker v-model="ev.time" class="cell-ipt" /></td>
<td><input class="cell-ipt" v-model="ev.outQty" /></td>
<td>
<select class="cell-ipt" v-model="ev.purpose">
<option value="">请选择</option>
<option value="样本抽检">样本抽检</option>
<option value="隔离试种">隔离试种</option>
<option value="销毁处理">销毁处理</option>
<option value="移交">移交</option>
<option value="留样">留样</option>
</select>
</td>
<td><input class="cell-ipt" v-model="ev.sampler" /></td>
<td><input class="cell-ipt" v-model="ev.samplerPhone" /></td>
<td><input class="cell-ipt" v-model="ev.operator" /></td>
<td><input class="cell-ipt" v-model="ev.operatorPhone" /></td>
</tr>
<tr v-if="!form.events || !form.events.length">
<td colspan="7" class="empty">暂无出库记录,请点击右上角「+ 添加行」新增</td>
</tr>
</tbody>
</table>
<div class="modal-actions">
<button class="btn btn-default" @click="editVisible = false">取消</button>
<button class="btn" @click="onSave">保存</button>
</div>
</div>
</div>
</div>
<!-- 新增弹窗 -->
<div v-if="addVisible" class="modal-mask" @click.self="addVisible = false">
<div class="modal-box">
<div class="modal-top">
<span class="modal-title">样本出库登记表</span>
<span class="modal-close" @click="addVisible = false">×</span>
</div>
<div class="modal-body">
<div class="section-title">基本信息</div>
<div class="info-grid">
<div class="info-item edit"><span class="info-label">检疫单证号</span><input class="info-ipt" v-model="addForm.cert" /></div>
<div class="info-item edit"><span class="info-label">申报单位</span><input class="info-ipt" v-model="addForm.unit" /></div>
<div class="info-item edit"><span class="info-label">植物名称</span><input class="info-ipt" v-model="addForm.plant" /></div>
<div class="info-item edit"><span class="info-label">品种名称</span><input class="info-ipt" v-model="addForm.variety" /></div>
<div class="info-item edit"><span class="info-label">入库数量</span><input class="info-ipt" v-model="addForm.inboundQty" /></div>
<div class="info-item"><span class="info-label">样本余量</span><span class="info-value balance">{{ calcBalance(addForm) }}</span></div>
</div>
<div class="section-title section-title-row">
<span>出库记录</span>
<button class="btn-mini" @click="addAddEventRow">+ 添加行</button>
</div>
<table class="inner-table">
<thead>
<tr>
<th style="width:16%">时间</th>
<th style="width:15%">出库数量</th>
<th style="width:11%">用途</th>
<th style="width:11%">取样人</th>
<th style="width:15%">联系电话</th>
<th style="width:11%">经办人</th>
<th style="width:15%">联系电话</th>
</tr>
</thead>
<tbody>
<tr v-for="(ev, idx) in addForm.events" :key="idx">
<td><DatePicker v-model="ev.time" class="cell-ipt" /></td>
<td><input class="cell-ipt" v-model="ev.outQty" /></td>
<td>
<select class="cell-ipt" v-model="ev.purpose">
<option value="">请选择</option>
<option value="样本抽检">样本抽检</option>
<option value="隔离试种">隔离试种</option>
<option value="销毁处理">销毁处理</option>
<option value="移交">移交</option>
<option value="留样">留样</option>
</select>
</td>
<td><input class="cell-ipt" v-model="ev.sampler" /></td>
<td><input class="cell-ipt" v-model="ev.samplerPhone" /></td>
<td><input class="cell-ipt" v-model="ev.operator" /></td>
<td><input class="cell-ipt" v-model="ev.operatorPhone" /></td>
</tr>
<tr v-if="!addForm.events || !addForm.events.length">
<td colspan="7" class="empty">暂无出库记录,请点击右上角「+ 添加行」新增</td>
</tr>
</tbody>
</table>
<div class="modal-actions">
<button class="btn btn-default" @click="addVisible = false">取消</button>
<button class="btn" @click="onConfirmAdd">保存</button>
</div>
</div>
</div>
</div>
<div v-if="delVisible" class="modal-mask" @click.self="delVisible = false">
<div class="modal-box small">
<div class="modal-top">
<span class="modal-title">删除确认</span>
<span class="modal-close" @click="delVisible = false">×</span>
</div>
<div class="modal-body">
<p class="del-tip">确定要删除检疫单证号 <b>{{ form.cert }}</b>(植物:{{ form.plant }})的出库登记表吗?该操作不可恢复。</p>
<div class="modal-actions">
<button class="btn btn-default" @click="delVisible = false">取消</button>
<button class="btn" @click="confirmDelete">确定删除</button>
</div>
</div>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import PagePlaceholder from "@/components/PagePlaceholder.vue"; import { ref, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import DatePicker from "@/components/DatePicker.vue";
import { sampleOutData } from "@/data/sampleOut";
import { usePersistentRef } from "@/utils/persistentData";
const { data: persistentList } = usePersistentRef("sample_out", sampleOutData);
const defaultFilters = () => ({ outStart: "", outEnd: "", cert: "", plant: "", variety: "", unit: "" });
const filters = ref(defaultFilters());
const appliedFilters = ref(defaultFilters());
const page = ref(1);
const pageSize = 10;
const jumpInput = ref(1);
const viewVisible = ref(false);
const editVisible = ref(false);
const addVisible = ref(false);
const defaultEventRow = () => ({ time: "", outQty: "", purpose: "", sampler: "", samplerPhone: "", operator: "", operatorPhone: "" });
const defaultAddForm = () => ({ cert: "", unit: "", plant: "", variety: "", inboundQty: "", events: [defaultEventRow(), defaultEventRow()] });
const addForm = ref(defaultAddForm());
const delVisible = ref(false);
const form = ref({});
const dataList = persistentList;
// 解析如 "0.0013 千克" → { val: 0.0013, unit: "千克" }
const parseQty = (str) => {
if (str == null) return { val: 0, unit: "" };
const m = String(str).trim().match(/^([\d.]+)\s*(.*)$/);
if (!m) return { val: 0, unit: "" };
return { val: parseFloat(m[1]) || 0, unit: m[2] || "" };
};
// 计算样本余量 = 入库数量 - 全部事件出库数量之和
const calcBalance = (rec) => {
if (!rec) return "";
const inbound = parseQty(rec.inboundQty);
if (!rec.events || !rec.events.length) return `${inbound.val.toFixed(4)} ${inbound.unit}`.trim();
const totalOut = rec.events.reduce((sum, ev) => sum + parseQty(ev.outQty).val, 0);
const bal = +(inbound.val - totalOut).toFixed(4);
const text = `${bal >= 0 ? bal : 0} ${inbound.unit}`.trim();
return text;
};
// 取某条记录的最新一次出库事件(按时间倒序,取最大)
const latestEvent = (row) => {
if (!row.events || !row.events.length) return { time: "", outQty: "", purpose: "" };
const sorted = [...row.events].sort((a, b) => (b.time || "").localeCompare(a.time || ""));
return sorted[0] || { time: "", outQty: "", purpose: "" };
};
const inDateRange = (date, start, end) => {
if (!date) return true;
if (start && date < start) return false;
if (end && date > end) return false;
return true;
};
const filteredData = computed(() => {
return dataList.value.filter((r) => {
if (appliedFilters.value.cert && !r.cert.includes(appliedFilters.value.cert)) return false;
if (appliedFilters.value.plant && !r.plant.includes(appliedFilters.value.plant)) return false;
if (appliedFilters.value.variety && !r.variety.includes(appliedFilters.value.variety)) return false;
if (appliedFilters.value.unit && !r.unit.includes(appliedFilters.value.unit)) return false;
const latest = latestEvent(r);
if (!inDateRange(latest.time, appliedFilters.value.outStart, appliedFilters.value.outEnd)) return false;
return true;
});
});
const totalPages = computed(() => Math.max(1, Math.ceil(filteredData.value.length / pageSize)));
const pagedData = computed(() => {
const start = (page.value - 1) * pageSize;
return filteredData.value.slice(start, start + pageSize);
});
const pagesToShow = computed(() => {
const total = totalPages.value;
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
return [1, 2, 3, 4, 5];
});
watch(filteredData, () => { if (page.value > totalPages.value) page.value = 1; });
const onQuery = () => {
appliedFilters.value = JSON.parse(JSON.stringify(filters.value));
page.value = 1;
ElMessage.success("查询完成,共 " + filteredData.value.length + " 条");
};
const onReset = () => {
filters.value = defaultFilters();
appliedFilters.value = defaultFilters();
page.value = 1;
};
const onJump = () => { page.value = Math.min(Math.max(1, jumpInput.value | 0), totalPages.value); };
const onAdd = () => { addForm.value = defaultAddForm(); addVisible.value = true; };
const onConfirmAdd = () => {
const next = (dataList.value.length ? Math.max(...dataList.value.map((r) => r.id)) : 0) + 1;
dataList.value = [...dataList.value, { id: next, ...JSON.parse(JSON.stringify(addForm.value)) }];
addVisible.value = false;
ElMessage.success("新增成功");
};
const onView = (row) => { form.value = JSON.parse(JSON.stringify(row)); viewVisible.value = true; };
const onEdit = (row) => { form.value = JSON.parse(JSON.stringify(row)); editVisible.value = true; };
const onDelete = (row) => { form.value = JSON.parse(JSON.stringify(row)); delVisible.value = true; };
const onSave = () => {
const idx = dataList.value.findIndex((r) => r.id === form.value.id);
if (idx >= 0) dataList.value[idx] = JSON.parse(JSON.stringify(form.value));
editVisible.value = false;
ElMessage.success("保存成功");
};
const confirmDelete = () => {
dataList.value = dataList.value.filter((r) => r.id !== form.value.id);
delVisible.value = false;
ElMessage.success("删除成功");
};
// 表格行操作
const addEventRow = () => {
if (!form.value.events) form.value.events = [];
form.value.events.push(defaultEventRow());
};
const addAddEventRow = () => {
if (!addForm.value.events) addForm.value.events = [];
addForm.value.events.push(defaultEventRow());
};
</script> </script>
<style scoped>
.page-list { padding: 16px 20px; }
.filter-box { background: #fff; border: 1px solid #f0f0f0; border-radius: 4px; padding: 18px 20px; margin-bottom: 16px; }
.filter-row { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 24px; margin-bottom: 14px; }
.filter-row:last-child { margin-bottom: 0; }
.filter-item { display: flex; align-items: center; gap: 6px; flex-shrink: 0; min-width: 0; }
.filter-add { margin-left: auto; }
.filter-item label { white-space: nowrap; flex-shrink: 0; min-width: 84px; color: #333; font-size: 14px; }
.ipt { width: 160px; height: 32px; padding: 0 8px; border: 1px solid #d4d4d4; border-radius: 3px; font-size: 14px; color: #555; }
.ipt:focus { border-color: #d71e1e; outline: none; }
.ipt::placeholder { color: #999; }
.dp-160 { width: 150px; }
.dash { color: #999; padding: 0 4px; }
.btn { height: 32px; padding: 0 16px; background: #d71e1e; color: #fff; border: none; border-radius: 3px; cursor: pointer; font-size: 14px; }
.btn:hover { opacity: 0.9; }
.btn-default { background: #fff; color: #555; border: 1px solid #d4d4d4; }
.btn-default:hover { color: #d71e1e; border-color: #d71e1e; }
.btn-add { background: #d71e1e; color: #fff; border: 1px solid #d71e1e; height: 32px; padding: 0 18px; border-radius: 3px; font-size: 14px; cursor: pointer; }
.btn-add:hover { background: #b81818; border-color: #b81818; }
.btn-mini { background: #fff; color: #d71e1e; border: 1px solid #d71e1e; height: 26px; padding: 0 12px; border-radius: 3px; font-size: 12px; cursor: pointer; }
.btn-mini:hover { background: #d71e1e; color: #fff; }
.tbl-wrap { background: #fff; border: 1px solid #f0f0f0; border-radius: 4px; overflow: visible; }
.simple-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 14px; }
.simple-table th, .simple-table td { padding: 14px 6px; border-bottom: 1px solid #f0f0f0; text-align: center; line-height: 1.5; vertical-align: middle; }
.simple-table th { background: #fafafa; color: #333; font-weight: bold; font-size: 14px; padding: 14px 6px; }
.simple-table td { font-size: 14px; word-break: break-all; }
.simple-table td.col-unit { text-align: left; padding-left: 12px; }
.simple-table td.col-balance { color: #d71e1e; font-weight: 600; }
.simple-table td.col-action { white-space: nowrap; }
.simple-table tr:hover td { background: #fef9f9; }
.op-link { color: #333; margin: 0 4px; cursor: pointer; }
.op-link:hover { color: #d71e1e; }
.empty { text-align: center; padding: 40px 0; color: #999; }
.pager { display: flex; align-items: center; padding: 14px 16px; gap: 8px; background: #fff; border: 1px solid #f0f0f0; border-top: none; border-radius: 0 0 4px 4px; }
.total { color: #666; font-size: 14px; margin-right: auto; }
.pg-btn { min-width: 30px; height: 30px; padding: 0 8px; border: 1px solid #d4d4d4; background: #fff; border-radius: 3px; cursor: pointer; font-size: 14px; color: #555; }
.pg-btn:hover:not(:disabled) { color: #d71e1e; border-color: #d71e1e; }
.pg-btn.active { background: #d71e1e; color: #fff; border-color: #d71e1e; }
.pg-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.pg-jump { color: #666; font-size: 14px; margin-left: 12px; }
.ipt-jump { width: 50px; height: 28px; padding: 0 4px; border: 1px solid #d4d4d4; border-radius: 3px; text-align: center; }
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 500; }
.modal-box { background: #fff; width: 960px; max-width: 95vw; max-height: 90vh; border-radius: 4px; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 8px 30px rgba(0,0,0,0.2); }
.modal-box.small { width: 420px; }
.modal-top { background: #d71e1e; color: #fff; padding: 10px 18px; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.modal-title { font-size: 16px; font-weight: bold; }
.modal-close { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 18px; color: #fff; border: 1px solid #fff; border-radius: 2px; opacity: 0.9; }
.modal-close:hover { opacity: 1; }
.modal-body { padding: 20px 32px 30px; overflow: auto; flex: 1; }
.modal-body::-webkit-scrollbar { width: 6px; }
.modal-body::-webkit-scrollbar-thumb { background: #d4d4d4; border-radius: 3px; }
.modal-body::-webkit-scrollbar-track { background: #f5f5f5; }
.section-title { font-size: 15px; font-weight: bold; color: #333; margin: 16px 0 14px; padding-left: 8px; border-left: 3px solid #d71e1e; }
.section-title:first-child { margin-top: 4px; }
.section-title-row { display: flex; align-items: center; justify-content: space-between; padding-right: 4px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 16px; border-top: 1px solid #f0f0f0; margin-top: 16px; }
.del-tip { line-height: 1.8; font-size: 14px; color: #333; }
/* 基本信息 - 无框、宽松 */
.info-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 18px 36px; padding: 8px 4px 12px; }
.info-item { display: flex; align-items: baseline; min-height: 28px; gap: 8px; }
.info-item.edit { align-items: center; }
.info-label { color: #888; font-size: 13px; white-space: nowrap; flex-shrink: 0; min-width: 70px; }
.info-value { color: #333; font-size: 14px; font-weight: 500; word-break: break-all; }
.info-value.balance { color: #d71e1e; font-weight: 600; }
.info-ipt { flex: 1; min-width: 0; height: 28px; padding: 0 8px; border: none; border-bottom: 1px solid #e0e0e0; border-radius: 0; font-size: 14px; color: #333; background: transparent; }
.info-ipt:focus { outline: none; border-bottom-color: #d71e1e; }
.inner-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; margin-bottom: 8px; }
.inner-table th, .inner-table td { padding: 10px 6px; border: none; border-bottom: 1px solid #f0f0f0; text-align: center; vertical-align: middle; }
.inner-table th { background: #fafafa; color: #333; font-weight: bold; }
.inner-table td { background: #fff; }
.inner-table .cell-ipt { width: 100%; height: 28px; padding: 0 6px; border: none; border-radius: 0; font-size: 13px; color: #333; background: transparent; }
.inner-table .cell-ipt:focus { outline: none; background: #fff8f8; }
.inner-table .empty { padding: 18px 0; color: #999; }
</style>
\ No newline at end of file
...@@ -31,7 +31,10 @@ ...@@ -31,7 +31,10 @@
<button class="btn btn-default" @click="onReset">重置</button> <button class="btn btn-default" @click="onReset">重置</button>
</div> </div>
<div class="filter-item filter-add"> <div class="filter-item filter-add">
<button class="btn btn-add" @click="onAdd">+ 新增入库</button> <button class="btn btn-add" @click="onAdd">新增入库</button>
<button class="btn btn-add" @click="triggerImport">导入</button>
<button class="btn btn-add" @click="downloadTemplate" title="下载导入模板">模板</button>
<input ref="fileInputRef" type="file" accept=".csv,.xlsx,.xls" class="file-hidden" @change="onFileChange" />
</div> </div>
</div> </div>
</div> </div>
...@@ -39,17 +42,17 @@ ...@@ -39,17 +42,17 @@
<div class="tbl-wrap"> <div class="tbl-wrap">
<table class="simple-table"> <table class="simple-table">
<colgroup> <colgroup>
<col style="width:5%" /> <col style="width:4%" />
<col style="width:13%" />
<col style="width:20%" />
<col style="width:9%" />
<col style="width:11%" />
<col style="width:12%" /> <col style="width:12%" />
<col style="width:17%" />
<col style="width:8%" /> <col style="width:8%" />
<col style="width:9%" /> <col style="width:9%" />
<col style="width:6%" /> <col style="width:10%" />
<col style="width:9%" /> <col style="width:8%" />
<col style="width:9%" /> <col style="width:9%" />
<col style="width:5%" />
<col style="width:7%" />
<col style="width:11%" />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
...@@ -59,7 +62,7 @@ ...@@ -59,7 +62,7 @@
<th>植物名称</th> <th>植物名称</th>
<th>品种名称</th> <th>品种名称</th>
<th>种苗来源国(地区)</th> <th>种苗来源国(地区)</th>
<th>引进数量</th> <th>入库数量</th>
<th>入库时间</th> <th>入库时间</th>
<th>联系人</th> <th>联系人</th>
<th>联系电话</th> <th>联系电话</th>
...@@ -127,6 +130,8 @@ ...@@ -127,6 +130,8 @@
<div class="form-field"><span class="label">种苗来源国</span><span class="ctrl mb-input">{{ form.origin }}</span></div> <div class="form-field"><span class="label">种苗来源国</span><span class="ctrl mb-input">{{ form.origin }}</span></div>
<div class="form-field"><span class="label">审批数量</span><span class="ctrl mb-input">{{ form.approveQty }}</span></div> <div class="form-field"><span class="label">审批数量</span><span class="ctrl mb-input">{{ form.approveQty }}</span></div>
<div class="form-field"><span class="label">引进数量</span><span class="ctrl mb-input">{{ form.qty }}</span></div> <div class="form-field"><span class="label">引进数量</span><span class="ctrl mb-input">{{ form.qty }}</span></div>
<div class="form-field"><span class="label">入库数量</span><span class="ctrl mb-input">{{ form.storeQty }}</span></div>
<div class="form-field"><span class="label">入境口岸</span><span class="ctrl mb-input">{{ form.entryPort }}</span></div>
<div class="form-field"><span class="label">入库时间</span><span class="ctrl mb-input">{{ form.storeDate }}</span></div> <div class="form-field"><span class="label">入库时间</span><span class="ctrl mb-input">{{ form.storeDate }}</span></div>
<div class="form-field"><span class="label">入库单位</span><span class="ctrl mb-input">甘州区农业技术推广中心</span></div> <div class="form-field"><span class="label">入库单位</span><span class="ctrl mb-input">甘州区农业技术推广中心</span></div>
</div> </div>
...@@ -152,6 +157,8 @@ ...@@ -152,6 +157,8 @@
<div class="form-field"><span class="label">种苗来源国</span><input class="ctrl" v-model="addForm.origin" /></div> <div class="form-field"><span class="label">种苗来源国</span><input class="ctrl" v-model="addForm.origin" /></div>
<div class="form-field"><span class="label">审批数量</span><input class="ctrl" v-model="addForm.approveQty" /></div> <div class="form-field"><span class="label">审批数量</span><input class="ctrl" v-model="addForm.approveQty" /></div>
<div class="form-field"><span class="label">引进数量</span><input class="ctrl" v-model="addForm.qty" /></div> <div class="form-field"><span class="label">引进数量</span><input class="ctrl" v-model="addForm.qty" /></div>
<div class="form-field"><span class="label">入库数量</span><input class="ctrl" v-model="addForm.storeQty" /></div>
<div class="form-field"><span class="label">入境口岸</span><input class="ctrl" v-model="addForm.entryPort" /></div>
<div class="form-field"><span class="label">入库时间</span><DatePicker v-model="addForm.storeDate" class="ctrl" /></div> <div class="form-field"><span class="label">入库时间</span><DatePicker v-model="addForm.storeDate" class="ctrl" /></div>
<div class="form-field"><span class="label">入库单位</span><input class="ctrl" v-model="addForm.storeUnit" /></div> <div class="form-field"><span class="label">入库单位</span><input class="ctrl" v-model="addForm.storeUnit" /></div>
</div> </div>
...@@ -181,6 +188,8 @@ ...@@ -181,6 +188,8 @@
<div class="form-field"><span class="label">种苗来源国</span><input class="ctrl" v-model="form.origin" /></div> <div class="form-field"><span class="label">种苗来源国</span><input class="ctrl" v-model="form.origin" /></div>
<div class="form-field"><span class="label">审批数量</span><input class="ctrl" v-model="form.approveQty" /></div> <div class="form-field"><span class="label">审批数量</span><input class="ctrl" v-model="form.approveQty" /></div>
<div class="form-field"><span class="label">引进数量</span><input class="ctrl" v-model="form.qty" /></div> <div class="form-field"><span class="label">引进数量</span><input class="ctrl" v-model="form.qty" /></div>
<div class="form-field"><span class="label">入库数量</span><input class="ctrl" v-model="form.storeQty" /></div>
<div class="form-field"><span class="label">入境口岸</span><input class="ctrl" v-model="form.entryPort" /></div>
<div class="form-field"><span class="label">入库时间</span><DatePicker v-model="form.storeDate" class="ctrl" /></div> <div class="form-field"><span class="label">入库时间</span><DatePicker v-model="form.storeDate" class="ctrl" /></div>
<div class="form-field"><span class="label">入库单位</span><input class="ctrl" value="甘州区农业技术推广中心" /></div> <div class="form-field"><span class="label">入库单位</span><input class="ctrl" value="甘州区农业技术推广中心" /></div>
</div> </div>
...@@ -213,6 +222,7 @@ ...@@ -213,6 +222,7 @@
<script setup> <script setup>
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import JSZip from "jszip";
import DatePicker from "@/components/DatePicker.vue"; import DatePicker from "@/components/DatePicker.vue";
import { sampleRegisterData } from "@/data/sampleRegister"; import { sampleRegisterData } from "@/data/sampleRegister";
import { usePersistentRef } from "@/utils/persistentData"; import { usePersistentRef } from "@/utils/persistentData";
...@@ -230,7 +240,7 @@ const jumpInput = ref(1); ...@@ -230,7 +240,7 @@ const jumpInput = ref(1);
const viewVisible = ref(false); const viewVisible = ref(false);
const editVisible = ref(false); const editVisible = ref(false);
const addVisible = ref(false); const addVisible = ref(false);
const defaultAddForm = () => ({ cert: "", unit: "", contact: "", phone: "", plant: "", usage: "种子", variety: "", origin: "", approveQty: "", qty: "", storeDate: "", storeUnit: "甘州区农业技术推广中心" }); const defaultAddForm = () => ({ cert: "", unit: "", contact: "", phone: "", plant: "", usage: "种子", variety: "", origin: "", approveQty: "", qty: "", storeQty: "", entryPort: "", storeDate: "", storeUnit: "甘州区农业技术推广中心" });
const addForm = ref(defaultAddForm()); const addForm = ref(defaultAddForm());
const delVisible = ref(false); const delVisible = ref(false);
const form = ref({}); const form = ref({});
...@@ -300,6 +310,179 @@ const confirmDelete = () => { ...@@ -300,6 +310,179 @@ const confirmDelete = () => {
delVisible.value = false; delVisible.value = false;
ElMessage.success("删除成功"); ElMessage.success("删除成功");
}; };
// ===== 导入 =====
// 表头 → 字段映射(兼容多种列名写法)
const headerMap = {
"检疫单证号": "cert",
"申报单位": "unit",
"联系人": "contact",
"联系电话": "phone",
"植物名称": "plant",
"产出用途": "usage",
"品种名称": "variety",
"种苗来源国(地区)": "origin",
"种苗来源国": "origin",
"审批数量": "approveQty",
"引进数量": "qty",
"入库数量": "storeQty",
"入境口岸": "entryPort",
"入库时间": "storeDate",
"入库单位": "storeUnit"
};
const importHeaders = Object.keys(headerMap);
const fileInputRef = ref(null);
const triggerImport = () => { fileInputRef.value?.click(); };
// 解析 CSV(处理引号、转义、换行)
function parseCSV(text) {
const rows = [];
let row = [];
let cell = "";
let inQuote = false;
// 去除 BOM
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuote) {
if (c === '"') {
if (text[i + 1] === '"') { cell += '"'; i++; }
else inQuote = false;
} else cell += c;
} else {
if (c === '"') inQuote = true;
else if (c === ",") { row.push(cell); cell = ""; }
else if (c === "\r") { /* skip */ }
else if (c === "\n") { row.push(cell); rows.push(row); row = []; cell = ""; }
else cell += c;
}
}
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
return rows;
}
// 解析 xlsx(用 jszip 解压,读 sharedStrings + sheet1)
async function parseXlsx(file) {
const zip = await JSZip.loadAsync(file);
let sharedStrings = [];
const ssFile = zip.file("xl/sharedStrings.xml");
if (ssFile) {
const ssXml = await ssFile.async("string");
const doc = new DOMParser().parseFromString(ssXml, "application/xml");
sharedStrings = Array.from(doc.querySelectorAll("si")).map((si) => {
return Array.from(si.querySelectorAll("t")).map((t) => t.textContent || "").join("");
});
}
const sheetFile = zip.file("xl/worksheets/sheet1.xml");
if (!sheetFile) throw new Error("未找到 sheet1");
const sheetXml = await sheetFile.async("string");
const doc = new DOMParser().parseFromString(sheetXml, "application/xml");
const rows = [];
const colIndex = (ref) => {
const m = ref.match(/[A-Z]+/);
if (!m) return 0;
let n = 0;
for (let i = 0; i < m[0].length; i++) n = n * 26 + (m[0].charCodeAt(i) - 64);
return n - 1;
};
doc.querySelectorAll("row").forEach((rowEl) => {
const cells = [];
Array.from(rowEl.querySelectorAll("c")).forEach((c) => {
const ref = c.getAttribute("r");
const colIdx = colIndex(ref);
while (cells.length < colIdx) cells.push("");
const type = c.getAttribute("t");
const vEl = c.querySelector("v");
const isEl = c.querySelector("is > t");
let val = "";
if (vEl) {
if (type === "s") val = sharedStrings[parseInt(vEl.textContent, 10)] || "";
else val = vEl.textContent || "";
} else if (isEl) val = isEl.textContent || "";
cells.push(val);
});
rows.push(cells);
});
return rows;
}
function rowsToObjects(rows) {
if (!rows.length) return [];
const headers = rows[0].map((h) => (h || "").trim());
return rows.slice(1)
.filter((r) => r.some((c) => c && String(c).trim() !== ""))
.map((r) => {
const obj = {};
headers.forEach((h, i) => {
const key = headerMap[h];
if (key) obj[key] = (r[i] != null ? String(r[i]) : "").trim();
});
// 默认值
if (!obj.usage) obj.usage = "种子";
if (!obj.storeUnit) obj.storeUnit = "甘州区农业技术推广中心";
return obj;
})
.filter((o) => o.cert);
}
const onFileChange = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const name = (file.name || "").toLowerCase();
let rows = [];
try {
if (name.endsWith(".csv") || name.endsWith(".txt")) {
rows = parseCSV(await file.text());
} else if (name.endsWith(".xlsx")) {
rows = await parseXlsx(file);
} else if (name.endsWith(".xls")) {
ElMessage.error("暂不支持 .xls(老版本),请另存为 .xlsx 或 .csv 后再导入");
e.target.value = "";
return;
} else {
ElMessage.error("仅支持 .csv 或 .xlsx 文件");
e.target.value = "";
return;
}
} catch (err) {
console.error(err);
ElMessage.error("文件解析失败:" + (err.message || err));
e.target.value = "";
return;
}
const list = rowsToObjects(rows);
if (!list.length) {
ElMessage.warning("未解析到有效数据,请检查表头或文件内容");
e.target.value = "";
return;
}
let nextId = dataList.value.length ? Math.max(...dataList.value.map((r) => r.id)) : 0;
const newRows = list.map((o) => ({ id: ++nextId, ...o }));
dataList.value = [...dataList.value, ...newRows];
ElMessage.success(`导入成功,共新增 ${newRows.length} 条`);
e.target.value = "";
};
const downloadTemplate = () => {
const csv =
"\uFEFF" +
importHeaders.join(",") +
"\n" +
"6204122022007095,甘肃富赢种子高新科技有限责任公司,刘玉堂,18993607501,翠菊,种子,翠菊,智利,40 千克,0.0014 千克,0.0013 千克,昆明长水国际机场,2026-09-10,甘州区农业技术推广中心\n" +
"6204122022007096,甘肃富赢种子高新科技有限责任公司,刘玉堂,18993607501,番茄,种子,番茄,日本,50 千克,1.9540 千克,1.8100 千克,昆明长水国际机场,2026-09-10,甘州区农业技术推广中心\n";
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "样本登记入库导入模板.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
</script> </script>
<style scoped> <style scoped>
...@@ -320,8 +503,10 @@ const confirmDelete = () => { ...@@ -320,8 +503,10 @@ const confirmDelete = () => {
.btn-default { background: #fff; color: #555; border: 1px solid #d4d4d4; } .btn-default { background: #fff; color: #555; border: 1px solid #d4d4d4; }
.btn-default:hover { color: #d71e1e; border-color: #d71e1e; } .btn-default:hover { color: #d71e1e; border-color: #d71e1e; }
.filter-spacer { display: inline-block; width: 32px; } .filter-spacer { display: inline-block; width: 32px; }
.btn-add { background: #d71e1e; color: #fff; border: 1px solid #d71e1e; height: 32px; padding: 0 18px; border-radius: 3px; font-size: 14px; cursor: pointer; } .btn-add { background: #d71e1e; color: #fff; border: 1px solid #d71e1e; height: 32px; padding: 0 18px; border-radius: 3px; font-size: 14px; cursor: pointer; margin-left: 8px; }
.btn-add:first-child { margin-left: 0; }
.btn-add:hover { background: #b81818; border-color: #b81818; } .btn-add:hover { background: #b81818; border-color: #b81818; }
.file-hidden { display: none; }
.tbl-wrap { background: #fff; border: 1px solid #f0f0f0; border-radius: 4px; overflow: visible; } .tbl-wrap { background: #fff; border: 1px solid #f0f0f0; border-radius: 4px; overflow: visible; }
.simple-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 14px; } .simple-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 14px; }
.simple-table th, .simple-table td { padding: 14px 6px; border-bottom: 1px solid #f0f0f0; text-align: center; line-height: 1.5; vertical-align: middle; } .simple-table th, .simple-table td { padding: 14px 6px; border-bottom: 1px solid #f0f0f0; text-align: center; line-height: 1.5; vertical-align: middle; }
......
<template>
<PagePlaceholder title="样本存放管理" desc="样本存放位置与状态管理开发中" icon="Box" />
</template>
<script setup>
import PagePlaceholder from "@/components/PagePlaceholder.vue";
</script>
<template> <template>
<PagePlaceholder title="数据字典" desc="系统数据字典维护开发中" icon="Collection" /> <div class="page-dict">
<div class="filter-row">
<div class="filter-item">
<span class="filter-label">字典名称</span>
<el-input v-model="filterForm.name" placeholder="请输入字典名称" clearable size="default" />
</div>
<div class="filter-item">
<span class="filter-label">字典编码</span>
<el-input v-model="filterForm.code" placeholder="请输入字典编码" clearable size="default" />
</div>
<div class="filter-item">
<span class="filter-label">状态</span>
<el-select v-model="filterForm.status" placeholder="请选择状态" clearable size="default">
<el-option label="启用" value="启用" />
<el-option label="禁用" value="禁用" />
</el-select>
</div>
<div class="filter-actions">
<el-button type="danger" @click="onQuery">查询</el-button>
<el-button @click="onReset">重置</el-button>
<el-button type="danger" @click="onAdd">+ 新增字典</el-button>
</div>
</div>
<div class="table-card">
<table class="data-table">
<colgroup>
<col style="width: 60px" />
<col style="width: 150px" />
<col style="width: 150px" />
<col style="width: 100px" />
<col style="width: 110px" />
<col style="width: 90px" />
<col style="width: 170px" />
<col style="width: 90px" />
<col style="width: 220px" />
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>字典名称</th>
<th>字典编码</th>
<th>字典类型</th>
<th>选项数量</th>
<th>状态</th>
<th>创建时间</th>
<th>排序</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(d, i) in pagedDicts" :key="d.id">
<td>{{ (page - 1) * pageSize + i + 1 }}</td>
<td><a class="link-name" @click="onViewItems(d)">{{ d.name }}</a></td>
<td>{{ d.code }}</td>
<td>
<el-tag size="small" :type="d.type === '系统' ? 'danger' : 'success'">{{ d.type }}</el-tag>
</td>
<td>{{ d.itemCount }}</td>
<td>
<el-switch
v-model="d.status"
active-value="启用"
inactive-value="禁用"
active-color="#d71e1e"
inactive-color="#d71e1e"
class="status-switch"
@change="onStatusChange(d, $event)"
/>
</td>
<td>{{ d.createTime }}</td>
<td>{{ d.sort }}</td>
<td>
<a class="op-link" @click="onView(d)">查看</a>
<a class="op-link" @click="onEdit(d)">编辑</a>
<a class="op-link" @click="onItems(d)">字典项</a>
<a class="op-link danger" @click="onDelete(d)">删除</a>
</td>
</tr>
<tr v-if="pagedDicts.length === 0">
<td colspan="9" class="empty">暂无数据</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span class="total" style="margin-right:auto;">共 {{ filteredDicts.length }} 条记录</span>
<button class="page-btn" :disabled="page === 1" @click="page = 1">首页</button>
<button class="page-btn" :disabled="page === 1" @click="page--">上一页</button>
<button v-for="p in pageList" :key="p" class="page-btn" :class="{ active: p === page }" @click="typeof p === 'number' && (page = p)">{{ p }}</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page++">下一页</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page = pageCount">末页</button>
<span class="jump">跳转至 <input class="jump-input" v-model.number="jumpPage" placeholder="1" @keyup.enter="goJump" /> 页</span>
</div>
</div>
<!-- 状态切换确认弹窗 -->
<el-dialog
v-model="statusConfirmVisible"
title="提示"
width="420px"
:show-close="true"
:close-on-click-modal="false"
class="status-confirm-dialog"
>
<div class="status-confirm-body">
是否修改字典状态为{{ statusConfirmData.nextStatus === '启用' ? '启动' : '禁用' }}?
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="onStatusConfirmCancel">取消</el-button>
<el-button type="danger" @click="onStatusConfirmOk">确认</el-button>
</div>
</template>
</el-dialog>
<!-- 查看 / 编辑 / 新增 字典弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" :close-on-click-modal="false" @close="onDialogClose">
<div class="form-grid">
<div class="form-item">
<label class="form-label required">字典名称</label>
<el-input v-model="form.name" :disabled="mode === 'view'" placeholder="请输入字典名称" />
</div>
<div class="form-item">
<label class="form-label required">字典编码</label>
<el-input v-model="form.code" :disabled="mode === 'view' || mode === 'edit'" placeholder="请输入字典编码(大写英文)" />
</div>
<div class="form-item">
<label class="form-label required">字典类型</label>
<el-select v-model="form.type" :disabled="mode === 'view'" placeholder="请选择字典类型" style="width:100%">
<el-option label="系统" value="系统" />
<el-option label="业务" value="业务" />
</el-select>
</div>
<div class="form-item">
<label class="form-label required">排序</label>
<el-input-number v-model="form.sort" :disabled="mode === 'view'" :min="0" :max="999" controls-position="right" style="width:100%" />
</div>
<div class="form-item full">
<label class="form-label">备注</label>
<el-input v-model="form.remark" :disabled="mode === 'view'" type="textarea" :rows="3" placeholder="请输入备注" />
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">{{ mode === 'view' ? '关闭' : '取消' }}</el-button>
<el-button v-if="mode !== 'view'" type="danger" @click="onSubmit">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 字典项管理弹窗 -->
<el-dialog v-model="itemsVisible" :title="`字典项 - ${currentDict?.name || ''}`" width="780px" :close-on-click-modal="false">
<div class="items-toolbar">
<div class="items-tip">维护字典下的具体选项值,业务系统通过「字典编码 + 选项值」获取标签</div>
<el-button type="danger" size="small" @click="onAddItem">+ 新增选项</el-button>
</div>
<table class="data-table items-table">
<colgroup>
<col style="width: 50px" />
<col style="width: 140px" />
<col style="width: 100px" />
<col style="width: 200px" />
<col style="width: 90px" />
<col style="width: 90px" />
<col style="width: 100px" />
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>选项标签</th>
<th>选项值</th>
<th>备注</th>
<th>排序</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(it, i) in currentDictItems" :key="it.id">
<td>{{ i + 1 }}</td>
<td>{{ it.label }}</td>
<td>{{ it.value }}</td>
<td>{{ it.remark }}</td>
<td>{{ it.sort }}</td>
<td>
<el-tag size="small" :type="it.status === '启用' ? 'success' : 'warning'">{{ it.status }}</el-tag>
</td>
<td>
<a class="op-link" @click="onEditItem(it, i)">编辑</a>
<a class="op-link danger" @click="onDeleteItem(i)">删除</a>
</td>
</tr>
<tr v-if="currentDictItems.length === 0">
<td colspan="7" class="empty">暂无数据,请点击「+ 新增选项」</td>
</tr>
</tbody>
</table>
<template #footer>
<div class="dialog-footer">
<el-button @click="itemsVisible = false">关闭</el-button>
</div>
</template>
</el-dialog>
<!-- 字典项新增 / 编辑弹窗 -->
<el-dialog v-model="itemDialogVisible" :title="itemForm.id ? '编辑选项' : '新增选项'" width="480px" :close-on-click-modal="false">
<div class="form-grid single">
<div class="form-item">
<label class="form-label required">选项标签</label>
<el-input v-model="itemForm.label" placeholder="请输入选项标签(业务展示文本)" />
</div>
<div class="form-item">
<label class="form-label required">选项值</label>
<el-input v-model="itemForm.value" placeholder="请输入选项值(系统存储值)" />
</div>
<div class="form-item">
<label class="form-label required">排序</label>
<el-input-number v-model="itemForm.sort" :min="0" :max="999" controls-position="right" style="width:100%" />
</div>
<div class="form-item">
<label class="form-label">状态</label>
<el-select v-model="itemForm.status" placeholder="请选择" style="width:100%">
<el-option label="启用" value="启用" />
<el-option label="禁用" value="禁用" />
</el-select>
</div>
<div class="form-item full">
<label class="form-label">备注</label>
<el-input v-model="itemForm.remark" type="textarea" :rows="2" placeholder="请输入备注" />
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="itemDialogVisible = false">取消</el-button>
<el-button type="danger" @click="onSubmitItem">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template> </template>
<script setup> <script setup>
import PagePlaceholder from "@/components/PagePlaceholder.vue"; import { ref, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
const STORAGE_KEY = "settings_dicts";
const defaultDicts = [
{
id: 1, name: "样本状态", code: "SAMPLE_STATUS", type: "系统", itemCount: 4, status: "启用", createTime: "2026-01-02 09:00:00", sort: 1, remark: "样本流转全生命周期状态",
items: [
{ id: 11, label: "已登记", value: "REGISTERED", sort: 1, status: "启用", remark: "已完成入库存档" },
{ id: 12, label: "检验中", value: "TESTING", sort: 2, status: "启用", remark: "实验室抽检进行中" },
{ id: 13, label: "已出库", value: "OUTBOUND", sort: 3, status: "启用", remark: "样本已完成出库流转" },
{ id: 14, label: "已销毁", value: "DESTROYED", sort: 4, status: "启用", remark: "样本已销毁归档" }
]
},
{
id: 2, name: "检疫结果", code: "QUARANTINE_RESULT", type: "系统", itemCount: 4, status: "启用", createTime: "2026-01-05 10:30:00", sort: 2, remark: "检疫结论判定分类",
items: [
{ id: 21, label: "合格", value: "QUALIFIED", sort: 1, status: "启用", remark: "未检出检疫性有害生物" },
{ id: 22, label: "不合格", value: "UNQUALIFIED", sort: 2, status: "启用", remark: "检出检疫性有害生物" },
{ id: 23, label: "待复检", value: "RECHECK", sort: 3, status: "启用", remark: "需二次复检确认" },
{ id: 24, label: "隔离试种", value: "ISOLATION", sort: 4, status: "启用", remark: "进入隔离试种观察" }
]
},
{
id: 3, name: "处置方式", code: "DISPOSAL_TYPE", type: "业务", itemCount: 5, status: "启用", createTime: "2026-01-10 14:20:00", sort: 3, remark: "除害处理方式选项",
items: [
{ id: 31, label: "销毁", value: "DESTROY", sort: 1, status: "启用", remark: "直接销毁处理" },
{ id: 32, label: "退回", value: "RETURN", sort: 2, status: "启用", remark: "退回输出国" },
{ id: 33, label: "除害处理", value: "TREATMENT", sort: 3, status: "启用", remark: "化学/物理除害" },
{ id: 34, label: "隔离试种", value: "ISOLATION", sort: 4, status: "启用", remark: "隔离试种观察" },
{ id: 35, label: "改变用途", value: "REUSE", sort: 5, status: "启用", remark: "降级使用" }
]
},
{
id: 4, name: "风险等级", code: "RISK_LEVEL", type: "系统", itemCount: 4, status: "启用", createTime: "2026-02-01 09:15:00", sort: 4, remark: "风险等级评估分级",
items: [
{ id: 41, label: "高风险", value: "HIGH", sort: 1, status: "启用", remark: "已确诊发现国家规定检疫性有害生物" },
{ id: 42, label: "中风险", value: "MEDIUM", sort: 2, status: "启用", remark: "现场疑似或初筛阳性" },
{ id: 43, label: "低风险", value: "LOW", sort: 3, status: "启用", remark: "监测到轻微异常或一般预警" },
{ id: 44, label: "已消除", value: "RESOLVED", sort: 4, status: "启用", remark: "除害处理合格" }
]
},
{
id: 5, name: "植物类别", code: "PLANT_CATEGORY", type: "业务", itemCount: 6, status: "启用", createTime: "2026-02-15 11:00:00", sort: 5, remark: "植物分类选项",
items: [
{ id: 51, label: "种子类", value: "SEED", sort: 1, status: "启用", remark: "" },
{ id: 52, label: "苗木类", value: "SEEDLING", sort: 2, status: "启用", remark: "" },
{ id: 53, label: "花卉类", value: "FLOWER", sort: 3, status: "启用", remark: "" },
{ id: 54, label: "蔬菜类", value: "VEGETABLE", sort: 4, status: "启用", remark: "" },
{ id: 55, label: "粮食作物", value: "GRAIN", sort: 5, status: "启用", remark: "" },
{ id: 56, label: "其他", value: "OTHER", sort: 6, status: "启用", remark: "" }
]
},
{
id: 6, name: "产出用途", code: "USE_TYPE", type: "业务", itemCount: 5, status: "启用", createTime: "2026-03-05 16:30:00", sort: 6, remark: "引种申报用途",
items: [
{ id: 61, label: "科研", value: "RESEARCH", sort: 1, status: "启用", remark: "" },
{ id: 62, label: "生产", value: "PRODUCTION", sort: 2, status: "启用", remark: "" },
{ id: 63, label: "试验", value: "EXPERIMENT", sort: 3, status: "启用", remark: "" },
{ id: 64, label: "展示", value: "EXHIBITION", sort: 4, status: "启用", remark: "" },
{ id: 65, label: "交换", value: "EXCHANGE", sort: 5, status: "启用", remark: "" }
]
},
{
id: 7, name: "审批状态", code: "APPROVAL_STATUS", type: "系统", itemCount: 4, status: "启用", createTime: "2026-03-20 10:45:00", sort: 7, remark: "审批流程状态",
items: [
{ id: 71, label: "待提交", value: "PENDING", sort: 1, status: "启用", remark: "" },
{ id: 72, label: "审核中", value: "REVIEWING", sort: 2, status: "启用", remark: "" },
{ id: 73, label: "已通过", value: "APPROVED", sort: 3, status: "启用", remark: "" },
{ id: 74, label: "已驳回", value: "REJECTED", sort: 4, status: "启用", remark: "" }
]
},
{
id: 8, name: "设备类型", code: "DEVICE_TYPE", type: "业务", itemCount: 4, status: "启用", createTime: "2026-04-08 09:30:00", sort: 8, remark: "监测设备类型",
items: [
{ id: 81, label: "温湿度传感器", value: "TEMP_HUMI", sort: 1, status: "启用", remark: "" },
{ id: 82, label: "虫情测报灯", value: "PEST_LAMP", sort: 2, status: "启用", remark: "" },
{ id: 83, label: "摄像头", value: "CAMERA", sort: 3, status: "启用", remark: "" },
{ id: 84, label: "气象站", value: "WEATHER", sort: 4, status: "启用", remark: "" }
]
}
];
const loadDicts = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch (e) { /* ignore */ }
return JSON.parse(JSON.stringify(defaultDicts));
};
const saveDicts = (list) => localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
const dicts = ref(loadDicts());
const filterForm = ref({ name: "", code: "", status: "" });
const page = ref(1);
const pageSize = 10;
const jumpPage = ref("");
const filteredDicts = computed(() => {
return dicts.value.filter((d) => {
if (filterForm.value.name && !d.name.includes(filterForm.value.name)) return false;
if (filterForm.value.code && !d.code.toLowerCase().includes(filterForm.value.code.toLowerCase())) return false;
if (filterForm.value.status && d.status !== filterForm.value.status) return false;
return true;
});
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredDicts.value.length / pageSize)));
const pagedDicts = computed(() => {
const start = (page.value - 1) * pageSize;
return filteredDicts.value.slice(start, start + pageSize);
});
const pageList = computed(() => {
const total = pageCount.value, cur = page.value;
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
if (cur <= 4) return [1, 2, 3, 4, 5, "...", total];
if (cur >= total - 3) return [1, "...", total - 4, total - 3, total - 2, total - 1, total];
return [1, "...", cur - 1, cur, cur + 1, "...", total];
});
const goJump = () => {
const n = parseInt(jumpPage.value, 10);
if (!isNaN(n) && n >= 1 && n <= pageCount.value) page.value = n;
jumpPage.value = "";
};
const onQuery = () => { page.value = 1; };
const onReset = () => {
filterForm.value = { name: "", code: "", status: "" };
page.value = 1;
};
const dialogVisible = ref(false);
const mode = ref("view");
const form = ref({});
const dialogTitle = computed(() => (mode.value === "add" ? "新增字典" : mode.value === "edit" ? "编辑字典" : "查看字典"));
const resetForm = () => {
form.value = { id: null, name: "", code: "", type: "业务", itemCount: 0, status: "启用", createTime: "", sort: 99, remark: "", items: [] };
};
const onView = (d) => { mode.value = "view"; form.value = { ...d }; dialogVisible.value = true; };
const onEdit = (d) => { mode.value = "edit"; form.value = { ...d }; dialogVisible.value = true; };
const onAdd = () => { mode.value = "add"; resetForm(); dialogVisible.value = true; };
const onSubmit = () => {
if (!form.value.name || !form.value.code || !form.value.type) {
ElMessage.warning("请填写完整的必填项");
return;
}
if (mode.value === "add") {
const newId = Math.max(...dicts.value.map((d) => d.id), 0) + 1;
dicts.value.unshift({
...form.value,
id: newId,
itemCount: (form.value.items || []).length,
createTime: new Date().toISOString().replace("T", " ").slice(0, 19)
});
ElMessage.success("新增成功");
} else {
const idx = dicts.value.findIndex((d) => d.id === form.value.id);
if (idx !== -1) {
dicts.value[idx] = { ...form.value, itemCount: (form.value.items || []).length };
}
ElMessage.success("保存成功");
}
saveDicts(dicts.value);
dialogVisible.value = false;
};
const onDialogClose = () => { form.value = {}; };
const onDelete = (d) => {
ElMessageBox.confirm(`确定删除字典「${d.name}」吗?关联的字典项也会一并删除`, "删除提示", { type: "warning" })
.then(() => {
dicts.value = dicts.value.filter((x) => x.id !== d.id);
saveDicts(dicts.value);
ElMessage.success("删除成功");
})
.catch(() => {});
};
const statusConfirmVisible = ref(false);
const statusConfirmData = ref({ dict: null, nextStatus: "", rollback: null });
const onStatusChange = (d, next) => {
const targetStatus = next;
statusConfirmData.value = { dict: d, nextStatus: targetStatus, rollback: d.status };
d.status = targetStatus;
statusConfirmVisible.value = true;
};
const onStatusConfirmCancel = () => {
const { dict, rollback } = statusConfirmData.value;
if (dict && rollback) dict.status = rollback;
statusConfirmVisible.value = false;
};
const onStatusConfirmOk = () => {
const { dict } = statusConfirmData.value;
saveDicts(dicts.value);
ElMessage.success(`已${dict.status === "启用" ? "启用" : "禁用"}「${dict.name}」`);
statusConfirmVisible.value = false;
};
// 字典项管理
const itemsVisible = ref(false);
const currentDict = ref(null);
const currentDictItems = computed(() => currentDict.value?.items || []);
const itemDialogVisible = ref(false);
const itemForm = ref({ id: null, label: "", value: "", sort: 0, status: "启用", remark: "" });
const resetItemForm = () => {
itemForm.value = { id: null, label: "", value: "", sort: (currentDictItems.value.length || 0) + 1, status: "启用", remark: "" };
};
const onItems = (d) => { currentDict.value = d; itemsVisible.value = true; };
const onViewItems = (d) => { currentDict.value = d; itemsVisible.value = true; };
const onAddItem = () => { resetItemForm(); itemDialogVisible.value = true; };
const onEditItem = (it, idx) => {
itemForm.value = { ...it };
itemDialogVisible.value = true;
};
const onDeleteItem = (idx) => {
ElMessageBox.confirm("确定删除该字典项吗?", "删除提示", { type: "warning" })
.then(() => {
currentDict.value.items.splice(idx, 1);
currentDict.value.itemCount = currentDict.value.items.length;
saveDicts(dicts.value);
ElMessage.success("删除成功");
})
.catch(() => {});
};
const onSubmitItem = () => {
if (!itemForm.value.label || !itemForm.value.value) {
ElMessage.warning("请填写选项标签与选项值");
return;
}
if (itemForm.value.id) {
const idx = currentDict.value.items.findIndex((x) => x.id === itemForm.value.id);
if (idx !== -1) currentDict.value.items[idx] = { ...itemForm.value };
} else {
const newId = Math.max(...currentDict.value.items.map((x) => x.id), 0) + 1;
currentDict.value.items.push({ ...itemForm.value, id: newId });
}
currentDict.value.itemCount = currentDict.value.items.length;
saveDicts(dicts.value);
itemDialogVisible.value = false;
ElMessage.success("保存成功");
};
onMounted(() => {
dicts.value = loadDicts();
});
</script> </script>
<style scoped>
.page-dict { padding: 18px 20px; }
.filter-row {
background: #fff;
padding: 14px 16px;
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: center;
border: 1px solid #e6e8ec;
border-radius: 4px;
margin-bottom: 14px;
}
.filter-item { display: flex; align-items: center; gap: 6px; }
.filter-label { font-size: 13px; color: #333; white-space: nowrap; flex-shrink: 0; }
.filter-item :deep(.el-input), .filter-item :deep(.el-select) { width: 200px; }
.filter-actions { margin-left: auto; display: flex; gap: 8px; }
.table-card { background: #fff; border: 1px solid #e6e8ec; border-radius: 4px; padding: 8px 0; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; text-align: center; }
.data-table th { background: #fafafa; color: #333; font-weight: 600; }
.data-table td { color: #333; }
.data-table tr:hover td { background: #f9fafc; }
.empty { text-align: center; color: #999; padding: 24px 0; }
.link-name { color: #1f3a5f; cursor: pointer; }
.link-name:hover { color: #d71e1e; text-decoration: underline; }
.op-link { color: #333; margin: 0 4px; cursor: pointer; }
.op-link:hover { color: #d71e1e; }
.op-link.danger:hover { color: #d71e1e; }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 6px; padding: 14px 20px; background: #fff; border: 1px solid #ebeef5; border-top: 0; border-radius: 0 0 3px 3px; }
.total { color: #555; font-size: 14px; margin-right: 12px; }
.page-btn { background: #fff; border: 1px solid #dcdfe6; color: #555; height: 28px; min-width: 28px; padding: 0 8px; font-size: 14px; cursor: pointer; border-radius: 3px; }
.page-btn:hover:not(:disabled) { color: #d71e1e; border-color: #d71e1e; }
.page-btn.active { background: #d71e1e; border-color: #d71e1e; color: #fff; }
.page-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.jump { color: #555; font-size: 14px; margin-left: 6px; }
.jump-input { width: 50px; height: 24px; padding: 0 4px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 13px; text-align: center; margin: 0 4px; }
.status-switch :deep(.el-switch__core) { width: 42px; height: 20px; border-radius: 999px; border: 1px solid #d71e1e; background-color: #fbeaea !important; }
.status-switch :deep(.el-switch__core::after) { width: 16px; height: 16px; background-color: #d71e1e !important; }
.status-switch.is-checked :deep(.el-switch__core) { background-color: #d71e1e !important; border-color: #d71e1e; }
.status-switch.is-checked :deep(.el-switch__core::after) { background-color: #fff !important; }
.status-confirm-dialog :deep(.el-dialog__body) { padding: 16px 20px; }
.status-confirm-body { font-size: 14px; color: #333; line-height: 1.6; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 24px; }
.form-grid.single { grid-template-columns: 1fr 1fr; }
.form-item { display: flex; flex-direction: column; gap: 4px; }
.form-item.full { grid-column: span 2; }
.form-label { font-size: 13px; color: #333; }
.form-label.required::before { content: "*"; color: #d71e1e; margin-right: 2px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; }
.items-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.items-tip { font-size: 13px; color: #666; }
.items-table { border: 1px solid #ebeef5; border-radius: 4px; }
</style>
\ No newline at end of file
<template> <template>
<PagePlaceholder title="操作日志" desc="用户操作日志查询与审计开发中" icon="Document" /> <div class="page-log">
<div class="filter-row">
<div class="filter-item">
<span class="filter-label">操作人</span>
<el-input v-model="filterForm.operator" placeholder="请输入操作人姓名/账号" clearable size="default" />
</div>
<div class="filter-item">
<span class="filter-label">操作类型</span>
<el-select v-model="filterForm.action" placeholder="请选择操作类型" clearable size="default">
<el-option v-for="a in actionOptions" :key="a" :label="a" :value="a" />
</el-select>
</div>
<div class="filter-item">
<span class="filter-label">模块</span>
<el-select v-model="filterForm.module" placeholder="请选择模块" clearable size="default">
<el-option v-for="m in moduleOptions" :key="m" :label="m" :value="m" />
</el-select>
</div>
<div class="filter-item">
<span class="filter-label">结果</span>
<el-select v-model="filterForm.result" placeholder="请选择结果" clearable size="default">
<el-option label="成功" value="成功" />
<el-option label="失败" value="失败" />
</el-select>
</div>
<div class="filter-item range-item">
<span class="filter-label">操作时间</span>
<el-date-picker
v-model="filterForm.dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
size="default"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</div>
<div class="filter-actions">
<el-button type="danger" @click="onQuery">查询</el-button>
<el-button @click="onReset">重置</el-button>
<el-button type="success" @click="onExport">导出</el-button>
</div>
</div>
<div class="table-card">
<table class="data-table">
<colgroup>
<col style="width: 60px" />
<col style="width: 110px" />
<col style="width: 100px" />
<col style="width: 110px" />
<col style="width: 140px" />
<col style="width: 240px" />
<col style="width: 360px" />
<col style="width: 80px" />
<col style="width: 170px" />
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>操作人</th>
<th>账号</th>
<th>操作类型</th>
<th>模块</th>
<th>操作描述</th>
<th>请求参数</th>
<th>结果</th>
<th>操作时间</th>
</tr>
</thead>
<tbody>
<tr v-for="(l, i) in pagedLogs" :key="l.id">
<td>{{ (page - 1) * pageSize + i + 1 }}</td>
<td>{{ l.operator }}</td>
<td>{{ l.account }}</td>
<td>
<el-tag size="small" :type="actionTagType(l.action)">{{ l.action }}</el-tag>
</td>
<td>{{ l.module }}</td>
<td :title="l.desc" class="ellipsis">{{ l.desc }}</td>
<td :title="l.params" class="ellipsis params-cell">{{ l.params }}</td>
<td>
<el-tag size="small" :type="l.result === '成功' ? 'success' : 'danger'">{{ l.result }}</el-tag>
</td>
<td>{{ l.opTime }}</td>
</tr>
<tr v-if="pagedLogs.length === 0">
<td colspan="9" class="empty">暂无数据</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span class="total" style="margin-right:auto;">共 {{ filteredLogs.length }} 条记录</span>
<button class="page-btn" :disabled="page === 1" @click="page = 1">首页</button>
<button class="page-btn" :disabled="page === 1" @click="page--">上一页</button>
<button v-for="p in pageList" :key="p" class="page-btn" :class="{ active: p === page }" @click="typeof p === 'number' && (page = p)">{{ p }}</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page++">下一页</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page = pageCount">末页</button>
<span class="jump">跳转至 <input class="jump-input" v-model.number="jumpPage" placeholder="1" @keyup.enter="goJump" /> 页</span>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import PagePlaceholder from "@/components/PagePlaceholder.vue"; import { ref, computed, onMounted } from "vue";
import { ElMessage } from "element-plus";
const STORAGE_KEY = "settings_logs";
const actionOptions = ["新增", "修改", "删除", "查询", "登录", "登出", "导入", "导出", "审批", "分配"];
const moduleOptions = ["首页", "委托书管理", "样本管理", "实验室检测", "田间调查", "除害处理", "隔离监测", "风险分析", "报告管理", "系统设置"];
const defaultLogs = [
{ id: 1, operator: "张文博", account: "gs_admin", action: "登录", module: "首页", desc: "登录系统", params: "username=gs_admin", result: "成功", opTime: "2026-09-20 08:30:12" },
{ id: 2, operator: "张文博", account: "gs_admin", action: "审批", module: "委托书管理", desc: "审批通过委托书 ENTRUST-2026-0092", params: "{entrustId:\"ENTRUST-2026-0092\", result:\"approved\"}", result: "成功", opTime: "2026-09-20 09:15:48" },
{ id: 3, operator: "王俊凯", account: "gz_admin", action: "新增", module: "样本管理", desc: "登记入库批次 BATCH-2026-0018 共12份样本", params: "{batchId:\"BATCH-2026-0018\", samples:12}", result: "成功", opTime: "2026-09-20 09:42:23" },
{ id: 4, operator: "李建华", account: "quar_001", action: "查询", module: "实验室检测", desc: "查询样本检测列表", params: "{page:1, pageSize:20, status:\"TESTING\"}", result: "成功", opTime: "2026-09-20 10:05:11" },
{ id: 5, operator: "李建华", account: "quar_001", action: "修改", module: "实验室检测", desc: "更新样本 SAMPLE-0006 检测结果:检出线虫", params: "{sampleId:\"SAMPLE-0006\", result:\"UNQUALIFIED\", pest:\"线虫\"}", result: "成功", opTime: "2026-09-20 10:18:55" },
{ id: 6, operator: "孙亚萍", account: "lab_001", action: "新增", module: "实验室检测", desc: "录入实验检验记录 LAB-2026-0312", params: "{labId:\"LAB-2026-0312\", sampleId:\"SAMPLE-0006\"}", result: "成功", opTime: "2026-09-20 10:35:42" },
{ id: 7, operator: "赵晓萌", account: "quar_002", action: "新增", module: "田间调查", desc: "登记田间调查记录 FIELD-2026-0089", params: "{fieldId:\"FIELD-2026-0089\", plot:\"lots\", area:5.6}", result: "成功", opTime: "2026-09-20 11:02:07" },
{ id: 8, operator: "赵晓萌", account: "quar_002", action: "修改", module: "田间调查", desc: "更新调查结论为异常,疑似发生蚜虫", params: "{fieldId:\"FIELD-2026-0089\", result:\"ABNORMAL\", pest:\"蚜虫\"}", result: "成功", opTime: "2026-09-20 11:14:33" },
{ id: 9, operator: "周磊", account: "lab_002", action: "导入", module: "实验室检测", desc: "批量导入检测数据 23 条", params: "{file:\"lab_20260920.xlsx\", count:23}", result: "成功", opTime: "2026-09-20 11:30:19" },
{ id: 10, operator: "王俊凯", account: "gz_admin", action: "分配", module: "除害处理", desc: "创建除害处理任务 DISPOSAL-2026-0034", params: "{disposalId:\"DISPOSAL-2026-0034\", type:\"TREATMENT\"}", result: "成功", opTime: "2026-09-20 12:08:50" },
{ id: 11, operator: "李建华", account: "quar_001", action: "修改", module: "除害处理", desc: "更新处理进度为处理完成", params: "{disposalId:\"DISPOSAL-2026-0034\", status:\"COMPLETED\"}", result: "成功", opTime: "2026-09-20 14:25:31" },
{ id: 12, operator: "张文博", account: "gs_admin", action: "导出", module: "风险分析", desc: "导出风险分析报告 2026年8月", params: "{reportId:\"RISK-2026-08\", format:\"doc\"}", result: "成功", opTime: "2026-09-20 15:02:46" },
{ id: 13, operator: "张文博", account: "gs_admin", action: "查询", module: "报告管理", desc: "查询报告列表", params: "{page:1, pageSize:20}", result: "成功", opTime: "2026-09-20 15:10:22" },
{ id: 14, operator: "王俊凯", account: "gz_admin", action: "新增", module: "系统设置", desc: "新增用户 quar_003", params: "{account:\"quar_003\", name:\"马晓东\", roleName:\"检疫员\"}", result: "成功", opTime: "2026-09-20 15:30:08" },
{ id: 15, operator: "王俊凯", account: "gz_admin", action: "修改", module: "系统设置", desc: "修改角色 ROLE_QUARANTINE 权限", params: "{roleId:\"ROLE_QUARANTINE\", perms:[\"sample\",\"field\",\"disposal\"]}", result: "成功", opTime: "2026-09-20 15:45:55" },
{ id: 16, operator: "李建华", account: "quar_001", action: "登录", module: "首页", desc: "登录系统", params: "username=quar_001", result: "成功", opTime: "2026-09-20 16:00:14" },
{ id: 17, operator: "孙亚萍", account: "lab_001", action: "查询", module: "隔离监测", desc: "查询监测数据", params: "{deviceId:\"DEV-001\", dateRange:\"2026-09-19~2026-09-20\"}", result: "成功", opTime: "2026-09-20 16:12:39" },
{ id: 18, operator: "周磊", account: "lab_002", action: "删除", module: "实验室检测", desc: "删除无效检测记录 LAB-2026-0289", params: "{labId:\"LAB-2026-0289\"}", result: "失败", opTime: "2026-09-20 16:30:21" },
{ id: 19, operator: "赵晓萌", account: "quar_002", action: "导出", module: "田间调查", desc: "导出田间调查记录 EXCEL", params: "{dateRange:\"2026-09-13~2026-09-20\", format:\"xlsx\"}", result: "成功", opTime: "2026-09-20 16:45:08" },
{ id: 20, operator: "张文博", account: "gs_admin", action: "审批", module: "委托书管理", desc: "审批退回委托书 ENTRUST-2026-0093,原因:检疫单证号缺失", params: "{entrustId:\"ENTRUST-2026-0093\", result:\"rejected\", reason:\"检疫单证号缺失\"}", result: "成功", opTime: "2026-09-20 17:02:55" },
{ id: 21, operator: "李建华", account: "quar_001", action: "新增", module: "样本管理", desc: "样本出库 SAMPLE-0006 出库1条", params: "{sampleId:\"SAMPLE-0006\", qty:0.05, purpose:\"样本抽检\"}", result: "成功", opTime: "2026-09-20 17:20:31" },
{ id: 22, operator: "王俊凯", account: "gz_admin", action: "修改", module: "系统设置", desc: "修改字典 DICT-SAMPLE-STATUS 字典项", params: "{dictId:1, itemId:11, label:\"已登记\"}", result: "成功", opTime: "2026-09-20 17:38:12" },
{ id: 23, operator: "孙亚萍", account: "lab_001", action: "查询", module: "样本管理", desc: "查询样本登记入库列表", params: "{page:1, pageSize:20, plant:\"番茄\"}", result: "成功", opTime: "2026-09-20 17:55:48" },
{ id: 24, operator: "周磊", account: "lab_002", action: "登出", module: "首页", desc: "退出登录", params: "username=lab_002", result: "成功", opTime: "2026-09-20 18:00:00" }
];
const loadLogs = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch (e) { /* ignore */ }
return JSON.parse(JSON.stringify(defaultLogs));
};
const saveLogs = (list) => localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
const logs = ref(loadLogs());
const filterForm = ref({ operator: "", action: "", module: "", result: "", dateRange: [] });
const page = ref(1);
const pageSize = 10;
const jumpPage = ref("");
const filteredLogs = computed(() => {
return logs.value.filter((l) => {
if (filterForm.value.operator) {
const kw = filterForm.value.operator;
if (!l.operator.includes(kw) && !l.account.includes(kw)) return false;
}
if (filterForm.value.action && l.action !== filterForm.value.action) return false;
if (filterForm.value.module && l.module !== filterForm.value.module) return false;
if (filterForm.value.result && l.result !== filterForm.value.result) return false;
if (filterForm.value.dateRange && filterForm.value.dateRange.length === 2) {
const [s, e] = filterForm.value.dateRange;
if (l.opTime < s || l.opTime > e) return false;
}
return true;
});
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredLogs.value.length / pageSize)));
const pagedLogs = computed(() => {
const start = (page.value - 1) * pageSize;
return filteredLogs.value.slice(start, start + pageSize);
});
const pageList = computed(() => {
const total = pageCount.value, cur = page.value;
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
if (cur <= 4) return [1, 2, 3, 4, 5, "...", total];
if (cur >= total - 3) return [1, "...", total - 4, total - 3, total - 2, total - 1, total];
return [1, "...", cur - 1, cur, cur + 1, "...", total];
});
const goJump = () => {
const n = parseInt(jumpPage.value, 10);
if (!isNaN(n) && n >= 1 && n <= pageCount.value) page.value = n;
jumpPage.value = "";
};
const onQuery = () => { page.value = 1; };
const onReset = () => {
filterForm.value = { operator: "", action: "", module: "", result: "", dateRange: [] };
page.value = 1;
};
const actionTagType = (action) => {
const map = { 新增: "success", 修改: "primary", 删除: "danger", 查询: "info", 登录: "success", 登出: "info", 导入: "warning", 导出: "warning", 审批: "primary", 分配: "warning" };
return map[action] || "info";
};
const onExport = () => {
const header = ["序号", "操作人", "账号", "操作类型", "模块", "操作描述", "请求参数", "结果", "操作时间"];
const rows = filteredLogs.value.map((l, i) => [i + 1, l.operator, l.account, l.action, l.module, l.desc, l.params, l.result, l.opTime]);
const csv = [header, ...rows].map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `操作日志_${new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19)}.csv`;
a.click();
URL.revokeObjectURL(url);
ElMessage.success(`已导出 ${filteredLogs.value.length} 条日志`);
};
onMounted(() => {
logs.value = loadLogs();
});
</script> </script>
<style scoped>
.page-log { padding: 18px 20px; }
.filter-row {
background: #fff;
padding: 14px 16px;
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: center;
border: 1px solid #e6e8ec;
border-radius: 4px;
margin-bottom: 14px;
}
.filter-item { display: flex; align-items: center; gap: 6px; }
.filter-item.range-item :deep(.el-date-editor) { width: 360px; }
.filter-label { font-size: 13px; color: #333; white-space: nowrap; flex-shrink: 0; }
.filter-item :deep(.el-input), .filter-item :deep(.el-select) { width: 180px; }
.filter-actions { margin-left: auto; display: flex; gap: 8px; }
.table-card { background: #fff; border: 1px solid #e6e8ec; border-radius: 4px; padding: 8px 0; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; text-align: center; }
.data-table th { background: #fafafa; color: #333; font-weight: 600; }
.data-table td { color: #333; }
.data-table tr:hover td { background: #f9fafc; }
.ellipsis { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.params-cell { font-family: "Consolas", "Monaco", monospace; font-size: 12px; color: #555; max-width: 360px; }
.empty { text-align: center; color: #999; padding: 24px 0; }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 6px; padding: 14px 20px; background: #fff; border: 1px solid #ebeef5; border-top: 0; border-radius: 0 0 3px 3px; }
.total { color: #555; font-size: 14px; margin-right: 12px; }
.page-btn { background: #fff; border: 1px solid #dcdfe6; color: #555; height: 28px; min-width: 28px; padding: 0 8px; font-size: 14px; cursor: pointer; border-radius: 3px; }
.page-btn:hover:not(:disabled) { color: #d71e1e; border-color: #d71e1e; }
.page-btn.active { background: #d71e1e; border-color: #d71e1e; color: #fff; }
.page-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.jump { color: #555; font-size: 14px; margin-left: 6px; }
.jump-input { width: 50px; height: 24px; padding: 0 4px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 13px; text-align: center; margin: 0 4px; }
</style>
\ No newline at end of file
<template> <template>
<PagePlaceholder title="角色权限" desc="角色与权限分配开发中" icon="UserFilled" /> <div class="page-role">
<div class="filter-row">
<div class="filter-item">
<span class="filter-label">角色名称</span>
<el-input v-model="filterForm.name" placeholder="请输入角色名称" clearable size="default" />
</div>
<div class="filter-item">
<span class="filter-label">角色编码</span>
<el-input v-model="filterForm.code" placeholder="请输入角色编码" clearable size="default" />
</div>
<div class="filter-item">
<span class="filter-label">状态</span>
<el-select v-model="filterForm.status" placeholder="请选择状态" clearable size="default">
<el-option label="启用" value="启用" />
<el-option label="禁用" value="禁用" />
</el-select>
</div>
<div class="filter-actions">
<el-button type="danger" @click="onQuery">查询</el-button>
<el-button @click="onReset">重置</el-button>
<el-button type="danger" @click="onAdd">+ 新增角色</el-button>
</div>
</div>
<div class="table-card">
<table class="data-table">
<colgroup>
<col style="width: 60px" />
<col style="width: 140px" />
<col style="width: 140px" />
<col style="width: 200px" />
<col style="width: 320px" />
<col style="width: 120px" />
<col style="width: 170px" />
<col style="width: 90px" />
<col style="width: 180px" />
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>角色名称</th>
<th>角色编码</th>
<th>数据权限范围</th>
<th>角色描述</th>
<th>用户数量</th>
<th>创建时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in pagedRoles" :key="r.id">
<td>{{ (page - 1) * pageSize + i + 1 }}</td>
<td>{{ r.name }}</td>
<td>{{ r.code }}</td>
<td>{{ r.dataScope }}</td>
<td>{{ r.desc }}</td>
<td>{{ r.userCount }}</td>
<td>{{ r.createTime }}</td>
<td>
<el-switch
v-model="r.status"
active-value="启用"
inactive-value="禁用"
active-color="#d71e1e"
inactive-color="#d71e1e"
class="status-switch"
@change="onStatusChange(r, $event)"
/>
</td>
<td>
<a class="op-link" @click="onView(r)">查看</a>
<a class="op-link" @click="onEdit(r)">编辑</a>
<a class="op-link" @click="onAssign(r)">分配权限</a>
<a class="op-link danger" @click="onDelete(r)">删除</a>
</td>
</tr>
<tr v-if="pagedRoles.length === 0">
<td colspan="9" class="empty">暂无数据</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span class="total" style="margin-right:auto;">共 {{ filteredRoles.length }} 条记录</span>
<button class="page-btn" :disabled="page === 1" @click="page = 1">首页</button>
<button class="page-btn" :disabled="page === 1" @click="page--">上一页</button>
<button v-for="p in pageList" :key="p" class="page-btn" :class="{ active: p === page }" @click="typeof p === 'number' && (page = p)">{{ p }}</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page++">下一页</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page = pageCount">末页</button>
<span class="jump">跳转至 <input class="jump-input" v-model.number="jumpPage" placeholder="1" @keyup.enter="goJump" /> 页</span>
</div>
</div>
<!-- 状态切换确认弹窗 -->
<el-dialog
v-model="statusConfirmVisible"
title="提示"
width="420px"
:show-close="true"
:close-on-click-modal="false"
class="status-confirm-dialog"
>
<div class="status-confirm-body">
是否修改角色状态为{{ statusConfirmData.nextStatus === '启用' ? '启动' : '禁用' }}?
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="onStatusConfirmCancel">取消</el-button>
<el-button type="danger" @click="onStatusConfirmOk">确认</el-button>
</div>
</template>
</el-dialog>
<!-- 查看 / 编辑 / 新增 弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" :close-on-click-modal="false" @close="onDialogClose">
<div class="form-grid">
<div class="form-item">
<label class="form-label required">角色名称</label>
<el-input v-model="form.name" :disabled="mode === 'view'" placeholder="请输入角色名称" />
</div>
<div class="form-item">
<label class="form-label required">角色编码</label>
<el-input v-model="form.code" :disabled="mode === 'view' || mode === 'edit'" placeholder="请输入角色编码" />
</div>
<div class="form-item full">
<label class="form-label required">数据权限范围</label>
<el-select v-model="form.dataScope" :disabled="mode === 'view'" placeholder="请选择数据权限范围" style="width:100%">
<el-option label="全部数据" value="全部数据" />
<el-option label="本部门数据" value="本部门数据" />
<el-option label="本部门及下级" value="本部门及下级" />
<el-option label="仅本人数据" value="仅本人数据" />
<el-option label="自定义" value="自定义" />
</el-select>
</div>
<div class="form-item full">
<label class="form-label">角色描述</label>
<el-input v-model="form.desc" :disabled="mode === 'view'" type="textarea" :rows="3" placeholder="请输入角色描述" />
</div>
<div class="form-item full">
<label class="form-label">菜单权限</label>
<div class="perm-tree">
<el-tree
ref="treeRef"
:data="menuTree"
:props="{ label: 'name', children: 'children' }"
show-checkbox
node-key="id"
:default-expand-all="true"
:check-strictly="false"
/>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">{{ mode === 'view' ? '关闭' : '取消' }}</el-button>
<el-button v-if="mode !== 'view'" type="danger" @click="onSubmit">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 分配权限确认弹窗 -->
<el-dialog
v-model="assignVisible"
:title="`分配权限 - ${currentRole?.name || ''}`"
width="560px"
:close-on-click-modal="false"
>
<div class="assign-body">
<div class="assign-tip">请勾选该角色可访问的菜单权限(半选表示包含子权限):</div>
<el-tree
ref="assignTreeRef"
:data="menuTree"
:props="{ label: 'name', children: 'children' }"
show-checkbox
node-key="id"
:default-checked-keys="currentRole?.perms || []"
:default-expand-all="true"
/>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="assignVisible = false">取消</el-button>
<el-button type="danger" @click="onAssignSubmit">保存</el-button>
</div>
</template>
</el-dialog>
</div>
</template> </template>
<script setup> <script setup>
import PagePlaceholder from "@/components/PagePlaceholder.vue"; import { ref, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
const STORAGE_KEY = "settings_roles";
const defaultRoles = [
{ id: 1, name: "植保站管理人员", code: "ROLE_ZBZ_ADMIN", dataScope: "全部数据", desc: "负责甘肃省植保植检站业务管理与流程审批", userCount: 1, createTime: "2026-01-01 09:00:00", status: "启用", perms: ["home", "entrust", "sample", "lab", "field", "disposal", "isolation", "analysis", "report", "settings"] },
{ id: 2, name: "隔离中心管理人员", code: "ROLE_GZ_ADMIN", dataScope: "本部门数据", desc: "负责甘州区农业技术推广中心日常业务管理", userCount: 1, createTime: "2026-01-02 10:00:00", status: "启用", perms: ["home", "sample", "field", "isolation", "report"] },
{ id: 3, name: "检疫员", code: "ROLE_QUARANTINE", dataScope: "本部门及下级", desc: "负责样品抽检、田间调查与除害处理等检疫操作", userCount: 2, createTime: "2026-02-15 14:20:00", status: "启用", perms: ["home", "sample", "field", "disposal"] },
{ id: 4, name: "实验员", code: "ROLE_LAB", dataScope: "本部门数据", desc: "负责实验室检测、检测记录与数据上报", userCount: 2, createTime: "2026-03-08 16:45:00", status: "启用", perms: ["home", "lab"] },
{ id: 5, name: "系统管理员", code: "ROLE_SYS_ADMIN", dataScope: "全部数据", desc: "负责平台运维、用户与角色管理、数据字典维护及系统配置", userCount: 1, createTime: "2026-01-01 08:30:00", status: "启用", perms: ["home", "settings", "settings-users", "settings-roles", "settings-dict", "settings-log", "entrust", "sample", "lab", "field", "disposal", "isolation", "analysis", "report"] }
];
const menuTree = [
{
id: "home", name: "首页",
children: []
},
{
id: "entrust", name: "委托书管理",
children: [
{ id: "entrust-reg", name: "委托书登记" },
{ id: "entrust-list", name: "委托书管理" },
{ id: "entrust-audit", name: "委托书审核" },
{ id: "entrust-archive", name: "委托书归档" }
]
},
{
id: "sample", name: "样本管理",
children: [
{ id: "sample-register", name: "样本登记入库" },
{ id: "sample-out", name: "样本出库管理" }
]
},
{
id: "lab", name: "实验室检测",
children: [
{ id: "lab-sample-test", name: "样本抽检" },
{ id: "lab-experiment-test", name: "实验检验" }
]
},
{
id: "field", name: "田间调查",
children: [
{ id: "field-survey", name: "田间调查" }
]
},
{
id: "disposal", name: "除害处理",
children: [
{ id: "disposal-list", name: "除害处理" }
]
},
{
id: "isolation", name: "隔离监测",
children: [
{ id: "isolation-device", name: "设备总览" },
{ id: "isolation-data", name: "监测数据" }
]
},
{
id: "analysis", name: "风险分析",
children: []
},
{
id: "report", name: "报告管理",
children: []
},
{
id: "settings", name: "系统设置",
children: [
{ id: "settings-users", name: "用户管理" },
{ id: "settings-roles", name: "角色管理" },
{ id: "settings-dict", name: "数据字典" },
{ id: "settings-log", name: "操作日志" }
]
}
];
const loadRoles = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch (e) { /* ignore */ }
return JSON.parse(JSON.stringify(defaultRoles));
};
const saveRoles = (list) => localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
const roles = ref(loadRoles());
const filterForm = ref({ name: "", code: "", status: "" });
const page = ref(1);
const pageSize = 10;
const jumpPage = ref("");
const filteredRoles = computed(() => {
return roles.value.filter((r) => {
if (filterForm.value.name && !r.name.includes(filterForm.value.name)) return false;
if (filterForm.value.code && !r.code.toLowerCase().includes(filterForm.value.code.toLowerCase())) return false;
if (filterForm.value.status && r.status !== filterForm.value.status) return false;
return true;
});
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredRoles.value.length / pageSize)));
const pagedRoles = computed(() => {
const start = (page.value - 1) * pageSize;
return filteredRoles.value.slice(start, start + pageSize);
});
const pageList = computed(() => {
const total = pageCount.value, cur = page.value;
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
if (cur <= 4) return [1, 2, 3, 4, 5, "...", total];
if (cur >= total - 3) return [1, "...", total - 4, total - 3, total - 2, total - 1, total];
return [1, "...", cur - 1, cur, cur + 1, "...", total];
});
const goJump = () => {
const n = parseInt(jumpPage.value, 10);
if (!isNaN(n) && n >= 1 && n <= pageCount.value) page.value = n;
jumpPage.value = "";
};
const onQuery = () => { page.value = 1; };
const onReset = () => {
filterForm.value = { name: "", code: "", status: "" };
page.value = 1;
};
const dialogVisible = ref(false);
const mode = ref("view");
const form = ref({});
const dialogTitle = computed(() => (mode.value === "add" ? "新增角色" : mode.value === "edit" ? "编辑角色" : "查看角色"));
const resetForm = () => {
form.value = { id: null, name: "", code: "", dataScope: "全部数据", desc: "", userCount: 0, status: "启用", createTime: "", perms: [] };
};
const onView = (r) => { mode.value = "view"; form.value = { ...r }; dialogVisible.value = true; };
const onEdit = (r) => { mode.value = "edit"; form.value = { ...r }; dialogVisible.value = true; };
const onAdd = () => { mode.value = "add"; resetForm(); dialogVisible.value = true; };
const onSubmit = () => {
if (!form.value.name || !form.value.code || !form.value.dataScope) {
ElMessage.warning("请填写完整的必填项");
return;
}
if (mode.value === "add") {
const newId = Math.max(...roles.value.map((r) => r.id), 0) + 1;
roles.value.unshift({
...form.value,
id: newId,
userCount: 0,
createTime: new Date().toISOString().replace("T", " ").slice(0, 19)
});
ElMessage.success("新增成功");
} else {
const idx = roles.value.findIndex((r) => r.id === form.value.id);
if (idx !== -1) roles.value[idx] = { ...form.value };
ElMessage.success("保存成功");
}
saveRoles(roles.value);
dialogVisible.value = false;
};
const onDialogClose = () => { form.value = {}; };
const onDelete = (r) => {
ElMessageBox.confirm(`确定删除角色「${r.name}」吗?`, "删除提示", { type: "warning" })
.then(() => {
roles.value = roles.value.filter((x) => x.id !== r.id);
saveRoles(roles.value);
ElMessage.success("删除成功");
})
.catch(() => {});
};
const statusConfirmVisible = ref(false);
const statusConfirmData = ref({ role: null, nextStatus: "", rollback: null });
const onStatusChange = (r, next) => {
const targetStatus = next;
statusConfirmData.value = { role: r, nextStatus: targetStatus, rollback: r.status };
r.status = targetStatus;
statusConfirmVisible.value = true;
};
const onStatusConfirmCancel = () => {
const { role, rollback } = statusConfirmData.value;
if (role && rollback) role.status = rollback;
statusConfirmVisible.value = false;
};
const onStatusConfirmOk = () => {
const { role } = statusConfirmData.value;
saveRoles(roles.value);
ElMessage.success(`已${role.status === "启用" ? "启用" : "禁用"}「${role.name}」`);
statusConfirmVisible.value = false;
};
// 分配权限
const assignVisible = ref(false);
const currentRole = ref(null);
const assignTreeRef = ref(null);
const onAssign = (r) => {
currentRole.value = r;
assignVisible.value = true;
};
const onAssignSubmit = () => {
const checked = assignTreeRef.value.getCheckedKeys();
const halfChecked = assignTreeRef.value.getHalfCheckedKeys();
const allKeys = [...checked, ...halfChecked];
const idx = roles.value.findIndex((r) => r.id === currentRole.value.id);
if (idx !== -1) {
roles.value[idx].perms = allKeys;
saveRoles(roles.value);
ElMessage.success(`已保存「${currentRole.value.name}」的权限`);
}
assignVisible.value = false;
};
onMounted(() => {
roles.value = loadRoles();
});
</script> </script>
<style scoped>
.page-role { padding: 18px 20px; }
.filter-row {
background: #fff;
padding: 14px 16px;
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: center;
border: 1px solid #e6e8ec;
border-radius: 4px;
margin-bottom: 14px;
}
.filter-item { display: flex; align-items: center; gap: 6px; }
.filter-label { font-size: 13px; color: #333; white-space: nowrap; flex-shrink: 0; }
.filter-item :deep(.el-input), .filter-item :deep(.el-select) { width: 200px; }
.filter-actions { margin-left: auto; display: flex; gap: 8px; }
.table-card { background: #fff; border: 1px solid #e6e8ec; border-radius: 4px; padding: 8px 0; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; text-align: center; }
.data-table th { background: #fafafa; color: #333; font-weight: 600; }
.data-table td { color: #333; }
.data-table tr:hover td { background: #f9fafc; }
.empty { text-align: center; color: #999; padding: 24px 0; }
.op-link { color: #333; margin: 0 4px; cursor: pointer; }
.op-link:hover { color: #d71e1e; }
.op-link.danger:hover { color: #d71e1e; }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 6px; padding: 14px 20px; background: #fff; border: 1px solid #ebeef5; border-top: 0; border-radius: 0 0 3px 3px; }
.total { color: #555; font-size: 14px; margin-right: 12px; }
.page-btn { background: #fff; border: 1px solid #dcdfe6; color: #555; height: 28px; min-width: 28px; padding: 0 8px; font-size: 14px; cursor: pointer; border-radius: 3px; }
.page-btn:hover:not(:disabled) { color: #d71e1e; border-color: #d71e1e; }
.page-btn.active { background: #d71e1e; border-color: #d71e1e; color: #fff; }
.page-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.jump { color: #555; font-size: 14px; margin-left: 6px; }
.jump-input { width: 50px; height: 24px; padding: 0 4px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 13px; text-align: center; margin: 0 4px; }
.status-switch :deep(.el-switch__core) { width: 42px; height: 20px; border-radius: 999px; border: 1px solid #d71e1e; background-color: #fbeaea !important; }
.status-switch :deep(.el-switch__core::after) { width: 16px; height: 16px; background-color: #d71e1e !important; }
.status-switch.is-checked :deep(.el-switch__core) { background-color: #d71e1e !important; border-color: #d71e1e; }
.status-switch.is-checked :deep(.el-switch__core::after) { background-color: #fff !important; }
.status-confirm-dialog :deep(.el-dialog__body) { padding: 16px 20px; }
.status-confirm-body { font-size: 14px; color: #333; line-height: 1.6; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 24px; }
.form-item { display: flex; flex-direction: column; gap: 4px; }
.form-item.full { grid-column: span 2; }
.form-label { font-size: 13px; color: #333; }
.form-label.required::before { content: "*"; color: #d71e1e; margin-right: 2px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; }
.perm-tree { border: 1px solid #ebeef5; border-radius: 4px; padding: 10px; max-height: 240px; overflow: auto; background: #fafafa; }
.assign-body { display: flex; flex-direction: column; gap: 8px; }
.assign-tip { font-size: 13px; color: #666; }
</style>
\ No newline at end of file
<template> <template>
<PagePlaceholder title="用户管理" desc="系统用户与账号管理开发中" icon="User" /> <div class="page-user">
<div class="filter-row">
<div class="filter-line">
<div class="filter-item">
<span class="filter-label">登录名</span>
<el-input v-model="filterForm.account" placeholder="请输入登录名" clearable size="default" />
</div>
<div class="filter-item">
<span class="filter-label">用户姓名</span>
<el-input v-model="filterForm.name" placeholder="请输入用户姓名" clearable size="default" />
</div>
<div class="filter-item">
<span class="filter-label">用户类型</span>
<el-select v-model="filterForm.userType" placeholder="请选择用户类型" clearable size="default">
<el-option label="甘肃省植保植检站用户" value="甘肃省植保植检站用户" />
<el-option label="甘州区农业技术推广中心用户" value="甘州区农业技术推广中心用户" />
<el-option label="检疫员" value="检疫员" />
<el-option label="实验检验员" value="实验检验员" />
</el-select>
</div>
</div>
<div class="filter-line">
<div class="filter-item">
<span class="filter-label">角色名称</span>
<el-select v-model="filterForm.roleName" placeholder="请选择角色" clearable size="default">
<el-option label="植保站管理人员" value="植保站管理人员" />
<el-option label="隔离中心管理人员" value="隔离中心管理人员" />
<el-option label="检疫员" value="检疫员" />
<el-option label="实验员" value="实验员" />
</el-select>
</div>
<div class="filter-actions-left">
<el-button type="danger" @click="onQuery">查询</el-button>
<el-button @click="onReset">重置</el-button>
</div>
<div class="filter-actions-right">
<el-button type="danger" @click="onAdd">+ 新增用户</el-button>
</div>
</div>
</div>
<div class="table-card">
<table class="data-table">
<colgroup>
<col style="width: 60px" />
<col style="width: 130px" />
<col style="width: 100px" />
<col style="width: 130px" />
<col style="width: 130px" />
<col style="width: 130px" />
<col style="width: 180px" />
<col style="width: 90px" />
<col style="width: 170px" />
<col style="width: 180px" />
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>登录名</th>
<th>用户姓名</th>
<th>用户类型</th>
<th>角色名称</th>
<th>手机号</th>
<th>邮箱</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(u, i) in pagedUsers" :key="u.id">
<td>{{ (page - 1) * pageSize + i + 1 }}</td>
<td>{{ u.account }}</td>
<td>{{ u.name }}</td>
<td>{{ u.userType }}</td>
<td>{{ u.roleName }}</td>
<td>{{ u.phone }}</td>
<td>{{ u.email }}</td>
<td>
<el-switch
v-model="u.status"
active-value="启用"
inactive-value="禁用"
active-color="#d71e1e"
inactive-color="#d71e1e"
class="status-switch"
@change="onStatusChange(u, $event)"
/>
</td>
<td>{{ u.createTime }}</td>
<td>
<a class="op-link" @click="onView(u)">查看</a>
<a class="op-link" @click="onEdit(u)">编辑</a>
<a class="op-link" @click="onResetPwd(u)">重置密码</a>
<a class="op-link danger" @click="onDelete(u)">删除</a>
</td>
</tr>
<tr v-if="pagedUsers.length === 0">
<td colspan="10" class="empty">暂无数据</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span class="total" style="margin-right:auto;">共 {{ filteredUsers.length }} 条记录</span>
<button class="page-btn" :disabled="page === 1" @click="page = 1">首页</button>
<button class="page-btn" :disabled="page === 1" @click="page--">上一页</button>
<button v-for="p in pageList" :key="p" class="page-btn" :class="{ active: p === page }" @click="typeof p === 'number' && (page = p)">{{ p }}</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page++">下一页</button>
<button class="page-btn" :disabled="page >= pageCount" @click="page = pageCount">末页</button>
<span class="jump">跳转至 <input class="jump-input" v-model.number="jumpPage" placeholder="1" @keyup.enter="goJump" /> 页</span>
</div>
</div>
<!-- 状态切换确认弹窗 -->
<el-dialog
v-model="statusConfirmVisible"
title="提示"
width="420px"
:show-close="true"
:close-on-click-modal="false"
class="status-confirm-dialog"
>
<div class="status-confirm-body">
是否修改用户状态为{{ statusConfirmData.nextStatus === '启用' ? '启动' : '禁用' }}?
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="onStatusConfirmCancel">取消</el-button>
<el-button type="danger" @click="onStatusConfirmOk">确认</el-button>
</div>
</template>
</el-dialog>
<!-- 查看 / 编辑 / 新增 弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" :close-on-click-modal="false" @close="onDialogClose">
<div class="form-grid">
<div class="form-item">
<label class="form-label required">账号</label>
<el-input v-model="form.account" :disabled="mode === 'view'" placeholder="请输入账号" />
</div>
<div class="form-item">
<label class="form-label required">姓名</label>
<el-input v-model="form.name" :disabled="mode === 'view'" placeholder="请输入姓名" />
</div>
<div class="form-item">
<label class="form-label required">用户类型</label>
<el-select v-model="form.userType" :disabled="mode === 'view'" placeholder="请选择用户类型" style="width:100%">
<el-option label="甘肃省植保植检站用户" value="甘肃省植保植检站用户" />
<el-option label="甘州区农业技术推广中心用户" value="甘州区农业技术推广中心用户" />
<el-option label="检疫员" value="检疫员" />
<el-option label="实验检验员" value="实验检验员" />
</el-select>
</div>
<div class="form-item">
<label class="form-label required">角色名称</label>
<el-select v-model="form.roleName" :disabled="mode === 'view'" placeholder="请选择角色" style="width:100%">
<el-option label="植保站管理人员" value="植保站管理人员" />
<el-option label="隔离中心管理人员" value="隔离中心管理人员" />
<el-option label="检疫员" value="检疫员" />
<el-option label="实验员" value="实验员" />
</el-select>
</div>
<div class="form-item">
<label class="form-label required">手机号</label>
<el-input v-model="form.phone" :disabled="mode === 'view'" placeholder="请输入手机号" />
</div>
<div class="form-item">
<label class="form-label">邮箱</label>
<el-input v-model="form.email" :disabled="mode === 'view'" placeholder="请输入邮箱" />
</div>
<div class="form-item">
<label class="form-label">是否锁定</label>
<el-select v-model="form.locked" :disabled="mode === 'view'" placeholder="请选择" style="width:100%">
<el-option label="否" :value="false" />
<el-option label="是" :value="true" />
</el-select>
</div>
<div class="form-item">
<label class="form-label">状态</label>
<el-select v-model="form.status" :disabled="mode === 'view'" placeholder="请选择" style="width:100%">
<el-option label="启用" value="启用" />
<el-option label="禁用" value="禁用" />
</el-select>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">{{ mode === 'view' ? '关闭' : '取消' }}</el-button>
<el-button v-if="mode !== 'view'" type="primary" @click="onSubmit">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template> </template>
<script setup> <script setup>
import PagePlaceholder from "@/components/PagePlaceholder.vue"; import { ref, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
const STORAGE_KEY = "settings_users";
const defaultUsers = [
{ id: 1, account: "gs_admin", name: "张文博", userType: "甘肃省植保植检站用户", roleName: "植保站管理人员", phone: "13919000001", email: "zhangwb@gszbzjz.cn", locked: false, status: "启用", creator: "系统初始化", createTime: "2026-01-01 09:00:00" },
{ id: 2, account: "gz_admin", name: "王俊凯", userType: "甘州区农业技术推广中心用户", roleName: "隔离中心管理人员", phone: "13919000002", email: "wangk@gznyzx.cn", locked: false, status: "启用", creator: "系统初始化", createTime: "2026-01-02 10:00:00" },
{ id: 3, account: "quar_001", name: "李建华", userType: "检疫员", roleName: "检疫员", phone: "13919000003", email: "lijh@gszbzjz.cn", locked: false, status: "启用", creator: "张文博", createTime: "2026-02-15 14:20:00" },
{ id: 4, account: "quar_002", name: "赵晓萌", userType: "检疫员", roleName: "检疫员", phone: "13919000004", email: "zhaoxm@gszbzjz.cn", locked: true, status: "禁用", creator: "张文博", createTime: "2026-02-20 11:30:00" },
{ id: 5, account: "lab_001", name: "孙亚萍", userType: "实验检验员", roleName: "实验员", phone: "13919000005", email: "sunyp@gszbzjz.cn", locked: false, status: "启用", creator: "王俊凯", createTime: "2026-03-08 16:45:00" },
{ id: 6, account: "lab_002", name: "周磊", userType: "实验检验员", roleName: "实验员", phone: "13919000006", email: "zhoulei@gszbzjz.cn", locked: false, status: "启用", creator: "王俊凯", createTime: "2026-03-10 09:15:00" }
];
const loadUsers = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch (e) { /* ignore */ }
return JSON.parse(JSON.stringify(defaultUsers));
};
const saveUsers = (list) => localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
const users = ref(loadUsers());
const filterForm = ref({ account: "", name: "", userType: "", roleName: "" });
const page = ref(1);
const pageSize = 10;
const jumpPage = ref("");
const filteredUsers = computed(() => {
return users.value.filter((u) => {
if (filterForm.value.account && !u.account.includes(filterForm.value.account)) return false;
if (filterForm.value.name && !u.name.includes(filterForm.value.name)) return false;
if (filterForm.value.userType && u.userType !== filterForm.value.userType) return false;
if (filterForm.value.roleName && u.roleName !== filterForm.value.roleName) return false;
return true;
});
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredUsers.value.length / pageSize)));
const pagedUsers = computed(() => {
const start = (page.value - 1) * pageSize;
return filteredUsers.value.slice(start, start + pageSize);
});
const pageList = computed(() => {
const total = pageCount.value, cur = page.value;
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
if (cur <= 4) return [1, 2, 3, 4, 5, "...", total];
if (cur >= total - 3) return [1, "...", total - 4, total - 3, total - 2, total - 1, total];
return [1, "...", cur - 1, cur, cur + 1, "...", total];
});
const goJump = () => {
const n = parseInt(jumpPage.value, 10);
if (!isNaN(n) && n >= 1 && n <= pageCount.value) page.value = n;
jumpPage.value = "";
};
const onQuery = () => { page.value = 1; };
const onReset = () => {
filterForm.value = { account: "", name: "", userType: "", roleName: "" };
page.value = 1;
};
const dialogVisible = ref(false);
const mode = ref("view");
const form = ref({});
const dialogTitle = computed(() => (mode.value === "add" ? "新增用户" : mode.value === "edit" ? "编辑用户" : "查看用户"));
const resetForm = () => {
form.value = { id: null, account: "", name: "", userType: "", roleName: "", phone: "", email: "", locked: false, status: "启用", creator: "系统初始化", createTime: "" };
};
const onView = (u) => { mode.value = "view"; form.value = { ...u }; dialogVisible.value = true; };
const onEdit = (u) => { mode.value = "edit"; form.value = { ...u }; dialogVisible.value = true; };
const onAdd = () => { mode.value = "add"; resetForm(); dialogVisible.value = true; };
const onSubmit = () => {
if (!form.value.account || !form.value.name || !form.value.userType || !form.value.roleName || !form.value.phone) {
ElMessage.warning("请填写完整的必填项");
return;
}
if (mode.value === "add") {
const newId = Math.max(...users.value.map((u) => u.id), 0) + 1;
users.value.unshift({
...form.value,
id: newId,
creator: "当前管理员",
createTime: new Date().toISOString().replace("T", " ").slice(0, 19)
});
ElMessage.success("新增成功");
} else {
const idx = users.value.findIndex((u) => u.id === form.value.id);
if (idx !== -1) users.value[idx] = { ...form.value };
ElMessage.success("保存成功");
}
saveUsers(users.value);
dialogVisible.value = false;
};
const onDialogClose = () => { form.value = {}; };
const onResetPwd = (u) => {
ElMessageBox.confirm(`确定将用户「${u.name}」的密码重置为 123456 吗?`, "重置密码", { type: "warning" })
.then(() => ElMessage.success(`已重置「${u.name}」的密码`))
.catch(() => {});
};
const onDelete = (u) => {
ElMessageBox.confirm(`确定删除用户「${u.name}」吗?`, "删除提示", { type: "warning" })
.then(() => {
users.value = users.value.filter((x) => x.id !== u.id);
saveUsers(users.value);
ElMessage.success("删除成功");
})
.catch(() => {});
};
const statusConfirmVisible = ref(false);
const statusConfirmData = ref({ user: null, nextStatus: "", rollback: null });
const onStatusChange = (u, next) => {
const targetStatus = next;
statusConfirmData.value = { user: u, nextStatus: targetStatus, rollback: u.status };
u.status = targetStatus;
statusConfirmVisible.value = true;
};
const onStatusConfirmCancel = () => {
const { user, rollback } = statusConfirmData.value;
if (user && rollback) user.status = rollback;
statusConfirmVisible.value = false;
};
const onStatusConfirmOk = () => {
const { user } = statusConfirmData.value;
saveUsers(users.value);
ElMessage.success(`已${user.status === "启用" ? "启用" : "禁用"}「${user.name}」`);
statusConfirmVisible.value = false;
};
onMounted(() => {
// 兼容外部修改
users.value = loadUsers();
});
</script> </script>
<style scoped>
.page-user { padding: 18px 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.page-title { font-size: 18px; font-weight: 600; color: #1f3a5f; }
.filter-row {
background: #fff;
padding: 14px 16px;
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: center;
border: 1px solid #e6e8ec;
border-radius: 4px;
margin-bottom: 14px;
}
.filter-item { display: flex; align-items: center; gap: 6px; }
.filter-label { font-size: 13px; color: #333; white-space: nowrap; flex-shrink: 0; }
.filter-item :deep(.el-input), .filter-item :deep(.el-select) { width: 180px; }
.filter-actions { margin-left: auto; display: flex; gap: 8px; }
.filter-line { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; width: 100%; }
.filter-line + .filter-line { margin-top: 12px; }
.filter-actions-left { display: flex; align-items: center; gap: 8px; }
.filter-actions-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.table-card { background: #fff; border: 1px solid #e6e8ec; border-radius: 4px; padding: 8px 0; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; text-align: center; }
.data-table th { background: #fafafa; color: #333; font-weight: 600; }
.data-table td { color: #333; }
.data-table tr:hover td { background: #f9fafc; }
.empty { text-align: center; color: #999; padding: 24px 0; }
.op-link { color: #333; margin: 0 4px; cursor: pointer; }
.op-link:hover { color: #d71e1e; }
.op-link.danger:hover { color: #d71e1e; }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 6px; padding: 14px 20px; background: #fff; border: 1px solid #ebeef5; border-top: 0; border-radius: 0 0 3px 3px; }
.total { color: #555; font-size: 14px; margin-right: 12px; }
.page-btn { background: #fff; border: 1px solid #dcdfe6; color: #555; height: 28px; min-width: 28px; padding: 0 8px; font-size: 14px; cursor: pointer; border-radius: 3px; }
.page-btn:hover:not(:disabled) { color: #d71e1e; border-color: #d71e1e; }
.page-btn.active { background: #d71e1e; border-color: #d71e1e; color: #fff; }
.page-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.jump { color: #555; font-size: 14px; margin-left: 6px; }
.jump-input { width: 50px; height: 24px; padding: 0 4px; border: 1px solid #dcdfe6; border-radius: 3px; font-size: 13px; text-align: center; margin: 0 4px; }
.status-switch :deep(.el-switch__core) { width: 42px; height: 20px; border-radius: 999px; border: 1px solid #d71e1e; background-color: #fbeaea !important; }
.status-switch :deep(.el-switch__core::after) { width: 16px; height: 16px; background-color: #d71e1e !important; }
.status-switch.is-checked :deep(.el-switch__core) { background-color: #d71e1e !important; border-color: #d71e1e; }
.status-switch.is-checked :deep(.el-switch__core::after) { background-color: #fff !important; }
.status-confirm-dialog :deep(.el-dialog__body) { padding: 16px 20px; }
.status-confirm-body { font-size: 14px; color: #333; line-height: 1.6; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 24px; }
.form-item { display: flex; flex-direction: column; gap: 4px; }
.form-label { font-size: 13px; color: #333; }
.form-label.required::before { content: "*"; color: #d71e1e; margin-right: 2px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; }
</style>
\ No newline at end of file
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论