From fc9103d3c93617bf4b739a05d01bca6b22f8ecc2 Mon Sep 17 00:00:00 2001
From: zhouzhou <3272660260@qq.com>
Date: 星期二, 12 五月 2026 13:03:11 +0800
Subject: [PATCH] #数据库备份
---
rsf-server/src/main/resources/mapper/system/DbBackupRecordMapper.xml | 26
wms-admin/src/views/system/role/modules/role-scope-drawer.vue | 141 ++
rsf-server/src/main/java/com/vincent/rsf/server/system/scheduler/DbBackupScheduler.java | 115 ++
rsf-server/src/main/resources/sql/20260511_add_db_backup_feature.sql | 164 +++
wms-admin/package-lock.json | 48 -
wms-admin/src/views/system/dbBackup/api/dbBackupTypes.d.ts | 62 +
rsf-server/src/main/java/com/vincent/rsf/server/system/entity/DbBackupRecord.java | 83 +
wms-admin/pnpm-lock.yaml | 27
wms-admin/package.json | 1
wms-admin/src/views/system/dbBackup/index.vue | 618 ++++++++++++++
/dev/null | 160 ---
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/DbBackupController.java | 129 +++
wms-admin/src/router/modules/system.ts | 16
rsf-server/src/main/java/com/vincent/rsf/server/common/config/MybatisPlusConfig.java | 2
rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/DbBackupServiceImpl.java | 728 +++++++++++++++++
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/param/DbBackupConfigParam.java | 20
rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/DbBackupRecordMapper.java | 25
wms-admin/src/views/system/dbBackup/api/dbBackup.ts | 107 ++
rsf-server/src/main/java/com/vincent/rsf/server/system/service/DbBackupService.java | 34
wms-admin/src/locales/langs/en.json | 3
wms-admin/src/locales/langs/zh.json | 3
21 files changed, 2,245 insertions(+), 267 deletions(-)
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/common/config/MybatisPlusConfig.java b/rsf-server/src/main/java/com/vincent/rsf/server/common/config/MybatisPlusConfig.java
index d6cad94..a820ade 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/common/config/MybatisPlusConfig.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/common/config/MybatisPlusConfig.java
@@ -71,6 +71,7 @@
"sys_menu_pda",
"sys_matnr_role_menu",
"sys_warehouse_role_menu",
+ "db_backup_record",
"man_loc_type_rela",
"man_qly_inspect_result",
"view_stock_manage",
@@ -167,4 +168,3 @@
};
}
}
-
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/DbBackupController.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/DbBackupController.java
new file mode 100644
index 0000000..767e77c
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/DbBackupController.java
@@ -0,0 +1,129 @@
+package com.vincent.rsf.server.system.controller;
+
+import com.vincent.rsf.framework.common.R;
+import com.vincent.rsf.server.common.annotation.OperationLog;
+import com.vincent.rsf.server.system.controller.param.DbBackupConfigParam;
+import com.vincent.rsf.server.system.entity.DbBackupRecord;
+import com.vincent.rsf.server.system.service.DbBackupService;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.OutputStream;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@RestController
+public class DbBackupController extends BaseController {
+
+ private final DbBackupService dbBackupService;
+
+ public DbBackupController(DbBackupService dbBackupService) {
+ this.dbBackupService = dbBackupService;
+ }
+
+ @PreAuthorize("hasAuthority('system:dbBackup:list')")
+ @GetMapping("/dbBackup/config")
+ public R getConfig() {
+ return R.ok().add(dbBackupService.getConfig());
+ }
+
+ @PreAuthorize("hasAuthority('system:dbBackup:save')")
+ @OperationLog("Save Database Backup Config")
+ @PostMapping("/dbBackup/config/save")
+ public R saveConfig(@RequestBody DbBackupConfigParam param) {
+ if (param == null) {
+ return R.error("鍙傛暟涓嶈兘涓虹┖");
+ }
+ dbBackupService.saveConfig(param);
+ return R.ok("淇濆瓨鎴愬姛");
+ }
+
+ @PreAuthorize("hasAuthority('system:dbBackup:list')")
+ @GetMapping("/dbBackup/tables")
+ public R listTables() {
+ return R.ok().add(dbBackupService.listBackupableTables());
+ }
+
+ @PreAuthorize("hasAuthority('system:dbBackup:run')")
+ @OperationLog("Manual Database Backup")
+ @PostMapping("/dbBackup/run")
+ public R runBackup(@RequestBody(required = false) Map<String, Object> body) {
+ if (dbBackupService.isBackupRunning()) {
+ return R.error("澶囦唤姝e湪杩涜涓紝璇风◢鍚庡啀璇�");
+ }
+ List<String> tables = null;
+ if (body != null && body.get("tables") instanceof List<?> rawTables) {
+ tables = rawTables.stream()
+ .filter(item -> item != null)
+ .map(String::valueOf)
+ .toList();
+ }
+ DbBackupRecord record = dbBackupService.executeManualBackup(tables);
+ Map<String, Object> result = new HashMap<>();
+ result.put("id", record.getId());
+ result.put("status", record.getStatus());
+ return R.ok("澶囦唤宸插紑濮�").add(result);
+ }
+
+ @PreAuthorize("hasAuthority('system:dbBackup:list')")
+ @GetMapping("/dbBackup/progress")
+ public R getProgress() {
+ return R.ok().add(dbBackupService.getProgress());
+ }
+
+ @PreAuthorize("hasAuthority('system:dbBackup:list')")
+ @GetMapping("/dbBackup/history")
+ public R getHistory(@RequestParam(defaultValue = "1") int current,
+ @RequestParam(defaultValue = "20") int pageSize) {
+ Map<String, Object> result = new HashMap<>();
+ result.put("records", dbBackupService.listHistory(current, pageSize));
+ result.put("total", dbBackupService.countHistory());
+ result.put("current", current);
+ result.put("size", pageSize);
+ return R.ok().add(result);
+ }
+
+ @PreAuthorize("hasAuthority('system:dbBackup:download')")
+ @GetMapping("/dbBackup/download")
+ public void downloadBackup(@RequestParam Long id, HttpServletResponse response) {
+ DbBackupRecord record = dbBackupService.getRecord(id);
+ if (record == null || record.getFileName() == null || !"SUCCESS".equals(record.getStatus())) {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
+ try {
+ String zipName = record.getFileName() + ".zip";
+ String encodedName = URLEncoder.encode(zipName, StandardCharsets.UTF_8).replace("+", "%20");
+ response.setContentType("application/zip");
+ response.setHeader("Content-Disposition", "attachment; filename=\"" + zipName + "\"; filename*=UTF-8''" + encodedName);
+ try (OutputStream output = response.getOutputStream()) {
+ output.write(dbBackupService.buildDownloadZip(record));
+ output.flush();
+ }
+ } catch (Exception e) {
+ log.error("涓嬭浇鏁版嵁搴撳浠藉け璐�", e);
+ response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ @PreAuthorize("hasAuthority('system:dbBackup:remove')")
+ @OperationLog("Delete Database Backup")
+ @PostMapping("/dbBackup/delete")
+ public R deleteBackup(@RequestBody Map<String, Long> body) {
+ if (body == null || body.get("id") == null) {
+ return R.error("缂哄皯澶囦唤璁板綍ID");
+ }
+ dbBackupService.deleteBackup(body.get("id"));
+ return R.ok("鍒犻櫎鎴愬姛");
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/param/DbBackupConfigParam.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/param/DbBackupConfigParam.java
new file mode 100644
index 0000000..afb145f
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/param/DbBackupConfigParam.java
@@ -0,0 +1,20 @@
+package com.vincent.rsf.server.system.controller.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class DbBackupConfigParam {
+
+ private Boolean backupEnabled;
+ private String backupCron;
+ private List<String> backupTables;
+ private List<String> backupExcludeTables;
+ private String backupPath;
+ private Integer retentionCount;
+ private Integer batchSize;
+ private Boolean uploadEnabled;
+ private String uploadUrl;
+ private String uploadToken;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/entity/DbBackupRecord.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/entity/DbBackupRecord.java
new file mode 100644
index 0000000..e5fc6f0
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/entity/DbBackupRecord.java
@@ -0,0 +1,83 @@
+package com.vincent.rsf.server.system.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+@Data
+@TableName("db_backup_record")
+public class DbBackupRecord implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty(value = "ID")
+ @TableId(value = "id", type = IdType.AUTO)
+ private Long id;
+
+ @ApiModelProperty(value = "澶囦唤寮�濮嬫椂闂�")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+ private Date backupTime;
+
+ @ApiModelProperty(value = "澶囦唤琛ㄥ垪琛�")
+ private String tablesDumped;
+
+ @ApiModelProperty(value = "澶囦唤鐩綍鍚�")
+ private String fileName;
+
+ @ApiModelProperty(value = "鏂囦欢澶у皬")
+ private Long fileSize;
+
+ @ApiModelProperty(value = "鐘舵��")
+ private String status;
+
+ @ApiModelProperty(value = "瑙﹀彂鏂瑰紡")
+ private String triggerType;
+
+ @ApiModelProperty(value = "閿欒淇℃伅")
+ private String errorMessage;
+
+ @ApiModelProperty(value = "涓婁紶鐘舵��")
+ private String uploadStatus;
+
+ @ApiModelProperty(value = "涓婁紶鏃堕棿")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+ private Date uploadTime;
+
+ @ApiModelProperty(value = "涓婁紶閿欒")
+ private String uploadError;
+
+ @ApiModelProperty(value = "鑰楁椂姣")
+ private Long durationMs;
+
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+ private Date createTime;
+
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+ private Date updateTime;
+
+ public String getBackupTime$() {
+ if (backupTime == null) {
+ return "";
+ }
+ return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(backupTime);
+ }
+
+ public String getUploadTime$() {
+ if (uploadTime == null) {
+ return "";
+ }
+ return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(uploadTime);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/DbBackupRecordMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/DbBackupRecordMapper.java
new file mode 100644
index 0000000..f8b868b
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/DbBackupRecordMapper.java
@@ -0,0 +1,25 @@
+package com.vincent.rsf.server.system.mapper;
+
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.vincent.rsf.server.system.entity.DbBackupRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Map;
+
+@Mapper
+@Repository
+public interface DbBackupRecordMapper extends BaseMapper<DbBackupRecord> {
+
+ @InterceptorIgnore(tenantLine = "true")
+ List<Map<String, String>> selectBackupableTables();
+
+ @InterceptorIgnore(tenantLine = "true")
+ List<DbBackupRecord> selectPageRecords(@Param("offset") int offset, @Param("size") int size);
+
+ @InterceptorIgnore(tenantLine = "true")
+ int countRecords();
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/scheduler/DbBackupScheduler.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/scheduler/DbBackupScheduler.java
new file mode 100644
index 0000000..81f32b4
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/scheduler/DbBackupScheduler.java
@@ -0,0 +1,115 @@
+package com.vincent.rsf.server.system.scheduler;
+
+import com.vincent.rsf.server.system.service.DbBackupService;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.support.CronExpression;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+@Component
+public class DbBackupScheduler {
+
+ private static final String DEFAULT_CRON = "0 0 2 * * ?";
+
+ private final DbBackupService dbBackupService;
+ private final ExecutorService executor = Executors.newSingleThreadExecutor(new NamedThreadFactory());
+ private volatile CronExpression cronExpression = CronExpression.parse(DEFAULT_CRON);
+
+ public DbBackupScheduler(DbBackupService dbBackupService) {
+ this.dbBackupService = dbBackupService;
+ }
+
+ @PostConstruct
+ public void start() {
+ executor.execute(this::backupLoop);
+ }
+
+ @PreDestroy
+ public void shutdown() {
+ executor.shutdownNow();
+ try {
+ if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
+ executor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ executor.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void backupLoop() {
+ while (!Thread.currentThread().isInterrupted()) {
+ try {
+ sleepUntilNextBackup();
+ if (!isBackupEnabled()) {
+ continue;
+ }
+ if (!dbBackupService.isBackupRunning()) {
+ log.info("寮�濮嬪畾鏃舵暟鎹簱澶囦唤");
+ dbBackupService.executeScheduledBackup();
+ } else {
+ log.info("鏁版嵁搴撳浠芥鍦ㄨ繘琛屼腑锛岃烦杩囨湰娆″畾鏃舵墽琛�");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (Exception e) {
+ log.warn("鏁版嵁搴撳畾鏃跺浠藉惊鐜紓甯�", e);
+ }
+ }
+ }
+
+ private void sleepUntilNextBackup() throws InterruptedException {
+ refreshCron();
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime next = cronExpression.next(now);
+ if (next == null) {
+ TimeUnit.MINUTES.sleep(1);
+ return;
+ }
+ long sleepMs = Math.max(1000L, Duration.between(now, next).toMillis());
+ log.info("鏁版嵁搴撲笅娆″浠芥椂闂�: {}", next);
+ TimeUnit.MILLISECONDS.sleep(sleepMs);
+ }
+
+ private boolean isBackupEnabled() {
+ Object enabled = dbBackupService.getConfig().get("backupEnabled");
+ return enabled == null || Boolean.parseBoolean(String.valueOf(enabled));
+ }
+
+ private void refreshCron() {
+ String backupCron = DEFAULT_CRON;
+ Map<String, Object> config = dbBackupService.getConfig();
+ Object cron = config.get("backupCron");
+ if (cron != null && !String.valueOf(cron).isBlank()) {
+ backupCron = String.valueOf(cron);
+ }
+ try {
+ cronExpression = CronExpression.parse(backupCron);
+ } catch (IllegalArgumentException e) {
+ log.warn("鏁版嵁搴撳浠� cron 琛ㄨ揪寮忔棤鏁�: {}, 鍥為��榛樿: {}", backupCron, DEFAULT_CRON);
+ cronExpression = CronExpression.parse(DEFAULT_CRON);
+ }
+ }
+
+ private static class NamedThreadFactory implements ThreadFactory {
+ private final AtomicInteger index = new AtomicInteger(1);
+
+ @Override
+ public Thread newThread(Runnable runnable) {
+ Thread thread = new Thread(runnable, "db-backup-scheduler-" + index.getAndIncrement());
+ thread.setDaemon(true);
+ return thread;
+ }
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/service/DbBackupService.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/service/DbBackupService.java
new file mode 100644
index 0000000..8571bd8
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/service/DbBackupService.java
@@ -0,0 +1,34 @@
+package com.vincent.rsf.server.system.service;
+
+import com.vincent.rsf.server.system.controller.param.DbBackupConfigParam;
+import com.vincent.rsf.server.system.entity.DbBackupRecord;
+
+import java.util.List;
+import java.util.Map;
+
+public interface DbBackupService {
+
+ Map<String, Object> getConfig();
+
+ void saveConfig(DbBackupConfigParam param);
+
+ List<Map<String, String>> listBackupableTables();
+
+ DbBackupRecord executeManualBackup(List<String> tables);
+
+ DbBackupRecord executeScheduledBackup();
+
+ List<DbBackupRecord> listHistory(int page, int size);
+
+ int countHistory();
+
+ DbBackupRecord getRecord(Long recordId);
+
+ byte[] buildDownloadZip(DbBackupRecord record) throws Exception;
+
+ void deleteBackup(Long recordId);
+
+ Map<String, Object> getProgress();
+
+ boolean isBackupRunning();
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/DbBackupServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/DbBackupServiceImpl.java
new file mode 100644
index 0000000..b88df53
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/DbBackupServiceImpl.java
@@ -0,0 +1,728 @@
+package com.vincent.rsf.server.system.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.vincent.rsf.common.utils.GsonUtils;
+import com.vincent.rsf.framework.exception.CoolException;
+import com.vincent.rsf.server.system.controller.param.DbBackupConfigParam;
+import com.vincent.rsf.server.system.entity.DbBackupRecord;
+import com.vincent.rsf.server.system.mapper.DbBackupRecordMapper;
+import com.vincent.rsf.server.system.service.ConfigService;
+import com.vincent.rsf.server.system.service.DbBackupService;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import javax.sql.DataSource;
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.Date;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Time;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.text.SimpleDateFormat;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.zip.GZIPOutputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+@Slf4j
+@Service("dbBackupService")
+public class DbBackupServiceImpl implements DbBackupService {
+
+ private static final String CONFIG_FLAG = "dbBackupConfig";
+ private static final String DEFAULT_CRON = "0 0 2 * * ?";
+ private static final String DEFAULT_PATH = "../stock/out/wms/dbBackups";
+ private static final int DEFAULT_RETENTION = 10;
+ private static final int DEFAULT_BATCH_SIZE = 1000;
+ private static final int MAX_BATCH_SIZE = 10000;
+ private static final Set<Integer> NUMERIC_TYPES = Set.of(
+ Types.TINYINT, Types.SMALLINT, Types.INTEGER, Types.BIGINT, Types.FLOAT,
+ Types.REAL, Types.DOUBLE, Types.NUMERIC, Types.DECIMAL
+ );
+
+ private final AtomicBoolean backupRunning = new AtomicBoolean(false);
+ private volatile BackupProgress progress = new BackupProgress();
+
+ private final ConfigService configService;
+ private final DbBackupRecordMapper dbBackupRecordMapper;
+ private final DataSource dataSource;
+ private final Executor taskExecutor;
+
+ public DbBackupServiceImpl(ConfigService configService,
+ DbBackupRecordMapper dbBackupRecordMapper,
+ DataSource dataSource,
+ @Qualifier("taskExecutor") Executor taskExecutor) {
+ this.configService = configService;
+ this.dbBackupRecordMapper = dbBackupRecordMapper;
+ this.dataSource = dataSource;
+ this.taskExecutor = taskExecutor;
+ }
+
+ @Override
+ public Map<String, Object> getConfig() {
+ return readConfig();
+ }
+
+ @Override
+ public void saveConfig(DbBackupConfigParam param) {
+ Map<String, Object> config = readConfig();
+ putIfNotNull(config, "backupEnabled", param.getBackupEnabled());
+ putIfNotBlank(config, "backupCron", param.getBackupCron());
+ putIfNotNull(config, "backupTables", cleanTableList(param.getBackupTables()));
+ putIfNotNull(config, "backupExcludeTables", cleanTableList(param.getBackupExcludeTables()));
+ putIfNotBlank(config, "backupPath", param.getBackupPath());
+ putIfPositive(config, "retentionCount", param.getRetentionCount(), DEFAULT_RETENTION);
+ putIfPositive(config, "batchSize", param.getBatchSize(), DEFAULT_BATCH_SIZE);
+ putIfNotNull(config, "uploadEnabled", param.getUploadEnabled());
+ putIfNotNull(config, "uploadUrl", param.getUploadUrl());
+ putIfNotNull(config, "uploadToken", param.getUploadToken());
+ if (!configService.setVal(CONFIG_FLAG, config)) {
+ throw new CoolException("淇濆瓨鏁版嵁搴撳浠介厤缃け璐�");
+ }
+ ConfigServiceImpl.CONFIG_CACHE.remove(CONFIG_FLAG);
+ }
+
+ @Override
+ public List<Map<String, String>> listBackupableTables() {
+ return dbBackupRecordMapper.selectBackupableTables();
+ }
+
+ @Override
+ public DbBackupRecord executeManualBackup(List<String> tables) {
+ return doBackup("MANUAL", tables);
+ }
+
+ @Override
+ public DbBackupRecord executeScheduledBackup() {
+ Map<String, Object> config = readConfig();
+ List<String> tables = getStringList(config, "backupTables");
+ return doBackup("SCHEDULED", tables.isEmpty() ? null : tables);
+ }
+
+ @Override
+ public List<DbBackupRecord> listHistory(int page, int size) {
+ int normalizedSize = Math.min(Math.max(size, 1), 200);
+ int offset = Math.max(0, page - 1) * normalizedSize;
+ return dbBackupRecordMapper.selectPageRecords(offset, normalizedSize);
+ }
+
+ @Override
+ public int countHistory() {
+ return dbBackupRecordMapper.countRecords();
+ }
+
+ @Override
+ public DbBackupRecord getRecord(Long recordId) {
+ return dbBackupRecordMapper.selectById(recordId);
+ }
+
+ @Override
+ public void deleteBackup(Long recordId) {
+ DbBackupRecord record = dbBackupRecordMapper.selectById(recordId);
+ if (record == null) {
+ return;
+ }
+ deleteBackupFile(record, readConfig());
+ dbBackupRecordMapper.deleteById(recordId);
+ }
+
+ @Override
+ public Map<String, Object> getProgress() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("running", backupRunning.get());
+ map.put("phase", progress.getPhase());
+ map.put("currentTable", progress.getCurrentTable());
+ map.put("tableIndex", progress.getTableIndex());
+ map.put("totalTables", progress.getTotalTables());
+ map.put("rowsDumped", progress.getRowsDumped());
+ map.put("message", progress.getMessage());
+ return map;
+ }
+
+ @Override
+ public boolean isBackupRunning() {
+ return backupRunning.get();
+ }
+
+ private DbBackupRecord doBackup(String triggerType, List<String> tables) {
+ if (!backupRunning.compareAndSet(false, true)) {
+ if ("MANUAL".equals(triggerType)) {
+ throw new CoolException("澶囦唤姝e湪杩涜涓紝璇风◢鍚庡啀璇�");
+ }
+ log.info("鏁版嵁搴撳浠芥鍦ㄨ繘琛屼腑锛岃烦杩囨湰娆″畾鏃舵墽琛�");
+ return null;
+ }
+
+ DbBackupRecord record = new DbBackupRecord();
+ record.setBackupTime(new java.util.Date());
+ record.setTriggerType(triggerType);
+ record.setStatus("RUNNING");
+ record.setUploadStatus("SKIPPED");
+ record.setTablesDumped(toJson(cleanTableList(tables)));
+ dbBackupRecordMapper.insert(record);
+
+ progress = new BackupProgress();
+ progress.setPhase("PREPARING");
+ progress.setMessage("鍑嗗澶囦唤");
+
+ if ("MANUAL".equals(triggerType)) {
+ taskExecutor.execute(() -> doBackupInternal(record, tables));
+ return record;
+ }
+
+ doBackupInternal(record, tables);
+ return record;
+ }
+
+ private void doBackupInternal(DbBackupRecord record, List<String> tables) {
+ long startTime = System.currentTimeMillis();
+ try {
+ Map<String, Object> config = readConfig();
+ String backupPath = getConfigString(config, "backupPath", DEFAULT_PATH);
+ Path baseDir = Paths.get(backupPath).normalize();
+ String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new java.util.Date());
+ String dirName = "wms_backup_" + timestamp;
+ Path backupDir = baseDir.resolve(dirName);
+ Files.createDirectories(backupDir);
+
+ List<String> tablesToBackup = resolveTables(tables, config);
+ if (tablesToBackup.isEmpty()) {
+ throw new CoolException("娌℃湁鍙浠界殑鏁版嵁琛�");
+ }
+ int batchSize = Math.min(Math.max(getConfigInt(config, "batchSize", DEFAULT_BATCH_SIZE), 1), MAX_BATCH_SIZE);
+
+ record.setTablesDumped(toJson(tablesToBackup));
+ progress.setTotalTables(tablesToBackup.size());
+ progress.setMessage("寮�濮嬪鍑烘暟鎹〃");
+ dumpDatabase(tablesToBackup, backupDir, batchSize);
+
+ long totalSize = calculateDirSize(backupDir);
+ record.setFileName(dirName);
+ record.setFileSize(totalSize);
+ record.setStatus("SUCCESS");
+
+ if (Boolean.TRUE.equals(config.get("uploadEnabled"))) {
+ progress.setPhase("UPLOADING");
+ progress.setMessage("涓婁紶澶囦唤鏂囦欢");
+ uploadBackupDir(record, backupDir, config);
+ }
+ } catch (Exception e) {
+ record.setStatus("FAILED");
+ record.setErrorMessage(truncate(e.getMessage(), 1024));
+ progress.setPhase("FAILED");
+ progress.setMessage(e.getMessage());
+ log.error("鏁版嵁搴撳浠藉け璐�", e);
+ } finally {
+ record.setDurationMs(System.currentTimeMillis() - startTime);
+ dbBackupRecordMapper.updateById(record);
+ cleanupOldBackups();
+ if (!"FAILED".equals(record.getStatus())) {
+ progress.setPhase("DONE");
+ progress.setMessage("澶囦唤瀹屾垚");
+ }
+ backupRunning.set(false);
+ }
+ }
+
+ private List<String> resolveTables(List<String> tables, Map<String, Object> config) {
+ Map<String, String> validTables = queryValidTables();
+ List<String> candidates = cleanTableList(tables);
+ if (candidates.isEmpty()) {
+ candidates = getStringList(config, "backupTables");
+ }
+ if (candidates.isEmpty()) {
+ candidates = new ArrayList<>(validTables.keySet());
+ }
+
+ Set<String> excluded = new HashSet<>(getStringList(config, "backupExcludeTables"));
+ List<String> result = new ArrayList<>();
+ for (String table : candidates) {
+ if (!validTables.containsKey(table) || excluded.contains(table)) {
+ continue;
+ }
+ result.add(table);
+ }
+ return result;
+ }
+
+ private Map<String, String> queryValidTables() {
+ List<Map<String, String>> rows = listBackupableTables();
+ Map<String, String> validTables = new LinkedHashMap<>();
+ for (Map<String, String> row : rows) {
+ Object name = row.get("name");
+ if (name == null) {
+ name = row.get("NAME");
+ }
+ if (name != null) {
+ validTables.put(String.valueOf(name), String.valueOf(name));
+ }
+ }
+ return validTables;
+ }
+
+ private void dumpDatabase(List<String> tables, Path backupDir, int batchSize) throws Exception {
+ try (Connection conn = dataSource.getConnection()) {
+ for (int i = 0; i < tables.size(); i++) {
+ String table = tables.get(i);
+ progress.setPhase("DUMPING");
+ progress.setCurrentTable(table);
+ progress.setTableIndex(i + 1);
+ progress.setMessage("姝e湪瀵煎嚭 " + table);
+ dumpTable(conn, table, backupDir.resolve(table + ".sql.gz"), batchSize);
+ }
+ }
+ }
+
+ private void dumpTable(Connection conn, String table, Path outputPath, int batchSize) throws Exception {
+ String quotedTable = quoteIdentifier(table);
+ try (OutputStream fos = Files.newOutputStream(outputPath);
+ GZIPOutputStream gos = new GZIPOutputStream(fos);
+ BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(gos, StandardCharsets.UTF_8))) {
+
+ String ddl;
+ try (Statement stmt = conn.createStatement();
+ ResultSet rs = stmt.executeQuery("SHOW CREATE TABLE " + quotedTable)) {
+ if (!rs.next()) {
+ return;
+ }
+ ddl = rs.getString(2);
+ }
+
+ writer.write("-- WMS Database Backup - Table: " + table);
+ writer.newLine();
+ writer.write("-- Date: " + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new java.util.Date()));
+ writer.newLine();
+ writer.write("SET NAMES utf8mb4;");
+ writer.newLine();
+ writer.write("SET FOREIGN_KEY_CHECKS=0;");
+ writer.newLine();
+ writer.newLine();
+ writer.write("DROP TABLE IF EXISTS " + quotedTable + ";");
+ writer.newLine();
+ writer.write(ddl + ";");
+ writer.newLine();
+ writer.newLine();
+
+ ColumnMeta columnMeta = readColumnMeta(conn, quotedTable);
+ long rowCount = 0;
+ try (Statement stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
+ stmt.setFetchSize(Integer.MIN_VALUE);
+ try (ResultSet rs = stmt.executeQuery("SELECT * FROM " + quotedTable)) {
+ while (rs.next()) {
+ if (rowCount % batchSize == 0) {
+ if (rowCount > 0) {
+ writer.write(";");
+ writer.newLine();
+ }
+ writer.write("INSERT INTO " + quotedTable + " VALUES ");
+ writer.newLine();
+ } else {
+ writer.write(",");
+ writer.newLine();
+ }
+ writeRow(rs, columnMeta, writer);
+ rowCount++;
+ progress.incrementRowsDumped();
+ }
+ }
+ }
+
+ if (rowCount > 0) {
+ writer.write(";");
+ writer.newLine();
+ } else {
+ writer.write("-- (empty table)");
+ writer.newLine();
+ }
+ writer.newLine();
+ writer.write("SET FOREIGN_KEY_CHECKS=1;");
+ writer.newLine();
+ }
+ }
+
+ private ColumnMeta readColumnMeta(Connection conn, String quotedTable) throws SQLException {
+ try (Statement stmt = conn.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT * FROM " + quotedTable + " WHERE 1=0")) {
+ ResultSetMetaData meta = rs.getMetaData();
+ int columnCount = meta.getColumnCount();
+ int[] columnTypes = new int[columnCount + 1];
+ for (int i = 1; i <= columnCount; i++) {
+ columnTypes[i] = meta.getColumnType(i);
+ }
+ return new ColumnMeta(columnCount, columnTypes);
+ }
+ }
+
+ private void writeRow(ResultSet rs, ColumnMeta meta, BufferedWriter writer) throws Exception {
+ writer.write("(");
+ for (int i = 1; i <= meta.columnCount(); i++) {
+ if (i > 1) {
+ writer.write(",");
+ }
+ writeValue(rs, i, meta.columnTypes()[i], writer);
+ }
+ writer.write(")");
+ }
+
+ private void writeValue(ResultSet rs, int columnIndex, int sqlType, BufferedWriter writer) throws Exception {
+ Object value = rs.getObject(columnIndex);
+ if (value == null) {
+ writer.write("NULL");
+ return;
+ }
+ if (NUMERIC_TYPES.contains(sqlType) && value instanceof Number) {
+ writer.write(formatNumber(value));
+ return;
+ }
+ switch (sqlType) {
+ case Types.BIT:
+ case Types.BOOLEAN:
+ writer.write(rs.getBoolean(columnIndex) ? "1" : "0");
+ break;
+ case Types.BINARY:
+ case Types.VARBINARY:
+ case Types.LONGVARBINARY:
+ case Types.BLOB:
+ byte[] bytes = rs.getBytes(columnIndex);
+ writer.write(bytes == null ? "NULL" : "X'" + bytesToHex(bytes) + "'");
+ break;
+ case Types.DATE:
+ Date date = rs.getDate(columnIndex);
+ writer.write(date == null ? "NULL" : "'" + new SimpleDateFormat("yyyy-MM-dd").format(date) + "'");
+ break;
+ case Types.TIMESTAMP:
+ case Types.TIMESTAMP_WITH_TIMEZONE:
+ Timestamp ts = rs.getTimestamp(columnIndex);
+ writer.write(ts == null ? "NULL" : "'" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(ts) + "'");
+ break;
+ case Types.TIME:
+ case Types.TIME_WITH_TIMEZONE:
+ Time time = rs.getTime(columnIndex);
+ writer.write(time == null ? "NULL" : "'" + time + "'");
+ break;
+ default:
+ String str = rs.getString(columnIndex);
+ writer.write(str == null ? "NULL" : "'" + escapeString(str) + "'");
+ break;
+ }
+ }
+
+ private String formatNumber(Object value) {
+ if (value instanceof BigDecimal decimal) {
+ return decimal.toPlainString();
+ }
+ if (value instanceof BigInteger integer) {
+ return integer.toString();
+ }
+ return String.valueOf(value);
+ }
+
+ private String quoteIdentifier(String identifier) {
+ if (identifier == null || !identifier.matches("[A-Za-z0-9_]+")) {
+ throw new CoolException("闈炴硶鏁版嵁琛ㄥ悕绉�: " + identifier);
+ }
+ return "`" + identifier + "`";
+ }
+
+ private String escapeString(String s) {
+ StringBuilder sb = new StringBuilder(s.length() + 16);
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '\0' -> sb.append("\\0");
+ case '\'' -> sb.append("\\'");
+ case '"' -> sb.append("\\\"");
+ case '\b' -> sb.append("\\b");
+ case '\n' -> sb.append("\\n");
+ case '\r' -> sb.append("\\r");
+ case '\t' -> sb.append("\\t");
+ case '\\' -> sb.append("\\\\");
+ default -> sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ private String bytesToHex(byte[] bytes) {
+ StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ sb.append(String.format("%02X", b));
+ }
+ return sb.toString();
+ }
+
+ private long calculateDirSize(Path backupDir) throws Exception {
+ try (var stream = Files.list(backupDir)) {
+ return stream.filter(Files::isRegularFile)
+ .mapToLong(path -> {
+ try {
+ return Files.size(path);
+ } catch (Exception e) {
+ return 0;
+ }
+ })
+ .sum();
+ }
+ }
+
+ private void uploadBackupDir(DbBackupRecord record, Path backupDir, Map<String, Object> config) {
+ String uploadUrl = getConfigString(config, "uploadUrl", "");
+ String uploadToken = getConfigString(config, "uploadToken", "");
+ if (uploadUrl.isBlank()) {
+ record.setUploadStatus("FAILED");
+ record.setUploadError("涓婁紶鍦板潃鏈厤缃�");
+ return;
+ }
+ try {
+ byte[] body = zipBackupDir(record.getFileName(), backupDir);
+ HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
+ .uri(URI.create(uploadUrl))
+ .timeout(Duration.ofMinutes(5))
+ .header("Content-Type", "application/zip")
+ .header("X-File-Name", record.getFileName() + ".zip")
+ .POST(HttpRequest.BodyPublishers.ofByteArray(body));
+ if (!uploadToken.isBlank()) {
+ requestBuilder.header("Authorization", "Bearer " + uploadToken);
+ }
+ HttpResponse<String> response = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(30))
+ .build()
+ .send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() >= 200 && response.statusCode() < 300) {
+ record.setUploadStatus("SUCCESS");
+ record.setUploadTime(new java.util.Date());
+ } else {
+ record.setUploadStatus("FAILED");
+ record.setUploadError(truncate("HTTP " + response.statusCode() + ": " + response.body(), 512));
+ }
+ } catch (Exception e) {
+ record.setUploadStatus("FAILED");
+ record.setUploadError(truncate(e.getMessage(), 512));
+ log.warn("澶囦唤鏂囦欢涓婁紶澶辫触", e);
+ }
+ }
+
+ public byte[] zipBackupDir(String rootName, Path backupDir) throws Exception {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (ZipOutputStream zos = new ZipOutputStream(output, StandardCharsets.UTF_8);
+ var stream = Files.list(backupDir)) {
+ for (Path file : (Iterable<Path>) stream::iterator) {
+ if (!Files.isRegularFile(file)) {
+ continue;
+ }
+ zos.putNextEntry(new ZipEntry(rootName + "/" + file.getFileName()));
+ Files.copy(file, zos);
+ zos.closeEntry();
+ }
+ }
+ return output.toByteArray();
+ }
+
+ private void cleanupOldBackups() {
+ Map<String, Object> config = readConfig();
+ int keepCount = Math.max(getConfigInt(config, "retentionCount", DEFAULT_RETENTION), 1);
+ try {
+ List<DbBackupRecord> oldRecords = dbBackupRecordMapper.selectList(
+ new QueryWrapper<DbBackupRecord>()
+ .eq("status", "SUCCESS")
+ .orderByDesc("backup_time")
+ .last("LIMIT 999999 OFFSET " + keepCount)
+ );
+ for (DbBackupRecord old : oldRecords) {
+ deleteBackupFile(old, config);
+ dbBackupRecordMapper.deleteById(old.getId());
+ }
+ } catch (Exception e) {
+ log.warn("娓呯悊鏃у浠借褰曞け璐�", e);
+ }
+ }
+
+ private void deleteBackupFile(DbBackupRecord record, Map<String, Object> config) {
+ if (record.getFileName() == null) {
+ return;
+ }
+ try {
+ Path dirPath = Paths.get(getConfigString(config, "backupPath", DEFAULT_PATH), record.getFileName()).normalize();
+ if (Files.isDirectory(dirPath)) {
+ try (var stream = Files.list(dirPath)) {
+ for (Path file : (Iterable<Path>) stream::iterator) {
+ Files.deleteIfExists(file);
+ }
+ }
+ Files.deleteIfExists(dirPath);
+ } else {
+ Files.deleteIfExists(dirPath);
+ }
+ } catch (Exception e) {
+ log.warn("鍒犻櫎澶囦唤鐩綍澶辫触: {}", record.getFileName(), e);
+ }
+ }
+
+ @Override
+ public byte[] buildDownloadZip(DbBackupRecord record) throws Exception {
+ Path backupDir = resolveBackupDir(record);
+ if (backupDir == null || !Files.isDirectory(backupDir)) {
+ throw new CoolException("澶囦唤鏂囦欢涓嶅瓨鍦�");
+ }
+ return zipBackupDir(record.getFileName(), backupDir);
+ }
+
+ public Path resolveBackupDir(DbBackupRecord record) {
+ if (record == null || record.getFileName() == null) {
+ return null;
+ }
+ return Paths.get(getConfigString(readConfig(), "backupPath", DEFAULT_PATH), record.getFileName()).normalize();
+ }
+
+ private Map<String, Object> readConfig() {
+ Map<String, Object> config = configService.getVal(CONFIG_FLAG, Map.class);
+ if (config == null) {
+ return defaultConfigMap();
+ }
+ Map<String, Object> merged = defaultConfigMap();
+ merged.putAll(config);
+ return merged;
+ }
+
+ private Map<String, Object> defaultConfigMap() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("backupEnabled", true);
+ map.put("backupCron", DEFAULT_CRON);
+ map.put("backupTables", Collections.emptyList());
+ map.put("backupExcludeTables", Collections.emptyList());
+ map.put("backupPath", DEFAULT_PATH);
+ map.put("retentionCount", DEFAULT_RETENTION);
+ map.put("batchSize", DEFAULT_BATCH_SIZE);
+ map.put("uploadEnabled", false);
+ map.put("uploadUrl", "");
+ map.put("uploadToken", "");
+ return map;
+ }
+
+ private void putIfNotNull(Map<String, Object> map, String key, Object value) {
+ if (value != null) {
+ map.put(key, value);
+ }
+ }
+
+ private void putIfNotBlank(Map<String, Object> map, String key, String value) {
+ if (value != null && !value.isBlank()) {
+ map.put(key, value.trim());
+ }
+ }
+
+ private void putIfPositive(Map<String, Object> map, String key, Integer value, int defaultValue) {
+ if (value != null) {
+ map.put(key, value > 0 ? value : defaultValue);
+ }
+ }
+
+ private String getConfigString(Map<String, Object> config, String key, String defaultValue) {
+ Object val = config.get(key);
+ return val == null ? defaultValue : String.valueOf(val);
+ }
+
+ private int getConfigInt(Map<String, Object> config, String key, int defaultValue) {
+ Object val = config.get(key);
+ if (val instanceof Number number) {
+ return number.intValue();
+ }
+ if (val instanceof String str) {
+ try {
+ return Integer.parseInt(str);
+ } catch (NumberFormatException ignored) {
+ return defaultValue;
+ }
+ }
+ return defaultValue;
+ }
+
+ private List<String> getStringList(Map<String, Object> config, String key) {
+ Object val = config.get(key);
+ if (!(val instanceof List<?> list)) {
+ return new ArrayList<>();
+ }
+ return cleanTableList(list.stream().map(item -> item == null ? null : String.valueOf(item)).toList());
+ }
+
+ private List<String> cleanTableList(List<String> tables) {
+ if (tables == null) {
+ return new ArrayList<>();
+ }
+ List<String> result = new ArrayList<>();
+ Set<String> seen = new HashSet<>();
+ for (String table : tables) {
+ if (table == null) {
+ continue;
+ }
+ String trimmed = table.trim();
+ if (!trimmed.matches("[A-Za-z0-9_]+")) {
+ continue;
+ }
+ String normalized = trimmed.toLowerCase(Locale.ROOT);
+ if (seen.add(normalized)) {
+ result.add(trimmed);
+ }
+ }
+ return result;
+ }
+
+ private String toJson(List<String> list) {
+ if (list == null) {
+ return null;
+ }
+ return GsonUtils.toJson(list);
+ }
+
+ private String truncate(String s, int maxLen) {
+ if (s == null || s.length() <= maxLen) {
+ return s;
+ }
+ return s.substring(0, maxLen);
+ }
+
+ private record ColumnMeta(int columnCount, int[] columnTypes) {
+ }
+
+ @Data
+ public static class BackupProgress {
+ private volatile String phase = "IDLE";
+ private volatile String currentTable = "";
+ private volatile int tableIndex;
+ private volatile int totalTables;
+ private volatile long rowsDumped;
+ private volatile String message = "";
+
+ public synchronized void incrementRowsDumped() {
+ rowsDumped++;
+ }
+ }
+}
diff --git a/rsf-server/src/main/resources/mapper/system/DbBackupRecordMapper.xml b/rsf-server/src/main/resources/mapper/system/DbBackupRecordMapper.xml
new file mode 100644
index 0000000..2299b09
--- /dev/null
+++ b/rsf-server/src/main/resources/mapper/system/DbBackupRecordMapper.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.vincent.rsf.server.system.mapper.DbBackupRecordMapper">
+
+ <select id="selectBackupableTables" resultType="map">
+ SELECT TABLE_NAME AS name, TABLE_COMMENT AS comment
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_TYPE = 'BASE TABLE'
+ ORDER BY TABLE_NAME
+ </select>
+
+ <select id="selectPageRecords" resultType="com.vincent.rsf.server.system.entity.DbBackupRecord">
+ SELECT id, backup_time, tables_dumped, file_name, file_size, status,
+ trigger_type, error_message, upload_status, upload_time, upload_error,
+ duration_ms, create_time, update_time
+ FROM db_backup_record
+ ORDER BY backup_time DESC
+ LIMIT #{offset}, #{size}
+ </select>
+
+ <select id="countRecords" resultType="int">
+ SELECT COUNT(*) FROM db_backup_record
+ </select>
+
+</mapper>
diff --git a/rsf-server/src/main/resources/sql/20260511_add_db_backup_feature.sql b/rsf-server/src/main/resources/sql/20260511_add_db_backup_feature.sql
new file mode 100644
index 0000000..2da7e57
--- /dev/null
+++ b/rsf-server/src/main/resources/sql/20260511_add_db_backup_feature.sql
@@ -0,0 +1,164 @@
+-- 鏁版嵁搴撳浠藉姛鑳斤細璁板綍琛ㄣ�丣SON閰嶇疆銆佺郴缁熻彍鍗曞拰鎸夐挳鏉冮檺
+
+SET @tenant_id := 1;
+
+CREATE TABLE IF NOT EXISTS `db_backup_record` (
+ `id` bigint NOT NULL AUTO_INCREMENT COMMENT '涓婚敭',
+ `backup_time` datetime NOT NULL COMMENT '澶囦唤寮�濮嬫椂闂�',
+ `tables_dumped` text COMMENT '澶囦唤鐨勮〃鍒楄〃(JSON鏁扮粍)',
+ `file_name` varchar(255) DEFAULT NULL COMMENT '澶囦唤鐩綍鍚�',
+ `file_size` bigint DEFAULT NULL COMMENT '鏂囦欢澶у皬(瀛楄妭)',
+ `status` varchar(32) NOT NULL DEFAULT 'RUNNING' COMMENT '鐘舵��:RUNNING/SUCCESS/FAILED',
+ `trigger_type` varchar(32) NOT NULL DEFAULT 'MANUAL' COMMENT '瑙﹀彂鏂瑰紡:MANUAL/SCHEDULED',
+ `error_message` varchar(1024) DEFAULT NULL COMMENT '澶辫触鍘熷洜',
+ `upload_status` varchar(32) DEFAULT NULL COMMENT '涓婁紶鐘舵��:SKIPPED/SUCCESS/FAILED',
+ `upload_time` datetime DEFAULT NULL COMMENT '涓婁紶鏃堕棿',
+ `upload_error` varchar(512) DEFAULT NULL COMMENT '涓婁紶澶辫触鍘熷洜',
+ `duration_ms` bigint DEFAULT NULL COMMENT '澶囦唤鑰楁椂(姣)',
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '鍒涘缓鏃堕棿',
+ `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '鏇存柊鏃堕棿',
+ PRIMARY KEY (`id`),
+ KEY `idx_db_backup_record_status` (`status`),
+ KEY `idx_db_backup_record_time` (`backup_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鏁版嵁搴撳浠借褰�';
+
+INSERT INTO sys_config (
+ uuid, name, flag, type, val, content, status, deleted, tenant_id,
+ create_by, create_time, update_by, update_time, memo
+)
+SELECT
+ CONCAT(UNIX_TIMESTAMP(NOW(3)), '001'),
+ '鏁版嵁搴撳浠介厤缃�',
+ 'dbBackupConfig',
+ 4,
+ '{"backupEnabled":true,"backupCron":"0 0 2 * * ?","backupTables":[],"backupExcludeTables":[],"backupPath":"../stock/out/wms/dbBackups","retentionCount":10,"batchSize":1000,"uploadEnabled":false,"uploadUrl":"","uploadToken":""}',
+ '鏁版嵁搴撳浠介厤缃�',
+ 1,
+ 0,
+ @tenant_id,
+ 1,
+ NOW(),
+ 1,
+ NOW(),
+ '鏁版嵁搴撳浠絁SON閰嶇疆'
+FROM dual
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_config WHERE deleted = 0 AND tenant_id = @tenant_id AND flag = 'dbBackupConfig'
+);
+
+SET @system_menu_id := (
+ SELECT id
+ FROM sys_menu
+ WHERE deleted = 0
+ AND tenant_id = @tenant_id
+ AND type = 0
+ AND (route = '/system' OR component = 'system' OR name = 'menu.system')
+ ORDER BY id
+ LIMIT 1
+);
+
+INSERT INTO sys_menu (
+ name, parent_id, parent_name, path, path_name, route, component, brief,
+ code, type, authority, icon, sort, meta, tenant_id, status, deleted,
+ create_time, create_by, update_time, update_by, memo
+)
+SELECT
+ 'menu.dbBackup',
+ @system_menu_id,
+ 'menu.system',
+ '/system/dbBackup',
+ '/system/dbBackup',
+ '/system/dbBackup',
+ 'dbBackup',
+ '鏁版嵁搴撳浠�',
+ NULL,
+ 0,
+ 'system:dbBackup:list',
+ 'DatabaseBackup',
+ 91,
+ '{"title":"menus.system.dbBackup","icon":"DatabaseBackup","keepAlive":true}',
+ @tenant_id,
+ 1,
+ 0,
+ NOW(),
+ 1,
+ NOW(),
+ 1,
+ '鏁版嵁搴撳浠介厤缃�佹墽琛屻�佸巻鍙茶褰�'
+FROM dual
+WHERE @system_menu_id IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM sys_menu
+ WHERE deleted = 0
+ AND tenant_id = @tenant_id
+ AND type = 0
+ AND (route = '/system/dbBackup' OR component = 'dbBackup' OR authority = 'system:dbBackup:list')
+ );
+
+SET @db_backup_menu_id := (
+ SELECT id
+ FROM sys_menu
+ WHERE deleted = 0
+ AND tenant_id = @tenant_id
+ AND type = 0
+ AND (route = '/system/dbBackup' OR component = 'dbBackup' OR authority = 'system:dbBackup:list')
+ ORDER BY id
+ LIMIT 1
+);
+
+INSERT INTO sys_menu (
+ name, parent_id, parent_name, path, path_name, route, component, brief,
+ code, type, authority, icon, sort, meta, tenant_id, status, deleted,
+ create_time, create_by, update_time, update_by, memo
+)
+SELECT permission_name, @db_backup_menu_id, 'menu.dbBackup', '1', 'menu.system', NULL, NULL, permission_name,
+ NULL, 1, permission_authority, NULL, permission_sort, NULL, @tenant_id, 1, 0,
+ NOW(), 1, NOW(), 1, permission_name
+FROM (
+ SELECT '鏌ョ湅鏁版嵁搴撳浠�' permission_name, 'system:dbBackup:list' permission_authority, 1 permission_sort
+ UNION ALL SELECT '淇濆瓨鏁版嵁搴撳浠介厤缃�', 'system:dbBackup:save', 2
+ UNION ALL SELECT '鎵ц鏁版嵁搴撳浠�', 'system:dbBackup:run', 3
+ UNION ALL SELECT '涓嬭浇鏁版嵁搴撳浠�', 'system:dbBackup:download', 4
+ UNION ALL SELECT '鍒犻櫎鏁版嵁搴撳浠�', 'system:dbBackup:remove', 5
+) permissions
+WHERE @db_backup_menu_id IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM sys_menu
+ WHERE deleted = 0
+ AND tenant_id = @tenant_id
+ AND type = 1
+ AND authority = permissions.permission_authority
+ );
+
+SET @admin_role_id := (
+ SELECT id
+ FROM sys_role
+ WHERE deleted = 0
+ AND tenant_id = @tenant_id
+ AND (id = 1 OR code IN ('ADMIN', 'R_ADMIN', 'R_SUPER') OR name IN ('绠$悊鍛�', '瓒呯骇绠$悊鍛�'))
+ ORDER BY id
+ LIMIT 1
+);
+
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT @admin_role_id, sm.id
+FROM sys_menu sm
+WHERE @admin_role_id IS NOT NULL
+ AND sm.deleted = 0
+ AND sm.tenant_id = @tenant_id
+ AND (
+ sm.id = @db_backup_menu_id
+ OR sm.authority IN (
+ 'system:dbBackup:list',
+ 'system:dbBackup:save',
+ 'system:dbBackup:run',
+ 'system:dbBackup:download',
+ 'system:dbBackup:remove'
+ )
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM sys_role_menu srm
+ WHERE srm.role_id = @admin_role_id
+ AND srm.menu_id = sm.id
+ );
diff --git a/wms-admin/package-lock.json b/wms-admin/package-lock.json
index b9b107e..6880530 100644
--- a/wms-admin/package-lock.json
+++ b/wms-admin/package-lock.json
@@ -53,7 +53,6 @@
"globals": "^15.9.0",
"husky": "^9.1.5",
"lint-staged": "^15.5.2",
- "playwright": "^1.59.1",
"prettier": "^3.5.3",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.81.0",
@@ -9178,53 +9177,6 @@
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
- }
- },
- "node_modules/playwright": {
- "version": "1.59.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
- "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "playwright-core": "1.59.1"
- },
- "bin": {
- "playwright": "cli.js"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "fsevents": "2.3.2"
- }
- },
- "node_modules/playwright-core": {
- "version": "1.59.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
- "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "playwright-core": "cli.js"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/playwright/node_modules/fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
diff --git a/wms-admin/package.json b/wms-admin/package.json
index 45407c9..c52775a 100644
--- a/wms-admin/package.json
+++ b/wms-admin/package.json
@@ -100,7 +100,6 @@
"globals": "^15.9.0",
"husky": "^9.1.5",
"lint-staged": "^15.5.2",
- "playwright": "^1.59.1",
"prettier": "^3.5.3",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.81.0",
diff --git a/wms-admin/pnpm-lock.yaml b/wms-admin/pnpm-lock.yaml
index 72313e9..d11b9d3 100644
--- a/wms-admin/pnpm-lock.yaml
+++ b/wms-admin/pnpm-lock.yaml
@@ -149,9 +149,6 @@
lint-staged:
specifier: ^15.5.2
version: 15.5.2
- playwright:
- specifier: ^1.59.1
- version: 1.59.1
prettier:
specifier: ^3.5.3
version: 3.6.2
@@ -5138,22 +5135,6 @@
integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==
}
- playwright-core@1.59.1:
- resolution:
- {
- integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==
- }
- engines: { node: '>=18' }
- hasBin: true
-
- playwright@1.59.1:
- resolution:
- {
- integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==
- }
- engines: { node: '>=18' }
- hasBin: true
-
postcss-html@1.8.0:
resolution:
{
@@ -9354,14 +9335,6 @@
confbox: 0.2.2
exsolve: 1.0.7
pathe: 2.0.3
-
- playwright-core@1.59.1: {}
-
- playwright@1.59.1:
- dependencies:
- playwright-core: 1.59.1
- optionalDependencies:
- fsevents: 2.3.2
postcss-html@1.8.0:
dependencies:
diff --git a/wms-admin/src/locales/langs/en.json b/wms-admin/src/locales/langs/en.json
index 932fbf0..f02bd0b 100644
--- a/wms-admin/src/locales/langs/en.json
+++ b/wms-admin/src/locales/langs/en.json
@@ -520,7 +520,8 @@
"role": "Role Manage",
"userCenter": "User Center",
"menu": "Menu Manage",
- "menuPda": "PDA Menu Manage"
+ "menuPda": "PDA Menu Manage",
+ "dbBackup": "Database Backup"
},
"checkOutBound": "Check Outbound"
},
diff --git a/wms-admin/src/locales/langs/zh.json b/wms-admin/src/locales/langs/zh.json
index 8026f1b..09f919a 100644
--- a/wms-admin/src/locales/langs/zh.json
+++ b/wms-admin/src/locales/langs/zh.json
@@ -520,7 +520,8 @@
"role": "瑙掕壊绠$悊",
"userCenter": "涓汉涓績",
"menu": "鑿滃崟绠$悊",
- "menuPda": "PDA鑿滃崟绠$悊"
+ "menuPda": "PDA鑿滃崟绠$悊",
+ "dbBackup": "鏁版嵁搴撳浠�"
},
"checkOutBound": "鐩樼偣鍑哄簱"
},
diff --git a/wms-admin/src/router/modules/system.ts b/wms-admin/src/router/modules/system.ts
index 7d2c68d..d169690 100644
--- a/wms-admin/src/router/modules/system.ts
+++ b/wms-admin/src/router/modules/system.ts
@@ -65,6 +65,22 @@
{ title: '鍒犻櫎', authMark: 'delete' }
]
}
+ },
+ {
+ path: 'dbBackup',
+ name: 'DbBackup',
+ component: '/system/dbBackup',
+ meta: {
+ title: 'menus.system.dbBackup',
+ keepAlive: true,
+ roles: ['R_SUPER', 'R_ADMIN'],
+ authList: [
+ { title: '淇濆瓨閰嶇疆', authMark: 'save' },
+ { title: '鎵ц澶囦唤', authMark: 'run' },
+ { title: '涓嬭浇澶囦唤', authMark: 'download' },
+ { title: '鍒犻櫎澶囦唤', authMark: 'remove' }
+ ]
+ }
}
]
}
diff --git a/wms-admin/src/views/system/dbBackup/api/dbBackup.ts b/wms-admin/src/views/system/dbBackup/api/dbBackup.ts
new file mode 100644
index 0000000..5971f42
--- /dev/null
+++ b/wms-admin/src/views/system/dbBackup/api/dbBackup.ts
@@ -0,0 +1,107 @@
+import { $t } from '@/locales'
+import { useUserStore } from '@/store/modules/user'
+import type { BaseResponse } from '@/types'
+import request from '@/utils/http'
+
+const { VITE_API_URL, VITE_WITH_CREDENTIALS } = import.meta.env
+
+export function fetchDbBackupConfig() {
+ return request.get<Api.System.DbBackup.DbBackupConfig>({
+ url: '/dbBackup/config'
+ })
+}
+
+export function saveDbBackupConfig(data: Api.System.DbBackup.DbBackupConfig) {
+ return request.post<void>({
+ url: '/dbBackup/config/save',
+ data
+ })
+}
+
+export function fetchDbBackupTables() {
+ return request.get<Api.System.DbBackup.DbBackupTable[]>({
+ url: '/dbBackup/tables'
+ })
+}
+
+export function runDbBackup(tables: string[]) {
+ return request.post<{ id: number; status: string }>({
+ url: '/dbBackup/run',
+ data: { tables }
+ })
+}
+
+export function fetchDbBackupProgress() {
+ return request.get<Api.System.DbBackup.DbBackupProgress>({
+ url: '/dbBackup/progress'
+ })
+}
+
+export function fetchDbBackupHistory(params: Api.System.DbBackup.DbBackupHistoryParams) {
+ return request.get<Api.System.DbBackup.DbBackupHistoryResponse>({
+ url: '/dbBackup/history',
+ params
+ })
+}
+
+export function deleteDbBackup(id: number | string) {
+ return request.post<void>({
+ url: '/dbBackup/delete',
+ data: { id }
+ })
+}
+
+const parseFilename = (contentDisposition: string | null, fallbackFilename: string) => {
+ if (!contentDisposition) return fallbackFilename
+
+ const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
+ if (utf8Match?.[1]) {
+ return decodeURIComponent(utf8Match[1])
+ }
+
+ const plainMatch = contentDisposition.match(/filename="?([^";]+)"?/i)
+ if (plainMatch?.[1]) {
+ return decodeURIComponent(plainMatch[1])
+ }
+
+ return fallbackFilename
+}
+
+export async function downloadDbBackup(row: Api.System.DbBackup.DbBackupRecord) {
+ const { accessToken, tenantId } = useUserStore()
+ const response = await fetch(`${VITE_API_URL}/dbBackup/download?id=${row.id}`, {
+ method: 'GET',
+ credentials: VITE_WITH_CREDENTIALS === 'true' ? 'include' : 'same-origin',
+ headers: {
+ Authorization: accessToken || '',
+ ...(tenantId !== null && tenantId !== undefined && tenantId !== ''
+ ? { 'X-Tenant-Id': String(tenantId) }
+ : {})
+ }
+ })
+
+ const contentType = response.headers.get('content-type') || ''
+ if (!response.ok || contentType.includes('application/json')) {
+ let errorMessage = '鏂囦欢涓嬭浇澶辫触'
+ try {
+ const errorResponse = (await response.json()) as BaseResponse<unknown>
+ errorMessage = errorResponse.msg || errorMessage
+ } catch {
+ errorMessage = response.statusText || errorMessage
+ }
+ throw new Error(errorMessage || $t('httpMsg.requestFailed'))
+ }
+
+ const blob = await response.blob()
+ const downloadUrl = window.URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = downloadUrl
+ link.download = parseFilename(
+ response.headers.get('content-disposition'),
+ row.fileName ? `${row.fileName}.zip` : 'db-backup.zip'
+ )
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ window.URL.revokeObjectURL(downloadUrl)
+}
diff --git a/wms-admin/src/views/system/dbBackup/api/dbBackupTypes.d.ts b/wms-admin/src/views/system/dbBackup/api/dbBackupTypes.d.ts
new file mode 100644
index 0000000..81ce3a6
--- /dev/null
+++ b/wms-admin/src/views/system/dbBackup/api/dbBackupTypes.d.ts
@@ -0,0 +1,62 @@
+declare namespace Api {
+ namespace System {
+ namespace DbBackup {
+ interface DbBackupConfig {
+ backupEnabled: boolean
+ backupCron: string
+ backupTables: string[]
+ backupExcludeTables: string[]
+ backupPath: string
+ retentionCount: number
+ batchSize: number
+ uploadEnabled: boolean
+ uploadUrl: string
+ uploadToken: string
+ }
+
+ interface DbBackupTable {
+ name: string
+ comment?: string
+ }
+
+ interface DbBackupProgress {
+ running: boolean
+ phase: string
+ currentTable: string
+ tableIndex: number
+ totalTables: number
+ rowsDumped: number
+ message: string
+ }
+
+ interface DbBackupRecord {
+ id: number
+ backupTime?: string
+ backupTime$?: string
+ tablesDumped?: string
+ fileName?: string
+ fileSize?: number
+ status: string
+ triggerType?: string
+ errorMessage?: string
+ uploadStatus?: string
+ uploadTime?: string
+ uploadTime$?: string
+ uploadError?: string
+ durationMs?: number
+ }
+
+ interface DbBackupHistoryParams {
+ current: number
+ pageSize: number
+ }
+
+ interface DbBackupHistoryResponse {
+ records: DbBackupRecord[]
+ total: number
+ current: number
+ size: number
+ }
+ }
+ }
+}
diff --git a/wms-admin/src/views/system/dbBackup/index.vue b/wms-admin/src/views/system/dbBackup/index.vue
new file mode 100644
index 0000000..b57a1b2
--- /dev/null
+++ b/wms-admin/src/views/system/dbBackup/index.vue
@@ -0,0 +1,618 @@
+<template>
+ <div class="db-backup-page">
+ <ElCard shadow="never" class="mb-4">
+ <div class="flex flex-col gap-1">
+ <h2 class="text-xl font-semibold text-[var(--art-text-gray-900)]">鏁版嵁搴撳浠�</h2>
+ <p class="text-sm text-[var(--art-text-gray-600)]">
+ JDBC 绾� Java 澶囦唤锛屾棤闇�瀹夎 MySQL 瀹㈡埛绔紝鏀寔鎵嬪姩瑙﹀彂鍜屽畾鏃舵墽琛�
+ </p>
+ </div>
+ </ElCard>
+
+ <ElCard shadow="never" class="mb-4">
+ <ElSkeleton :loading="loading" animated :rows="8">
+ <ElForm ref="formRef" :model="form" :rules="rules" label-width="128px">
+ <div class="grid grid-cols-1 xl:grid-cols-2 gap-x-6">
+ <ElFormItem label="鍚敤鑷姩澶囦唤" prop="backupEnabled">
+ <div class="flex flex-wrap items-center gap-3">
+ <ElSwitch v-model="form.backupEnabled" />
+ <span class="text-sm text-[var(--art-text-gray-500)]">
+ 鍏抽棴鍚庡畾鏃跺浠戒笉鍐嶆墽琛岋紝鎵嬪姩澶囦唤涓嶅彈褰卞搷
+ </span>
+ </div>
+ </ElFormItem>
+
+ <ElFormItem label="瀹氭椂琛ㄨ揪寮�" prop="backupCron">
+ <ElInput v-model="form.backupCron" placeholder="0 0 2 * * ?" clearable />
+ <div class="mt-1 text-xs text-[var(--art-text-gray-500)]">
+ Spring Cron锛屽 0 0 2 * * ?锛堟瘡澶╁噷鏅�2鐐癸級
+ </div>
+ </ElFormItem>
+
+ <ElFormItem label="澶囦唤琛�" prop="backupTables" class="xl:col-span-2">
+ <ElSelect
+ v-model="form.backupTables"
+ multiple
+ filterable
+ clearable
+ collapse-tags
+ collapse-tags-tooltip
+ placeholder="鐣欑┖鍒欏浠芥墍鏈夎〃"
+ class="w-full"
+ >
+ <ElOption
+ v-for="table in tableOptions"
+ :key="table.name"
+ :label="formatTableLabel(table)"
+ :value="table.name"
+ />
+ </ElSelect>
+ </ElFormItem>
+
+ <ElFormItem label="鎺掗櫎琛�" prop="backupExcludeTables" class="xl:col-span-2">
+ <ElSelect
+ v-model="form.backupExcludeTables"
+ multiple
+ filterable
+ clearable
+ collapse-tags
+ collapse-tags-tooltip
+ placeholder="杩欎簺琛ㄤ笉浼氳澶囦唤"
+ class="w-full"
+ >
+ <ElOption
+ v-for="table in tableOptions"
+ :key="table.name"
+ :label="formatTableLabel(table)"
+ :value="table.name"
+ />
+ </ElSelect>
+ <div class="mt-1 text-xs text-[var(--art-text-gray-500)]">
+ 鎺掗櫎琛ㄤ紭鍏堢骇楂樹簬澶囦唤琛�
+ </div>
+ </ElFormItem>
+
+ <ElFormItem label="淇濈暀浠芥暟" prop="retentionCount">
+ <ElInputNumber v-model="form.retentionCount" :min="1" :max="365" />
+ <span class="ml-3 text-sm text-[var(--art-text-gray-500)]">
+ 鑷姩娓呯悊鏇存棭鐨勬垚鍔熷浠�
+ </span>
+ </ElFormItem>
+
+ <ElFormItem label="姣忔壒琛屾暟" prop="batchSize">
+ <ElInputNumber v-model="form.batchSize" :min="1" :max="10000" :step="100" />
+ <span class="ml-3 text-sm text-[var(--art-text-gray-500)]">
+ 姣忔潯 INSERT 鍖呭惈鐨勮鏁�
+ </span>
+ </ElFormItem>
+
+ <ElFormItem label="瀛樺偍璺緞" prop="backupPath" class="xl:col-span-2">
+ <ElInput v-model="form.backupPath" clearable />
+ </ElFormItem>
+
+ <ElFormItem label="涓婁紶鍏叡鏈嶅姟鍣�" prop="uploadEnabled">
+ <ElSwitch v-model="form.uploadEnabled" />
+ </ElFormItem>
+
+ <template v-if="form.uploadEnabled">
+ <ElFormItem label="涓婁紶鍦板潃" prop="uploadUrl">
+ <ElInput v-model="form.uploadUrl" placeholder="https://server.example.com/backup" />
+ </ElFormItem>
+ <ElFormItem label="涓婁紶 Token" prop="uploadToken">
+ <ElInput v-model="form.uploadToken" type="password" show-password autocomplete="off" />
+ </ElFormItem>
+ </template>
+ </div>
+
+ <ElFormItem>
+ <ElSpace wrap>
+ <ElButton type="primary" :loading="saving" @click="handleSaveConfig">
+ 淇濆瓨閰嶇疆
+ </ElButton>
+ <ElButton type="success" plain :loading="backupRunning" @click="openRunDialog">
+ {{ backupRunning ? '澶囦唤涓�...' : '绔嬪嵆澶囦唤' }}
+ </ElButton>
+ <ElButton :loading="progressLoading" @click="refreshProgress">鍒锋柊杩涘害</ElButton>
+ </ElSpace>
+ </ElFormItem>
+ </ElForm>
+ </ElSkeleton>
+ </ElCard>
+
+ <ElCard v-if="shouldShowProgress" shadow="never" class="mb-4">
+ <div class="flex flex-col gap-3">
+ <div class="flex flex-wrap items-center gap-3">
+ <ElTag :type="progressTagType">{{ progressLabel }}</ElTag>
+ <span class="text-sm text-[var(--art-text-gray-700)]">
+ 褰撳墠琛細{{ progress.currentTable || '-' }}锛坽{ progress.tableIndex || 0 }}/{{
+ progress.totalTables || 0
+ }}锛�
+ </span>
+ <span class="text-sm text-[var(--art-text-gray-700)]">
+ 宸插鍑鸿鏁帮細{{ formatNumber(progress.rowsDumped) }}
+ </span>
+ </div>
+ <ElProgress :percentage="progressPercent" :status="progressStatus" />
+ <div v-if="progress.message" class="text-sm text-[var(--art-text-gray-500)]">
+ {{ progress.message }}
+ </div>
+ </div>
+ </ElCard>
+
+ <ElCard shadow="never" class="db-backup-history-card">
+ <ArtTableHeader v-model:columns="columnChecks" :loading="historyLoading" @refresh="loadHistory">
+ <template #left>
+ <div class="text-lg font-semibold">澶囦唤璁板綍</div>
+ </template>
+ </ArtTableHeader>
+
+ <ArtTable
+ :loading="historyLoading"
+ :data="history"
+ :columns="columns"
+ :pagination="pagination"
+ height="360px"
+ empty-height="360px"
+ :show-table-header="false"
+ empty-text="鏆傛棤澶囦唤璁板綍"
+ @pagination:size-change="handleSizeChange"
+ @pagination:current-change="handleCurrentChange"
+ >
+ <template #operation="{ row }">
+ <ElTooltip content="涓嬭浇" placement="top">
+ <ArtButtonTable
+ icon="ri:download-2-line"
+ iconClass="bg-theme/12 text-theme"
+ @click="handleDownload(row)"
+ />
+ </ElTooltip>
+ <ElTooltip content="鍒犻櫎" placement="top">
+ <ArtButtonTable type="delete" @click="handleDelete(row)" />
+ </ElTooltip>
+ </template>
+ </ArtTable>
+ </ElCard>
+
+ <ElDialog v-model="runDialogVisible" title="绔嬪嵆澶囦唤" width="520px">
+ <ElForm label-width="96px">
+ <ElFormItem label="澶囦唤鑼冨洿">
+ <ElRadioGroup v-model="runMode">
+ <ElRadio value="all">鍏ㄩ儴琛�</ElRadio>
+ <ElRadio value="selected">鎸囧畾琛�</ElRadio>
+ </ElRadioGroup>
+ </ElFormItem>
+ <ElFormItem v-if="runMode === 'selected'" label="閫夋嫨琛�">
+ <ElSelect
+ v-model="runTables"
+ multiple
+ filterable
+ collapse-tags
+ collapse-tags-tooltip
+ class="w-full"
+ placeholder="璇烽�夋嫨瑕佸浠界殑琛�"
+ >
+ <ElOption
+ v-for="table in tableOptions"
+ :key="table.name"
+ :label="formatTableLabel(table)"
+ :value="table.name"
+ />
+ </ElSelect>
+ </ElFormItem>
+ </ElForm>
+ <template #footer>
+ <ElButton @click="runDialogVisible = false">鍙栨秷</ElButton>
+ <ElButton type="primary" :loading="backupRunning" @click="handleRunBackup">寮�濮嬪浠�</ElButton>
+ </template>
+ </ElDialog>
+
+ <ElDialog v-model="errorDialogVisible" title="閿欒璇︽儏" width="640px">
+ <ElInput v-model="currentError" type="textarea" :rows="8" readonly />
+ </ElDialog>
+ </div>
+</template>
+
+<script setup lang="ts">
+ import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
+ import ArtTable from '@/components/core/tables/art-table/index.vue'
+ import ArtTableHeader from '@/components/core/tables/art-table-header/index.vue'
+ import type { ColumnOption } from '@/types'
+ import { ElButton, ElMessage, ElMessageBox, ElTag, type FormInstance, type FormRules } from 'element-plus'
+ import {
+ deleteDbBackup,
+ downloadDbBackup,
+ fetchDbBackupConfig,
+ fetchDbBackupHistory,
+ fetchDbBackupProgress,
+ fetchDbBackupTables,
+ runDbBackup,
+ saveDbBackupConfig
+ } from './api/dbBackup'
+
+ defineOptions({ name: 'DbBackup' })
+
+ type DbBackupConfig = Api.System.DbBackup.DbBackupConfig
+ type DbBackupRecord = Api.System.DbBackup.DbBackupRecord
+ type DbBackupTable = Api.System.DbBackup.DbBackupTable
+
+ const DEFAULT_CONFIG: DbBackupConfig = {
+ backupEnabled: true,
+ backupCron: '0 0 2 * * ?',
+ backupTables: [],
+ backupExcludeTables: [],
+ backupPath: '../stock/out/wms/dbBackups',
+ retentionCount: 10,
+ batchSize: 1000,
+ uploadEnabled: false,
+ uploadUrl: '',
+ uploadToken: ''
+ }
+
+ const formRef = ref<FormInstance>()
+ const form = reactive<DbBackupConfig>({ ...DEFAULT_CONFIG })
+ const loading = ref(false)
+ const saving = ref(false)
+ const progressLoading = ref(false)
+ const backupRunning = ref(false)
+ const historyLoading = ref(false)
+ const tableOptions = ref<DbBackupTable[]>([])
+ const history = ref<DbBackupRecord[]>([])
+ const current = ref(1)
+ const pageSize = ref(20)
+ const total = ref(0)
+ const runDialogVisible = ref(false)
+ const runMode = ref<'all' | 'selected'>('all')
+ const runTables = ref<string[]>([])
+ const errorDialogVisible = ref(false)
+ const currentError = ref('')
+ const progressTimer = ref<number>()
+ const progress = reactive<Api.System.DbBackup.DbBackupProgress>({
+ running: false,
+ phase: 'IDLE',
+ currentTable: '',
+ tableIndex: 0,
+ totalTables: 0,
+ rowsDumped: 0,
+ message: ''
+ })
+
+ const validateCron = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
+ if (!form.backupEnabled) {
+ callback()
+ return
+ }
+ if (!value || value.trim().split(/\s+/).length < 6) {
+ callback(new Error('璇疯緭鍏ユ湁鏁堢殑 Spring Cron 琛ㄨ揪寮�'))
+ return
+ }
+ callback()
+ }
+
+ const rules: FormRules<DbBackupConfig> = {
+ backupCron: [{ validator: validateCron, trigger: 'blur' }],
+ backupPath: [{ required: true, message: '璇疯緭鍏ュ瓨鍌ㄨ矾寰�', trigger: 'blur' }],
+ retentionCount: [{ required: true, message: '璇疯緭鍏ヤ繚鐣欎唤鏁�', trigger: 'change' }],
+ batchSize: [{ required: true, message: '璇疯緭鍏ユ瘡鎵硅鏁�', trigger: 'change' }],
+ uploadUrl: [
+ {
+ validator: (_rule, value: string, callback) => {
+ if (form.uploadEnabled && !value) {
+ callback(new Error('璇疯緭鍏ヤ笂浼犲湴鍧�'))
+ return
+ }
+ callback()
+ },
+ trigger: 'blur'
+ }
+ ]
+ }
+
+ const phaseLabels: Record<string, string> = {
+ IDLE: '绌洪棽',
+ PREPARING: '鍑嗗涓�',
+ DUMPING: '姝e湪瀵煎嚭鏁版嵁',
+ UPLOADING: '姝e湪涓婁紶',
+ CLEANUP: '娓呯悊鏃у浠�',
+ DONE: '瀹屾垚',
+ FAILED: '澶辫触'
+ }
+
+ const statusLabels: Record<string, string> = {
+ RUNNING: '鎵ц涓�',
+ SUCCESS: '鎴愬姛',
+ FAILED: '澶辫触'
+ }
+
+ const triggerLabels: Record<string, string> = {
+ MANUAL: '鎵嬪姩',
+ SCHEDULED: '瀹氭椂'
+ }
+
+ const progressLabel = computed(() => phaseLabels[progress.phase] || progress.phase || '-')
+ const shouldShowProgress = computed(() => progress.running || ['DONE', 'FAILED'].includes(progress.phase))
+ const progressPercent = computed(() => {
+ if (!progress.totalTables) return progress.running ? 5 : 0
+ return Math.min(100, Math.round((progress.tableIndex / progress.totalTables) * 100))
+ })
+ const progressTagType = computed(() => {
+ if (progress.phase === 'DONE') return 'success'
+ if (progress.phase === 'FAILED') return 'danger'
+ return 'primary'
+ })
+ const progressStatus = computed(() => {
+ if (progress.phase === 'DONE') return 'success'
+ if (progress.phase === 'FAILED') return 'exception'
+ return undefined
+ })
+ const pagination = computed(() => ({
+ current: current.value,
+ pageSize: pageSize.value,
+ total: total.value
+ }))
+
+ const formatNumber = (num?: number) => {
+ return Number(num || 0).toLocaleString()
+ }
+
+ const formatText = (value: unknown) => {
+ if (value === undefined || value === null || value === '') return '-'
+ return String(value)
+ }
+
+ const formatTableLabel = (table: DbBackupTable) => {
+ return table.comment ? `${table.name}锛�${table.comment}锛塦 : table.name
+ }
+
+ const formatFileSize = (bytes?: number) => {
+ if (!bytes) return '-'
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
+ }
+
+ const formatDuration = (ms?: number) => {
+ if (!ms) return '-'
+ if (ms < 1000) return `${ms}ms`
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
+ return `${(ms / 60000).toFixed(1)}min`
+ }
+
+ const showErrorDetail = (message?: string) => {
+ currentError.value = message || '-'
+ errorDialogVisible.value = true
+ }
+
+ const buildColumns = (): ColumnOption<DbBackupRecord>[] => [
+ { type: 'index', width: 60, label: '搴忓彿' },
+ {
+ prop: 'backupTime',
+ label: '澶囦唤鏃堕棿',
+ minWidth: 170,
+ formatter: (row) => formatText(row.backupTime$ || row.backupTime)
+ },
+ {
+ prop: 'status',
+ label: '鐘舵��',
+ width: 100,
+ formatter: (row) => {
+ const type = row.status === 'SUCCESS' ? 'success' : row.status === 'FAILED' ? 'danger' : 'primary'
+ return h(ElTag, { type }, () => statusLabels[row.status] || row.status)
+ }
+ },
+ {
+ prop: 'triggerType',
+ label: '瑙﹀彂鏂瑰紡',
+ width: 110,
+ formatter: (row) => triggerLabels[row.triggerType || ''] || formatText(row.triggerType)
+ },
+ { prop: 'fileName', label: '鏂囦欢鍚�', minWidth: 180, showOverflowTooltip: true },
+ {
+ prop: 'fileSize',
+ label: '鏂囦欢澶у皬',
+ width: 120,
+ formatter: (row) => formatFileSize(row.fileSize)
+ },
+ {
+ prop: 'durationMs',
+ label: '鑰楁椂',
+ width: 110,
+ formatter: (row) => formatDuration(row.durationMs)
+ },
+ {
+ prop: 'uploadStatus',
+ label: '涓婁紶鐘舵��',
+ width: 120,
+ formatter: (row) => formatText(row.uploadStatus)
+ },
+ {
+ prop: 'errorMessage',
+ label: '閿欒',
+ width: 120,
+ formatter: (row) =>
+ row.errorMessage
+ ? h(ElButton, { link: true, type: 'primary', onClick: () => showErrorDetail(row.errorMessage) }, () => '鏌ョ湅璇︽儏')
+ : '-'
+ },
+ {
+ prop: 'operation',
+ label: '鎿嶄綔',
+ width: 130,
+ fixed: 'right',
+ useSlot: true
+ }
+ ]
+
+ const columns = ref<ColumnOption<DbBackupRecord>[]>(buildColumns())
+ const columnChecks = ref(columns.value.map((column) => ({ ...column, checked: column.visible !== false })))
+
+ const syncProgress = (data: Api.System.DbBackup.DbBackupProgress) => {
+ Object.assign(progress, data)
+ backupRunning.value = Boolean(data.running)
+ if (data.running) {
+ startProgressPolling()
+ return
+ }
+ stopProgressPolling()
+ }
+
+ const loadConfig = async () => {
+ const data = await fetchDbBackupConfig()
+ Object.assign(form, { ...DEFAULT_CONFIG, ...data })
+ }
+
+ const loadTables = async () => {
+ tableOptions.value = await fetchDbBackupTables()
+ }
+
+ const refreshProgress = async () => {
+ progressLoading.value = true
+ try {
+ syncProgress(await fetchDbBackupProgress())
+ } finally {
+ progressLoading.value = false
+ }
+ }
+
+ const loadHistory = async () => {
+ historyLoading.value = true
+ try {
+ const data = await fetchDbBackupHistory({
+ current: current.value,
+ pageSize: pageSize.value
+ })
+ history.value = data.records || []
+ total.value = data.total || 0
+ } finally {
+ historyLoading.value = false
+ }
+ }
+
+ const handleSaveConfig = async () => {
+ await formRef.value?.validate()
+ saving.value = true
+ try {
+ await saveDbBackupConfig({ ...form })
+ ElMessage.success('淇濆瓨鎴愬姛')
+ } finally {
+ saving.value = false
+ }
+ }
+
+ const openRunDialog = () => {
+ runMode.value = form.backupTables.length ? 'selected' : 'all'
+ runTables.value = [...form.backupTables]
+ runDialogVisible.value = true
+ }
+
+ const handleRunBackup = async () => {
+ const tables = runMode.value === 'selected' ? runTables.value : []
+ if (runMode.value === 'selected' && !tables.length) {
+ ElMessage.warning('璇烽�夋嫨瑕佸浠界殑琛�')
+ return
+ }
+ await ElMessageBox.confirm(
+ tables.length ? `纭畾澶囦唤閫変腑鐨� ${tables.length} 寮犺〃鍚楋紵` : '纭畾澶囦唤鎵�鏈夎〃鍚楋紵',
+ '鎻愮ず',
+ { type: 'warning' }
+ )
+ backupRunning.value = true
+ Object.assign(progress, {
+ running: true,
+ phase: 'PREPARING',
+ currentTable: '',
+ tableIndex: 0,
+ totalTables: 0,
+ rowsDumped: 0,
+ message: '鍑嗗澶囦唤'
+ })
+ try {
+ await runDbBackup(tables)
+ runDialogVisible.value = false
+ await loadHistory()
+ startProgressPolling()
+ } catch (error) {
+ backupRunning.value = false
+ throw error
+ }
+ }
+
+ const handleDownload = async (row: DbBackupRecord) => {
+ if (row.status !== 'SUCCESS') {
+ ElMessage.warning('鍙湁鎴愬姛鐨勫浠藉彲浠ヤ笅杞�')
+ return
+ }
+ await downloadDbBackup(row)
+ }
+
+ const handleDelete = async (row: DbBackupRecord) => {
+ await ElMessageBox.confirm(`纭畾鍒犻櫎姝ゅ浠借褰�${row.fileName ? `锛�${row.fileName}锛塦 : ''}鍚楋紵`, '鎻愮ず', {
+ type: 'warning'
+ })
+ await deleteDbBackup(row.id)
+ ElMessage.success('鍒犻櫎鎴愬姛')
+ await loadHistory()
+ }
+
+ const handleSizeChange = (size: number) => {
+ pageSize.value = size
+ current.value = 1
+ void loadHistory()
+ }
+
+ const handleCurrentChange = (page: number) => {
+ current.value = page
+ void loadHistory()
+ }
+
+ const stopProgressPolling = () => {
+ if (!progressTimer.value) return
+ window.clearInterval(progressTimer.value)
+ progressTimer.value = undefined
+ }
+
+ const startProgressPolling = () => {
+ if (progressTimer.value) return
+ progressTimer.value = window.setInterval(async () => {
+ try {
+ const data = await fetchDbBackupProgress()
+ syncProgress(data)
+ if (!data.running) {
+ await loadHistory()
+ if (data.phase === 'DONE') ElMessage.success('澶囦唤瀹屾垚')
+ if (data.phase === 'FAILED') ElMessage.error(`澶囦唤澶辫触锛�${data.message || ''}`)
+ }
+ } catch {
+ stopProgressPolling()
+ }
+ }, 2000)
+ }
+
+ onMounted(async () => {
+ loading.value = true
+ try {
+ await Promise.all([loadConfig(), loadTables(), loadHistory(), refreshProgress()])
+ } finally {
+ loading.value = false
+ }
+ })
+
+ onBeforeUnmount(() => {
+ stopProgressPolling()
+ })
+</script>
+
+<style scoped>
+ .db-backup-page {
+ min-height: 100%;
+ }
+
+ .db-backup-history-card {
+ min-height: 500px;
+ }
+
+ .db-backup-history-card :deep(.el-card__body) {
+ display: flex;
+ flex-direction: column;
+ }
+</style>
diff --git a/wms-admin/src/views/system/role/modules/role-scope-drawer.vue b/wms-admin/src/views/system/role/modules/role-scope-drawer.vue
index 5e457b9..8c56798 100644
--- a/wms-admin/src/views/system/role/modules/role-scope-drawer.vue
+++ b/wms-admin/src/views/system/role/modules/role-scope-drawer.vue
@@ -5,10 +5,11 @@
size="min(100vw, 960px)"
direction="rtl"
append-to-body
+ class="role-scope-drawer"
:close-on-click-modal="false"
>
- <div class="flex h-full flex-col gap-4">
- <div class="flex flex-wrap items-center justify-between gap-3">
+ <div class="role-scope-drawer__content">
+ <div class="role-scope-drawer__toolbar">
<div class="flex flex-wrap gap-2">
<ElButton @click="toggleSelectAll" v-ripple>
{{
@@ -19,9 +20,7 @@
</ElButton>
<ElButton @click="toggleExpandAll" v-ripple>
{{
- expandedKeys.length === 0
- ? $t('roleScope.actions.expandAll')
- : $t('roleScope.actions.collapseAll')
+ isExpanded ? $t('roleScope.actions.collapseAll') : $t('roleScope.actions.expandAll')
}}
</ElButton>
</div>
@@ -40,28 +39,28 @@
</div>
</div>
- <ElCard
- class="flex min-h-0 flex-1 flex-col overflow-hidden [&_.el-card__body]:flex-1 [&_.el-card__body]:min-h-0 [&_.el-card__body]:overflow-auto"
- shadow="never"
- >
+ <ElCard class="role-scope-drawer__tree-card" shadow="never">
<ElSkeleton v-if="loading" :rows="10" animated />
<ElEmpty
v-else-if="treeData.length === 0"
:description="$t('roleScope.messages.emptyData')"
/>
- <ElTree
- v-else
- ref="treeRef"
- node-key="id"
- show-checkbox
- check-strictly
- check-on-click-node
- :data="treeData"
- :props="treeProps"
- :default-expanded-keys="expandedKeys"
- @check-change="handleCheckChange"
- @check="handleCheck"
- />
+ <div v-else class="scope-tree-scroll">
+ <ElTree
+ ref="treeRef"
+ node-key="id"
+ show-checkbox
+ check-strictly
+ check-on-click-node
+ :data="treeData"
+ :props="treeProps"
+ :default-expanded-keys="expandedKeys"
+ @check-change="handleCheckChange"
+ @check="handleCheck"
+ @node-expand="syncExpandedState"
+ @node-collapse="syncExpandedState"
+ />
+ </div>
</ElCard>
</div>
</ElDrawer>
@@ -108,6 +107,7 @@
const selectedKeys = ref<Array<string>>([])
const halfCheckedKeys = ref<Array<string>>([])
const expandedKeys = ref<Array<string>>([])
+ const isExpanded = ref(false)
const treeProps = {
label: 'label',
@@ -177,8 +177,50 @@
return keys
}
+ const getNodeFromMap = (nodesMap: unknown, key: string) => {
+ if (nodesMap instanceof Map) return nodesMap.get(key)
+ return (nodesMap as Record<string, { childNodes?: unknown[]; expanded?: boolean }> | undefined)?.[
+ key
+ ]
+ }
+
+ const applyNodeExpansion = (expand: boolean, keys = collectNodeKeys(treeData.value).parentKeys) => {
+ const nodesMap = treeRef.value?.store?.nodesMap
+ if (!nodesMap) return
+
+ keys.forEach((key) => {
+ const node = getNodeFromMap(nodesMap, key)
+ if (node) node.expanded = expand
+ })
+ }
+
+ const syncExpandedState = () => {
+ const { parentKeys } = collectNodeKeys(treeData.value)
+ const nodesMap = treeRef.value?.store?.nodesMap
+ if (!nodesMap || parentKeys.length === 0) {
+ expandedKeys.value = []
+ isExpanded.value = false
+ return
+ }
+
+ expandedKeys.value = parentKeys.filter((key) => Boolean(getNodeFromMap(nodesMap, key)?.expanded))
+ isExpanded.value = expandedKeys.value.length === parentKeys.length
+ }
+
+ const setTreeExpansion = async (
+ expand: boolean,
+ keys = collectNodeKeys(treeData.value).parentKeys
+ ) => {
+ expandedKeys.value = expand ? keys : []
+ isExpanded.value = expand && keys.length > 0
+
+ await nextTick()
+ applyNodeExpansion(expand, keys)
+ }
+
const loadTree = async () => {
loading.value = true
+ isExpanded.value = false
try {
const [scopeIds, scopeTree] = await Promise.all([
props.role?.id ? fetchGetRoleScopeList(props.kind, props.role.id) : Promise.resolve([]),
@@ -191,11 +233,14 @@
const { parentKeys } = collectNodeKeys(treeData.value)
expandedKeys.value = parentKeys
+ isExpanded.value = parentKeys.length > 0
} finally {
loading.value = false
}
await nextTick()
+ applyNodeExpansion(isExpanded.value, expandedKeys.value)
+ syncExpandedState()
programmaticChecking.value = true
treeRef.value?.setCheckedKeys(selectedKeys.value)
halfCheckedKeys.value = treeRef.value?.getHalfCheckedKeys?.() || []
@@ -248,12 +293,8 @@
programmaticChecking.value = false
}
- const toggleExpandAll = () => {
- if (expandedKeys.value.length === 0) {
- expandedKeys.value = collectNodeKeys(treeData.value).parentKeys
- } else {
- expandedKeys.value = []
- }
+ const toggleExpandAll = async () => {
+ await setTreeExpansion(!isExpanded.value)
}
const handleSave = async () => {
@@ -285,3 +326,47 @@
{ immediate: true }
)
</script>
+
+<style scoped>
+ :global(.role-scope-drawer .el-drawer__body) {
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .role-scope-drawer__content {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 16px;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ }
+
+ .role-scope-drawer__toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-width: 0;
+ }
+
+ .role-scope-drawer__tree-card {
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .role-scope-drawer__tree-card :deep(.el-card__body) {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .scope-tree-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ }
+</style>
diff --git a/wms-admin/tmp-playwright-loc-verify.mjs b/wms-admin/tmp-playwright-loc-verify.mjs
deleted file mode 100644
index 7dac485..0000000
--- a/wms-admin/tmp-playwright-loc-verify.mjs
+++ /dev/null
@@ -1,160 +0,0 @@
-import { chromium } from 'playwright'
-
-const baseURL = 'http://localhost:3010'
-const username = 'Super'
-const password = '123456'
-const screenshotPath = 'C:/env/code/wms-admin/loc-page.png'
-
-async function passSlider(page) {
- const slider = page.locator('.drag_verify').first()
- await slider.waitFor({ timeout: 15000 })
-
- const handle = slider.locator('.dv_handler').first()
- const handleBox = await handle.boundingBox()
- const sliderBox = await slider.boundingBox()
-
- if (!handleBox || !sliderBox) {
- throw new Error('Unable to locate slider handle')
- }
-
- const startX = handleBox.x + handleBox.width / 2
- const startY = handleBox.y + handleBox.height / 2
- const endX = sliderBox.x + sliderBox.width - handleBox.width / 2 - 4
-
- await page.mouse.move(startX, startY)
- await page.mouse.down()
- await page.mouse.move(endX, startY, { steps: 24 })
- await page.mouse.up()
-}
-
-async function clickLocMenu(page) {
- const candidates = [
- page.getByText('menu.loc.title', { exact: true }).first(),
- page.getByText(/menu\.loc\.title/i).first(),
- page.getByText(/搴撲綅|loc/i).first()
- ]
-
- for (const candidate of candidates) {
- if (await candidate.count()) {
- await candidate.click()
- return true
- }
- }
-
- return false
-}
-
-async function main() {
- const browser = await chromium.launch({ headless: true })
- const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } })
-
- page.on('pageerror', (error) => {
- console.log('PAGEERROR=' + String(error))
- })
- page.on('console', (message) => {
- if (message.type() === 'error') {
- console.log('CONSOLE_ERROR=' + message.text())
- }
- })
- page.on('response', async (response) => {
- if (response.url().includes('/rsf-server/login')) {
- try {
- console.log('LOGIN_RESPONSE_STATUS=' + response.status())
- console.log('LOGIN_RESPONSE_BODY=' + (await response.text()))
- } catch (error) {
- console.log('LOGIN_RESPONSE_READ_ERROR=' + String(error))
- }
- }
- })
-
- await page.goto(`${baseURL}/auth/login`, { waitUntil: 'networkidle' })
-
- const usernameInput = page
- .locator('input[placeholder*="璐﹀彿"], input[placeholder*="鐢ㄦ埛鍚�"]')
- .first()
- const passwordInput = page.locator('input[type="password"]').first()
-
- await usernameInput.fill(username)
- await passwordInput.fill(password)
- await passSlider(page)
-
- await Promise.all([
- page.waitForTimeout(2000),
- page.getByRole('button', { name: /鐧诲綍/i }).click()
- ])
-
- await page.waitForLoadState('networkidle').catch(() => {})
-
- console.log('POST_LOGIN_URL=' + page.url())
- console.log('LS_KEYS=' + JSON.stringify(await page.evaluate(() => Object.keys(localStorage))))
- console.log('PAGE_TEXT=' + (await page.locator('body').innerText()).slice(0, 4000))
-
- const clicked = await clickLocMenu(page)
- console.log('LOC_MENU_CLICKED=' + clicked)
-
- if (!clicked) {
- await page.goto(`${baseURL}/#/bas/loc`, { waitUntil: 'networkidle' }).catch(() => {})
- }
-
- await page.waitForLoadState('networkidle').catch(() => {})
- await page.waitForTimeout(1500)
-
- console.log('AFTER_LOC_CLICK_URL=' + page.url())
- console.log('PAGE_TEXT_AFTER_LOC=' + (await page.locator('body').innerText()).slice(0, 4000))
-
- const treeTitle = page.getByText('鍒嗙被鏍�', { exact: true })
- await treeTitle.waitFor({ timeout: 15000 })
-
- const layoutMetrics = await page.evaluate(() => {
- const treeCard = Array.from(document.querySelectorAll('.el-card')).find((node) =>
- node.textContent?.includes('鍒嗙被鏍�')
- )
- const searchArea = Array.from(document.querySelectorAll('*')).find((node) =>
- node.textContent?.includes('鍏抽敭瀛�')
- )
- const tableArea = Array.from(document.querySelectorAll('.el-card')).find((node) =>
- node.textContent?.includes('鏂板')
- )
- const rect = (node) => {
- if (!node) return null
- const box = node.getBoundingClientRect()
- return { x: box.x, y: box.y, width: box.width, height: box.height }
- }
- return {
- treeCard: rect(treeCard),
- searchArea: rect(searchArea),
- tableArea: rect(tableArea),
- viewport: { width: window.innerWidth, height: window.innerHeight }
- }
- })
-
- console.log('LAYOUT_METRICS=' + JSON.stringify(layoutMetrics))
-
- if (layoutMetrics.treeCard && layoutMetrics.tableArea) {
- console.log('ALIGNMENT_DELTA=' + Math.abs(layoutMetrics.treeCard.y - layoutMetrics.tableArea.y))
- }
-
- if (layoutMetrics.searchArea && layoutMetrics.tableArea) {
- console.log(
- 'SEARCH_TO_TABLE_GAP=' +
- (layoutMetrics.tableArea.y - (layoutMetrics.searchArea.y + layoutMetrics.searchArea.height))
- )
- }
-
- if (layoutMetrics.treeCard && layoutMetrics.viewport) {
- console.log(
- 'TREE_HEIGHT_RATIO=' + layoutMetrics.treeCard.height / layoutMetrics.viewport.height
- )
- }
-
- await page.screenshot({ path: screenshotPath, fullPage: true })
- console.log('FINAL_URL=' + page.url())
- console.log('SCREENSHOT=' + screenshotPath)
-
- await browser.close()
-}
-
-main().catch(async (error) => {
- console.error(error)
- process.exitCode = 1
-})
--
Gitblit v1.9.1