2026-06-26 17:39:57 +08:00
|
|
|
<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";
|
2026-06-30 18:21:30 +08:00
|
|
|
|
2026-06-26 17:39:57 +08:00
|
|
|
import baseService from "@/service/baseService";
|
2026-06-30 18:21:30 +08:00
|
|
|
import { getToken } from "@/utils/cache";
|
2026-06-26 17:39:57 +08:00
|
|
|
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}`;
|
2026-06-30 18:21:30 +08:00
|
|
|
const token = getToken();
|
2026-06-26 17:39:57 +08:00
|
|
|
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>
|