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

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
+2 -2
View File
@@ -1,6 +1,6 @@
# 气象数据管理系统 # 气象数据平台
基于 Spring Boot 3.5 与 Vue 3 的全栈气象数据管理与分析平台。 基于 Spring Boot 3.5 与 Vue 3 的全栈气象数据平台。
> 开发者文档:[CLAUDE.md](CLAUDE.md) | 模块文档:[system-admin](system-admin/CLAUDE.md) | [system-common](system-common/CLAUDE.md) | [system-dynamic-datasource](system-dynamic-datasource/CLAUDE.md) | [weather-data-ui](weather-data-ui/CLAUDE.md) > 开发者文档:[CLAUDE.md](CLAUDE.md) | 模块文档:[system-admin](system-admin/CLAUDE.md) | [system-common](system-common/CLAUDE.md) | [system-dynamic-datasource](system-dynamic-datasource/CLAUDE.md) | [weather-data-ui](weather-data-ui/CLAUDE.md)
> >
+1 -1
View File
@@ -7,7 +7,7 @@
<packaging>pom</packaging> <packaging>pom</packaging>
<name>weather-data</name> <name>weather-data</name>
<description>气象数据管理系统</description> <description>气象数据平台</description>
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -3,7 +3,7 @@ package com.weather.common.aspect;
import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.qiniu.util.StringUtils; import cn.hutool.core.util.StrUtil;
import com.weather.common.annotation.DataFilter; import com.weather.common.annotation.DataFilter;
import com.weather.common.constant.Constant; import com.weather.common.constant.Constant;
import com.weather.common.exception.ErrorCode; import com.weather.common.exception.ErrorCode;
@@ -12,6 +12,7 @@ import com.weather.common.interceptor.DataScope;
import com.weather.modules.security.user.SecurityUser; import com.weather.modules.security.user.SecurityUser;
import com.weather.modules.security.user.UserDetail; import com.weather.modules.security.user.UserDetail;
import com.weather.modules.sys.enums.SuperAdminEnum; import com.weather.modules.sys.enums.SuperAdminEnum;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint; import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Before;
@@ -28,6 +29,7 @@ import java.util.Map;
* *
* @author 123 * @author 123
*/ */
@Slf4j
@Aspect @Aspect
@Component @Component
public class DataFilterAspect { public class DataFilterAspect {
@@ -54,7 +56,7 @@ public class DataFilterAspect {
String sqlFilter = getSqlFilter(user, point); String sqlFilter = getSqlFilter(user, point);
map.put(Constant.SQL_FILTER, new DataScope(sqlFilter)); map.put(Constant.SQL_FILTER, new DataScope(sqlFilter));
} catch (Exception e) { } catch (Exception e) {
log.error("数据过滤SQL生成失败", e);
} }
return; return;
@@ -85,7 +87,7 @@ public class DataFilterAspect {
if (CollUtil.isNotEmpty(deptIdList)) { if (CollUtil.isNotEmpty(deptIdList)) {
sqlFilter.append(tableAlias).append(dataFilter.deptId()); sqlFilter.append(tableAlias).append(dataFilter.deptId());
sqlFilter.append(" in(").append(StringUtils.join(deptIdList, ",")).append(")"); sqlFilter.append(" in(").append(StrUtil.join(",", deptIdList)).append(")");
} }
//查询本人数据 //查询本人数据
@@ -5,6 +5,7 @@ package com.weather.common.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.weather.modules.security.user.SecurityUser; import com.weather.modules.security.user.SecurityUser;
import com.weather.modules.security.user.UserDetail; import com.weather.modules.security.user.UserDetail;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject; import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -15,6 +16,7 @@ import java.util.Date;
* *
* @author 123 * @author 123
*/ */
@Slf4j
@Component @Component
public class FieldMetaObjectHandler implements MetaObjectHandler { public class FieldMetaObjectHandler implements MetaObjectHandler {
private final static String CREATE_DATE = "createDate"; private final static String CREATE_DATE = "createDate";
@@ -28,24 +30,35 @@ public class FieldMetaObjectHandler implements MetaObjectHandler {
UserDetail user = SecurityUser.getUser(); UserDetail user = SecurityUser.getUser();
Date date = new Date(); Date date = new Date();
//创建者 //创建者(用户ID为空时跳过填充,避免写入无效数据)
if (user.getId() != null) {
strictInsertFill(metaObject, CREATOR, Long.class, user.getId()); strictInsertFill(metaObject, CREATOR, Long.class, user.getId());
} else {
log.warn("insertFill: 用户ID为空,跳过creator/deptId/updater字段填充");
}
//创建时间 //创建时间
strictInsertFill(metaObject, CREATE_DATE, Date.class, date); strictInsertFill(metaObject, CREATE_DATE, Date.class, date);
//创建者所属部门 //创建者所属部门
if (user.getDeptId() != null) {
strictInsertFill(metaObject, DEPT_ID, Long.class, user.getDeptId()); strictInsertFill(metaObject, DEPT_ID, Long.class, user.getDeptId());
}
//更新者 //更新者
if (user.getId() != null) {
strictInsertFill(metaObject, UPDATER, Long.class, user.getId()); strictInsertFill(metaObject, UPDATER, Long.class, user.getId());
}
//更新时间 //更新时间
strictInsertFill(metaObject, UPDATE_DATE, Date.class, date); strictInsertFill(metaObject, UPDATE_DATE, Date.class, date);
} }
@Override @Override
public void updateFill(MetaObject metaObject) { public void updateFill(MetaObject metaObject) {
//更新者 Long userId = SecurityUser.getUserId();
strictUpdateFill(metaObject, UPDATER, Long.class, SecurityUser.getUserId()); //更新者(用户ID为空时跳过填充)
if (userId != null) {
strictUpdateFill(metaObject, UPDATER, Long.class, userId);
}
//更新时间 //更新时间
strictUpdateFill(metaObject, UPDATE_DATE, Date.class, new Date()); strictUpdateFill(metaObject, UPDATE_DATE, Date.class, new Date());
} }
@@ -5,9 +5,9 @@ package com.weather.common.interceptor;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.PluginUtils; import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.StringValue;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression; import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.PlainSelect;
@@ -25,6 +25,7 @@ import java.util.Map;
* *
* @author 123 * @author 123
*/ */
@Slf4j
public class DataFilterInterceptor implements InnerInterceptor { public class DataFilterInterceptor implements InnerInterceptor {
@Override @Override
@@ -67,16 +68,20 @@ public class DataFilterInterceptor implements InnerInterceptor {
Select select = (Select) CCJSqlParserUtil.parse(buildSql); Select select = (Select) CCJSqlParserUtil.parse(buildSql);
PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
// 将过滤条件解析为真实的 SQL 表达式,而非 StringValue 字符串字面量
Expression filterExpression = CCJSqlParserUtil.parseExpression(scope.getSqlFilter());
Expression expression = plainSelect.getWhere(); Expression expression = plainSelect.getWhere();
if(expression == null){ if (expression == null) {
plainSelect.setWhere(new StringValue(scope.getSqlFilter())); plainSelect.setWhere(filterExpression);
}else{ } else {
AndExpression andExpression = new AndExpression(expression, new StringValue(scope.getSqlFilter())); AndExpression andExpression = new AndExpression(expression, filterExpression);
plainSelect.setWhere(andExpression); plainSelect.setWhere(andExpression);
} }
return select.toString().replaceAll("'", ""); return select.toString();
}catch (JSQLParserException e){ } catch (JSQLParserException e) {
log.error("数据过滤SQL解析失败,回退到原始SQL", e);
return buildSql; return buildSql;
} }
} }
@@ -11,6 +11,7 @@ import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse; import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter; import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
@@ -24,6 +25,7 @@ import java.io.IOException;
* *
* @author 123 * @author 123
*/ */
@Slf4j
public class Oauth2Filter extends AuthenticatingFilter { public class Oauth2Filter extends AuthenticatingFilter {
@Override @Override
@@ -81,7 +83,7 @@ public class Oauth2Filter extends AuthenticatingFilter {
String json = JsonUtils.toJsonString(r); String json = JsonUtils.toJsonString(r);
httpResponse.getWriter().print(json); httpResponse.getWriter().print(json);
} catch (IOException e1) { } catch (IOException e1) {
log.error("登录失败响应写入异常", e1);
} }
return false; return false;
@@ -2,8 +2,11 @@
package com.weather.modules.security.user; package com.weather.modules.security.user;
import com.weather.modules.sys.enums.SuperAdminEnum;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** /**
* 用户 * 用户
@@ -12,6 +15,8 @@ import org.apache.shiro.subject.Subject;
*/ */
public class SecurityUser { public class SecurityUser {
private static final Logger log = LoggerFactory.getLogger(SecurityUser.class);
public static Subject getSubject() { public static Subject getSubject() {
try { try {
return SecurityUtils.getSubject(); return SecurityUtils.getSubject();
@@ -32,7 +37,13 @@ public class SecurityUser {
} }
} }
UserDetail fallback = UserContextHolder.get(); UserDetail fallback = UserContextHolder.get();
return fallback != null ? fallback : new UserDetail(); if (fallback != null) {
return fallback;
}
log.warn("无法获取当前用户信息,Subject和ThreadLocal均为空,返回空UserDetail");
UserDetail empty = new UserDetail();
empty.setSuperAdmin(SuperAdminEnum.NO.value());
return empty;
} }
/** /**
@@ -24,12 +24,15 @@ public class SseAlertService {
private final Set<SseEmitter> emitters = new CopyOnWriteArraySet<>(); private final Set<SseEmitter> emitters = new CopyOnWriteArraySet<>();
/** SSE 连接超时时间(30分钟),避免客户端异常断开后连接泄漏 */
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
/** /**
* 创建并注册一个新的 SSE 连接。 * 创建并注册一个新的 SSE 连接。
* 绑定清理回调:连接完成/超时/异常时自动移除。 * 绑定清理回调:连接完成/超时/异常时自动移除。
*/ */
public SseEmitter createEmitter() { public SseEmitter createEmitter() {
SseEmitter emitter = new SseEmitter(0L); // 无超时 SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
emitters.add(emitter); emitters.add(emitter);
log.info("SSE 连接已建立,当前连接数: {}", emitters.size()); log.info("SSE 连接已建立,当前连接数: {}", emitters.size());
@@ -18,9 +18,8 @@ import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays; import java.util.*;
import java.util.List; import java.util.stream.Collectors;
import java.util.Map;
/** /**
* 字典类型 * 字典类型
@@ -87,11 +86,14 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
public List<DictType> getAllList() { public List<DictType> getAllList() {
List<DictType> typeList = baseDao.getDictTypeList(); List<DictType> typeList = baseDao.getDictTypeList();
List<DictData> dataList = sysDictDataDao.getDictDataList(); List<DictData> dataList = sysDictDataDao.getDictDataList();
// 按 dictTypeId 分组,O(n+m) 替代 O(n*m) 嵌套循环
Map<Long, List<DictData>> dataMap = dataList.stream()
.collect(Collectors.groupingBy(DictData::getDictTypeId));
for (DictType type : typeList) { for (DictType type : typeList) {
for (DictData data : dataList) { List<DictData> matched = dataMap.get(type.getId());
if (type.getId().equals(data.getDictTypeId())) { if (matched != null) {
type.getDataList().add(data); type.getDataList().addAll(matched);
}
} }
} }
return typeList; return typeList;
@@ -1,8 +1,6 @@
package com.weather.modules.weather.dailydata; package com.weather.modules.weather.dailydata;
import com.alibaba.excel.EasyExcel; import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.weather.modules.weather.dailydata.dto.ImportProgress; import com.weather.modules.weather.dailydata.dto.ImportProgress;
import com.weather.modules.weather.dailydata.service.WeatherDailyDataService; import com.weather.modules.weather.dailydata.service.WeatherDailyDataService;
import com.weather.modules.weather.dailydata.vo.WeatherExcelVO; import com.weather.modules.weather.dailydata.vo.WeatherExcelVO;
@@ -13,6 +11,7 @@ import com.weather.common.redis.RedisKeys;
import com.weather.common.redis.RedisUtils; import com.weather.common.redis.RedisUtils;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -22,6 +21,7 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@Slf4j @Slf4j
@Component @Component
@@ -30,6 +30,10 @@ public class WeatherDataImportManager {
private final WeatherDailyDataService weatherDailyDataService; private final WeatherDailyDataService weatherDailyDataService;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
/** 已完成/失败任务的保留时间(毫秒) */
private static final long TASK_TTL_MS = TimeUnit.HOURS.toMillis(1);
private final Map<String, ImportProgress> tasks = new ConcurrentHashMap<>(); private final Map<String, ImportProgress> tasks = new ConcurrentHashMap<>();
public String submitImport(MultipartFile file) throws IOException { public String submitImport(MultipartFile file) throws IOException {
@@ -67,23 +71,14 @@ public class WeatherDataImportManager {
private void processImport(String taskId, File tempFile) { private void processImport(String taskId, File tempFile) {
ImportProgress progress = tasks.get(taskId); ImportProgress progress = tasks.get(taskId);
AnalysisEventListener<WeatherExcelVO> countListener = new AnalysisEventListener<>() { // 单次读取:WeatherDataListener 同时负责计数和入库,避免双次 I/O
@Override EasyExcel.read(tempFile, WeatherExcelVO.class,
public void invoke(WeatherExcelVO data, AnalysisContext context) { new WeatherDataListener(weatherDailyDataService, progress))
progress.setTotalRows(progress.getTotalRows() + 1);
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
log.info("任务 {} 共 {} 行", taskId, progress.getTotalRows());
}
};
EasyExcel.read(tempFile, WeatherExcelVO.class, countListener).sheet().doRead();
EasyExcel.read(tempFile, WeatherExcelVO.class, new WeatherDataListener(weatherDailyDataService, progress))
.sheet() .sheet()
.doRead(); .doRead();
progress.setStatus("COMPLETED"); progress.setStatus("COMPLETED");
progress.setCompletedAt(System.currentTimeMillis());
log.info("导入任务 {} 完成,共导入 {} 行", taskId, progress.getProcessedRows().get()); log.info("导入任务 {} 完成,共导入 {} 行", taskId, progress.getProcessedRows().get());
// 导入完成后清除天气汇总缓存,等待下次定时任务刷新 // 导入完成后清除天气汇总缓存,等待下次定时任务刷新
@@ -96,6 +91,36 @@ public class WeatherDataImportManager {
} }
public ImportProgress getProgress(String taskId) { public ImportProgress getProgress(String taskId) {
return tasks.get(taskId); ImportProgress progress = tasks.get(taskId);
if (progress != null && isExpired(progress)) {
tasks.remove(taskId);
return null;
}
return progress;
}
private boolean isExpired(ImportProgress progress) {
if ("PROCESSING".equals(progress.getStatus())) {
return false;
}
Long completedAt = progress.getCompletedAt();
return completedAt != null && System.currentTimeMillis() - completedAt > TASK_TTL_MS;
}
/**
* 定时清理过期任务,每30分钟执行一次
*/
@Scheduled(fixedRate = 30 * 60 * 1000)
public void cleanExpiredTasks() {
int removed = 0;
for (Map.Entry<String, ImportProgress> entry : tasks.entrySet()) {
if (isExpired(entry.getValue())) {
tasks.remove(entry.getKey());
removed++;
}
}
if (removed > 0) {
log.info("清理过期导入任务 {} 个,当前剩余 {} 个", removed, tasks.size());
}
} }
} }
@@ -35,6 +35,11 @@ public class WeatherDataListener extends AnalysisEventListener<WeatherExcelVO> {
@Override @Override
public void invoke(WeatherExcelVO data, AnalysisContext context) { public void invoke(WeatherExcelVO data, AnalysisContext context) {
// 同时计数(替代独立的 countListener,避免双次读取)
if (progress != null) {
progress.incrementTotalRows();
}
WeatherDailyDataEntity entity = ConvertUtils.sourceToTarget(data, WeatherDailyDataEntity.class); WeatherDailyDataEntity entity = ConvertUtils.sourceToTarget(data, WeatherDailyDataEntity.class);
try { try {
@@ -30,9 +30,8 @@ import io.swagger.v3.oas.annotations.Parameters;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap; import java.util.*;
import java.util.List; import java.util.stream.Collectors;
import java.util.Map;
/** /**
@@ -66,13 +65,21 @@ public class WeatherDailyDataController {
@RequiresPermissions("dailyweather:weatherdailydata:page") @RequiresPermissions("dailyweather:weatherdailydata:page")
public Result<PageData<WeatherDailyDataDto>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params){ public Result<PageData<WeatherDailyDataDto>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params){
PageData<WeatherDailyDataDto> page = weatherDailyDataService.page(params); PageData<WeatherDailyDataDto> page = weatherDailyDataService.page(params);
// 批量查询站点名称,避免 N+1 问题
Set<Long> stationIds = page.getList().stream()
.map(WeatherDailyDataDto::getStationId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (!stationIds.isEmpty()) {
Map<Long, WeatherStationDTO> stationMap = weatherStationService.getByStationCodes(stationIds);
page.getList().forEach(item -> { page.getList().forEach(item -> {
Long stationId = item.getStationId(); WeatherStationDTO station = stationMap.get(item.getStationId());
WeatherStationDTO station = weatherStationService.getByStationCode(stationId);
if (station != null) { if (station != null) {
item.setStationName(station.getStationName()); item.setStationName(station.getStationName());
} }
}); });
}
return new Result<PageData<WeatherDailyDataDto>>().ok(page); return new Result<PageData<WeatherDailyDataDto>>().ok(page);
} }
@@ -22,18 +22,13 @@ public interface WeatherDailyDataDao extends BaseDao<WeatherDailyDataEntity> {
List<WeatherDailyDataEntity> selectSummarizeList(@Param(Constants.WRAPPER) LambdaQueryWrapper<WeatherDailyDataEntity> wrapper); List<WeatherDailyDataEntity> selectSummarizeList(@Param(Constants.WRAPPER) LambdaQueryWrapper<WeatherDailyDataEntity> wrapper);
/** /**
* 按站点 + 月-日 + 年份范围查询汇总数据 * 按站点 + 精确日期列表查询汇总数据(缓存未命中降级路径)
* 使用等值 IN 查询命中 uk_station_date 唯一索引,避免 MONTH/DAY/YEAR 函数导致全表扫描
* @param stationIds 站点 ID 列表 * @param stationIds 站点 ID 列表
* @param month 月份 1-12 * @param dates 精确日期列表,格式 yyyy-MM-dd,如 ["2020-07-01","2021-07-01"]
* @param day 日 1-31
* @param startYear 起始年份
* @param endYear 结束年份
*/ */
List<WeatherDailyDataEntity> selectSummarizeByMonthDay(@Param("stationIds") List<Long> stationIds, List<WeatherDailyDataEntity> selectSummarizeByMonthDay(@Param("stationIds") List<Long> stationIds,
@Param("month") int month, @Param("dates") List<String> dates);
@Param("day") int day,
@Param("startYear") int startYear,
@Param("endYear") int endYear);
int insertBatchMultiRow(@Param("list") List<WeatherDailyDataEntity> list); int insertBatchMultiRow(@Param("list") List<WeatherDailyDataEntity> list);
} }
@@ -7,12 +7,26 @@ import java.util.concurrent.atomic.AtomicInteger;
@Data @Data
public class ImportProgress { public class ImportProgress {
private String taskId; private String taskId;
private volatile int totalRows; private final AtomicInteger totalRows = new AtomicInteger(0);
private final AtomicInteger processedRows = new AtomicInteger(0); private final AtomicInteger processedRows = new AtomicInteger(0);
private volatile String status; private volatile String status;
private volatile String errorMessage; private volatile String errorMessage;
/** 任务完成时间戳,用于过期清理 */
private volatile Long completedAt;
public ImportProgress(String taskId) { public ImportProgress(String taskId) {
this.taskId = taskId; this.taskId = taskId;
} }
public int getTotalRows() {
return totalRows.get();
}
public void setTotalRows(int totalRows) {
this.totalRows.set(totalRows);
}
public void incrementTotalRows() {
totalRows.incrementAndGet();
}
} }
@@ -242,18 +242,22 @@ public class WeatherDailyDataServiceImpl extends CrudServiceImpl<WeatherDailyDat
} }
/** /**
* 直接查询数据库获取汇总数据 * 直接查询数据库获取汇总数据(缓存未命中降级路径)。
* <p>用 MONTH/DAY 定位具体日期 + YEAR BETWEEN 限定年份范围,避免日期 BETWEEN 全量问题</p> * <p>生成精确日期列表使用 IN 等值查询,命中 uk_station_date 唯一索引,
* 避免 MONTH()/DAY()/YEAR() 函数导致全表扫描。</p>
*/ */
private List<DailyWeatherSummarizeDto> querySummarizeFromDb( private List<DailyWeatherSummarizeDto> querySummarizeFromDb(
List<Long> stationIds, LocalDate startTime, LocalDate endTime) { List<Long> stationIds, LocalDate startTime, LocalDate endTime) {
int month = startTime.getMonthValue();
int day = startTime.getDayOfMonth();
List<String> dates = new ArrayList<>();
for (int y = startTime.getYear(); y <= endTime.getYear(); y++) {
dates.add(String.format("%04d-%02d-%02d", y, month, day));
}
List<WeatherDailyDataEntity> list = weatherDailyDataDao.selectSummarizeByMonthDay( List<WeatherDailyDataEntity> list = weatherDailyDataDao.selectSummarizeByMonthDay(
stationIds, stationIds, dates);
startTime.getMonthValue(),
startTime.getDayOfMonth(),
startTime.getYear(),
endTime.getYear());
return BeanUtil.copyToList(list, DailyWeatherSummarizeDto.class); return BeanUtil.copyToList(list, DailyWeatherSummarizeDto.class);
} }
@@ -1,13 +1,15 @@
package com.weather.modules.weather.filescan.controller; package com.weather.modules.weather.filescan.controller;
import com.weather.common.constant.Constant; import cn.hutool.core.util.StrUtil;
import com.weather.modules.weather.filescan.dto.WeatherFileScanRecordDTO; import com.weather.modules.weather.filescan.dto.WeatherFileScanRecordDTO;
import com.weather.modules.weather.filescan.service.WeatherFileScanRecordService; import com.weather.modules.weather.filescan.service.WeatherFileScanRecordService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.shiro.authz.annotation.RequiresPermissions; import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -16,6 +18,10 @@ import org.springframework.web.bind.annotation.*;
import java.io.File; import java.io.File;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
@RestController @RestController
@RequestMapping("filescan/file") @RequestMapping("filescan/file")
@@ -25,10 +31,17 @@ public class FileDownloadController {
@Resource @Resource
private WeatherFileScanRecordService weatherFileScanRecordService; private WeatherFileScanRecordService weatherFileScanRecordService;
/** 文件缓存最大有效期(小时),图片类文件变更频率极低 */
private static final long FILE_CACHE_MAX_AGE_HOURS = 1;
/** 文件缓存有效期(天),用于 Last-Modified 比较 */
private static final long FILE_CACHE_MAX_AGE_DAYS = 7;
@GetMapping("display/{id}") @GetMapping("display/{id}")
@Operation(summary = "获取展示文件") @Operation(summary = "获取展示文件")
@RequiresPermissions("filescan:record:display") @RequiresPermissions("filescan:record:display")
public ResponseEntity<org.springframework.core.io.Resource> displayFile(@PathVariable("id") Long id) { public ResponseEntity<org.springframework.core.io.Resource> displayFile(
@PathVariable("id") Long id, HttpServletRequest request) {
WeatherFileScanRecordDTO record = weatherFileScanRecordService.get(id); WeatherFileScanRecordDTO record = weatherFileScanRecordService.get(id);
if (record == null || record.getDisplayPath() == null) { if (record == null || record.getDisplayPath() == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
@@ -39,25 +52,57 @@ public class FileDownloadController {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
FileSystemResource resource = new FileSystemResource(file); // ETag 使用文件 MD5 hash,扫描时已计算
String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE; String eTag = "\"" + (StrUtil.isNotBlank(record.getMd5Hash()) ? record.getMd5Hash() : file.lastModified()) + "\"";
String ext = record.getFileExt(); long lastModified = file.lastModified();
if (ext == null) {
// fall through to default // 条件请求:If-None-MatchETag 匹配则返回 304
} else if ("png".equalsIgnoreCase(ext)) { String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH);
contentType = MediaType.IMAGE_PNG_VALUE; if (StrUtil.isNotBlank(ifNoneMatch) && ifNoneMatch.equals(eTag)) {
} else if ("gif".equalsIgnoreCase(ext)) { return ResponseEntity.status(304)
contentType = MediaType.IMAGE_GIF_VALUE; .eTag(eTag)
} else if ("jpg".equalsIgnoreCase(ext) || "jpeg".equalsIgnoreCase(ext)) { .cacheControl(CacheControl.maxAge(FILE_CACHE_MAX_AGE_HOURS, TimeUnit.HOURS).cachePublic())
contentType = MediaType.IMAGE_JPEG_VALUE; .build();
} else if ("txt".equalsIgnoreCase(ext)) {
contentType = MediaType.TEXT_PLAIN_VALUE;
} }
// 条件请求:If-Modified-Since(文件未修改则返回 304)
long ifModifiedSince = request.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
if (ifModifiedSince > 0 && lastModified / 1000 <= ifModifiedSince / 1000) {
return ResponseEntity.status(304)
.eTag(eTag)
.lastModified(lastModified)
.cacheControl(CacheControl.maxAge(FILE_CACHE_MAX_AGE_HOURS, TimeUnit.HOURS).cachePublic())
.build();
}
FileSystemResource resource = new FileSystemResource(file);
String contentType = resolveContentType(record.getFileExt());
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType)) .contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION, .header(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename=\"" + URLEncoder.encode(record.getFileName(), StandardCharsets.UTF_8) + "\"") "inline; filename=\"" + URLEncoder.encode(record.getFileName(), StandardCharsets.UTF_8) + "\"")
.eTag(eTag)
.lastModified(lastModified)
.cacheControl(CacheControl.maxAge(FILE_CACHE_MAX_AGE_HOURS, TimeUnit.HOURS)
.cachePublic()
.staleWhileRevalidate(FILE_CACHE_MAX_AGE_DAYS, TimeUnit.DAYS))
.body(resource); .body(resource);
} }
private String resolveContentType(String ext) {
if (ext == null) return MediaType.APPLICATION_OCTET_STREAM_VALUE;
return switch (ext.toLowerCase()) {
case "png" -> MediaType.IMAGE_PNG_VALUE;
case "gif" -> MediaType.IMAGE_GIF_VALUE;
case "jpg", "jpeg" -> MediaType.IMAGE_JPEG_VALUE;
case "svg" -> "image/svg+xml";
case "webp" -> "image/webp";
case "bmp" -> "image/bmp";
case "txt" -> MediaType.TEXT_PLAIN_VALUE;
case "json" -> MediaType.APPLICATION_JSON_VALUE;
case "pdf" -> MediaType.APPLICATION_PDF_VALUE;
default -> MediaType.APPLICATION_OCTET_STREAM_VALUE;
};
}
} }
@@ -20,7 +20,7 @@ import com.weather.modules.weather.filescan.dto.ModelFileItemVO;
import com.weather.modules.weather.filescan.dto.WeatherFileScanRecordDTO; import com.weather.modules.weather.filescan.dto.WeatherFileScanRecordDTO;
import com.weather.modules.weather.filescan.entity.WeatherFileScanRecordEntity; import com.weather.modules.weather.filescan.entity.WeatherFileScanRecordEntity;
import com.weather.modules.weather.filescan.service.WeatherFileScanRecordService; import com.weather.modules.weather.filescan.service.WeatherFileScanRecordService;
import lombok.AllArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -35,12 +35,16 @@ import java.util.stream.Collectors;
@Slf4j @Slf4j
@Service @Service
@AllArgsConstructor @RequiredArgsConstructor
public class WeatherFileScanRecordServiceImpl extends CrudServiceImpl<WeatherFileScanRecordDao, WeatherFileScanRecordEntity, WeatherFileScanRecordDTO> implements WeatherFileScanRecordService { public class WeatherFileScanRecordServiceImpl extends CrudServiceImpl<WeatherFileScanRecordDao, WeatherFileScanRecordEntity, WeatherFileScanRecordDTO> implements WeatherFileScanRecordService {
private final SysDeptService sysDeptService; private final SysDeptService sysDeptService;
private final SysDeptDao sysDeptDao; private final SysDeptDao sysDeptDao;
/** 部门名称缓存,部门数据变化频率极低,缓存5分钟 */
private volatile Map<Long, String> deptNameCache;
private volatile long deptNameCacheExpireAt;
@Override @Override
public PageData<WeatherFileScanRecordDTO> page(Map<String, Object> params) { public PageData<WeatherFileScanRecordDTO> page(Map<String, Object> params) {
IPage<WeatherFileScanRecordEntity> page = baseDao.selectPage( IPage<WeatherFileScanRecordEntity> page = baseDao.selectPage(
@@ -52,10 +56,24 @@ public class WeatherFileScanRecordServiceImpl extends CrudServiceImpl<WeatherFil
return pageData; return pageData;
} }
private void enrichDeptNames(List<WeatherFileScanRecordDTO> list) { private Map<Long, String> getDeptNameMap() {
List<SysDeptDTO> depts = sysDeptService.list(Map.of()); long now = System.currentTimeMillis();
Map<Long, String> deptMap = depts.stream() if (deptNameCache != null && now < deptNameCacheExpireAt) {
return deptNameCache;
}
synchronized (this) {
if (deptNameCache != null && now < deptNameCacheExpireAt) {
return deptNameCache;
}
deptNameCache = sysDeptService.list(Map.of()).stream()
.collect(Collectors.toMap(SysDeptDTO::getId, SysDeptDTO::getName, (a, b) -> a)); .collect(Collectors.toMap(SysDeptDTO::getId, SysDeptDTO::getName, (a, b) -> a));
deptNameCacheExpireAt = now + 5 * 60 * 1000;
return deptNameCache;
}
}
private void enrichDeptNames(List<WeatherFileScanRecordDTO> list) {
Map<Long, String> deptMap = getDeptNameMap();
for (WeatherFileScanRecordDTO dto : list) { for (WeatherFileScanRecordDTO dto : list) {
if (dto.getDeptId() != null) { if (dto.getDeptId() != null) {
dto.setDeptName(deptMap.get(dto.getDeptId())); dto.setDeptName(deptMap.get(dto.getDeptId()));
@@ -8,6 +8,7 @@ import com.weather.modules.weather.station.entity.WeatherStationEntity;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* 气象站点表 * 气象站点表
@@ -25,6 +26,11 @@ public interface WeatherStationService extends CrudService<WeatherStationEntity,
WeatherStationDTO getByStationCode(Long stationCode); WeatherStationDTO getByStationCode(Long stationCode);
/**
* 批量查询站点信息,避免 N+1 问题
*/
Map<Long, WeatherStationDTO> getByStationCodes(Set<Long> stationCodes);
List<WeatherStationInfoSimpleDto> getStationByDeptId(); List<WeatherStationInfoSimpleDto> getStationByDeptId();
List<WeatherStationInfoSimpleDto> getStationByStationName(); List<WeatherStationInfoSimpleDto> getStationByStationName();
@@ -5,8 +5,11 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.weather.common.constant.Constant; import com.weather.common.constant.Constant;
import com.weather.common.page.PageData; import com.weather.common.page.PageData;
import com.weather.common.redis.RedisKeys;
import com.weather.common.redis.RedisUtils;
import com.weather.common.service.impl.CrudServiceImpl; import com.weather.common.service.impl.CrudServiceImpl;
import com.weather.common.utils.ConvertUtils; import com.weather.common.utils.ConvertUtils;
import com.weather.modules.security.user.SecurityUser;
import com.weather.modules.sys.service.SysDeptService; import com.weather.modules.sys.service.SysDeptService;
import com.weather.modules.weather.station.dao.WeatherStationDao; import com.weather.modules.weather.station.dao.WeatherStationDao;
import com.weather.modules.weather.station.dto.WeatherStationDTO; import com.weather.modules.weather.station.dto.WeatherStationDTO;
@@ -14,11 +17,13 @@ import com.weather.modules.weather.station.dto.WeatherStationInfoSimpleDto;
import com.weather.modules.weather.station.entity.WeatherStationEntity; import com.weather.modules.weather.station.entity.WeatherStationEntity;
import com.weather.modules.weather.station.service.WeatherStationService; import com.weather.modules.weather.station.service.WeatherStationService;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import lombok.AllArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.*;
import java.util.Map; import java.util.stream.Collectors;
/** /**
* 气象站点表 * 气象站点表
@@ -26,10 +31,12 @@ import java.util.Map;
* @author Mark 123 * @author Mark 123
* @since 1.0.0 2026-03-05 * @since 1.0.0 2026-03-05
*/ */
@Slf4j
@Service @Service
@AllArgsConstructor @RequiredArgsConstructor
public class WeatherStationServiceImpl extends CrudServiceImpl<WeatherStationDao, WeatherStationEntity, WeatherStationDTO> implements WeatherStationService { public class WeatherStationServiceImpl extends CrudServiceImpl<WeatherStationDao, WeatherStationEntity, WeatherStationDTO> implements WeatherStationService {
private final SysDeptService sysDeptService; private final SysDeptService sysDeptService;
private final RedisUtils redisUtils;
@Override @Override
public QueryWrapper<WeatherStationEntity> getWrapper(Map<String, Object> params) { public QueryWrapper<WeatherStationEntity> getWrapper(Map<String, Object> params) {
String stationName = (String) params.get("stationName"); String stationName = (String) params.get("stationName");
@@ -59,12 +66,57 @@ public class WeatherStationServiceImpl extends CrudServiceImpl<WeatherStationDao
return ConvertUtils.sourceToTarget(baseDao.selectOne(wrapper),WeatherStationDTO.class); return ConvertUtils.sourceToTarget(baseDao.selectOne(wrapper),WeatherStationDTO.class);
} }
@Override
public Map<Long, WeatherStationDTO> getByStationCodes(Set<Long> stationCodes) {
if (stationCodes == null || stationCodes.isEmpty()) {
return Collections.emptyMap();
}
List<String> codeList = stationCodes.stream().map(String::valueOf).collect(Collectors.toList());
QueryWrapper<WeatherStationEntity> wrapper = new QueryWrapper<>();
wrapper.in("station_code", codeList);
List<WeatherStationEntity> entities = baseDao.selectList(wrapper);
return entities.stream()
.collect(Collectors.toMap(
e -> Long.valueOf(e.getStationCode()),
e -> ConvertUtils.sourceToTarget(e, WeatherStationDTO.class),
(a, b) -> a));
}
/** 站点列表缓存有效期(小时),站点变更频率极低 */
private static final long STATION_LIST_CACHE_HOURS = 2;
@SuppressWarnings("unchecked")
@Override @Override
public List<WeatherStationInfoSimpleDto> getStationByDeptId() { public List<WeatherStationInfoSimpleDto> getStationByDeptId() {
Long deptId = SecurityUser.getDeptId();
String cacheKey = RedisKeys.getStationListKey(deptId != null ? deptId : 0L);
// 命中缓存直接返回(Jackson 反序列化为 List<Map>,需校验元素类型)
Object cached = redisUtils.get(cacheKey);
if (cached instanceof List list) {
if (!list.isEmpty()) {
Object first = list.get(0);
if (first instanceof WeatherStationInfoSimpleDto) {
return (List<WeatherStationInfoSimpleDto>) list;
}
// Jackson 反序列化后元素是 LinkedHashMap,转换回 DTO
if (first instanceof Map) {
return (List<WeatherStationInfoSimpleDto>) list.stream()
.map(m -> BeanUtil.copyProperties(m, WeatherStationInfoSimpleDto.class))
.toList();
}
}
}
QueryWrapper<WeatherStationEntity> wrapper = new QueryWrapper<>(); QueryWrapper<WeatherStationEntity> wrapper = new QueryWrapper<>();
wrapper = sysDeptService.limitedQueryScope(wrapper); wrapper = sysDeptService.limitedQueryScope(wrapper);
List<WeatherStationEntity> entities = baseDao.selectList(wrapper); List<WeatherStationEntity> entities = baseDao.selectList(wrapper);
return BeanUtil.copyToList(entities, WeatherStationInfoSimpleDto.class); List<WeatherStationInfoSimpleDto> result = BeanUtil.copyToList(entities, WeatherStationInfoSimpleDto.class);
// 缓存结果,站点数据变更频率极低(2小时)
redisUtils.set(cacheKey, result, RedisUtils.HOUR_ONE_EXPIRE * STATION_LIST_CACHE_HOURS);
log.debug("站点列表缓存已更新,共 {} 条", result.size());
return result;
} }
@Override @Override
@@ -72,4 +124,31 @@ public class WeatherStationServiceImpl extends CrudServiceImpl<WeatherStationDao
return List.of(); return List.of();
} }
/** 清除站点列表缓存(站点增删改时调用) */
private void invalidateStationCache() {
redisUtils.deleteByPattern(RedisKeys.getStationListPattern());
log.debug("站点列表缓存已清除");
}
@Override
public boolean insert(WeatherStationEntity entity) {
boolean result = super.insert(entity);
invalidateStationCache();
return result;
}
@Override
public boolean updateById(WeatherStationEntity entity) {
boolean result = super.updateById(entity);
invalidateStationCache();
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long[] ids) {
super.delete(ids);
invalidateStationCache();
}
} }
@@ -49,9 +49,10 @@
<foreach collection="stationIds" item="sid" open="(" separator="," close=")"> <foreach collection="stationIds" item="sid" open="(" separator="," close=")">
#{sid} #{sid}
</foreach> </foreach>
AND MONTH(observe_date) = #{month} AND observe_date IN
AND DAY(observe_date) = #{day} <foreach collection="dates" item="d" open="(" separator="," close=")">
AND YEAR(observe_date) BETWEEN #{startYear} AND #{endYear} #{d}
</foreach>
ORDER BY station_id, observe_date ORDER BY station_id, observe_date
</select> </select>
@@ -56,6 +56,20 @@ public class RedisKeys {
return "sys:user:permissions:" + userId; return "sys:user:permissions:" + userId;
} }
/**
* 站点列表缓存Key(按部门权限区分)
*/
public static String getStationListKey(Long deptId) {
return "weather:station:list:" + deptId;
}
/**
* 站点列表缓存匹配模式(站点变更时清除所有部门缓存)
*/
public static String getStationListPattern() {
return "weather:station:list:*";
}
/** /**
* 天气汇总缓存Key(按站点分组,月-日维度) * 天气汇总缓存Key(按站点分组,月-日维度)
*/ */
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>气象数据 - 管理系统</title> <title>气象数据平台</title>
<script> <script>
//全局钩子 //全局钩子
window.SITE_CONFIG = { window.SITE_CONFIG = {
@@ -215,6 +215,8 @@ function handleClearAll(): void {
class="alert-scrollbar" class="alert-scrollbar"
:class="{ 'is-dragging': isDragging }" :class="{ 'is-dragging': isDragging }"
:style="positionStyle" :style="positionStyle"
role="alert"
aria-live="assertive"
@pointerdown="onScrollbarPointerDown" @pointerdown="onScrollbarPointerDown"
> >
<span class="alert-scrollbar__icon"> <span class="alert-scrollbar__icon">
@@ -429,6 +431,12 @@ function handleClearAll(): void {
animation: scrollbar-marquee 20s linear infinite; animation: scrollbar-marquee 20s linear infinite;
} }
@media (prefers-reduced-motion: reduce) {
.alert-scrollbar__text.is-scroll {
animation: none;
}
}
@keyframes scrollbar-marquee { @keyframes scrollbar-marquee {
0% { transform: translateX(0); } 0% { transform: translateX(0); }
80% { transform: translateX(calc(-100% + 530px)); } 80% { transform: translateX(calc(-100% + 530px)); }
@@ -13,7 +13,7 @@ import { onBeforeUnmount, shallowRef } from "vue";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue"; import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import { IDomEditor, IEditorConfig } from "@wangeditor/editor"; import { IDomEditor, IEditorConfig } from "@wangeditor/editor";
import app from "@/constants/app"; import app from "@/constants/app";
import { getToken } from "@/utils/cache"; import baseService from "@/service/baseService";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -43,20 +43,24 @@ const editorRef = shallowRef();
type InsertFnType = (url: string, alt: string, href: string) => void; type InsertFnType = (url: string, alt: string, href: string) => void;
// 编辑器配置 // 编辑器配置(使用 customUpload 替代静态 token URL,每次上传时动态获取 token)
const editorConfig: Partial<IEditorConfig> = { const editorConfig: Partial<IEditorConfig> = {
placeholder: props.placeholder, placeholder: props.placeholder,
readOnly: props.disabled, readOnly: props.disabled,
MENU_CONF: { MENU_CONF: {
uploadImage: { uploadImage: {
server: `${app.api}/sys/oss/upload?token=${getToken()}`, // 上传地址 async customUpload(file: File, insertFn: InsertFnType) {
fieldName: "file", const formData = new FormData();
// 自定义插入图片 formData.append("file", file);
customInsert(res: any, insertFn: InsertFnType) { try {
// res 即服务端的返回结果 const res: any = await baseService.upload("/sys/oss/upload", formData);
// 从 res 中找到 url alt href ,然后插图图片 if (res.code === 0 && res.data?.src) {
insertFn(res.data.src, "", ""); insertFn(res.data.src, "", "");
} }
} catch (e) {
console.error("图片上传失败", e);
}
}
} }
} }
}; };
@@ -34,7 +34,7 @@ function setLastSeenId(id: string): void {
try { try {
sessionStorage.setItem(SEEN_KEY, id); sessionStorage.setItem(SEEN_KEY, id);
} catch { } catch {
// 静默失败 console.error("useAlertMarquee: sessionStorage.setItem 失败");
} }
} }
@@ -129,7 +129,7 @@ async function reconcileAlerts(): Promise<void> {
} }
} }
} catch { } catch {
// 静默失败 console.error("useAlertMarquee: 全量对账失败");
} }
} }
@@ -162,6 +162,7 @@ function initSse(): void {
token = getToken(); token = getToken();
if (!token) return; if (!token) return;
} catch { } catch {
console.error("useAlertMarquee: 获取token失败,SSE初始化取消");
return; return;
} }
@@ -175,7 +176,7 @@ function initSse(): void {
const alert = JSON.parse(e.data) as AlertMessage; const alert = JSON.parse(e.data) as AlertMessage;
mergeNewAlerts([alert]); mergeNewAlerts([alert]);
} catch { } catch {
// JSON 解析失败,忽略 console.error("useAlertMarquee: SSE alert 事件解析失败");
} }
}); });
@@ -184,7 +185,7 @@ function initSse(): void {
const { id } = JSON.parse(e.data) as { id: string }; const { id } = JSON.parse(e.data) as { id: string };
removeAlert(id); removeAlert(id);
} catch { } catch {
// 忽略 console.error("useAlertMarquee: SSE alert-withdrawn 事件解析失败");
} }
}); });
@@ -195,7 +196,7 @@ function initSse(): void {
removeAlert(id); removeAlert(id);
} }
} catch { } catch {
// 忽略 console.error("useAlertMarquee: SSE alert-deleted 事件解析失败");
} }
}); });
@@ -227,7 +228,7 @@ function startPollingFallback(): void {
await reconcileAlerts(); await reconcileAlerts();
} }
} catch { } catch {
// 静默失败 console.error("useAlertMarquee: 轮询拉取通知失败");
} }
}, 10000); }, 10000);
} }
@@ -242,7 +243,7 @@ async function startPolling(_intervalMs?: number): Promise<void> {
const data = await fetchNotifications(); const data = await fetchNotifications();
mergeNewAlerts(data); mergeNewAlerts(data);
} catch { } catch {
// 静默失败 console.error("useAlertMarquee: 启动时拉取存量通知失败");
} }
// 尝试建立 SSE 连接(失败则降级为轮询) // 尝试建立 SSE 连接(失败则降级为轮询)
+3 -38
View File
@@ -1,15 +1,13 @@
import app from "@/constants/app"; import app from "@/constants/app";
import { EMitt, EThemeSetting } from "@/constants/enum"; import { EMitt, EThemeSetting } from "@/constants/enum";
import { IObject, IViewHooks, IViewHooksOptions } from "@/types/interface"; import { IObject, IViewHooks, IViewHooksOptions } from "@/types/interface";
import { registerDynamicToRouterAndNext } from "@/router";
import baseService from "@/service/baseService"; import baseService from "@/service/baseService";
import { getToken } from "@/utils/cache";
import emits from "@/utils/emits"; import emits from "@/utils/emits";
import { getThemeConfigCacheByKey } from "@/utils/theme"; import { getThemeConfigCacheByKey } from "@/utils/theme";
import { checkPermission, getDictLabel } from "@/utils/utils"; import { checkPermission, getDictLabel } from "@/utils/utils";
import qs from "qs"; import qs from "qs";
import { onActivated, onMounted } from "vue"; import { onActivated, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter } from "vue-router";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
@@ -20,7 +18,6 @@ import { ElMessage, ElMessageBox } from "element-plus";
*/ */
const useView = (props: IViewHooksOptions | IObject): IViewHooks => { const useView = (props: IViewHooksOptions | IObject): IViewHooks => {
const router = useRouter(); const router = useRouter();
const route = useRoute();
const store = useAppStore(); const store = useAppStore();
const defaultOptions: IViewHooksOptions = { const defaultOptions: IViewHooksOptions = {
createdIsNeed: true, createdIsNeed: true,
@@ -190,13 +187,9 @@ const useView = (props: IViewHooksOptions | IObject): IViewHooks => {
}); });
}); });
}, },
// 导出 // 导出token 通过请求拦截器 Header 传递,不再出现在 URL 中)
exportHandle() { exportHandle() {
window.location.href = `${app.api}${state.exportURL}?${qs.stringify({ window.location.href = `${app.api}${state.exportURL}?${qs.stringify(state.dataForm)}`;
...state.dataForm,
token: getToken()
})}`;
// baseService.download(state.exportURL, { ...state.dataForm, token: getToken() });
}, },
//关闭当前窗口 //关闭当前窗口
closeCurrentTab() { closeCurrentTab() {
@@ -205,34 +198,6 @@ const useView = (props: IViewHooksOptions | IObject): IViewHooks => {
} else { } else {
router.replace("/home"); 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 { useAppStore } from "@/store";
import { useAlertMarquee } from "@/composables/useAlertMarquee"; import { useAlertMarquee } from "@/composables/useAlertMarquee";
import { useImportTaskStore } from "@/store/importTasks"; 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 BaseSidebar from "../sidebar/base-sidebar.vue";
import Breadcrumb from "./breadcrumb.vue"; import Breadcrumb from "./breadcrumb.vue";
import CollapseSidebarBtn from "./collapse-sidebar-btn.vue"; import CollapseSidebarBtn from "./collapse-sidebar-btn.vue";
@@ -21,7 +21,7 @@ import "@/assets/css/header.less";
*/ */
export default defineComponent({ export default defineComponent({
name: "Header", name: "Header",
components: { BaseSidebar, Breadcrumb, CollapseSidebarBtn, Expand, HeaderMixNavMenus, Logo }, components: {Bell, Message, BaseSidebar, Breadcrumb, CollapseSidebarBtn, Expand, HeaderMixNavMenus, Logo },
setup() { setup() {
const store = useAppStore(); const store = useAppStore();
const { messageCount: alertCount, toggleDrawer: toggleNotificationCenter } = useAlertMarquee(); const { messageCount: alertCount, toggleDrawer: toggleNotificationCenter } = useAlertMarquee();
@@ -43,13 +43,13 @@ export default defineComponent({
<template> <template>
<div class="rr-header-ctx"> <div class="rr-header-ctx">
<div class="rr-header-ctx-logo hidden-xs-only"> <div class="rr-header-ctx-logo hidden-xs-only">
<logo :logoUrl="logo" logoName="气象数据管理系统"></logo> <logo :logoUrl="logo" logoName="气象数据平台"></logo>
</div> </div>
<div class="rr-header-right"> <div class="rr-header-right">
<div class="rr-header-right-left"> <div class="rr-header-right-left">
<div class="rr-header-right-items rr-header-action" :style="`display:${state.sidebarLayout === ESidebarLayoutEnum.Top ? 'none' : ''}`"> <div class="rr-header-right-items rr-header-action" :style="`display:${state.sidebarLayout === ESidebarLayoutEnum.Top ? 'none' : ''}`">
<collapse-sidebar-btn></collapse-sidebar-btn> <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"> <div class="el-badge">
<el-icon><refresh-right /></el-icon> <el-icon><refresh-right /></el-icon>
</div> </div>
@@ -60,11 +60,9 @@ export default defineComponent({
<header-mix-nav-menus v-else-if="state.sidebarLayout === ESidebarLayoutEnum.Mix"></header-mix-nav-menus> <header-mix-nav-menus v-else-if="state.sidebarLayout === ESidebarLayoutEnum.Mix"></header-mix-nav-menus>
<breadcrumb v-else></breadcrumb> <breadcrumb v-else></breadcrumb>
</div> </div>
</div> <div class="rr-header-notify" role="button" tabindex="0" aria-label="通知中心" @click="toggleNotificationCenter" @keydown.enter="toggleNotificationCenter">
<div style="display: flex; align-items: center; flex-shrink: 0">
<div class="rr-header-notify" @click="toggleNotificationCenter">
<el-badge :value="combinedCount" :hidden="combinedCount === 0"> <el-badge :value="combinedCount" :hidden="combinedCount === 0">
<el-icon :size="18"><Bell /></el-icon> <el-icon><Bell /></el-icon>
</el-badge> </el-badge>
</div> </div>
<expand :userName="store.state.user.username"></expand> <expand :userName="store.state.user.username"></expand>
@@ -75,17 +73,20 @@ export default defineComponent({
<style scoped> <style scoped>
.rr-header-notify { .rr-header-notify {
display: flex;
align-items: center;
justify-content: center;
height: 50px; height: 50px;
padding: 0 8px; line-height: 56px;
padding: 0 12px;
color: rgba(255, 255, 255, 0.66);
cursor: pointer; cursor: pointer;
color: #909399; transition: background 0.2s, color 0.2s;
transition: color 0.2s;
} }
.rr-header-notify:hover { .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> </style>
+22 -8
View File
@@ -4,7 +4,7 @@ import emits from "@/utils/emits";
import { getThemeConfigCache, getThemeConfigCacheByKey, getThemeConfigToClass } from "@/utils/theme"; import { getThemeConfigCache, getThemeConfigCacheByKey, getThemeConfigToClass } from "@/utils/theme";
import { getValueByKeys } from "@/utils/utils"; import { getValueByKeys } from "@/utils/utils";
import { useMediaQuery } from "@vueuse/core"; 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 { RouteRecordRaw, useRouter } from "vue-router";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import BaseHeader from "./header/base-header.vue"; import BaseHeader from "./header/base-header.vue";
@@ -36,23 +36,37 @@ export default defineComponent({
.concat(isMobile.value ? ["ui-mobile"] : []) .concat(isMobile.value ? ["ui-mobile"] : [])
.join(" ") .join(" ")
); );
emits.on(EMitt.OnSelectHeaderNavMenusByMixNav, (path) => { const onSelectHeaderNav = (path: any) => {
state.mixLayoutRoutes = store.state.routes.find((x: RouteRecordRaw) => x.path === path)?.children ?? []; 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; state.themeClass[type] = "ui-" + value;
}); };
emits.on(EMitt.OnSetNavLayout, (vl) => { const onSetNavLayout = (vl: any) => {
state.sidebarLayout = vl; state.sidebarLayout = vl;
state.isShowNav = vl !== ESidebarLayoutEnum.Top; state.isShowNav = vl !== ESidebarLayoutEnum.Top;
if (vl === ESidebarLayoutEnum.Mix) { if (vl === ESidebarLayoutEnum.Mix) {
const currRoute = getValueByKeys(getValueByKeys(router.currentRoute.value.meta, "matched", [])[0], "path", ""); const currRoute = getValueByKeys(getValueByKeys(router.currentRoute.value.meta, "matched", [])[0], "path", "");
state.mixLayoutRoutes = store.state.routes.find((x: RouteRecordRaw) => x.path === currRoute)?.children ?? []; state.mixLayoutRoutes = store.state.routes.find((x: RouteRecordRaw) => x.path === currRoute)?.children ?? [];
} }
}); };
emits.on(EMitt.OnLoading, (vl) => { const onLoading = (vl: boolean) => {
state.loading = vl; 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 }; return { state, ESidebarLayoutEnum, containerClassNames };
} }
}); });
-8
View File
@@ -165,12 +165,4 @@ export interface IViewHooks extends IViewHooksOptions, IObject {
* 关闭当前tab页 * 关闭当前tab页
*/ */
closeCurrentTab: () => void; 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 => { 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; return response;
} }
// 错误提示 // 401 未授权先跳转登录,不弹错误提示
ElMessage.error(response.data.msg);
if (response.data.code === 401) { if (response.data.code === 401) {
//自定义业务状态码
redirectLogin(); redirectLogin();
return Promise.reject(new Error("未授权"));
} }
// 其他业务错误提示
ElMessage.error(response.data.msg);
return Promise.reject(new Error(response.data.msg || "Error")); return Promise.reject(new Error(response.data.msg || "Error"));
}, },
(error) => { (error) => {
@@ -80,11 +81,5 @@ const redirectLogin = () => {
}; };
export default (o: AxiosRequestConfig): Promise<IHttpResponse> => { export default (o: AxiosRequestConfig): Promise<IHttpResponse> => {
return new Promise((resolve, reject) => { return http(o).then((res) => res.data);
http(o)
.then((res) => {
return resolve(res.data);
})
.catch(reject);
});
}; };
@@ -63,7 +63,7 @@
</span> </span>
<span v-else-if="item.prop === 'dayAvgWindDirection' || item.prop === 'maxWindDirection'"> <span v-else-if="item.prop === 'dayAvgWindDirection' || item.prop === 'maxWindDirection'">
{{ windDirectionLabel(scope.row[item.prop]) }} {{ windDirectionLabelLocal(scope.row[item.prop]) }}
</span> </span>
<span v-else>{{ scope.row[item.prop] ?? "--" }}</span> <span v-else>{{ scope.row[item.prop] ?? "--" }}</span>
</template> </template>
@@ -93,20 +93,11 @@ import AddOrUpdate from "./weatherdailydata-add-or-update.vue";
import ImportExcel from "./weatherdailydata-import.vue"; import ImportExcel from "./weatherdailydata-import.vue";
import baseService from "@/service/baseService"; import baseService from "@/service/baseService";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { windDirectionCode, windDirectionLabel } from "@/utils/windDirection";
const _store = useAppStore(); const _store = useAppStore();
function _windDirectionCode(deg: any) { function windDirectionLabelLocal(deg: any) {
if (deg == null || deg === "" || isNaN(deg)) return null; return windDirectionLabel(deg as number | null | string, _store.state.dicts);
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;
} }
const columnGroups = [ const columnGroups = [
@@ -175,21 +166,29 @@ interface WeatherStation {
stationCode: string; stationCode: string;
} }
const loading = ref(false); 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) => { const remoteSearchStation = (keyword: string) => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
loading.value = true; loading.value = true;
try { try {
const res = await baseService.get("/station/weatherstation/list"); const res = await baseService.get("/station/weatherstation/list");
if (res.code === 0) { if (res.code === 0) {
// TypeScript 自动知道 item 是 WeatherStation 类型 stationOptions.value = res.data.filter(
stationOptions.value = res.data.filter((item: { stationName: string | string[]; stationCode: string | string[] }) => !keyword || item.stationName.includes(keyword) || item.stationCode.includes(keyword)); (item: { stationName: string | string[]; stationCode: string | string[] }) =>
!keyword || item.stationName.includes(keyword) || item.stationCode.includes(keyword)
);
} }
} finally { } finally {
loading.value = false; loading.value = false;
} }
}, 300);
}; };
// 1. 定义真实的表单数据(保持数组,供 <el-select multiple> 正常绑定) // 1. 定义真实的表单数据(保持数组,供 <el-select multiple> 正常绑定)
+1 -2
View File
@@ -2,7 +2,7 @@
<div class="rr-login"> <div class="rr-login">
<div class="rr-login-wrap"> <div class="rr-login-wrap">
<div class="rr-login-left hidden-sm-and-down"> <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>
<div class="rr-login-right"> <div class="rr-login-right">
@@ -43,7 +43,6 @@ import { setCache } from "@/utils/cache";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { getUuid, isAlphanumeric } from "@/utils/utils"; import { getUuid, isAlphanumeric } from "@/utils/utils";
import app from "@/constants/app"; import app from "@/constants/app";
import SvgIcon from "@/components/base/svg-icon/index";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
@@ -28,7 +28,7 @@
<aside class="file-list-panel"> <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 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"> <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="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-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> <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 class="preview-panel__body" v-if="fileMode === 'image'">
<div v-if="!previewBlobUrl && selectedFileId" class="preview-panel__loading">加载中...</div> <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"> <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>
<div v-if="previewItem.content" class="preview-panel__text">{{ previewItem.content }}</div> <div v-if="previewItem.content" class="preview-panel__text">{{ previewItem.content }}</div>
</div> </div>
@@ -287,9 +287,11 @@ const onPanEnd = () => {
document.removeEventListener("mouseup", onPanEnd); document.removeEventListener("mouseup", onPanEnd);
}; };
const transformGroups = (list: DeptFileGroupVO[]) => { /** 跨部门缓存:{ deptId: ImageItem[] },切换 tab 时瞬间命中 */
const imageItems: ImageItem[] = []; const deptFileCache = {} as Record<string, ImageItem[]>;
const transformGroups = (list: DeptFileGroupVO[]): ImageItem[] => {
const imageItems: ImageItem[] = [];
list.forEach((group) => { list.forEach((group) => {
(group.fileList || []).forEach((file) => { (group.fileList || []).forEach((file) => {
imageItems.push({ imageItems.push({
@@ -303,19 +305,33 @@ const transformGroups = (list: DeptFileGroupVO[]) => {
}); });
}); });
}); });
return imageItems;
state.imageItems = imageItems;
state.selectedFileIndex = 0;
}; };
const loadData = async (deptId: string) => { const loadData = async (deptId: string) => {
// 命中跨部门缓存 → 直接渲染
const cached = deptFileCache[deptId];
if (cached) {
state.imageItems = cached;
state.selectedFileIndex = 0;
selectFile(0);
return;
}
state.loading = true; state.loading = true;
state.errorMessage = ""; state.errorMessage = "";
try { try {
const res = await baseService.get("/filescan/record/tree", { deptId }); const res = await baseService.get("/filescan/record/tree", { deptId });
transformGroups((res.data || []) as DeptFileGroupVO[]); const items = transformGroups((res.data || []) as DeptFileGroupVO[]);
const preloads = state.imageItems.filter((item) => isImageFile(item.type)).map((item) => loadBlobUrl(item.fileId)); deptFileCache[deptId] = items;
state.imageItems = items;
state.selectedFileIndex = 0;
// 加载当前部门全部图片 blob
selectFile(0); selectFile(0);
const preloads = items
.filter((item) => isImageFile(item.type))
.map((item) => loadBlobUrl(item.fileId));
await Promise.allSettled(preloads); await Promise.allSettled(preloads);
} catch (error: any) { } catch (error: any) {
state.errorMessage = error?.message || "加载实时监测图片失败"; 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> => { const loadDeptTree = async (): Promise<string | null> => {
try { try {
const res = await baseService.get("/sys/dept/list"); const res = await baseService.get("/sys/dept/list");
@@ -359,9 +391,24 @@ const loadDeptTree = async (): Promise<string | null> => {
const selectTab = (levelIdx: number, tab: DeptTab) => { const selectTab = (levelIdx: number, tab: DeptTab) => {
state.selectedPath = [...state.selectedPath.slice(0, levelIdx), tab.id]; state.selectedPath = [...state.selectedPath.slice(0, levelIdx), tab.id];
state.imageItems = [];
state.selectedFileIndex = 0; state.selectedFileIndex = 0;
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); loadData(tab.id);
}
}; };
const refresh = () => { 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(() => { onUnmounted(() => {
Object.values(blobUrlCache).forEach((url) => URL.revokeObjectURL(url)); Object.values(blobUrlCache).forEach((url) => URL.revokeObjectURL(url));
document.removeEventListener("keydown", onKeyDown);
}); });
onMounted(async () => { onMounted(async () => {
const deptId = await loadDeptTree(); const deptId = await loadDeptTree();
if (deptId !== null) { 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> </script>