feat: 新增设置导入与导出 close #623

This commit is contained in:
wushuo
2026-03-06 09:50:39 +08:00
parent 2f93d065a5
commit becbcdc656
7 changed files with 163 additions and 12 deletions

View File

@@ -20,6 +20,7 @@ import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.text.StrFormatter;
import cn.hutool.core.util.*;
import cn.hutool.http.Header;
@@ -32,10 +33,13 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -277,4 +281,49 @@ public class ConfigController extends BaseController {
OutputStream outputStream = response.getOutputStream();
IoUtil.writeUtf8(outputStream, true, customCss);
}
@Auth
@Operation(summary = "导出设置")
@GetMapping("/exportConfig")
public void backupConfig() throws IOException {
String version = MavenUtils.getVersion();
String filename = StrUtil.format("ani-rss.backup.{}.zip", version);
String contentType = getContentType(filename);
HttpServletResponse response = Global.RESPONSE.get();
response.setContentType(contentType);
response.setHeader(Header.CONTENT_DISPOSITION.toString(), StrFormatter.format("inline; filename=\"{}\"", filename));
@Cleanup
OutputStream outputStream = response.getOutputStream();
ConfigUtil.backup(outputStream);
}
@Auth
@Operation(summary = "导入设置")
@PostMapping("/importConfig")
public Result<Void> importConfig(@RequestParam("file") MultipartFile file) throws IOException {
String originalFilename = file.getOriginalFilename();
String extName = FileUtil.extName(originalFilename);
Assert.isTrue("zip".equals(extName), "导入格式异常");
File configDir = ConfigUtil.getConfigDir();
// 删除旧的种子记录
FileUtil.del(configDir + "/torrents");
@Cleanup
InputStream inputStream = file.getInputStream();
ZipUtil.unzip(inputStream, configDir, StandardCharsets.UTF_8);
// 重新加载设置
ConfigUtil.load();
AniUtil.load();
return Result.success("导入成功");
}
}

View File

@@ -59,6 +59,7 @@ public class AniUtil {
.setIgnoreNullValue(true)
.setOverride(false);
ANI_LIST.clear();
for (Ani ani : anis) {
Ani newAni = AniUtil.createAni();
BeanUtil.copyProperties(newAni, ani, copyOptions);

View File

@@ -15,6 +15,7 @@ import cn.hutool.core.bean.copier.CopyOptions;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.lang.func.Func1;
import cn.hutool.core.lang.func.LambdaUtil;
import cn.hutool.core.text.StrFormatter;
@@ -25,9 +26,11 @@ import cn.hutool.core.util.ZipUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.system.OsInfo;
import cn.hutool.system.SystemUtil;
import lombok.Cleanup;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
@@ -338,6 +341,19 @@ public class ConfigUtil {
log.info("正在备份设置 {}", backupFile.getName());
try {
@Cleanup
OutputStream outputStream = FileUtil.getOutputStream(backupFile);
backup(outputStream);
log.info("备份设置成功 {}", backupFile.getName());
} catch (Exception e) {
log.error("备份失败 {}", backupFile.getName());
log.error(e.getMessage(), e);
}
}
public static synchronized void backup(OutputStream outputStream) {
File configDir = getConfigDir();
List<File> backupFiles = Stream.of(
"files", "torrents", "database.db",
AniUtil.FILE_NAME, ConfigUtil.FILE_NAME
@@ -348,19 +364,16 @@ public class ConfigUtil {
.toList();
try {
ZipUtil.zip(backupFile, StandardCharsets.UTF_8, true, pathname -> {
ZipUtil.zip(outputStream, StandardCharsets.UTF_8, true, pathname -> {
if (pathname.isFile()) {
return !List.of(".DS_Store", ".DS_Store@SynoResource")
.contains(pathname.getName());
String name = pathname.getName();
return !name.startsWith(".");
}
File[] files = FileUtils.listFiles(pathname);
return !ArrayUtil.isEmpty(files);
}, backupFiles.toArray(new File[0]));
log.info("备份设置成功 {}", backupFile.getName());
} catch (Exception e) {
log.error("备份失败 {}", backupFile.getName());
log.error(e.getMessage(), e);
} finally {
IoUtil.close(outputStream);
}
}

View File

@@ -21,6 +21,9 @@
<el-collapse-item name="7" title="其他">
<other :config="props.config"/>
</el-collapse-item>
<el-collapse-item name="8" title="备份">
<backup :config="props.config"/>
</el-collapse-item>
</el-collapse>
</template>
@@ -33,6 +36,7 @@ import Rss from "@/config/basic/Rss.vue";
import Trackers from "@/config/basic/Trackers.vue";
import Other from "@/config/basic/Other.vue";
import Bangumi from "@/config/basic/Bangumi.vue";
import Backup from "./basic/Backup.vue";
let activeName = ref('1')

View File

@@ -0,0 +1,71 @@
<template>
<input id="backup-file" hidden="hidden" type="file" @change="changeFile">
<div class="content flex">
<el-button bg @click="exportConfig" icon="Upload">导出设置</el-button>
<el-button bg @click="importConfig" icon="Download">导入设置</el-button>
</div>
</template>
<script setup>
import {authorization} from "@/js/global.js";
import * as http from "@/js/http.js"
import {ElMessage, ElMessageBox} from "element-plus";
import {markRaw} from "vue";
import {WarnTriangleFilled} from "@element-plus/icons-vue";
let importConfig = () => {
ElMessageBox.confirm(
`<strong style="color: var(--el-color-danger);">
将会覆盖掉现有的设置、订阅、下载记录, 是否执意继续?
</strong>`,
'警告',
{
dangerouslyUseHTMLString: true,
confirmButtonText: '继续',
confirmButtonClass: 'is-text is-has-bg el-button--danger',
cancelButtonText: '取消',
cancelButtonClass: 'is-text is-has-bg',
type: 'warning',
icon: markRaw(WarnTriangleFilled),
}
)
.then(() => {
let element = document.querySelector('#backup-file');
element.click();
})
}
let changeFile = () => {
let element = document.querySelector('#backup-file');
http.importConfig(element.files[0])
.then(res => {
let {code, message} = res
if (code !== 200) {
ElMessage.error(message)
return
}
ElMessage.success(message)
setTimeout(() => {
location.reload();
}, 1000)
})
}
let exportConfig = () => {
let element = document.createElement('a');
element.href = `api/exportConfig?s=${authorization.value}`
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
let props = defineProps(['config'])
</script>
<style scoped>
.content {
width: 100%;
justify-content: center;
}
</style>

View File

@@ -26,9 +26,9 @@ let fetch_ = async (url, method, body) => {
headers['Content-Type'] = 'application/json'
}
return await fetch(url, {
'method': method,
'body': body ? JSON.stringify(body) : null,
'headers': headers
method: method,
body: body ? JSON.stringify(body) : null,
headers: headers
})
.then(res => res.json())
.then(res => {

View File

@@ -1,5 +1,6 @@
import api from "@/js/api.js";
import CryptoJS from "crypto-js";
import {authorization} from "./global.js";
/**
* 获取设置
@@ -351,4 +352,16 @@ export let getAniBySubjectId = (id) => api.post(`api/getAniBySubjectId?id=${id}`
* @param hash 种子hash
* @returns {Promise<unknown>}
*/
export let deleteTorrent = (id, hash) => api.post(`api/deleteTorrent?id=${id}&hash=${hash}`)
export let deleteTorrent = (id, hash) => api.post(`api/deleteTorrent?id=${id}&hash=${hash}`)
export let importConfig = (file) => {
const formData = new FormData();
formData.append("file", file);
return fetch('api/importConfig', {
method: 'POST',
body: formData,
headers: {
'Authorization': authorization.value
}
}).then(res => res.json())
}