项目名称修改,性能优化,结构优化

This commit is contained in:
2026-07-01 11:52:27 +08:00
parent c7114a4381
commit e358a01ee7
35 changed files with 567 additions and 239 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>气象数据 - 管理系统</title>
<title>气象数据平台</title>
<script>
//全局钩子
window.SITE_CONFIG = {
@@ -215,6 +215,8 @@ function handleClearAll(): void {
class="alert-scrollbar"
:class="{ 'is-dragging': isDragging }"
:style="positionStyle"
role="alert"
aria-live="assertive"
@pointerdown="onScrollbarPointerDown"
>
<span class="alert-scrollbar__icon">
@@ -429,6 +431,12 @@ function handleClearAll(): void {
animation: scrollbar-marquee 20s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.alert-scrollbar__text.is-scroll {
animation: none;
}
}
@keyframes scrollbar-marquee {
0% { transform: translateX(0); }
80% { transform: translateX(calc(-100% + 530px)); }
@@ -13,7 +13,7 @@ import { onBeforeUnmount, shallowRef } from "vue";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import { IDomEditor, IEditorConfig } from "@wangeditor/editor";
import app from "@/constants/app";
import { getToken } from "@/utils/cache";
import baseService from "@/service/baseService";
const props = defineProps({
modelValue: {
@@ -43,19 +43,23 @@ const editorRef = shallowRef();
type InsertFnType = (url: string, alt: string, href: string) => void;
// 编辑器配置
// 编辑器配置(使用 customUpload 替代静态 token URL,每次上传时动态获取 token)
const editorConfig: Partial<IEditorConfig> = {
placeholder: props.placeholder,
readOnly: props.disabled,
MENU_CONF: {
uploadImage: {
server: `${app.api}/sys/oss/upload?token=${getToken()}`, // 上传地址
fieldName: "file",
// 自定义插入图片
customInsert(res: any, insertFn: InsertFnType) {
// res 即服务端的返回结果
// 从 res 中找到 url alt href ,然后插图图片
insertFn(res.data.src, "", "");
async customUpload(file: File, insertFn: InsertFnType) {
const formData = new FormData();
formData.append("file", file);
try {
const res: any = await baseService.upload("/sys/oss/upload", formData);
if (res.code === 0 && res.data?.src) {
insertFn(res.data.src, "", "");
}
} catch (e) {
console.error("图片上传失败", e);
}
}
}
}
@@ -34,7 +34,7 @@ function setLastSeenId(id: string): void {
try {
sessionStorage.setItem(SEEN_KEY, id);
} catch {
// 静默失败
console.error("useAlertMarquee: sessionStorage.setItem 失败");
}
}
@@ -129,7 +129,7 @@ async function reconcileAlerts(): Promise<void> {
}
}
} catch {
// 静默失败
console.error("useAlertMarquee: 全量对账失败");
}
}
@@ -162,6 +162,7 @@ function initSse(): void {
token = getToken();
if (!token) return;
} catch {
console.error("useAlertMarquee: 获取token失败,SSE初始化取消");
return;
}
@@ -175,7 +176,7 @@ function initSse(): void {
const alert = JSON.parse(e.data) as AlertMessage;
mergeNewAlerts([alert]);
} catch {
// JSON 解析失败,忽略
console.error("useAlertMarquee: SSE alert 事件解析失败");
}
});
@@ -184,7 +185,7 @@ function initSse(): void {
const { id } = JSON.parse(e.data) as { id: string };
removeAlert(id);
} catch {
// 忽略
console.error("useAlertMarquee: SSE alert-withdrawn 事件解析失败");
}
});
@@ -195,7 +196,7 @@ function initSse(): void {
removeAlert(id);
}
} catch {
// 忽略
console.error("useAlertMarquee: SSE alert-deleted 事件解析失败");
}
});
@@ -227,7 +228,7 @@ function startPollingFallback(): void {
await reconcileAlerts();
}
} catch {
// 静默失败
console.error("useAlertMarquee: 轮询拉取通知失败");
}
}, 10000);
}
@@ -242,7 +243,7 @@ async function startPolling(_intervalMs?: number): Promise<void> {
const data = await fetchNotifications();
mergeNewAlerts(data);
} catch {
// 静默失败
console.error("useAlertMarquee: 启动时拉取存量通知失败");
}
// 尝试建立 SSE 连接(失败则降级为轮询)
+3 -38
View File
@@ -1,15 +1,13 @@
import app from "@/constants/app";
import { EMitt, EThemeSetting } from "@/constants/enum";
import { IObject, IViewHooks, IViewHooksOptions } from "@/types/interface";
import { registerDynamicToRouterAndNext } from "@/router";
import baseService from "@/service/baseService";
import { getToken } from "@/utils/cache";
import emits from "@/utils/emits";
import { getThemeConfigCacheByKey } from "@/utils/theme";
import { checkPermission, getDictLabel } from "@/utils/utils";
import qs from "qs";
import { onActivated, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useRouter } from "vue-router";
import { useAppStore } from "@/store";
import { ElMessage, ElMessageBox } from "element-plus";
@@ -20,7 +18,6 @@ import { ElMessage, ElMessageBox } from "element-plus";
*/
const useView = (props: IViewHooksOptions | IObject): IViewHooks => {
const router = useRouter();
const route = useRoute();
const store = useAppStore();
const defaultOptions: IViewHooksOptions = {
createdIsNeed: true,
@@ -190,13 +187,9 @@ const useView = (props: IViewHooksOptions | IObject): IViewHooks => {
});
});
},
// 导出
// 导出token 通过请求拦截器 Header 传递,不再出现在 URL 中)
exportHandle() {
window.location.href = `${app.api}${state.exportURL}?${qs.stringify({
...state.dataForm,
token: getToken()
})}`;
// baseService.download(state.exportURL, { ...state.dataForm, token: getToken() });
window.location.href = `${app.api}${state.exportURL}?${qs.stringify(state.dataForm)}`;
},
//关闭当前窗口
closeCurrentTab() {
@@ -205,34 +198,6 @@ const useView = (props: IViewHooksOptions | IObject): IViewHooks => {
} else {
router.replace("/home");
}
},
// 处理流程路由
handleFlowRoute(data: IObject) {
const routeParams = {
path: `/flow/task-form`,
query: {
taskId: data.taskId,
processInstanceId: data.processInstanceId,
processDefinitionId: data.processDefinitionId,
showType: "taskHandle",
_mt: `${route.meta.title} - ${data.processDefinitionName}`
}
};
registerDynamicToRouterAndNext(routeParams);
},
// 查看流程详情
flowDetailRoute(data: IObject) {
const routeParams = {
path: `/flow/task-form`,
query: {
taskId: data.taskId,
processInstanceId: data.processInstanceId,
processDefinitionId: data.processDefinitionId,
showType: "detail",
_mt: `${route.meta.title} - ${data.processDefinitionName}`
}
};
registerDynamicToRouterAndNext(routeParams);
}
};
@@ -7,7 +7,7 @@ import { computed, defineComponent, reactive } from "vue";
import { useAppStore } from "@/store";
import { useAlertMarquee } from "@/composables/useAlertMarquee";
import { useImportTaskStore } from "@/store/importTasks";
import { Bell } from "@element-plus/icons-vue";
import {Bell, Message} from "@element-plus/icons-vue";
import BaseSidebar from "../sidebar/base-sidebar.vue";
import Breadcrumb from "./breadcrumb.vue";
import CollapseSidebarBtn from "./collapse-sidebar-btn.vue";
@@ -21,7 +21,7 @@ import "@/assets/css/header.less";
*/
export default defineComponent({
name: "Header",
components: { BaseSidebar, Breadcrumb, CollapseSidebarBtn, Expand, HeaderMixNavMenus, Logo },
components: {Bell, Message, BaseSidebar, Breadcrumb, CollapseSidebarBtn, Expand, HeaderMixNavMenus, Logo },
setup() {
const store = useAppStore();
const { messageCount: alertCount, toggleDrawer: toggleNotificationCenter } = useAlertMarquee();
@@ -43,13 +43,13 @@ export default defineComponent({
<template>
<div class="rr-header-ctx">
<div class="rr-header-ctx-logo hidden-xs-only">
<logo :logoUrl="logo" logoName="气象数据管理系统"></logo>
<logo :logoUrl="logo" logoName="气象数据平台"></logo>
</div>
<div class="rr-header-right">
<div class="rr-header-right-left">
<div class="rr-header-right-items rr-header-action" :style="`display:${state.sidebarLayout === ESidebarLayoutEnum.Top ? 'none' : ''}`">
<collapse-sidebar-btn></collapse-sidebar-btn>
<div @click="onRefresh" style="cursor: pointer">
<div role="button" tabindex="0" aria-label="刷新页面" @click="onRefresh" @keydown.enter="onRefresh" style="cursor: pointer">
<div class="el-badge">
<el-icon><refresh-right /></el-icon>
</div>
@@ -60,11 +60,9 @@ export default defineComponent({
<header-mix-nav-menus v-else-if="state.sidebarLayout === ESidebarLayoutEnum.Mix"></header-mix-nav-menus>
<breadcrumb v-else></breadcrumb>
</div>
</div>
<div style="display: flex; align-items: center; flex-shrink: 0">
<div class="rr-header-notify" @click="toggleNotificationCenter">
<div class="rr-header-notify" role="button" tabindex="0" aria-label="通知中心" @click="toggleNotificationCenter" @keydown.enter="toggleNotificationCenter">
<el-badge :value="combinedCount" :hidden="combinedCount === 0">
<el-icon :size="18"><Bell /></el-icon>
<el-icon><Bell /></el-icon>
</el-badge>
</div>
<expand :userName="store.state.user.username"></expand>
@@ -75,17 +73,20 @@ export default defineComponent({
<style scoped>
.rr-header-notify {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
padding: 0 8px;
line-height: 56px;
padding: 0 12px;
color: rgba(255, 255, 255, 0.66);
cursor: pointer;
color: #909399;
transition: color 0.2s;
transition: background 0.2s, color 0.2s;
}
.rr-header-notify:hover {
color: #f56c6c;
color: #fff;
background: rgba(0, 0, 0, 0.08);
}
.rr-header-notify :deep(.el-badge) {
line-height: normal;
}
</style>
+22 -8
View File
@@ -4,7 +4,7 @@ import emits from "@/utils/emits";
import { getThemeConfigCache, getThemeConfigCacheByKey, getThemeConfigToClass } from "@/utils/theme";
import { getValueByKeys } from "@/utils/utils";
import { useMediaQuery } from "@vueuse/core";
import { computed, defineComponent, reactive } from "vue";
import { computed, defineComponent, onBeforeUnmount, reactive } from "vue";
import { RouteRecordRaw, useRouter } from "vue-router";
import { useAppStore } from "@/store";
import BaseHeader from "./header/base-header.vue";
@@ -36,23 +36,37 @@ export default defineComponent({
.concat(isMobile.value ? ["ui-mobile"] : [])
.join(" ")
);
emits.on(EMitt.OnSelectHeaderNavMenusByMixNav, (path) => {
const onSelectHeaderNav = (path: any) => {
state.mixLayoutRoutes = store.state.routes.find((x: RouteRecordRaw) => x.path === path)?.children ?? [];
});
emits.on(EMitt.OnSetTheme, ([type, value]) => {
};
const onSetTheme = ([type, value]: [string, string]) => {
state.themeClass[type] = "ui-" + value;
});
emits.on(EMitt.OnSetNavLayout, (vl) => {
};
const onSetNavLayout = (vl: any) => {
state.sidebarLayout = vl;
state.isShowNav = vl !== ESidebarLayoutEnum.Top;
if (vl === ESidebarLayoutEnum.Mix) {
const currRoute = getValueByKeys(getValueByKeys(router.currentRoute.value.meta, "matched", [])[0], "path", "");
state.mixLayoutRoutes = store.state.routes.find((x: RouteRecordRaw) => x.path === currRoute)?.children ?? [];
}
});
emits.on(EMitt.OnLoading, (vl) => {
};
const onLoading = (vl: boolean) => {
state.loading = vl;
};
emits.on(EMitt.OnSelectHeaderNavMenusByMixNav, onSelectHeaderNav);
emits.on(EMitt.OnSetTheme, onSetTheme);
emits.on(EMitt.OnSetNavLayout, onSetNavLayout);
emits.on(EMitt.OnLoading, onLoading);
// 组件卸载时清理事件监听,避免内存泄漏
onBeforeUnmount(() => {
emits.off(EMitt.OnSelectHeaderNavMenusByMixNav, onSelectHeaderNav);
emits.off(EMitt.OnSetTheme, onSetTheme);
emits.off(EMitt.OnSetNavLayout, onSetNavLayout);
emits.off(EMitt.OnLoading, onLoading);
});
return { state, ESidebarLayoutEnum, containerClassNames };
}
});
-8
View File
@@ -165,12 +165,4 @@ export interface IViewHooks extends IViewHooksOptions, IObject {
* 关闭当前tab页
*/
closeCurrentTab: () => void;
/**
* 处理流程
*/
handleFlowRoute: (e: IObject) => void;
/**
* 查看流程详情
*/
flowDetailRoute: (e: IObject) => void;
}
+2 -1
View File
@@ -67,5 +67,6 @@ export const removeCache = (key: string, isSessionStorage?: boolean): void => {
};
export const getToken = (): string => {
return getCache(CacheToken, {}, {})["token"];
const cache = getCache(CacheToken, {}, {});
return cache?.["token"] ?? "";
};
@@ -1,2 +0,0 @@
// utils/chartBuilder.ts — 已废弃,请使用 composables/useWeatherChart.ts
export {}
+6 -11
View File
@@ -39,14 +39,15 @@ http.interceptors.response.use(
return response;
}
// 错误提示
ElMessage.error(response.data.msg);
// 401 未授权先跳转登录,不弹错误提示
if (response.data.code === 401) {
//自定义业务状态码
redirectLogin();
return Promise.reject(new Error("未授权"));
}
// 其他业务错误提示
ElMessage.error(response.data.msg);
return Promise.reject(new Error(response.data.msg || "Error"));
},
(error) => {
@@ -80,11 +81,5 @@ const redirectLogin = () => {
};
export default (o: AxiosRequestConfig): Promise<IHttpResponse> => {
return new Promise((resolve, reject) => {
http(o)
.then((res) => {
return resolve(res.data);
})
.catch(reject);
});
return http(o).then((res) => res.data);
};
@@ -63,7 +63,7 @@
</span>
<span v-else-if="item.prop === 'dayAvgWindDirection' || item.prop === 'maxWindDirection'">
{{ windDirectionLabel(scope.row[item.prop]) }}
{{ windDirectionLabelLocal(scope.row[item.prop]) }}
</span>
<span v-else>{{ scope.row[item.prop] ?? "--" }}</span>
</template>
@@ -93,20 +93,11 @@ import AddOrUpdate from "./weatherdailydata-add-or-update.vue";
import ImportExcel from "./weatherdailydata-import.vue";
import baseService from "@/service/baseService";
import { useAppStore } from "@/store";
import { windDirectionCode, windDirectionLabel } from "@/utils/windDirection";
const _store = useAppStore();
function _windDirectionCode(deg: any) {
if (deg == null || deg === "" || isNaN(deg)) return null;
const d = Number(deg);
if (d < 0 || d > 360) return null;
return ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor((d + 22.5) / 45) % 8];
}
function windDirectionLabel(deg: any) {
const code = _windDirectionCode(deg);
if (!code) return "—";
const type = _store.state.dicts.find((d: any) => d.dictType === "wind_direction_type");
const entry = type?.dataList?.find((e: any) => e.dictValue === code);
return entry?.dictLabel || code;
function windDirectionLabelLocal(deg: any) {
return windDirectionLabel(deg as number | null | string, _store.state.dicts);
}
const columnGroups = [
@@ -175,21 +166,29 @@ interface WeatherStation {
stationCode: string;
}
const loading = ref(false);
const stationOptions = ref<WeatherStation[]>([]); // 明确这是一个 WeatherStation 对象的数组
const stationOptions = ref<WeatherStation[]>([]);
/** 远程搜索防抖定时器 */
let searchTimer: ReturnType<typeof setTimeout> | null = null;
/**
* 远程搜索区站
* 远程搜索区站300ms 防抖)
*/
const remoteSearchStation = async (keyword: string) => {
loading.value = true;
try {
const res = await baseService.get("/station/weatherstation/list");
if (res.code === 0) {
// TypeScript 自动知道 item 是 WeatherStation 类型
stationOptions.value = res.data.filter((item: { stationName: string | string[]; stationCode: string | string[] }) => !keyword || item.stationName.includes(keyword) || item.stationCode.includes(keyword));
const remoteSearchStation = (keyword: string) => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
loading.value = true;
try {
const res = await baseService.get("/station/weatherstation/list");
if (res.code === 0) {
stationOptions.value = res.data.filter(
(item: { stationName: string | string[]; stationCode: string | string[] }) =>
!keyword || item.stationName.includes(keyword) || item.stationCode.includes(keyword)
);
}
} finally {
loading.value = false;
}
} finally {
loading.value = false;
}
}, 300);
};
// 1. 定义真实的表单数据(保持数组,供 <el-select multiple> 正常绑定)
+1 -2
View File
@@ -2,7 +2,7 @@
<div class="rr-login">
<div class="rr-login-wrap">
<div class="rr-login-left hidden-sm-and-down">
<p class="rr-login-left-title">气象数据管理系统</p>
<p class="rr-login-left-title">气象数据平台</p>
</div>
<div class="rr-login-right">
@@ -43,7 +43,6 @@ import { setCache } from "@/utils/cache";
import { ElMessage } from "element-plus";
import { getUuid, isAlphanumeric } from "@/utils/utils";
import app from "@/constants/app";
import SvgIcon from "@/components/base/svg-icon/index";
import { useAppStore } from "@/store";
import { useRouter } from "vue-router";
@@ -28,7 +28,7 @@
<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" />
<img v-if="blobUrlCache[item.fileId]" :src="blobUrlCache[item.fileId]" loading="lazy" 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>
@@ -50,7 +50,7 @@
<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" />
<img :src="previewBlobUrl" :alt="previewItem.displayName" :style="zoomStyle" class="preview-panel__img" draggable="false" loading="eager" />
</div>
<div v-if="previewItem.content" class="preview-panel__text">{{ previewItem.content }}</div>
</div>
@@ -287,9 +287,11 @@ const onPanEnd = () => {
document.removeEventListener("mouseup", onPanEnd);
};
const transformGroups = (list: DeptFileGroupVO[]) => {
const imageItems: ImageItem[] = [];
/** 跨部门缓存:{ deptId: ImageItem[] },切换 tab 时瞬间命中 */
const deptFileCache = {} as Record<string, ImageItem[]>;
const transformGroups = (list: DeptFileGroupVO[]): ImageItem[] => {
const imageItems: ImageItem[] = [];
list.forEach((group) => {
(group.fileList || []).forEach((file) => {
imageItems.push({
@@ -303,19 +305,33 @@ const transformGroups = (list: DeptFileGroupVO[]) => {
});
});
});
state.imageItems = imageItems;
state.selectedFileIndex = 0;
return imageItems;
};
const loadData = async (deptId: string) => {
// 命中跨部门缓存 → 直接渲染
const cached = deptFileCache[deptId];
if (cached) {
state.imageItems = cached;
state.selectedFileIndex = 0;
selectFile(0);
return;
}
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));
const items = transformGroups((res.data || []) as DeptFileGroupVO[]);
deptFileCache[deptId] = items;
state.imageItems = items;
state.selectedFileIndex = 0;
// 加载当前部门全部图片 blob
selectFile(0);
const preloads = items
.filter((item) => isImageFile(item.type))
.map((item) => loadBlobUrl(item.fileId));
await Promise.allSettled(preloads);
} catch (error: any) {
state.errorMessage = error?.message || "加载实时监测图片失败";
@@ -325,6 +341,22 @@ const loadData = async (deptId: string) => {
}
};
/** 收集部门树中所有叶子节点 ID */
const collectLeafDeptIds = (nodes: DeptNode[]): string[] => {
const ids: string[] = [];
const walk = (list: DeptNode[]) => {
for (const n of list) {
if (n.children?.length) {
walk(n.children);
} else {
ids.push(n.id);
}
}
};
walk(nodes);
return ids;
};
const loadDeptTree = async (): Promise<string | null> => {
try {
const res = await baseService.get("/sys/dept/list");
@@ -359,9 +391,24 @@ const loadDeptTree = async (): Promise<string | null> => {
const selectTab = (levelIdx: number, tab: DeptTab) => {
state.selectedPath = [...state.selectedPath.slice(0, levelIdx), tab.id];
state.imageItems = [];
state.selectedFileIndex = 0;
loadData(tab.id);
state.imageItems = [];
// 跨部门缓存命中 → 瞬间渲染 + 加载全部图片 blob
if (deptFileCache[tab.id]) {
state.imageItems = deptFileCache[tab.id];
state.selectedFileIndex = 0;
state.loading = false;
selectFile(0);
// 确保当前部门全部图片 blob 已加载
const preloads = state.imageItems
.filter((item) => isImageFile(item.type))
.filter((item) => !blobUrlCache[item.fileId])
.map((item) => loadBlobUrl(item.fileId));
Promise.allSettled(preloads);
} else {
loadData(tab.id);
}
};
const refresh = () => {
@@ -463,15 +510,68 @@ const convertToPngAndCopy = (blob: Blob): Promise<void> => {
});
};
const onKeyDown = (e: KeyboardEvent) => {
if (state.imageItems.length <= 1) return;
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
e.preventDefault();
const next = (state.selectedFileIndex + 1) % state.imageItems.length;
selectFile(next);
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
e.preventDefault();
const prev = (state.selectedFileIndex - 1 + state.imageItems.length) % state.imageItems.length;
selectFile(prev);
}
};
onUnmounted(() => {
Object.values(blobUrlCache).forEach((url) => URL.revokeObjectURL(url));
document.removeEventListener("keydown", onKeyDown);
});
onMounted(async () => {
const deptId = await loadDeptTree();
if (deptId !== null) {
loadData(deptId);
// 立即加载首个部门 → 用户看到内容
await loadData(deptId);
// 后台预载所有其他叶子部门的文件列表 → 切换 tab 秒开
preloadAllDepts(deptId);
}
document.addEventListener("keydown", onKeyDown);
});
/** 后台预载所有部门:文件元数据 + 全部图片 blob(排除当前已加载的部门) */
const preloadAllDepts = (currentDeptId: string) => {
const leafIds = collectLeafDeptIds(state.deptTreeData);
const remaining = leafIds.filter((id) => id !== currentDeptId && !deptFileCache[id]);
if (remaining.length === 0) return;
// 逐个部门串行预载:先拉元数据,再拉全部图片 blob
const preloadNext = async () => {
const id = remaining.shift();
if (!id) return;
try {
const res = await baseService.get("/filescan/record/tree", { deptId: id });
const items = transformGroups((res.data || []) as DeptFileGroupVO[]);
deptFileCache[id] = items;
// 预载该部门全部图片 blob
const blobTasks = items
.filter((item) => isImageFile(item.type))
.map((item) => loadBlobUrl(item.fileId));
await Promise.allSettled(blobTasks);
} catch {
// 预载失败静默忽略,用户切换时走正常 loadData 流程
}
// 继续下一个,50ms 间隔避免阻塞主线程
if (remaining.length > 0) {
setTimeout(preloadNext, 50);
}
};
preloadNext();
};
onUnmounted(() => {
Object.values(blobUrlCache).forEach((url) => URL.revokeObjectURL(url));
document.removeEventListener("keydown", onKeyDown);
});
</script>