项目名称修改,性能优化,结构优化
This commit is contained in:
@@ -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 连接(失败则降级为轮询)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -165,12 +165,4 @@ export interface IViewHooks extends IViewHooksOptions, IObject {
|
||||
* 关闭当前tab页
|
||||
*/
|
||||
closeCurrentTab: () => void;
|
||||
/**
|
||||
* 处理流程
|
||||
*/
|
||||
handleFlowRoute: (e: IObject) => void;
|
||||
/**
|
||||
* 查看流程详情
|
||||
*/
|
||||
flowDetailRoute: (e: IObject) => void;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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> 正常绑定)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user