提交 32bcb7ec 作者: zhongye-dev

feat: 驾驶舱模块优化(页面更名、地图交互与说明浮层)

- 可视化大屏更名为驾驶舱,同步菜单/面包屑/页面标题
- 专题研判「研判视角」去掉「按」字,有害生物名录属性统一为全国农业植物检疫性有害生物
- 修复地图容器隐藏时 geo resize 报错;地图下钻弹窗支持拖动
- 保护区图层圆点改绿、隔离中心图例图标与地图一致
- 全国疫区分布态势标题新增点击式说明浮层

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
上级 1f3a650b
......@@ -38,7 +38,7 @@ export const menuConfig = [
children: [
{ id: "stats-business", name: "业务统计", path: "/stats/business" },
{ id: "stats-analysis", name: "风险分析", path: "/analysis" },
{ id: "stats-dashboard", name: "可视化大屏", path: "/stats/dashboard" }
{ id: "stats-dashboard", name: "驾驶舱", path: "/stats/dashboard" }
]
},
{
......
......@@ -18,7 +18,7 @@ const routes = [
{ path: "/isolation/data", name: "isolation-data", component: () => import("@/views/isolation/MonitorData.vue"), meta: { title: "监测数据", breadcrumb: "隔离监测 / 监测数据" } },
{ path: "/analysis", name: "analysis", component: () => import("@/views/analysis/DataAnalysis.vue"), meta: { title: "风险分析", breadcrumb: "统计分析 / 风险分析" } },
{ path: "/stats/business", name: "stats-business", component: () => import("@/views/stats/BusinessStats.vue"), meta: { title: "业务统计", breadcrumb: "统计分析 / 业务统计" } },
{ path: "/stats/dashboard", name: "stats-dashboard", component: () => import("@/views/stats/Dashboard.vue"), meta: { title: "可视化大屏", breadcrumb: "统计分析 / 可视化大屏" } },
{ path: "/stats/dashboard", name: "stats-dashboard", component: () => import("@/views/stats/Dashboard.vue"), meta: { title: "驾驶舱", breadcrumb: "统计分析 / 驾驶舱" } },
{ path: "/release-cert", name: "release-cert", component: () => import("@/views/release/ReleaseCert.vue"), meta: { title: "放行证书管理", breadcrumb: "放行证书管理" } },
{ path: "/report", name: "report", component: () => import("@/views/report/ReportManage.vue"), meta: { title: "报告管理", breadcrumb: "报告管理" } },
{ path: "/base/quarantine", name: "base-quarantine", component: () => import("@/views/base/Quarantine.vue"), meta: { title: "检疫员管理", breadcrumb: "基础信息管理 / 检疫员管理" } },
......
......@@ -292,7 +292,7 @@ const quicks = [
{ id: 1, name: "样本登记入库", path: "/sample/register", icon: "Box" },
{ id: 2, name: "试种观察", path: "/field/survey", icon: "Document" },
{ id: 3, name: "监测数据", path: "/isolation/data", icon: "DataLine" },
{ id: 4, name: "可视化大屏", path: "/stats/dashboard", icon: "DataAnalysis" }
{ id: 4, name: "驾驶舱", path: "/stats/dashboard", icon: "DataAnalysis" }
];
const todos = [
{ id: 1, title: "28 份样本待入库", path: "/sample/register" },
......
......@@ -4,7 +4,7 @@
<div class="dash-header">
<div class="hd-left">
<span class="hd-deco hd-deco-l"></span>
<h1 class="hd-title">植物检疫检测隔离中心 · 数据可视化大屏</h1>
<h1 class="hd-title">植物检疫检测隔离中心 · 驾驶舱</h1>
<span class="hd-deco hd-deco-r"></span>
</div>
<div class="hd-right">
......@@ -155,6 +155,18 @@
<div class="panel map-panel">
<div class="panel-head">
<span class="panel-title">{{ mapTitle }}</span>
<span class="mc-tip legend-tip" @click.stop="toggleStateTip">?</span>
<Teleport to="body">
<div
v-if="stateTipOpen"
class="state-tip-pop"
:style="{ top: stateTipPos.top + 'px', left: stateTipPos.left + 'px' }"
@click.stop
>
<div class="stp-head">全国疫区分布态势</div>
<p v-for="(t, i) in MAP_STATE_TIP" :key="i">{{ t }}</p>
</div>
</Teleport>
<span class="pest-tag" v-if="pestMode">当前有害生物:{{ pestKey }}</span>
<div class="map-crumbs" v-if="mapStack.length > 1">
<template v-for="(c, i) in mapStack" :key="c.adcode + '-' + i">
......@@ -200,8 +212,8 @@
<span class="lg-item"><span class="lg-dot" style="background:#ffc107"></span>中风险</span>
<span class="lg-item"><span class="lg-dot" style="background:#5ec1ff"></span>低风险</span>
</template>
<span class="lg-item"><span class="lg-dot" style="background:#ffd54f"></span>国家级隔离中心</span>
<span class="lg-item"><span class="lg-dot" style="background:#4fc3f7"></span>区域隔离中心</span>
<span class="lg-item"><span class="lg-pin" style="background:#ffd54f"></span>国家级隔离中心</span>
<span class="lg-item"><span class="lg-diamond" style="background:#4fc3f7"></span>区域隔离中心</span>
</div>
<div class="map-meta">
......@@ -499,8 +511,13 @@
<!-- 地图下钻面板(点击省份打开) -->
<transition name="drill-fade">
<div v-if="drillProvince" class="drill-panel" @click.stop>
<div class="drill-head">
<div
v-if="drillProvince"
class="drill-panel"
@click.stop
:style="drillPos ? { left: drillPos.left + 'px', top: drillPos.top + 'px', right: 'auto', transform: 'none' } : undefined"
>
<div class="drill-head" @mousedown="onDrillDragStart">
<div class="drill-title-row">
<span class="drill-title">{{ drillProvince.name }} · 检疫性有害生物分布</span>
<button class="drill-close" @click="closeDrill" title="关闭">×</button>
......@@ -712,6 +729,7 @@ function refreshTime() {
onMounted(() => {
refreshTime();
timeTimer = setInterval(refreshTime, 30000);
document.addEventListener("click", onDocCloseStateTip);
// 默认进入即全屏状态
document.body.classList.add("dash-fs");
fullscreen.value = true;
......@@ -719,6 +737,8 @@ onMounted(() => {
});
onUnmounted(() => {
clearInterval(timeTimer);
onDrillDragEnd();
document.removeEventListener("click", onDocCloseStateTip);
document.body.classList.remove("dash-fs");
if (mapChart) { mapChart.dispose(); mapChart = null; }
if (taMapChart) { taMapChart.dispose(); taMapChart = null; }
......@@ -1000,6 +1020,27 @@ const mapZoom = ref(1.2);
const defaultZoom = (level) => (level === "country" ? 1.2 : 1);
const RISK_LABEL = { 1: "低风险", 2: "中风险", 3: "高风险", 4: "极高风险" };
// 「全国疫区分布态势」说明(点击标题旁问号弹出)
const MAP_STATE_TIP = [
"以行政区划地图展示各地检疫性有害生物的疫情风险分布。",
"省份底色按风险等级着色:极高风险 ≥ 11 种;高风险 8 ~ 10 种;中风险 5 ~ 7 种;低风险 1 ~ 4 种(按该省/市涉及的检疫性有害生物种类数划分),无分布记录不参与评级。",
"可叠加疫区、保护区、缓冲区、疫情发生点图层,并标注国家级、区域隔离中心点位。",
"点击省份可逐级下钻,查看该地区有害生物分布明细。",
"支持按年份、地区、有害生物、风险等级筛选联动查看。"
];
// 点击问号显示的说明浮层
const stateTipOpen = ref(false);
const stateTipPos = ref({ top: 0, left: 0 });
function toggleStateTip(e) {
if (stateTipOpen.value) { stateTipOpen.value = false; return; }
const r = e.currentTarget.getBoundingClientRect();
stateTipPos.value = { top: r.bottom + 6, left: Math.min(r.left, Math.max(8, window.innerWidth - 400)) };
stateTipOpen.value = true;
}
function onDocCloseStateTip(e) {
if (e.target.closest && e.target.closest(".state-tip-pop")) return;
stateTipOpen.value = false;
}
// 风险等级下拉框文本 → 等级数值,用于地图联动筛选
const RISK_LEVEL_NUM = { "低风险": 1, "中风险": 2, "高风险": 3, "极高风险": 4 };
......@@ -1066,19 +1107,41 @@ function resetMap() {
if (mapChart) updateMapOption();
}
// 安全 resize:容器尺寸为 0(如所在页签隐藏)时跳过,避免 ECharts geo 崩溃
function safeResize(chart, el) {
if (!chart || !el) return;
if (!el.clientWidth || !el.clientHeight) return;
chart.resize();
}
// 等待容器出现非零尺寸,避免 0×0 容器初始化地图时 viewRect 为 null
function waitForSize(el, maxFrames = 60) {
return new Promise((resolve) => {
if (!el) return resolve(false);
let n = 0;
const check = () => {
if (!el.isConnected) return resolve(false);
if (el.clientWidth && el.clientHeight) return resolve(true);
if (++n >= maxFrames) return resolve(false);
requestAnimationFrame(check);
};
check();
});
}
async function initMap() {
if (!mapEl.value) return;
const geo = await loadGeo();
echarts.registerMap("china", geo);
// 等到父容器布局完成后再 init,避免 0×0 容器导致 viewRect 为 null
await nextTick();
await waitForSize(mapEl.value);
if (!mapEl.value) return;
mapChart = echarts.init(mapEl.value, undefined, { renderer: "canvas" });
updateMapOption();
// 连续两次 rAF 后再 resize 一次,确保浏览器布局完成
requestAnimationFrame(() => requestAnimationFrame(() => mapChart && mapChart.resize()));
requestAnimationFrame(() => requestAnimationFrame(() => safeResize(mapChart, mapEl.value)));
// 用 ResizeObserver 精确响应容器尺寸变化(替代 window.resize)
if (typeof ResizeObserver !== "undefined") {
......@@ -1086,13 +1149,13 @@ async function initMap() {
const ro = new ResizeObserver(() => {
if (!mapChart) return;
if (resizeTimer) cancelAnimationFrame(resizeTimer);
resizeTimer = requestAnimationFrame(() => mapChart.resize());
resizeTimer = requestAnimationFrame(() => safeResize(mapChart, mapEl.value));
});
ro.observe(mapEl.value);
// 保存到模块作用域以便卸载
mapEl.value.__ro__ = ro;
} else {
window.addEventListener("resize", () => mapChart && mapChart.resize());
window.addEventListener("resize", () => safeResize(mapChart, mapEl.value));
}
}
......@@ -1357,6 +1420,34 @@ function updateMapOption() {
const drillProvince = ref(null); // 当前打开的分布详情面板
const expandedPests = ref({}); // 每个有害生物的展开状态
// 下钻面板拖动:drillPos 为 null 时使用默认定位,拖动后记录绝对坐标
const drillPos = ref(null);
let drillDrag = null;
function onDrillDragStart(e) {
if (e.button !== 0) return;
if (e.target.closest && e.target.closest(".drill-close")) return;
const panel = e.currentTarget.closest(".drill-panel");
if (!panel) return;
const rect = panel.getBoundingClientRect();
drillDrag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, w: rect.width, h: rect.height };
drillPos.value = { left: rect.left, top: rect.top };
document.addEventListener("mousemove", onDrillDragMove);
document.addEventListener("mouseup", onDrillDragEnd);
e.preventDefault();
}
function onDrillDragMove(e) {
if (!drillDrag) return;
const { dx, dy, w, h } = drillDrag;
const left = Math.max(0, Math.min(e.clientX - dx, window.innerWidth - w));
const top = Math.max(0, Math.min(e.clientY - dy, window.innerHeight - h));
drillPos.value = { left, top };
}
function onDrillDragEnd() {
drillDrag = null;
document.removeEventListener("mousemove", onDrillDragMove);
document.removeEventListener("mouseup", onDrillDragEnd);
}
function openPanel(o) {
drillProvince.value = {
level: o.level,
......@@ -1369,6 +1460,7 @@ function openPanel(o) {
pests: o.pests
};
expandedPests.value = {};
drillPos.value = null;
}
function panelSummary(pests) {
......@@ -1534,6 +1626,8 @@ async function drillFromCenter() {
function closeDrill() {
drillProvince.value = null;
expandedPests.value = {};
drillPos.value = null;
onDrillDragEnd();
}
function togglePest(idx) {
......@@ -1543,7 +1637,7 @@ function togglePest(idx) {
// ====== 图层切换 ======
const layers = [
{ key: "epidemic", label: "疫区", color: "#f44336" },
{ key: "protect", label: "保护区", color: "#00e5ff" },
{ key: "protect", label: "保护区", color: "#2ecc71" },
{ key: "buffer", label: "缓冲区", color: "#26c6da" },
{ key: "hot", label: "疫情发生点", color: "#ff5722" }
];
......@@ -1604,12 +1698,8 @@ const TA_PORTS = [
{ name: "广州南沙港", lng: 113.53, lat: 22.8 }, { name: "天津港", lng: 117.65, lat: 39.03 },
{ name: "深圳盐田港", lng: 114.24, lat: 22.56 }, { name: "宁波舟山港", lng: 121.87, lat: 29.94 }
];
// 名录属性:按类群归属名录
const TA_LIST_NAME = {
昆虫: "《进境植物检疫性有害生物名录》", 线虫: "《进境植物检疫性有害生物名录》",
真菌: "《全国农业植物检疫性有害生物名录》", 细菌: "《全国农业植物检疫性有害生物名录》",
病毒: "《进境植物检疫性有害生物名录》", 杂草: "《全国农业植物检疫性有害生物名录》"
};
// 名录属性:菜豆象~假高粱(32 种)统一归属全国农业植物检疫性有害生物
const TA_LIST_NAME = "全国农业植物检疫性有害生物";
// 确定性伪随机:同一(视角+对象+年份)结果稳定可复现
function taSeed(str) {
......@@ -1630,7 +1720,7 @@ const taObject = computed(() => {
return [
{ label: "中文名", value: p.name || "-" },
{ label: "学名", value: p.latin || "-", latin: true },
{ label: "名录属性", value: TA_LIST_NAME[p.category] || "《进境植物检疫性有害生物名录》" },
{ label: "名录属性", value: TA_LIST_NAME },
{ label: "类群", value: p.category || "-" }
];
}
......@@ -1965,6 +2055,7 @@ async function initTaMap() {
const geo = await loadWorldGeo();
if (geo) echarts.registerMap("world", geo);
await nextTick();
await waitForSize(taMapEl.value);
if (!taMapEl.value) return;
if (!taMapChart) {
taMapChart = echarts.init(taMapEl.value, undefined, { renderer: "canvas" });
......@@ -1975,13 +2066,13 @@ async function initTaMap() {
});
}
if (geo) taMapChart.setOption(taMapOption(), true);
requestAnimationFrame(() => requestAnimationFrame(() => taMapChart && taMapChart.resize()));
requestAnimationFrame(() => requestAnimationFrame(() => safeResize(taMapChart, taMapEl.value)));
if (typeof ResizeObserver !== "undefined" && !taMapEl.value.__taRo__) {
let t = null;
const ro = new ResizeObserver(() => {
if (!taMapChart) return;
if (t) cancelAnimationFrame(t);
t = requestAnimationFrame(() => taMapChart.resize());
t = requestAnimationFrame(() => safeResize(taMapChart, taMapEl.value));
});
ro.observe(taMapEl.value);
taMapEl.value.__taRo__ = ro;
......@@ -2441,6 +2532,28 @@ function formatNum(n) {
.lg-title { font-size: 11px; color: #86d9ff; }
.lg-item { display: flex; align-items: center; gap: 4px; font-size: 11px; }
.lg-dot { width: 8px; height: 8px; border-radius: 50%; }
.lg-pin { width: 9px; height: 9px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); }
.lg-diamond { width: 8px; height: 8px; border-radius: 1px; transform: rotate(45deg); }
.legend-tip { margin-left: 0; }
/* 标题旁问号的说明浮层 */
.state-tip-pop {
position: fixed;
z-index: 3000;
width: 380px;
max-width: calc(100vw - 16px);
padding: 10px 12px;
background: linear-gradient(180deg, rgba(0, 30, 60, 0.97), rgba(0, 20, 50, 0.97));
border: 1px solid rgba(0, 229, 255, 0.45);
border-radius: 4px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 12px rgba(0, 229, 255, 0.2);
color: #cfe3ff;
font-size: 12px;
line-height: 1.7;
backdrop-filter: blur(6px);
}
.stp-head { font-weight: 700; color: #fff; font-size: 13px; margin-bottom: 6px; letter-spacing: 0.5px; }
.state-tip-pop p { margin: 0 0 4px; }
.state-tip-pop p:last-child { margin-bottom: 0; }
.map-meta {
display: flex; justify-content: space-between; padding: 4px 8px;
......@@ -2612,6 +2725,8 @@ function formatNum(n) {
padding: 10px 14px 8px;
border-bottom: 1px solid rgba(0, 229, 255, 0.2);
background: rgba(0, 50, 100, 0.3);
cursor: move;
user-select: none;
}
.drill-title-row { display: flex; align-items: center; gap: 8px; }
.drill-title {
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论