Files
weather-data/weather-data-ui/src/views/weather/realtime-monitoring.vue
T

816 lines
20 KiB
Vue

<template>
<div class="mod-weather__real-time-monitoring" v-loading="state.loading">
<div class="page-header">
<div>
<h2 class="page-title">气候实时监测</h2>
</div>
<div class="page-header-actions">
<el-tag type="info">层级 {{ state.selectedPath.length }} </el-tag>
<el-tag type="success">文件 {{ state.imageItems.length }}</el-tag>
<el-button type="primary" plain icon="Refresh" @click="refresh">刷新</el-button>
</div>
</div>
<el-alert v-if="state.errorMessage" :title="state.errorMessage" type="error" :closable="false" show-icon class="mb-16" />
<div class="dept-bar" v-if="levelRows.length">
<div class="level-row" v-for="(row, rowIdx) in levelRows" :key="rowIdx">
<span class="level-label" v-if="row.label">{{ row.label }}</span>
<div class="level-tabs">
<div v-for="tab in row.tabs" :key="tab.id" class="level-tab" :class="[{ 'is-active': row.selectedId === tab.id }, `level-${rowIdx}`]" @click="selectTab(rowIdx, tab)">
{{ tab.name }}
</div>
</div>
</div>
</div>
<div class="page-body" v-if="state.imageItems.length">
<aside class="file-list-panel">
<div v-for="(item, index) in state.imageItems" :key="item.anchorId" class="file-list-item" :class="{ 'is-active': state.selectedFileIndex === index }" @click="selectFile(index)">
<div class="file-list-item__thumb">
<img v-if="blobUrlCache[item.fileId]" :src="blobUrlCache[item.fileId]" class="file-list-item__img" />
<span v-else-if="isImageFile(item.type)" class="file-list-item__icon file-list-item__icon--img"></span>
<span v-else-if="isTextFile(item.type)" class="file-list-item__icon file-list-item__icon--txt"></span>
<span v-else class="file-list-item__icon file-list-item__icon--other"></span>
</div>
<span class="file-list-item__name">{{ item.displayName || `文件-${item.fileId}` }}</span>
</div>
</aside>
<section class="preview-panel" v-if="previewItem">
<div class="preview-panel__header">
<h3 class="preview-panel__title">{{ previewItem.displayName || `文件-${previewItem.fileId}` }}</h3>
<div class="preview-panel__actions">
<el-button v-if="fileMode === 'image'" type="primary" plain size="small" :loading="state.copyingId === previewItem.anchorId" @click="copyImage(previewItem)"> 复制图片 </el-button>
<el-button v-else-if="fileMode === 'text'" type="primary" plain size="small" :loading="state.copyingId === previewItem.anchorId" @click="copyText(previewItem)"> 复制全部文本 </el-button>
<el-button v-if="fileMode === 'image'" type="success" plain size="small" @click="downloadFile(previewItem)"> 下载原图 </el-button>
</div>
</div>
<div class="preview-panel__body" v-if="fileMode === 'image'">
<div v-if="!previewBlobUrl && selectedFileId" class="preview-panel__loading">加载中...</div>
<div v-show="previewBlobUrl" class="preview-zoom-area" @wheel.prevent="onWheel" @mousedown="onPanStart" @dblclick="resetZoom">
<img :src="previewBlobUrl" :alt="previewItem.displayName" :style="zoomStyle" class="preview-panel__img" draggable="false" />
</div>
<div v-if="previewItem.content" class="preview-panel__text">{{ previewItem.content }}</div>
</div>
<div class="preview-panel__body" v-else-if="fileMode === 'text'">
<div class="preview-panel__text preview-panel__text--full">{{ previewItem.content || "暂无文本内容" }}</div>
</div>
<div class="preview-panel__body" v-else-if="fileMode === 'unsupported'">
<div class="image-card__unsupported">当前文件类型暂不支持预览</div>
</div>
</section>
<div class="preview-panel preview-panel--empty" v-else>
<el-empty description="请在左侧选择要预览的文件" :image-size="120" />
</div>
</div>
<el-empty v-else-if="!state.loading && !state.errorMessage" description="暂无可展示图片" />
</div>
</template>
<script lang="ts" setup>
import app from "@/constants/app";
import baseService from "@/service/baseService";
import { getToken } from "@/utils/cache";
import { copyToClipboard } from "@/utils/utils";
import { ElMessage } from "element-plus";
import axios from "axios";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
interface DeptNode {
id: string;
name: string;
children?: DeptNode[];
}
interface DeptTab {
id: string;
name: string;
}
interface DeptFileItemVO {
displayName: string;
fileId: string;
type: string;
content: string;
}
interface DeptFileGroupVO {
deptId: string;
deptName: string;
fileList: DeptFileItemVO[];
}
interface ImageItem {
deptId: string;
deptName: string;
displayName: string;
fileId: string;
type: string;
content: string;
anchorId: string;
}
const state = reactive({
loading: true,
errorMessage: "",
imageItems: [] as ImageItem[],
copyingId: "",
deptTreeData: [] as DeptNode[],
selectedPath: [] as string[],
selectedFileIndex: 0
});
const blobUrlCache = {} as Record<string, string>;
const blobPromises = {} as Record<string, Promise<string> | undefined>;
const previewBlobUrl = ref("");
const loadBlobUrl = (fileId: string): Promise<string> => {
if (blobUrlCache[fileId]) return Promise.resolve(blobUrlCache[fileId]);
if (blobPromises[fileId]) return blobPromises[fileId];
const base = (app.api || "").replace(/\/$/, "");
const url = `${base}/filescan/file/display/${fileId}`;
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers.token = token;
const promise = axios
.get(url, { responseType: "blob", headers })
.then((res) => {
const objectUrl = URL.createObjectURL(res.data as Blob);
blobUrlCache[fileId] = objectUrl;
return objectUrl;
})
.finally(() => {
delete blobPromises[fileId];
});
blobPromises[fileId] = promise;
return promise;
};
const activeDeptId = computed(() => {
if (state.selectedPath.length > 0) {
return state.selectedPath[state.selectedPath.length - 1];
}
return null;
});
interface LevelRow {
tabs: DeptTab[];
selectedId: string | null;
label: string;
}
const LEVEL_LABELS = ["一级", "二级", "三级", "四级", "五级", "六级"];
const levelRows = computed<LevelRow[]>(() => {
const rows: LevelRow[] = [];
let nodes: DeptNode[] = state.deptTreeData;
for (let i = 0; i < state.selectedPath.length; i++) {
const selId = state.selectedPath[i];
rows.push({
tabs: nodes.map((n) => ({ id: n.id, name: n.name })),
selectedId: selId,
label: LEVEL_LABELS[i] || `${i + 1}级`
});
const selNode = nodes.find((n) => n.id === selId);
if (selNode?.children?.length) {
nodes = selNode.children;
} else {
nodes = [];
break;
}
}
if (nodes.length > 0) {
rows.push({
tabs: nodes.map((n) => ({ id: n.id, name: n.name })),
selectedId: null,
label: LEVEL_LABELS[rows.length] || `${rows.length + 1}级`
});
}
return rows;
});
const previewItem = computed(() => {
if (state.selectedFileIndex >= 0 && state.selectedFileIndex < state.imageItems.length) {
return state.imageItems[state.selectedFileIndex];
}
return state.imageItems.length > 0 ? state.imageItems[0] : null;
});
const IMAGE_EXTS = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"];
const TEXT_EXTS = ["txt", "md", "log", "csv", "json", "xml", "html", "htm", "yaml", "yml"];
const isImageFile = (type: string) => {
if (!type) return true;
const lower = type.toLowerCase();
return IMAGE_EXTS.some((ext) => lower.includes(ext));
};
const isTextFile = (type: string) => {
if (!type) return false;
const lower = type.toLowerCase();
return TEXT_EXTS.some((ext) => lower.includes(ext));
};
const fileMode = computed(() => {
const item = previewItem.value;
if (!item) return "none";
if (isImageFile(item.type)) return "image";
if (isTextFile(item.type)) return "text";
return "unsupported";
});
const zoomScale = ref(1);
const panX = ref(0);
const panY = ref(0);
const isPanning = ref(false);
let panStartX = 0;
let panStartY = 0;
let panOriginX = 0;
let panOriginY = 0;
const zoomStyle = computed(() => ({
transform: `translate(${panX.value}px, ${panY.value}px) scale(${zoomScale.value})`,
cursor: isPanning.value ? "grabbing" : "grab",
transition: isPanning.value ? "none" : "transform 0.15s ease"
}));
const resetZoom = () => {
zoomScale.value = 1;
panX.value = 0;
panY.value = 0;
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
const newScale = Math.max(0.2, Math.min(5, zoomScale.value + delta));
zoomScale.value = newScale;
if (newScale <= 1) {
panX.value = 0;
panY.value = 0;
}
};
const onPanStart = (e: MouseEvent) => {
if (zoomScale.value <= 1) return;
isPanning.value = true;
panStartX = e.clientX;
panStartY = e.clientY;
panOriginX = panX.value;
panOriginY = panY.value;
document.addEventListener("mousemove", onPanMove);
document.addEventListener("mouseup", onPanEnd);
};
const onPanMove = (e: MouseEvent) => {
if (!isPanning.value) return;
panX.value = panOriginX + (e.clientX - panStartX);
panY.value = panOriginY + (e.clientY - panStartY);
};
const onPanEnd = () => {
isPanning.value = false;
document.removeEventListener("mousemove", onPanMove);
document.removeEventListener("mouseup", onPanEnd);
};
const transformGroups = (list: DeptFileGroupVO[]) => {
const imageItems: ImageItem[] = [];
list.forEach((group) => {
(group.fileList || []).forEach((file) => {
imageItems.push({
deptId: group.deptId,
deptName: group.deptName,
displayName: file.displayName,
fileId: file.fileId,
type: file.type,
content: file.content || "",
anchorId: `img-${file.fileId}`
});
});
});
state.imageItems = imageItems;
state.selectedFileIndex = 0;
};
const loadData = async (deptId: string) => {
state.loading = true;
state.errorMessage = "";
try {
const res = await baseService.get("/filescan/record/tree", { deptId });
transformGroups((res.data || []) as DeptFileGroupVO[]);
const preloads = state.imageItems.filter((item) => isImageFile(item.type)).map((item) => loadBlobUrl(item.fileId));
selectFile(0);
await Promise.allSettled(preloads);
} catch (error: any) {
state.errorMessage = error?.message || "加载实时监测图片失败";
state.imageItems = [];
} finally {
state.loading = false;
}
};
const loadDeptTree = async (): Promise<string | null> => {
try {
const res = await baseService.get("/sys/dept/list");
const treeData = (res.data || []) as DeptNode[];
state.deptTreeData = treeData;
if (!treeData.length) return null;
const firstRoot = treeData[0];
const firstChild = firstRoot.children?.length ? firstRoot.children[0] : firstRoot;
const buildPath = (nodes: DeptNode[], targetId: string, path: string[]): string[] | null => {
for (const node of nodes) {
const newPath = [...path, node.id];
if (node.id === targetId) return newPath;
if (node.children?.length) {
const found = buildPath(node.children, targetId, newPath);
if (found) return found;
}
}
return null;
};
const path = buildPath(treeData, firstChild.id, []);
state.selectedPath = path || [firstRoot.id];
return firstChild.id;
} catch {
state.deptTreeData = [];
return null;
}
};
const selectTab = (levelIdx: number, tab: DeptTab) => {
state.selectedPath = [...state.selectedPath.slice(0, levelIdx), tab.id];
state.imageItems = [];
state.selectedFileIndex = 0;
loadData(tab.id);
};
const refresh = () => {
if (activeDeptId.value !== null) {
loadData(activeDeptId.value);
}
};
const selectedFileId = ref("");
const selectFile = async (index: number) => {
state.selectedFileIndex = index;
const item = state.imageItems[index];
if (!item) return;
selectedFileId.value = item.fileId;
resetZoom();
if (isImageFile(item.type)) {
previewBlobUrl.value = "";
const objectUrl = await loadBlobUrl(item.fileId);
previewBlobUrl.value = objectUrl;
} else {
previewBlobUrl.value = "";
}
};
const downloadFile = async (item: ImageItem) => {
const filename = item.displayName || `file-${item.fileId}`;
await loadBlobUrl(item.fileId);
const a = document.createElement("a");
a.href = blobUrlCache[item.fileId];
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const copyText = (item: ImageItem) => {
state.copyingId = item.anchorId;
copyToClipboard(item.content || "");
ElMessage.success("文本已复制成功");
state.copyingId = "";
};
const copyImage = async (item: ImageItem) => {
if (!isImageFile(item.type)) {
ElMessage.warning("当前文件不是图片类型,无法复制");
return;
}
state.copyingId = item.anchorId;
try {
await loadBlobUrl(item.fileId);
const blobUrl = blobUrlCache[item.fileId];
const res = await fetch(blobUrl);
const blob = await res.blob();
try {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
} catch (e: any) {
if (e.name === "NotAllowedError" || e.message?.includes?.("not supported")) {
await convertToPngAndCopy(blob);
} else {
throw e;
}
}
ElMessage.success("图片已复制成功");
} catch (error: any) {
ElMessage.error(error?.message || "复制图片失败");
} finally {
state.copyingId = "";
}
};
const convertToPngAndCopy = (blob: Blob): Promise<void> => {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("Canvas 不可用"));
ctx.drawImage(img, 0, 0);
canvas.toBlob((pngBlob) => {
if (pngBlob) {
navigator.clipboard.write([new ClipboardItem({ "image/png": pngBlob })]).then(resolve).catch(reject);
} else {
reject(new Error("转 PNG 失败"));
}
}, "image/png");
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("图片加载失败"));
};
img.src = url;
});
};
onUnmounted(() => {
Object.values(blobUrlCache).forEach((url) => URL.revokeObjectURL(url));
});
onMounted(async () => {
const deptId = await loadDeptTree();
if (deptId !== null) {
loadData(deptId);
}
});
</script>
<style scoped>
.mod-weather__real-time-monitoring {
min-height: calc(100vh - 140px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 24px;
color: #303133;
}
.page-desc {
margin: 8px 0 0;
color: #909399;
line-height: 1.6;
}
.page-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.mb-16 {
margin-bottom: 16px;
}
/* ==== 部门选择栏 ==== */
.dept-bar {
margin-bottom: 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px 16px 8px;
}
.level-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.level-row:last-child {
margin-bottom: 4px;
}
.level-label {
font-size: 13px;
color: #909399;
flex-shrink: 0;
min-width: 32px;
}
.level-tabs {
display: flex;
gap: 0;
flex-wrap: wrap;
}
.level-tab {
padding: 4px 16px;
font-size: 13px;
color: #606266;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
cursor: pointer;
user-select: none;
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.level-tab + .level-tab {
margin-left: 6px;
}
.level-tab:hover {
color: var(--el-color-primary);
background: #ecf5ff;
}
.level-tab.is-active {
color: #fff;
font-weight: 500;
}
.level-tab.is-active.level-0 {
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.level-tab.is-active.level-1 {
background: var(--el-color-primary-light-3);
border-color: var(--el-color-primary-light-3);
}
.level-tab.is-active.level-2 {
background: var(--el-color-success);
border-color: var(--el-color-success);
}
.level-tab.is-active.level-3 {
background: var(--el-color-warning);
border-color: var(--el-color-warning);
}
.level-tab.is-active.level-4 {
background: var(--el-color-danger);
border-color: var(--el-color-danger);
}
.level-tab.is-active.level-5 {
background: #8b5cf6;
border-color: #8b5cf6;
}
/* ==== 主体两栏 ==== */
.page-body {
display: grid;
grid-template-columns: 25% minmax(0, 1fr);
gap: 12px;
}
/* ==== 左侧文件列表 ==== */
.file-list-panel {
position: sticky;
top: 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 4px;
max-height: calc(100vh - 320px);
overflow-y: auto;
}
.file-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
font-size: 13px;
color: #606266;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.file-list-item:hover {
background: #f5f7fa;
}
.file-list-item.is-active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 500;
}
.file-list-item__thumb {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 6px;
overflow: hidden;
background: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
}
.file-list-item__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.file-list-item__icon {
font-size: 13px;
font-weight: 600;
color: #fff;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.file-list-item__icon--img {
background: var(--el-color-success);
}
.file-list-item__icon--txt {
background: var(--el-color-warning);
}
.file-list-item__icon--other {
background: #c0c4cc;
}
.file-list-item__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.5;
}
/* ==== 右侧预览面板 ==== */
.preview-panel {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 10px;
padding: 20px;
}
.preview-panel--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
.preview-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.preview-panel__actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.preview-panel__title {
margin: 0;
font-size: 20px;
color: #303133;
word-break: break-all;
}
.preview-panel__body {
display: flex;
flex-direction: column;
gap: 12px;
}
.preview-panel__loading {
text-align: center;
color: #909399;
padding: 60px 0;
font-size: 14px;
}
.preview-zoom-area {
overflow: hidden;
border-radius: 8px;
background: #f5f7fa;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-panel__img {
display: block;
max-width: 100%;
max-height: 60vh;
border-radius: 8px;
user-select: none;
pointer-events: auto;
}
.preview-panel__text--full {
min-height: 200px;
max-height: 60vh;
overflow-y: auto;
}
.preview-panel__text {
font-size: 13px;
color: #606266;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
padding: 12px 14px;
background: #f5f7fa;
border-radius: 8px;
}
.image-card__error,
.image-card__unsupported {
color: #e6a23c;
font-size: 14px;
line-height: 1.6;
padding: 12px 14px;
background: #fdf6ec;
border-radius: 8px;
}
/* ==== 响应式 ==== */
@media (max-width: 992px) {
.page-body {
grid-template-columns: 1fr;
}
.file-list-panel {
position: static;
max-height: 200px;
}
}
@media (max-width: 768px) {
.page-header,
.preview-panel__header,
.preview-panel__actions {
flex-direction: column;
align-items: stretch;
}
.level-tab {
padding: 4px 10px;
font-size: 12px;
}
}
</style>