refactor: 默认模板服务端+统一导出导入+页面配置版本历史+前端
This commit is contained in:
@@ -402,43 +402,67 @@ public class AdminController {
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
// ==================== 脚本批量导入导出 ====================
|
||||
// ==================== 全配置批量导入导出 ====================
|
||||
|
||||
/**
|
||||
* 导出所有站点脚本为 ZIP:每个站点一个 .groovy 文件
|
||||
* 导出所有配置为 ZIP:scripts/{siteId}.groovy, pages/{siteId}.json, rules/{type}.json
|
||||
*/
|
||||
@GetMapping("/scripts/export")
|
||||
public ResponseEntity<byte[]> exportScripts(HttpServletRequest request) {
|
||||
checkAdmin(request);
|
||||
List<SiteConfig> configs = siteConfigMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SiteConfig>()
|
||||
.eq(SiteConfig::getDeleted, 0)
|
||||
.eq(SiteConfig::getIsLatest, true)
|
||||
.isNotNull(SiteConfig::getScriptStoragePath));
|
||||
try {
|
||||
var baos = new java.io.ByteArrayOutputStream();
|
||||
try (var zos = new java.util.zip.ZipOutputStream(baos)) {
|
||||
for (SiteConfig sc : configs) {
|
||||
// 1. 脚本
|
||||
for (SiteConfig sc : siteConfigMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SiteConfig>()
|
||||
.eq(SiteConfig::getDeleted, 0).eq(SiteConfig::getIsLatest, true)
|
||||
.isNotNull(SiteConfig::getScriptStoragePath))) {
|
||||
String script = storageService.read(sc.getScriptStoragePath());
|
||||
if (script == null || script.isBlank()) continue;
|
||||
var entry = new java.util.zip.ZipEntry(sc.getSiteId() + ".groovy");
|
||||
zos.putNextEntry(entry);
|
||||
zos.write(script.getBytes(StandardCharsets.UTF_8));
|
||||
zos.closeEntry();
|
||||
addZipEntry(zos, "scripts/" + sc.getSiteId() + ".groovy", script);
|
||||
}
|
||||
// 2. 页面配置(去重取最新版本)
|
||||
Set<String> seenPages = new HashSet<>();
|
||||
for (var sp : ((com.par.core.service.impl.SitePagesServiceImpl) siteService).mapper
|
||||
.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.par.core.entity.SitePages>()
|
||||
.eq(com.par.core.entity.SitePages::getDeleted, 0)
|
||||
.orderByDesc(com.par.core.entity.SitePages::getId))) {
|
||||
if (seenPages.add(sp.getSiteId())) {
|
||||
addZipEntry(zos, "pages/" + sp.getSiteId() + ".json", sp.getEntriesJson());
|
||||
}
|
||||
}
|
||||
// 3. 风险规则(去重取最新版本)
|
||||
Set<String> seenRules = new HashSet<>();
|
||||
var ruleMapper = new com.par.core.mapper.RiskRuleMapper() {};
|
||||
for (var rr : ruleMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.par.core.entity.RiskRule>()
|
||||
.eq(com.par.core.entity.RiskRule::getDeleted, 0)
|
||||
.orderByDesc(com.par.core.entity.RiskRule::getId))) {
|
||||
if (seenRules.add(rr.getSiteType())) {
|
||||
addZipEntry(zos, "rules/" + rr.getSiteType() + ".json", rr.getRulesJson());
|
||||
}
|
||||
}
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=scripts.zip")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=par-config.zip")
|
||||
.contentType(MediaType.parseMediaType("application/zip"))
|
||||
.body(baos.toByteArray());
|
||||
} catch (Exception e) {
|
||||
log.error("导出脚本失败", e);
|
||||
log.error("导出失败", e);
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
}
|
||||
|
||||
private void addZipEntry(java.util.zip.ZipOutputStream zos, String name, String content) throws Exception {
|
||||
var entry = new java.util.zip.ZipEntry(name);
|
||||
zos.putNextEntry(entry);
|
||||
zos.write(content.getBytes(StandardCharsets.UTF_8));
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入脚本 ZIP:每个 .groovy 文件名 = siteId,保存到本地
|
||||
* 导入 ZIP:scripts/*.groovy, pages/*.json, rules/*.json
|
||||
*/
|
||||
@PostMapping("/scripts/import")
|
||||
public ApiResponse<Map<String, Object>> importScripts(
|
||||
@@ -449,37 +473,40 @@ public class AdminController {
|
||||
try (var zis = new java.util.zip.ZipInputStream(file.getInputStream())) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.isDirectory() || !entry.getName().endsWith(".groovy")) continue;
|
||||
String siteId = entry.getName().replace(".groovy", "");
|
||||
String script = new String(zis.readAllBytes(), StandardCharsets.UTF_8);
|
||||
if (script.isBlank()) continue;
|
||||
if (entry.isDirectory()) continue;
|
||||
String name = entry.getName();
|
||||
String content = new String(zis.readAllBytes(), StandardCharsets.UTF_8);
|
||||
if (content.isBlank()) continue;
|
||||
try {
|
||||
int nextVersion = 1;
|
||||
List<SiteConfig> existing = siteConfigMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SiteConfig>()
|
||||
.eq(SiteConfig::getSiteId, siteId)
|
||||
.eq(SiteConfig::getDeleted, 0)
|
||||
.orderByDesc(SiteConfig::getVersion)
|
||||
.last("limit 1"));
|
||||
if (!existing.isEmpty()) {
|
||||
try { nextVersion = Integer.parseInt(existing.get(0).getVersion()) + 1; }
|
||||
catch (NumberFormatException ignored) {}
|
||||
if (name.startsWith("scripts/") && name.endsWith(".groovy")) {
|
||||
String sid = name.replace("scripts/", "").replace(".groovy", "");
|
||||
configService.submitConfig(sid, String.valueOf(System.currentTimeMillis()), content, null);
|
||||
} else if (name.startsWith("pages/") && name.endsWith(".json")) {
|
||||
String sid = name.replace("pages/", "").replace(".json", "");
|
||||
siteService.savePages(sid, parseJson(content));
|
||||
} else if (name.startsWith("rules/") && name.endsWith(".json")) {
|
||||
String type = name.replace("rules/", "").replace(".json", "");
|
||||
((com.par.core.service.RiskRuleService) riskService).saveRules(type, parseJson(content));
|
||||
}
|
||||
configService.submitConfig(siteId, String.valueOf(nextVersion), script, null);
|
||||
ok++;
|
||||
} catch (Exception ex) {
|
||||
log.error("导入脚本失败 siteId={}: {}", siteId, ex.getMessage());
|
||||
fail++;
|
||||
}
|
||||
} catch (Exception ex) { log.error("导入失败: {}", name, ex); fail++; }
|
||||
zis.closeEntry();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("解压导入ZIP失败", e);
|
||||
return ApiResponse.error(500, "导入失败: " + e.getMessage());
|
||||
}
|
||||
} catch (Exception e) { return ApiResponse.error(500, "导入失败: " + e.getMessage()); }
|
||||
return ApiResponse.success(Map.of("ok", ok, "fail", fail));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> parseJson(String json) {
|
||||
try { return new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, java.util.List.class); }
|
||||
catch (Exception e) { return java.util.List.of(); }
|
||||
}
|
||||
|
||||
private final com.par.core.service.SitePagesService siteService;
|
||||
private final com.par.core.service.RiskRuleService riskService;
|
||||
|
||||
// ==================== 旧脚本导入导出(保留兼容) ====================
|
||||
|
||||
private void checkAdmin(HttpServletRequest request) {
|
||||
TrustLevel level = (TrustLevel) request.getAttribute("trustLevel");
|
||||
if (level == null || level != TrustLevel.ADMIN) {
|
||||
|
||||
@@ -111,6 +111,26 @@ public class RiskRuleController {
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/** 获取或保存默认风险规则模板 */
|
||||
@GetMapping("/api/v1/admin/default-rules/{type}")
|
||||
public ApiResponse<Map<String, Object>> getDefaultRules(@PathVariable String type, HttpServletRequest request) {
|
||||
checkAdmin(request);
|
||||
String key = "default:" + type;
|
||||
Map<String, Object> data = riskRuleService.getRules(key);
|
||||
if (data == null) data = Map.of("version", 0, "rules", List.of());
|
||||
return ApiResponse.success(data);
|
||||
}
|
||||
|
||||
@PostMapping("/api/v1/admin/default-rules/{type}")
|
||||
public ApiResponse<Void> saveDefaultRules(
|
||||
@PathVariable String type, @RequestBody Map<String, Object> payload, HttpServletRequest request) {
|
||||
checkAdmin(request);
|
||||
@SuppressWarnings("unchecked")
|
||||
var rules = (List<Map<String, Object>>) payload.get("rules");
|
||||
riskRuleService.saveRules("default:" + type, rules != null ? rules : List.of());
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
private void checkAdmin(HttpServletRequest request) {
|
||||
var level = request.getAttribute("trustLevel");
|
||||
if (level == null || !"ADMIN".equals(level.toString())) {
|
||||
|
||||
@@ -116,6 +116,28 @@ public class SitePagesController {
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/** 获取或保存默认页面模板 */
|
||||
@GetMapping("/api/v1/admin/default-pages/{type}")
|
||||
public ApiResponse<Map<String, Object>> getDefaultPages(@PathVariable String type, HttpServletRequest request) {
|
||||
checkAdmin(request);
|
||||
String defaultId = "default:" + type;
|
||||
Map<String, Object> data = sitePagesService.getPages(defaultId);
|
||||
if (data == null) {
|
||||
data = Map.of("siteId", defaultId, "version", "0", "entries", List.of());
|
||||
}
|
||||
return ApiResponse.success(data);
|
||||
}
|
||||
|
||||
@PutMapping("/api/v1/admin/default-pages/{type}")
|
||||
public ApiResponse<Void> saveDefaultPages(
|
||||
@PathVariable String type, @RequestBody Map<String, Object> payload, HttpServletRequest request) {
|
||||
checkAdmin(request);
|
||||
@SuppressWarnings("unchecked")
|
||||
var entries = (List<Map<String, Object>>) payload.get("entries");
|
||||
sitePagesService.savePages("default:" + type, entries != null ? entries : List.of());
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
private void checkAdmin(HttpServletRequest request) {
|
||||
var level = request.getAttribute("trustLevel");
|
||||
if (level == null || !"ADMIN".equals(level.toString())) {
|
||||
|
||||
@@ -144,7 +144,7 @@ const exportScripts = async () => {
|
||||
const r = await api.raw.get('/admin/scripts/export', { responseType: 'blob' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(new Blob([r]))
|
||||
a.download = 'scripts.zip'; a.click(); URL.revokeObjectURL(a.href)
|
||||
a.download = 'par-config.zip'; a.click(); URL.revokeObjectURL(a.href)
|
||||
} catch { ElMessage.error('导出失败') }
|
||||
finally { exporting.value = false }
|
||||
}
|
||||
|
||||
@@ -36,8 +36,12 @@
|
||||
<el-table-column label="更新时间" width="150">
|
||||
<template #default="s">{{ fmt(s.row.updatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="s">
|
||||
<el-select v-model="viewVer" placeholder="历史" size="small" style="width:70px" clearable
|
||||
@change="v=>loadVersion(s.row,v)" @click.stop>
|
||||
<el-option v-for="v in s.row.versions||[]" :key="v" :label="'v'+v" :value="v" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="toggleEdit(s.row)" :type="editSiteId===s.row.siteId?'warning':''">
|
||||
{{ editSiteId===s.row.siteId ? '收起' : '编辑' }}
|
||||
</el-button>
|
||||
@@ -73,35 +77,18 @@
|
||||
import api from '../api'
|
||||
|
||||
const loading = ref(false), saving = ref(false)
|
||||
const sites = ref([]), pagesMap = ref({})
|
||||
const sites = ref([]), pagesMap = ref({}), defaultMap = ref({}), viewVer = ref('')
|
||||
const editSiteId = ref(''), editorJson = ref('')
|
||||
|
||||
const defaultPages = {
|
||||
nexusphp: [
|
||||
{key:'home', label:'首页', url:'{base}', method:'GET'},
|
||||
{key:'search', label:'搜索页', url:'{base}/torrents.php', method:'GET'},
|
||||
{key:'detail', label:'种子详情', url:'{base}/details.php?id=', method:'GET'},
|
||||
{key:'signIn', label:'签到页', url:'{base}/attendance.php', method:'POST'},
|
||||
{key:'user', label:'用户详情', url:'{base}/userdetails.php?id=', method:'GET'},
|
||||
],
|
||||
mtorrent: [
|
||||
{key:'home', label:'首页', url:'{base}/api/member/profile', method:'GET'},
|
||||
{key:'search',label:'搜索页', url:'{base}/api/torrent/search', method:'POST'},
|
||||
],
|
||||
gazelle: [
|
||||
{key:'home', label:'首页', url:'{base}/index.php', method:'GET'},
|
||||
{key:'search',label:'搜索', url:'{base}/torrents.php', method:'GET'},
|
||||
],
|
||||
unit3d: [
|
||||
{key:'home', label:'首页', url:'{base}/api/torrents/filter', method:'GET'},
|
||||
{key:'search',label:'搜索', url:'{base}/api/torrents/filter?name=', method:'GET'},
|
||||
],
|
||||
}
|
||||
const fallbackPages = { nexusphp: [{key:'home',label:'首页',url:'{base}',method:'GET'}] }
|
||||
|
||||
const defaults = computed(() => {
|
||||
const types = [...new Set((Array.isArray(sites.value)?sites.value:[]).map(s=>s.siteType).filter(Boolean))]
|
||||
const base = ['nexusphp','mtorrent','gazelle','unit3d']
|
||||
return [...new Set([...types,...base])].map(t => ({type:t, label:t, entries: (defaultPages[t]||[]) }))
|
||||
return [...new Set([...types,...base])].map(t => ({
|
||||
type:t, label:t,
|
||||
entries: defaultMap.value[t] || fallbackPages[t] || [{key:'home',label:'首页',url:'{base}',method:'GET'}]
|
||||
}))
|
||||
})
|
||||
|
||||
const fmt = (t) => t ? (t.substring(0,19)||'').replace('T',' ') : '-'
|
||||
@@ -114,6 +101,7 @@ const merged = computed(() => {
|
||||
entryCount: pagesMap.value[s.siteId]?.entryCount || 0,
|
||||
updatedAt: pagesMap.value[s.siteId]?.updatedAt || null,
|
||||
entriesJson: pagesMap.value[s.siteId]?.entriesJson || null,
|
||||
versions: pagesMap.value[s.siteId]?.versions || [],
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -126,18 +114,41 @@ const fetchAll = async () => {
|
||||
])
|
||||
sites.value = (sr.status==='fulfilled' ? (sr.value?.data || sr.value || []) : [])
|
||||
const pages = pr.status==='fulfilled' ? (pr.value?.data || pr.value || []) : []
|
||||
const map = {}
|
||||
const map = {}, verMap = {}
|
||||
for (const p of pages) {
|
||||
const entries = (() => { try { return JSON.parse(p.entriesJson||'[]') } catch {return []} })()
|
||||
map[p.siteId] = { version: p.version, entryCount: entries.length, updatedAt: p.updatedAt, entriesJson: p.entriesJson }
|
||||
const v = p.version
|
||||
if (!map[p.siteId] || v > map[p.siteId].version) {
|
||||
map[p.siteId] = { version: v, entryCount: entries.length, updatedAt: p.updatedAt, entriesJson: p.entriesJson }
|
||||
}
|
||||
if (!verMap[p.siteId]) verMap[p.siteId] = new Set()
|
||||
verMap[p.siteId].add(v)
|
||||
}
|
||||
for (const k in verMap) map[k].versions = [...verMap[k]].sort((a,b)=>parseInt(b)-parseInt(a))
|
||||
pagesMap.value = map
|
||||
// 加载默认模板
|
||||
for (const t of ['nexusphp','mtorrent','gazelle','unit3d']) {
|
||||
try {
|
||||
const r = await api.raw.get('/admin/default-pages/'+t)
|
||||
const data = r?.data || r
|
||||
if (data?.entries) defaultMap.value[t] = data.entries
|
||||
} catch {}
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const loadVersion = async (row, ver) => {
|
||||
if (!ver) return
|
||||
try {
|
||||
const r = await api.raw.get('/admin/site-pages/'+row.siteId+'/version/'+ver)
|
||||
editorJson.value = JSON.stringify(JSON.parse((r?.data||r)?.content||'[]'), null, 2)
|
||||
} catch { ElMessage.error('加载版本失败') }
|
||||
}
|
||||
|
||||
const loadDefault = (row) => {
|
||||
editSiteId.value = row.siteId
|
||||
editorJson.value = JSON.stringify(defaultPages[row.siteType] || defaultPages['nexusphp'], null, 2)
|
||||
const entries = defaultMap.value[row.siteType] || fallbackPages[row.siteType] || [{key:'home',label:'首页',url:'{base}',method:'GET'}]
|
||||
editorJson.value = JSON.stringify(entries, null, 2)
|
||||
window.scrollTo({top: document.body.scrollHeight, behavior:'smooth'})
|
||||
}
|
||||
|
||||
@@ -165,7 +176,10 @@ const toggleEdit = (row) => {
|
||||
const savePages = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.raw.put('/sites/'+editSiteId.value+'/pages', { entries: JSON.parse(editorJson.value) })
|
||||
const url = editSiteId.value.startsWith('default:')
|
||||
? '/admin/default-pages/'+editSiteId.value.replace('default:','')
|
||||
: '/sites/'+editSiteId.value+'/pages'
|
||||
await api.raw.put(url, { entries: JSON.parse(editorJson.value) })
|
||||
ElMessage.success('已保存'); editSiteId.value = ''; fetchAll()
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
|
||||
Reference in New Issue
Block a user