前端部分更新
This commit is contained in:
@@ -0,0 +1,815 @@
|
||||
<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 { CacheToken } from "@/constants/cacheKey";
|
||||
import baseService from "@/service/baseService";
|
||||
import { getCache } 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 = getCache(CacheToken, { isSessionStorage: true }, {})?.token;
|
||||
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>
|
||||
Reference in New Issue
Block a user