项目名称修改,性能优化,结构优化
This commit is contained in:
+2
-2
@@ -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)
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>weather-data</name>
|
||||
<description>气象数据管理系统</description>
|
||||
<description>气象数据平台</description>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.weather.common.aspect;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
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.constant.Constant;
|
||||
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.UserDetail;
|
||||
import com.weather.modules.sys.enums.SuperAdminEnum;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
@@ -28,6 +29,7 @@ import java.util.Map;
|
||||
*
|
||||
* @author 123
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
public class DataFilterAspect {
|
||||
@@ -54,7 +56,7 @@ public class DataFilterAspect {
|
||||
String sqlFilter = getSqlFilter(user, point);
|
||||
map.put(Constant.SQL_FILTER, new DataScope(sqlFilter));
|
||||
} catch (Exception e) {
|
||||
|
||||
log.error("数据过滤SQL生成失败", e);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -85,7 +87,7 @@ public class DataFilterAspect {
|
||||
if (CollUtil.isNotEmpty(deptIdList)) {
|
||||
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.weather.modules.security.user.SecurityUser;
|
||||
import com.weather.modules.security.user.UserDetail;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.reflection.MetaObject;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -15,6 +16,7 @@ import java.util.Date;
|
||||
*
|
||||
* @author 123
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class FieldMetaObjectHandler implements MetaObjectHandler {
|
||||
private final static String CREATE_DATE = "createDate";
|
||||
@@ -28,24 +30,35 @@ public class FieldMetaObjectHandler implements MetaObjectHandler {
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
Date date = new Date();
|
||||
|
||||
//创建者
|
||||
//创建者(用户ID为空时跳过填充,避免写入无效数据)
|
||||
if (user.getId() != null) {
|
||||
strictInsertFill(metaObject, CREATOR, Long.class, user.getId());
|
||||
} else {
|
||||
log.warn("insertFill: 用户ID为空,跳过creator/deptId/updater字段填充");
|
||||
}
|
||||
//创建时间
|
||||
strictInsertFill(metaObject, CREATE_DATE, Date.class, date);
|
||||
|
||||
//创建者所属部门
|
||||
if (user.getDeptId() != null) {
|
||||
strictInsertFill(metaObject, DEPT_ID, Long.class, user.getDeptId());
|
||||
}
|
||||
|
||||
//更新者
|
||||
if (user.getId() != null) {
|
||||
strictInsertFill(metaObject, UPDATER, Long.class, user.getId());
|
||||
}
|
||||
//更新时间
|
||||
strictInsertFill(metaObject, UPDATE_DATE, Date.class, date);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFill(MetaObject metaObject) {
|
||||
//更新者
|
||||
strictUpdateFill(metaObject, UPDATER, Long.class, SecurityUser.getUserId());
|
||||
Long userId = SecurityUser.getUserId();
|
||||
//更新者(用户ID为空时跳过填充)
|
||||
if (userId != null) {
|
||||
strictUpdateFill(metaObject, UPDATER, Long.class, userId);
|
||||
}
|
||||
//更新时间
|
||||
strictUpdateFill(metaObject, UPDATE_DATE, Date.class, new Date());
|
||||
}
|
||||
|
||||
+12
-7
@@ -5,9 +5,9 @@ package com.weather.common.interceptor;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sf.jsqlparser.JSQLParserException;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.StringValue;
|
||||
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
|
||||
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
|
||||
import net.sf.jsqlparser.statement.select.PlainSelect;
|
||||
@@ -25,6 +25,7 @@ import java.util.Map;
|
||||
*
|
||||
* @author 123
|
||||
*/
|
||||
@Slf4j
|
||||
public class DataFilterInterceptor implements InnerInterceptor {
|
||||
|
||||
@Override
|
||||
@@ -67,16 +68,20 @@ public class DataFilterInterceptor implements InnerInterceptor {
|
||||
Select select = (Select) CCJSqlParserUtil.parse(buildSql);
|
||||
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
|
||||
|
||||
// 将过滤条件解析为真实的 SQL 表达式,而非 StringValue 字符串字面量
|
||||
Expression filterExpression = CCJSqlParserUtil.parseExpression(scope.getSqlFilter());
|
||||
|
||||
Expression expression = plainSelect.getWhere();
|
||||
if(expression == null){
|
||||
plainSelect.setWhere(new StringValue(scope.getSqlFilter()));
|
||||
}else{
|
||||
AndExpression andExpression = new AndExpression(expression, new StringValue(scope.getSqlFilter()));
|
||||
if (expression == null) {
|
||||
plainSelect.setWhere(filterExpression);
|
||||
} else {
|
||||
AndExpression andExpression = new AndExpression(expression, filterExpression);
|
||||
plainSelect.setWhere(andExpression);
|
||||
}
|
||||
|
||||
return select.toString().replaceAll("'", "");
|
||||
}catch (JSQLParserException e){
|
||||
return select.toString();
|
||||
} catch (JSQLParserException e) {
|
||||
log.error("数据过滤SQL解析失败,回退到原始SQL", e);
|
||||
return buildSql;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.shiro.authc.AuthenticationException;
|
||||
import org.apache.shiro.authc.AuthenticationToken;
|
||||
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
|
||||
@@ -24,6 +25,7 @@ import java.io.IOException;
|
||||
*
|
||||
* @author 123
|
||||
*/
|
||||
@Slf4j
|
||||
public class Oauth2Filter extends AuthenticatingFilter {
|
||||
|
||||
@Override
|
||||
@@ -81,7 +83,7 @@ public class Oauth2Filter extends AuthenticatingFilter {
|
||||
String json = JsonUtils.toJsonString(r);
|
||||
httpResponse.getWriter().print(json);
|
||||
} catch (IOException e1) {
|
||||
|
||||
log.error("登录失败响应写入异常", e1);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
package com.weather.modules.security.user;
|
||||
|
||||
import com.weather.modules.sys.enums.SuperAdminEnum;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
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 {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SecurityUser.class);
|
||||
|
||||
public static Subject getSubject() {
|
||||
try {
|
||||
return SecurityUtils.getSubject();
|
||||
@@ -32,7 +37,13 @@ public class SecurityUser {
|
||||
}
|
||||
}
|
||||
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<>();
|
||||
|
||||
/** SSE 连接超时时间(30分钟),避免客户端异常断开后连接泄漏 */
|
||||
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
|
||||
|
||||
/**
|
||||
* 创建并注册一个新的 SSE 连接。
|
||||
* 绑定清理回调:连接完成/超时/异常时自动移除。
|
||||
*/
|
||||
public SseEmitter createEmitter() {
|
||||
SseEmitter emitter = new SseEmitter(0L); // 无超时
|
||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||
emitters.add(emitter);
|
||||
log.info("SSE 连接已建立,当前连接数: {}", emitters.size());
|
||||
|
||||
|
||||
+9
-7
@@ -18,9 +18,8 @@ import lombok.AllArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 字典类型
|
||||
@@ -87,11 +86,14 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
|
||||
public List<DictType> getAllList() {
|
||||
List<DictType> typeList = baseDao.getDictTypeList();
|
||||
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 (DictData data : dataList) {
|
||||
if (type.getId().equals(data.getDictTypeId())) {
|
||||
type.getDataList().add(data);
|
||||
}
|
||||
List<DictData> matched = dataMap.get(type.getId());
|
||||
if (matched != null) {
|
||||
type.getDataList().addAll(matched);
|
||||
}
|
||||
}
|
||||
return typeList;
|
||||
|
||||
+41
-16
@@ -1,8 +1,6 @@
|
||||
package com.weather.modules.weather.dailydata;
|
||||
|
||||
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.service.WeatherDailyDataService;
|
||||
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 lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@@ -22,6 +21,7 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -30,6 +30,10 @@ public class WeatherDataImportManager {
|
||||
|
||||
private final WeatherDailyDataService weatherDailyDataService;
|
||||
private final RedisUtils redisUtils;
|
||||
|
||||
/** 已完成/失败任务的保留时间(毫秒) */
|
||||
private static final long TASK_TTL_MS = TimeUnit.HOURS.toMillis(1);
|
||||
|
||||
private final Map<String, ImportProgress> tasks = new ConcurrentHashMap<>();
|
||||
|
||||
public String submitImport(MultipartFile file) throws IOException {
|
||||
@@ -67,23 +71,14 @@ public class WeatherDataImportManager {
|
||||
private void processImport(String taskId, File tempFile) {
|
||||
ImportProgress progress = tasks.get(taskId);
|
||||
|
||||
AnalysisEventListener<WeatherExcelVO> countListener = new AnalysisEventListener<>() {
|
||||
@Override
|
||||
public void invoke(WeatherExcelVO data, AnalysisContext context) {
|
||||
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))
|
||||
// 单次读取:WeatherDataListener 同时负责计数和入库,避免双次 I/O
|
||||
EasyExcel.read(tempFile, WeatherExcelVO.class,
|
||||
new WeatherDataListener(weatherDailyDataService, progress))
|
||||
.sheet()
|
||||
.doRead();
|
||||
|
||||
progress.setStatus("COMPLETED");
|
||||
progress.setCompletedAt(System.currentTimeMillis());
|
||||
log.info("导入任务 {} 完成,共导入 {} 行", taskId, progress.getProcessedRows().get());
|
||||
|
||||
// 导入完成后清除天气汇总缓存,等待下次定时任务刷新
|
||||
@@ -96,6 +91,36 @@ public class WeatherDataImportManager {
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -35,6 +35,11 @@ public class WeatherDataListener extends AnalysisEventListener<WeatherExcelVO> {
|
||||
|
||||
@Override
|
||||
public void invoke(WeatherExcelVO data, AnalysisContext context) {
|
||||
// 同时计数(替代独立的 countListener,避免双次读取)
|
||||
if (progress != null) {
|
||||
progress.incrementTotalRows();
|
||||
}
|
||||
|
||||
WeatherDailyDataEntity entity = ConvertUtils.sourceToTarget(data, WeatherDailyDataEntity.class);
|
||||
|
||||
try {
|
||||
|
||||
+12
-5
@@ -30,9 +30,8 @@ import io.swagger.v3.oas.annotations.Parameters;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
||||
/**
|
||||
@@ -66,13 +65,21 @@ public class WeatherDailyDataController {
|
||||
@RequiresPermissions("dailyweather:weatherdailydata:page")
|
||||
public Result<PageData<WeatherDailyDataDto>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> 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 -> {
|
||||
Long stationId = item.getStationId();
|
||||
WeatherStationDTO station = weatherStationService.getByStationCode(stationId);
|
||||
WeatherStationDTO station = stationMap.get(item.getStationId());
|
||||
if (station != null) {
|
||||
item.setStationName(station.getStationName());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Result<PageData<WeatherDailyDataDto>>().ok(page);
|
||||
}
|
||||
|
||||
+4
-9
@@ -22,18 +22,13 @@ public interface WeatherDailyDataDao extends BaseDao<WeatherDailyDataEntity> {
|
||||
List<WeatherDailyDataEntity> selectSummarizeList(@Param(Constants.WRAPPER) LambdaQueryWrapper<WeatherDailyDataEntity> wrapper);
|
||||
|
||||
/**
|
||||
* 按站点 + 月-日 + 年份范围查询汇总数据
|
||||
* 按站点 + 精确日期列表查询汇总数据(缓存未命中降级路径)
|
||||
* 使用等值 IN 查询命中 uk_station_date 唯一索引,避免 MONTH/DAY/YEAR 函数导致全表扫描
|
||||
* @param stationIds 站点 ID 列表
|
||||
* @param month 月份 1-12
|
||||
* @param day 日 1-31
|
||||
* @param startYear 起始年份
|
||||
* @param endYear 结束年份
|
||||
* @param dates 精确日期列表,格式 yyyy-MM-dd,如 ["2020-07-01","2021-07-01"]
|
||||
*/
|
||||
List<WeatherDailyDataEntity> selectSummarizeByMonthDay(@Param("stationIds") List<Long> stationIds,
|
||||
@Param("month") int month,
|
||||
@Param("day") int day,
|
||||
@Param("startYear") int startYear,
|
||||
@Param("endYear") int endYear);
|
||||
@Param("dates") List<String> dates);
|
||||
|
||||
int insertBatchMultiRow(@Param("list") List<WeatherDailyDataEntity> list);
|
||||
}
|
||||
+15
-1
@@ -7,12 +7,26 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
@Data
|
||||
public class ImportProgress {
|
||||
private String taskId;
|
||||
private volatile int totalRows;
|
||||
private final AtomicInteger totalRows = new AtomicInteger(0);
|
||||
private final AtomicInteger processedRows = new AtomicInteger(0);
|
||||
private volatile String status;
|
||||
private volatile String errorMessage;
|
||||
/** 任务完成时间戳,用于过期清理 */
|
||||
private volatile Long completedAt;
|
||||
|
||||
public ImportProgress(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public int getTotalRows() {
|
||||
return totalRows.get();
|
||||
}
|
||||
|
||||
public void setTotalRows(int totalRows) {
|
||||
this.totalRows.set(totalRows);
|
||||
}
|
||||
|
||||
public void incrementTotalRows() {
|
||||
totalRows.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
+11
-7
@@ -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(
|
||||
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(
|
||||
stationIds,
|
||||
startTime.getMonthValue(),
|
||||
startTime.getDayOfMonth(),
|
||||
startTime.getYear(),
|
||||
endTime.getYear());
|
||||
stationIds, dates);
|
||||
|
||||
return BeanUtil.copyToList(list, DailyWeatherSummarizeDto.class);
|
||||
}
|
||||
|
||||
+60
-15
@@ -1,13 +1,15 @@
|
||||
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.service.WeatherFileScanRecordService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -16,6 +18,10 @@ import org.springframework.web.bind.annotation.*;
|
||||
import java.io.File;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("filescan/file")
|
||||
@@ -25,10 +31,17 @@ public class FileDownloadController {
|
||||
@Resource
|
||||
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}")
|
||||
@Operation(summary = "获取展示文件")
|
||||
@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);
|
||||
if (record == null || record.getDisplayPath() == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -39,25 +52,57 @@ public class FileDownloadController {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
FileSystemResource resource = new FileSystemResource(file);
|
||||
String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
String ext = record.getFileExt();
|
||||
if (ext == null) {
|
||||
// fall through to default
|
||||
} else if ("png".equalsIgnoreCase(ext)) {
|
||||
contentType = MediaType.IMAGE_PNG_VALUE;
|
||||
} else if ("gif".equalsIgnoreCase(ext)) {
|
||||
contentType = MediaType.IMAGE_GIF_VALUE;
|
||||
} else if ("jpg".equalsIgnoreCase(ext) || "jpeg".equalsIgnoreCase(ext)) {
|
||||
contentType = MediaType.IMAGE_JPEG_VALUE;
|
||||
} else if ("txt".equalsIgnoreCase(ext)) {
|
||||
contentType = MediaType.TEXT_PLAIN_VALUE;
|
||||
// ETag 使用文件 MD5 hash,扫描时已计算
|
||||
String eTag = "\"" + (StrUtil.isNotBlank(record.getMd5Hash()) ? record.getMd5Hash() : file.lastModified()) + "\"";
|
||||
long lastModified = file.lastModified();
|
||||
|
||||
// 条件请求:If-None-Match(ETag 匹配则返回 304)
|
||||
String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH);
|
||||
if (StrUtil.isNotBlank(ifNoneMatch) && ifNoneMatch.equals(eTag)) {
|
||||
return ResponseEntity.status(304)
|
||||
.eTag(eTag)
|
||||
.cacheControl(CacheControl.maxAge(FILE_CACHE_MAX_AGE_HOURS, TimeUnit.HOURS).cachePublic())
|
||||
.build();
|
||||
}
|
||||
|
||||
// 条件请求: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()
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"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);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+23
-5
@@ -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.entity.WeatherFileScanRecordEntity;
|
||||
import com.weather.modules.weather.filescan.service.WeatherFileScanRecordService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -35,12 +35,16 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@RequiredArgsConstructor
|
||||
public class WeatherFileScanRecordServiceImpl extends CrudServiceImpl<WeatherFileScanRecordDao, WeatherFileScanRecordEntity, WeatherFileScanRecordDTO> implements WeatherFileScanRecordService {
|
||||
|
||||
private final SysDeptService sysDeptService;
|
||||
private final SysDeptDao sysDeptDao;
|
||||
|
||||
/** 部门名称缓存,部门数据变化频率极低,缓存5分钟 */
|
||||
private volatile Map<Long, String> deptNameCache;
|
||||
private volatile long deptNameCacheExpireAt;
|
||||
|
||||
@Override
|
||||
public PageData<WeatherFileScanRecordDTO> page(Map<String, Object> params) {
|
||||
IPage<WeatherFileScanRecordEntity> page = baseDao.selectPage(
|
||||
@@ -52,10 +56,24 @@ public class WeatherFileScanRecordServiceImpl extends CrudServiceImpl<WeatherFil
|
||||
return pageData;
|
||||
}
|
||||
|
||||
private void enrichDeptNames(List<WeatherFileScanRecordDTO> list) {
|
||||
List<SysDeptDTO> depts = sysDeptService.list(Map.of());
|
||||
Map<Long, String> deptMap = depts.stream()
|
||||
private Map<Long, String> getDeptNameMap() {
|
||||
long now = System.currentTimeMillis();
|
||||
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));
|
||||
deptNameCacheExpireAt = now + 5 * 60 * 1000;
|
||||
return deptNameCache;
|
||||
}
|
||||
}
|
||||
|
||||
private void enrichDeptNames(List<WeatherFileScanRecordDTO> list) {
|
||||
Map<Long, String> deptMap = getDeptNameMap();
|
||||
for (WeatherFileScanRecordDTO dto : list) {
|
||||
if (dto.getDeptId() != null) {
|
||||
dto.setDeptName(deptMap.get(dto.getDeptId()));
|
||||
|
||||
+6
@@ -8,6 +8,7 @@ import com.weather.modules.weather.station.entity.WeatherStationEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 气象站点表
|
||||
@@ -25,6 +26,11 @@ public interface WeatherStationService extends CrudService<WeatherStationEntity,
|
||||
|
||||
WeatherStationDTO getByStationCode(Long stationCode);
|
||||
|
||||
/**
|
||||
* 批量查询站点信息,避免 N+1 问题
|
||||
*/
|
||||
Map<Long, WeatherStationDTO> getByStationCodes(Set<Long> stationCodes);
|
||||
|
||||
List<WeatherStationInfoSimpleDto> getStationByDeptId();
|
||||
|
||||
List<WeatherStationInfoSimpleDto> getStationByStationName();
|
||||
|
||||
+84
-5
@@ -5,8 +5,11 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.weather.common.constant.Constant;
|
||||
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.utils.ConvertUtils;
|
||||
import com.weather.modules.security.user.SecurityUser;
|
||||
import com.weather.modules.sys.service.SysDeptService;
|
||||
import com.weather.modules.weather.station.dao.WeatherStationDao;
|
||||
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.service.WeatherStationService;
|
||||
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.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 气象站点表
|
||||
@@ -26,10 +31,12 @@ import java.util.Map;
|
||||
* @author Mark 123
|
||||
* @since 1.0.0 2026-03-05
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@RequiredArgsConstructor
|
||||
public class WeatherStationServiceImpl extends CrudServiceImpl<WeatherStationDao, WeatherStationEntity, WeatherStationDTO> implements WeatherStationService {
|
||||
private final SysDeptService sysDeptService;
|
||||
private final RedisUtils redisUtils;
|
||||
@Override
|
||||
public QueryWrapper<WeatherStationEntity> getWrapper(Map<String, Object> params) {
|
||||
String stationName = (String) params.get("stationName");
|
||||
@@ -59,12 +66,57 @@ public class WeatherStationServiceImpl extends CrudServiceImpl<WeatherStationDao
|
||||
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
|
||||
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<>();
|
||||
wrapper = sysDeptService.limitedQueryScope(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
|
||||
@@ -72,4 +124,31 @@ public class WeatherStationServiceImpl extends CrudServiceImpl<WeatherStationDao
|
||||
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=")">
|
||||
#{sid}
|
||||
</foreach>
|
||||
AND MONTH(observe_date) = #{month}
|
||||
AND DAY(observe_date) = #{day}
|
||||
AND YEAR(observe_date) BETWEEN #{startYear} AND #{endYear}
|
||||
AND observe_date IN
|
||||
<foreach collection="dates" item="d" open="(" separator="," close=")">
|
||||
#{d}
|
||||
</foreach>
|
||||
ORDER BY station_id, observe_date
|
||||
</select>
|
||||
|
||||
|
||||
@@ -56,6 +56,20 @@ public class RedisKeys {
|
||||
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(按站点分组,月-日维度)
|
||||
*/
|
||||
|
||||
@@ -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,20 +43,24 @@ 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 ,然后插图图片
|
||||
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) => {
|
||||
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) {
|
||||
// TypeScript 自动知道 item 是 WeatherStation 类型
|
||||
stationOptions.value = res.data.filter((item: { stationName: string | string[]; stationCode: string | string[] }) => !keyword || item.stationName.includes(keyword) || item.stationCode.includes(keyword));
|
||||
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;
|
||||
}
|
||||
}, 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;
|
||||
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