328 lines
13 KiB
Vue
328 lines
13 KiB
Vue
<template>
|
||
<div>
|
||
<!-- 顶栏 -->
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||
<h2 style="margin:0">适配校验:{{ siteId }}</h2>
|
||
<el-button @click="$router.push('/access-results')">← 返回列表</el-button>
|
||
</div>
|
||
|
||
<!-- 控制栏 -->
|
||
<el-card shadow="never" style="margin-bottom:12px">
|
||
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
||
|
||
<el-select v-model="selectedConfigId" placeholder="选择配置版本" @change="onConfigChange" style="width:240px">
|
||
<el-option v-for="v in allConfigVersions" :key="v.id"
|
||
:label="'v'+v.version + (v.status==='pending'?' [待审核]':'') + (v.isLatest?' [当前]':'')" :value="v.id" />
|
||
</el-select>
|
||
|
||
<el-divider direction="vertical" />
|
||
|
||
<span class="text-xs"><strong>{{ pages.length }}</strong> 页面</span>
|
||
<span class="text-xs"><strong>{{ totalResults }}</strong> 次提交</span>
|
||
|
||
<el-divider direction="vertical" />
|
||
|
||
<!-- 解析摘要 -->
|
||
<template v-if="selectedConfig">
|
||
<el-tag v-if="parseSummary.matched>0" size="small" type="success">
|
||
✓ {{ parseSummary.matched }} 页匹配
|
||
</el-tag>
|
||
<el-tag v-if="parseSummary.unmatched>0" size="small" type="warning">
|
||
⚠ {{ parseSummary.unmatched }} 页未匹配
|
||
</el-tag>
|
||
<el-tag v-if="!parseSummary.hasParsed" size="small" type="info">未解析</el-tag>
|
||
</template>
|
||
|
||
<el-button size="small" @click="refresh" :loading="loading">刷新</el-button>
|
||
<el-button size="small" @click="doBatchParse" :loading="parseLoading" v-if="selectedConfigId">全部重新解析</el-button>
|
||
|
||
<!-- 审批/拒绝(待审核配置) -->
|
||
<template v-if="selectedConfig?.status==='pending'">
|
||
<el-divider direction="vertical" />
|
||
<el-button size="small" type="success" @click="approveConfig" :loading="approving">审批通过</el-button>
|
||
<el-button size="small" type="danger" @click="rejectConfig" :loading="rejecting">拒绝</el-button>
|
||
</template>
|
||
|
||
<el-button size="small" @click="configVisible=true" v-if="selectedConfigId">编辑脚本</el-button>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 脚本编辑弹窗 -->
|
||
<el-dialog v-model="configVisible" title="Groovy 脚本 · v{{ selectedConfig?.version }}" width="880px" :close-on-click-modal="false">
|
||
<div v-if="scriptLoading" style="text-align:center;padding:20px;color:#909399">加载中...</div>
|
||
<template v-else>
|
||
<div style="margin-bottom:6px;font-size:11px;color:#909399">
|
||
内置变量: <code>content</code>(页面内容) <code>url</code>(访问URL) <code>jsoup</code> <code>json</code> <code>utils</code>
|
||
· 返回 Map{pageName,extracted,message} 或 List
|
||
</div>
|
||
<el-input v-model="scriptContent" type="textarea" :rows="22" style="font-family:monospace;font-size:12px"
|
||
placeholder="def doc = jsoup.parse(content) def rows = [] doc.select('table tr').each { row -> rows << [ title: utils.text(utils.selectFirst(row,'td.name a')), size: utils.text(utils.selectFirst(row,'td.size')) ] } return [pageName:'search', extracted:rows]" />
|
||
</template>
|
||
<template #footer>
|
||
<el-button @click="configVisible=false">关闭</el-button>
|
||
<el-button type="primary" @click="saveScript" :loading="saving">保存脚本</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 解析中 -->
|
||
<div v-if="parseLoading" style="text-align:center;padding:12px;color:#909399">
|
||
<el-icon class="is-loading"><Loading /></el-icon> 正在解析各页面适配结果...
|
||
</div>
|
||
|
||
<!-- URL 卡片列表 -->
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<el-card v-for="page in pages" :key="page.url" shadow="hover" size="small">
|
||
<template #header>
|
||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<!-- 解析状态图标 -->
|
||
<span v-if="page.parsed?.matched" style="color:#67c23a;font-size:16px">✓</span>
|
||
<span v-else-if="page.parsed" style="color:#e6a23c;font-size:16px">⚠</span>
|
||
<span v-else style="color:#c0c4cc;font-size:16px">○</span>
|
||
|
||
<strong style="font-size:14px;word-break:break-all">{{ page.url }}</strong>
|
||
<el-tag v-if="page.statusCode" size="small" :type="statusTag(page.statusCode)">{{ page.statusCode }}</el-tag>
|
||
<span v-if="page.responseTimeMs!=null" class="text-xs" style="color:#909399">{{ page.responseTimeMs }}ms</span>
|
||
|
||
<!-- 解析信息 -->
|
||
<template v-if="page.parsed?.matched">
|
||
<el-tag size="small" type="success">{{ page.parsed.pageName }}</el-tag>
|
||
<span class="text-xs" style="color:#909399">{{ page.parsed.rowCount }}行 {{ Object.keys(page.parsed.extracted?.[0]||{}).length }}字段</span>
|
||
</template>
|
||
<el-tag v-else-if="page.parsed" size="small" type="warning">未匹配规则</el-tag>
|
||
|
||
<el-tag size="small" type="info">×{{ page.totalCount }}</el-tag>
|
||
</div>
|
||
<div style="display:flex;gap:6px">
|
||
<el-button size="small" @click="toggleView(page)">
|
||
{{ expandedSet.has(page.url) ? '收起' : '查看' }}
|
||
</el-button>
|
||
<el-button size="small" @click="reparseUrl(page)" :loading="reparsingUrls.has(page.url)">重新解析</el-button>
|
||
<el-popconfirm title="确认删除此访问结果?" @confirm="deleteResult(page)">
|
||
<template #reference>
|
||
<el-button size="small" type="danger" text>删除</el-button>
|
||
</template>
|
||
</el-popconfirm>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 展开内容 -->
|
||
<template v-if="expandedSet.has(page.url)">
|
||
<!-- 解析结果 -->
|
||
<div v-if="page.parsed?.matched && page.parsed.extracted?.length" style="margin-bottom:8px">
|
||
<el-table :data="page.parsed.extracted" border stripe size="small" max-height="300">
|
||
<el-table-column type="index" label="#" width="45" />
|
||
<el-table-column v-for="col in Object.keys(page.parsed.extracted[0])" :key="col"
|
||
:prop="col" :label="col" min-width="100" show-overflow-tooltip>
|
||
<template #default="scope">
|
||
<span :style="scope.row[col]==null?'color:#c0c4cc':''">{{ scope.row[col] ?? '-' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<div v-else style="color:#909399;font-size:12px;margin-bottom:8px">
|
||
{{ page.parsed?.message || '暂无提取数据' }}
|
||
</div>
|
||
|
||
|
||
</template>
|
||
</el-card>
|
||
|
||
<div v-if="pages.length===0 && !loading" style="text-align:center;padding:60px;color:#909399">
|
||
暂无访问结果
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { useRoute } from 'vue-router'
|
||
import api from '../api'
|
||
import { Loading } from '@element-plus/icons-vue'
|
||
|
||
const route = useRoute()
|
||
const siteId = route.params.siteId
|
||
|
||
const loading = ref(false)
|
||
const parseLoading = ref(false)
|
||
const selectedConfigId = ref(null)
|
||
const selectedConfig = ref(null)
|
||
const configVisible = ref(false)
|
||
watch(configVisible, (v) => { if (v) loadScript() })
|
||
const scriptContent = ref('')
|
||
const scriptLoading = ref(false)
|
||
const saving = ref(false)
|
||
const approving = ref(false)
|
||
const rejecting = ref(false)
|
||
|
||
const allConfigVersions = ref([])
|
||
const allResults = ref([])
|
||
const pageParsed = ref({})
|
||
const expandedSet = reactive(new Set())
|
||
const reparsingUrls = reactive(new Set())
|
||
|
||
const totalResults = computed(() => allResults.value.length)
|
||
|
||
// 始终从 allResults 按 URL 分组
|
||
const pages = computed(() => {
|
||
const map = new Map()
|
||
for (const r of allResults.value) {
|
||
const key = r.url || '(无URL)'
|
||
if (!map.has(key)) {
|
||
map.set(key, { url: key, accessResultId: r.id, statusCode: r.statusCode, responseTimeMs: r.responseTimeMs,
|
||
createdAt: r.createdAt, totalCount: 0, parsed: null })
|
||
}
|
||
map.get(key).totalCount++
|
||
}
|
||
for (const [url, parsed] of Object.entries(pageParsed.value)) {
|
||
if (map.has(url)) map.get(url).parsed = parsed
|
||
}
|
||
return Array.from(map.values())
|
||
})
|
||
|
||
// 全局解析摘要
|
||
const parseSummary = computed(() => {
|
||
let matched = 0, unmatched = 0
|
||
for (const p of pages.value) {
|
||
if (p.parsed) {
|
||
if (p.parsed.matched) matched++
|
||
else unmatched++
|
||
}
|
||
}
|
||
return { matched, unmatched, hasParsed: matched + unmatched > 0 }
|
||
})
|
||
|
||
// 加载配置版本
|
||
const loadConfigs = async () => {
|
||
try {
|
||
const [ar, pr] = await Promise.allSettled([
|
||
api.configs.bySite(siteId), api.adminConfigs.pendingBySite(siteId)
|
||
])
|
||
const approved = ar.status==='fulfilled' ? (ar.value.data||[]) : []
|
||
const pending = pr.status==='fulfilled' ? (pr.value.data||[]) : []
|
||
allConfigVersions.value = [...pending, ...approved]
|
||
if (allConfigVersions.value.length) selectedConfigId.value = allConfigVersions.value[0].id
|
||
} catch {}
|
||
}
|
||
|
||
const onConfigChange = async () => {
|
||
const cfg = allConfigVersions.value.find(v => v.id===selectedConfigId.value)
|
||
if (!cfg) return
|
||
selectedConfig.value = cfg
|
||
}
|
||
|
||
const doBatchParse = async () => {
|
||
if (!selectedConfigId.value) return
|
||
parseLoading.value = true
|
||
try {
|
||
const r = await api.adapter.parseBatch(selectedConfigId.value, siteId)
|
||
const map = {}
|
||
for (const p of (r.data?.pages || [])) {
|
||
if (p.parsed) map[p.url] = p.parsed
|
||
}
|
||
pageParsed.value = map
|
||
} catch { /* 非关键 */ }
|
||
finally { parseLoading.value = false }
|
||
}
|
||
|
||
const refresh = async () => {
|
||
loading.value = true
|
||
try {
|
||
const r = await api.accessResults.bySite(siteId)
|
||
allResults.value = r.data||[]
|
||
if (selectedConfigId.value) await doBatchParse()
|
||
} catch { ElMessage.error('获取访问结果失败') }
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
// 编辑
|
||
const loadScript = async () => {
|
||
if (!selectedConfig.value) return
|
||
scriptLoading.value = true
|
||
try { const r = await api.adminConfigs.getScript(selectedConfig.value.id); scriptContent.value = r.data?.script || '' }
|
||
catch { scriptContent.value = '' }
|
||
finally { scriptLoading.value = false }
|
||
}
|
||
const saveScript = async () => {
|
||
saving.value = true
|
||
try {
|
||
await api.adminConfigs.updateScript(selectedConfig.value.id, scriptContent.value)
|
||
ElMessage.success('脚本已保存'); doBatchParse()
|
||
} catch(e) { ElMessage.error(e.response?.data?.message||'保存失败') }
|
||
finally { saving.value = false }
|
||
}
|
||
const approveConfig = async () => {
|
||
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 () => {
|
||
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 toggleView = (page) => {
|
||
if (expandedSet.has(page.url)) expandedSet.delete(page.url)
|
||
else expandedSet.add(page.url)
|
||
}
|
||
|
||
const reparseUrl = async (page) => {
|
||
if (!selectedConfigId.value || !page.accessResultId) return
|
||
reparsingUrls.add(page.url)
|
||
try {
|
||
const r = await api.adapter.parse(selectedConfigId.value, page.accessResultId)
|
||
const parsed = r.data
|
||
if (parsed) {
|
||
const newParsed = { ...pageParsed.value }
|
||
newParsed[page.url] = parsed
|
||
pageParsed.value = newParsed
|
||
}
|
||
ElMessage.success('已重新解析')
|
||
} catch { ElMessage.error('解析失败') }
|
||
finally { reparsingUrls.delete(page.url) }
|
||
}
|
||
|
||
const deleteResult = async (page) => {
|
||
try {
|
||
await api.accessResults.delete(page.accessResultId)
|
||
ElMessage.success('已删除')
|
||
refresh()
|
||
} catch { ElMessage.error('删除失败') }
|
||
}
|
||
|
||
const statusTag = (code) => {
|
||
if (code>=200&&code<300) return 'success'
|
||
if (code>=300&&code<400) return 'warning'
|
||
return 'danger'
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await refresh()
|
||
await loadConfigs()
|
||
if (selectedConfigId.value) {
|
||
const cfg = allConfigVersions.value.find(v => v.id===selectedConfigId.value)
|
||
if (cfg) selectedConfig.value = cfg
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.config-json {
|
||
background:#1e1e1e; color:#d4d4d4; padding:10px; border-radius:4px;
|
||
font-size:11px; line-height:1.5; margin:0; white-space:pre-wrap; word-break:break-all;
|
||
max-height:500px; overflow:auto;
|
||
}
|
||
.config-json :deep(.key) { color:#9cdcfe }
|
||
.config-json :deep(.string) { color:#ce9178 }
|
||
.config-json :deep(.number) { color:#b5cea8 }
|
||
.config-json :deep(.boolean) { color:#569cd6 }
|
||
.config-json :deep(.null) { color:#569cd6 }
|
||
</style>
|