From 2d8fce78eadd9ccad60321c2682dff16771aa05b Mon Sep 17 00:00:00 2001 From: mediabot-pt <295750538+mediabot-pt@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:14:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=88=E5=B9=B6=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E5=88=B0=E9=80=82=E9=85=8D=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E9=A1=B5=EF=BC=8C=E6=94=AF=E6=8C=81=E7=89=88=E6=9C=AC=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E3=80=81=E7=BC=96=E8=BE=91=E3=80=81=E5=AE=A1=E6=89=B9?= =?UTF-8?q?/=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../par/api/controller/AdminController.java | 70 ++++- .../par/core/dto/SiteAccessSummaryDTO.java | 1 + .../service/impl/AccessResultServiceImpl.java | 16 + web/src/api/index.js | 7 + web/src/router/index.js | 4 +- web/src/views/AccessResultCheck.vue | 279 ++++++++++++------ web/src/views/AccessResults.vue | 6 + web/src/views/Layout.vue | 10 +- 8 files changed, 291 insertions(+), 102 deletions(-) diff --git a/par-api/src/main/java/com/par/api/controller/AdminController.java b/par-api/src/main/java/com/par/api/controller/AdminController.java index 49102fe..e150734 100644 --- a/par-api/src/main/java/com/par/api/controller/AdminController.java +++ b/par-api/src/main/java/com/par/api/controller/AdminController.java @@ -2,6 +2,7 @@ package com.par.api.controller; import com.par.core.dto.AccountDTO; import com.par.core.dto.ApiResponse; +import com.par.core.dto.SiteConfigDTO; import com.par.core.entity.Account; import com.par.core.entity.SiteConfig; import com.par.core.enums.ConfigStatus; @@ -12,13 +13,16 @@ import com.par.core.mapper.SiteConfigMapper; import com.par.core.mapper.SiteMapper; import com.par.core.entity.Psk; import com.par.core.service.AccountService; +import com.par.core.service.ConfigService; import com.par.core.service.PskService; import com.par.core.storage.CachedStorageService; +import com.par.common.util.HmacUtil; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -39,6 +43,7 @@ public class AdminController { private final SiteMapper siteMapper; private final AccountService accountService; private final PskService pskService; + private final ConfigService configService; private final CachedStorageService storageService; private final com.par.core.service.SiteService siteService; @@ -124,7 +129,7 @@ public class AdminController { @PostMapping("/reviews/{reviewId}/reject") public ApiResponse rejectConfig( @PathVariable Long reviewId, - @RequestParam("comment") String comment, + @RequestParam(value = "comment", required = false, defaultValue = "") String comment, HttpServletRequest request) { checkAdmin(request); @@ -151,6 +156,69 @@ public class AdminController { return ApiResponse.success(); } + /** + * 获取指定站点的待审核配置列表 + */ + @GetMapping("/configs/pending/{siteId}") + public ApiResponse> listPendingConfigsBySite( + @PathVariable String siteId, HttpServletRequest request) { + checkAdmin(request); + List configs = siteConfigMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(SiteConfig::getSiteId, siteId) + .eq(SiteConfig::getStatus, ConfigStatus.PENDING) + .eq(SiteConfig::getDeleted, 0)); + return ApiResponse.success(configs.stream().map(configService::toDTO).toList()); + } + + /** + * 查看指定版本配置内容 + */ + @GetMapping("/configs/{configId}/content") + public ApiResponse> getConfigContent( + @PathVariable Long configId, HttpServletRequest request) { + checkAdmin(request); + SiteConfig config = siteConfigMapper.selectById(configId); + if (config == null) return ApiResponse.error(404, "Config not found"); + String content = null; + try { content = storageService.read(config.getStoragePath()); } catch (Exception e) { + return ApiResponse.error(500, "读取配置失败: " + e.getMessage()); + } + return ApiResponse.success(Map.of("id", config.getId(), "content", content != null ? content : "")); + } + + /** + * 编辑待审核配置内容 + */ + @PutMapping("/configs/{configId}/content") + public ApiResponse updateConfigContent( + @PathVariable Long configId, + @RequestBody Map payload, + HttpServletRequest request) { + checkAdmin(request); + SiteConfig config = siteConfigMapper.selectById(configId); + if (config == null) return ApiResponse.error(404, "Config not found"); + if (config.getStatus() != ConfigStatus.PENDING) { + return ApiResponse.error(400, "只能编辑待审核的配置"); + } + + String content = payload.get("content"); + if (content == null || content.isBlank()) { + return ApiResponse.error(400, "content is required"); + } + + // 更新存储文件 + storageService.store(config.getStoragePath(), content); + + // 更新 hash 和文件大小 + config.setConfigHash(HmacUtil.sha256(content)); + config.setFileSize((long) content.getBytes(StandardCharsets.UTF_8).length); + siteConfigMapper.updateById(config); + + log.info("Config content updated: id={}, siteId={}", configId, config.getSiteId()); + return ApiResponse.success(configService.toDTO(config)); + } + /** * 设置用户信任等级(禁止修改管理员、禁止提升为管理员) */ diff --git a/par-core/src/main/java/com/par/core/dto/SiteAccessSummaryDTO.java b/par-core/src/main/java/com/par/core/dto/SiteAccessSummaryDTO.java index 993511b..76e0dfc 100644 --- a/par-core/src/main/java/com/par/core/dto/SiteAccessSummaryDTO.java +++ b/par-core/src/main/java/com/par/core/dto/SiteAccessSummaryDTO.java @@ -16,6 +16,7 @@ public class SiteAccessSummaryDTO { private int validCount; private int invalidCount; private int uncheckedCount; + private int pendingConfigCount; private LocalDateTime lastSubmitted; private List pages; } diff --git a/par-core/src/main/java/com/par/core/service/impl/AccessResultServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/AccessResultServiceImpl.java index 2b93e2a..7caa6a2 100644 --- a/par-core/src/main/java/com/par/core/service/impl/AccessResultServiceImpl.java +++ b/par-core/src/main/java/com/par/core/service/impl/AccessResultServiceImpl.java @@ -4,7 +4,9 @@ import com.par.core.dto.AccessPageSummaryDTO; import com.par.core.dto.AccessResultDTO; import com.par.core.dto.SiteAccessSummaryDTO; import com.par.core.entity.AccessResult; +import com.par.core.entity.SiteConfig; import com.par.core.mapper.AccessResultMapper; +import com.par.core.mapper.SiteConfigMapper; import com.par.core.service.AccessResultService; import com.par.core.storage.CachedStorageService; import lombok.RequiredArgsConstructor; @@ -22,6 +24,7 @@ import java.util.stream.Collectors; public class AccessResultServiceImpl implements AccessResultService { private final AccessResultMapper mapper; + private final SiteConfigMapper siteConfigMapper; private final CachedStorageService storageService; @Override @@ -154,6 +157,19 @@ public class AccessResultServiceImpl implements AccessResultService { site.setUncheckedCount(site.getTotalResults() - site.getValidCount() - site.getInvalidCount()); } + // 补充待审核配置数量 + List pendingConfigs = siteConfigMapper.selectPending(); + for (SiteConfig c : pendingConfigs) { + String sid = c.getSiteId(); + SiteAccessSummaryDTO site = siteMap.computeIfAbsent(sid, k -> { + SiteAccessSummaryDTO s = new SiteAccessSummaryDTO(); + s.setSiteId(k); + s.setPages(new ArrayList<>()); + return s; + }); + site.setPendingConfigCount(site.getPendingConfigCount() + 1); + } + return new ArrayList<>(siteMap.values()); } diff --git a/web/src/api/index.js b/web/src/api/index.js index 9c5dad0..4b921c6 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -74,5 +74,12 @@ export default { validate: (id, isValid, note) => api.post(`/admin/access-results/${id}/validate`, { isValid, note: note || '' }), batchValidate: (siteId, url, isValid) => api.post('/admin/access-results/batch-validate', { siteId, url, isValid }), }, + adminConfigs: { + pendingBySite: (siteId) => api.get(`/admin/configs/pending/${siteId}`), + getContent: (configId) => api.get(`/admin/configs/${configId}/content`), + updateContent: (configId, content) => api.put(`/admin/configs/${configId}/content`, { content }), + approve: (configId) => api.post(`/admin/reviews/${configId}/approve`), + reject: (configId, comment) => api.post(`/admin/reviews/${configId}/reject`, null, { params: { comment: comment || '' } }), + }, health: () => api.get('/health', { baseURL: '' }) } diff --git a/web/src/router/index.js b/web/src/router/index.js index 2166ed5..7d147d8 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -32,9 +32,7 @@ const routes = [ }, { path: 'configs', - name: 'Configs', - component: () => import('../views/Configs.vue'), - meta: { title: '配置审核' } + redirect: '/access-results' }, { path: 'users', diff --git a/web/src/views/AccessResultCheck.vue b/web/src/views/AccessResultCheck.vue index b15cd6a..7395b81 100644 --- a/web/src/views/AccessResultCheck.vue +++ b/web/src/views/AccessResultCheck.vue @@ -5,36 +5,80 @@ ← 返回列表 - - -
-
配置版本:{{ configVersion || '-' }}
-
页面数:{{ groupedPages.length }}
-
总次数:{{ allResults.length }}
- {{ validCount }} 有效 - {{ invalidCount }} 无效 - {{ uncheckedCount }} 待验 - 刷新 -
-
-
- - + + + + +
+ + + + + +
+
加载配置中...
-
{{ configError }}
-
配置已折叠
-

+        
暂无配置
+
- +
+ + +
+ 页面数:{{ groupedPages.length }} + 总次数:{{ allResults.length }} + {{ validCount }} 有效 + {{ invalidCount }} 无效 + {{ uncheckedCount }} 待验 + 刷新 +
+
+ + -
- - {{ r.statusCode }} - - {{ r.responseTimeMs }}ms - {{ formatTime(r.createdAt) }} - {{ r.clientId }} + {{ r.statusCode }} + {{ r.responseTimeMs }}ms + {{ formatTime(r.createdAt) }} + {{ r.clientId }}
-
+
有效 无效 查看
- - -
+
还有 {{ page.totalCount - 5 }} 条更早记录
@@ -94,7 +128,7 @@ - + {{ viewingResult.url || '-' }} {{ viewingResult.statusCode }} @@ -123,17 +157,24 @@ const siteId = route.params.siteId const loading = ref(false) const allResults = ref([]) -const configVersion = ref('') + +const allConfigVersions = ref([]) +const selectedConfig = ref(null) +const selectedConfigJson = ref('') +const configTab = ref('') const configLoading = ref(true) -const configError = ref('') -const configContent = ref('') -const configCollapsed = ref(false) +const editing = ref(false) +const editContent = ref('') +const saving = ref(false) +const approving = ref(false) +const rejecting = ref(false) const contentVisible = ref(false) const contentLoading = ref(false) const viewingResult = ref({}) const viewingContentType = ref('html') const viewingContent = ref('') + const expandedUrls = reactive(new Set()) const MAX_VISIBLE = 5 @@ -142,7 +183,6 @@ const toggleExpand = (url) => { else expandedUrls.add(url) } -// 按 URL 分组(最新在前),默认显示最近5条 const groupedPages = computed(() => { const map = new Map() for (const r of allResults.value) { @@ -156,8 +196,7 @@ const groupedPages = computed(() => { const hasUnchecked = results.some(r => r.isValid == null) const totalCount = results.length const visibleResults = expandedUrls.has(url) ? results.slice(0, MAX_VISIBLE) : results.slice(0, 1) - const visibleCount = visibleResults.length - return { url, results, totalCount, visibleResults, visibleCount, allValid, allInvalid, hasUnchecked } + return { url, results, totalCount, visibleResults, visibleCount: visibleResults.length, allValid, allInvalid, hasUnchecked } }) }) @@ -167,31 +206,104 @@ const uncheckedCount = computed(() => allResults.value.filter(r => r.isValid == const refresh = async () => { loading.value = true - try { - const r = await api.accessResults.bySite(siteId) - allResults.value = r.data || [] - } catch { ElMessage.error('获取访问结果失败') } + try { const r = await api.accessResults.bySite(siteId); allResults.value = r.data || [] } + catch { ElMessage.error('获取访问结果失败') } finally { loading.value = false } } -const loadConfig = async () => { +// 加载所有配置版本(已批准 + 待审核) +const loadConfigs = async () => { configLoading.value = true try { - const r = await api.configs.bySite(siteId) - const configs = r.data || [] - if (configs.length) { - configVersion.value = configs[0].version - const resp = await api.configs.content(siteId, configs[0].version) - configContent.value = typeof resp.data === 'string' ? resp.data : JSON.stringify(resp.data, null, 2) - } else { - configError.value = '该站点暂无适配配置' + const [approvedRes, pendingRes] = await Promise.allSettled([ + api.configs.bySite(siteId), + api.adminConfigs.pendingBySite(siteId) + ]) + const approved = approvedRes.status === 'fulfilled' ? (approvedRes.value.data || []) : [] + const pending = pendingRes.status === 'fulfilled' ? (pendingRes.value.data || []) : [] + // 合并:待审核在前,已批准在后 + allConfigVersions.value = [...pending, ...approved] + } catch { + allConfigVersions.value = [] + } finally { + configLoading.value = false + // 默认选中第一个待审核,否则第一个已批准 + if (allConfigVersions.value.length) { + const first = allConfigVersions.value[0] + configTab.value = String(first.id) + selectConfig(first) } - } catch (e) { - configError.value = e.response?.data?.message || '获取配置失败' - } finally { configLoading.value = false } + } } -const toggleConfigCollapse = () => { configCollapsed.value = !configCollapsed.value } +const onConfigTabChange = (tabName) => { + const cfg = allConfigVersions.value.find(v => String(v.id) === tabName) + if (cfg) selectConfig(cfg) +} + +const selectConfig = async (cfg) => { + selectedConfig.value = cfg + editing.value = false + // 加载配置内容 + try { + if (cfg.status === 'pending') { + const r = await api.adminConfigs.getContent(cfg.id) + selectedConfigJson.value = r.data?.content || '' + } else { + const r = await api.configs.content(siteId, cfg.version) + selectedConfigJson.value = typeof r.data === 'string' ? r.data : JSON.stringify(r.data || {}, null, 2) + } + } catch { + selectedConfigJson.value = '' + } +} + +const startEdit = () => { + editContent.value = selectedConfigJson.value + editing.value = true +} + +const cancelEdit = () => { editing.value = false } + +const saveEdit = async () => { + if (!selectedConfig.value) return + saving.value = true + try { + await api.adminConfigs.updateContent(selectedConfig.value.id, editContent.value) + selectedConfigJson.value = editContent.value + editing.value = false + ElMessage.success('配置已保存') + } catch (e) { ElMessage.error(e.response?.data?.message || '保存失败') } + finally { saving.value = false } +} + +const approveConfig = async () => { + if (!selectedConfig.value) return + try { + await ElMessageBox.confirm('确认审批通过?通过后版本号将自动加1。', '审批配置', { confirmButtonText: '确认' }) + } catch { return } + approving.value = true + try { + await api.adminConfigs.approve(selectedConfig.value.id) + ElMessage.success('配置已审批通过') + loadConfigs() + } catch (e) { ElMessage.error(e.response?.data?.message || '审批失败') } + finally { approving.value = false } +} + +const rejectConfig = async () => { + if (!selectedConfig.value) return + try { + await ElMessageBox.confirm('确认拒绝该配置?', '拒绝配置', { confirmButtonText: '确认', type: 'warning' }) + } catch { return } + rejecting.value = true + try { + await api.adminConfigs.reject(selectedConfig.value.id, '') + ElMessage.success('配置已拒绝') + loadConfigs() + } catch (e) { ElMessage.error(e.response?.data?.message || '拒绝失败') } + finally { rejecting.value = false } +} const batchValidate = async (url, isValid) => { try { @@ -220,51 +332,36 @@ const viewContent = async (row) => { const r = await api.accessResults.get(row.id) viewingResult.value = r.data || {} viewingContent.value = r.data?.content || '' - } catch { - ElMessage.error('获取内容失败') - viewingContent.value = '加载失败' - } finally { contentLoading.value = false } -} - -const formatTime = (t) => { - if (!t) return '-' - return t.substring(5, 16) // MM-DD HH:mm + } catch { ElMessage.error('获取内容失败'); viewingContent.value = '加载失败' } + finally { contentLoading.value = false } } +const formatTime = (t) => t ? t.substring(5, 16) : '-' const statusTag = (code) => { if (code >= 200 && code < 300) return 'success' if (code >= 300 && code < 400) return 'warning' return 'danger' } -// 配置 JSON 语法高亮 +// JSON 语法高亮 const highlightedConfig = computed(() => { - try { - const json = typeof configContent.value === 'string' ? JSON.parse(configContent.value) : configContent.value - return syntaxHighlight(JSON.stringify(json, null, 2)) - } catch { return escapeHtml(configContent.value || '') } + try { return syntaxHighlight(JSON.stringify(JSON.parse(selectedConfigJson.value), null, 2)) } + catch { return escapeHtml(selectedConfigJson.value || '') } }) - const highlightedViewJson = computed(() => { - try { - const json = typeof viewingContent.value === 'string' ? JSON.parse(viewingContent.value) : viewingContent.value - return syntaxHighlight(JSON.stringify(json, null, 2)) - } catch { return escapeHtml(viewingContent.value || '') } + try { return syntaxHighlight(JSON.stringify(typeof viewingContent.value === 'string' ? JSON.parse(viewingContent.value) : viewingContent.value, null, 2)) } + catch { return escapeHtml(viewingContent.value || '') } }) - const highlightedViewHtml = computed(() => { return escapeHtml(viewingContent.value || '') - .replace(/(<\/?)(\w+)([\s\S]*?)(\/?>)/g, - '$1$2$3$4') - .replace(/(\w+)=(".*?")/g, - '$1=$2') + .replace(/(<\/?)(\w+)([\s\S]*?)(\/?>)/g, '$1$2$3$4') + .replace(/(\w+)=(".*?")/g, '$1=$2') }) const escapeHtml = (str) => { if (!str) return '' return str.replace(/&/g, '&').replace(//g, '>') } - const syntaxHighlight = (json) => escapeHtml(json).replace( /("(\\u[a-fA-F0-9]{4}|\\[^u]|[^"\\])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (match) => { @@ -278,7 +375,7 @@ const syntaxHighlight = (json) => escapeHtml(json).replace( onMounted(() => { refresh() - loadConfig() + loadConfigs() }) @@ -293,7 +390,7 @@ onMounted(() => { margin: 0; white-space: pre-wrap; word-break: break-all; - max-height: calc(100vh - 280px); + max-height: calc(100vh - 440px); overflow: auto; } .content-json, .content-html { diff --git a/web/src/views/AccessResults.vue b/web/src/views/AccessResults.vue index 6a40dda..d28c187 100644 --- a/web/src/views/AccessResults.vue +++ b/web/src/views/AccessResults.vue @@ -27,6 +27,12 @@
+ + +