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