Files
par/web/src/views/AccessResultCheck.vue
2026-07-06 00:11:40 +08:00

349 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div style="display:flex;flex-direction:column;height:calc(100vh - 80px)">
<!-- 顶栏 -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;flex-shrink:0">
<h2 style="margin:0">适配校验:{{ siteId }}</h2>
<el-button @click="$router.push('/access-results')">← 返回列表</el-button>
</div>
<!-- 控制栏 -->
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:8px;flex-shrink:0">
<el-select v-model="selectedConfigId" placeholder="选择版本" @change="onConfigChange" style="width:200px">
<el-option v-for="v in allConfigVersions" :key="v.id"
:label="'v'+v.version + (v.status==='pending'?' [待审]':'') + (v.isLatest?' [当前]':'')" :value="v.id" />
</el-select>
<span v-if="selectedConfig" class="text-xs" style="color:#909399">
保存: {{ fmtTime(selectedConfig.updatedAt) }} &nbsp;|&nbsp; 发布: {{ fmtTime(selectedConfig.createdAt) }}
&nbsp;|&nbsp; {{ pages.length }} 个页面 · {{ totalResults }} 次提交
</span>
<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>
</template>
<el-button size="small" @click="refresh" :loading="loading">刷新</el-button>
<el-button size="small" @click="doBatchParse" :loading="parseLoading" v-if="selectedConfigId">全部重解析</el-button>
</div>
<!-- 主体:脚本 + 结果 -->
<div style="display:flex;gap:12px;flex:1;min-height:0">
<!-- 左侧:脚本编辑器(可折叠) -->
<div :style="{flex: scriptExpanded ? '0 0 440px' : '0 0 auto',display:'flex',flexDirection:'column',minHeight:0}">
<div style="font-size:13px;font-weight:500;display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
<span style="cursor:pointer" @click="scriptExpanded=!scriptExpanded">
<el-icon><component :is="scriptExpanded?'ArrowLeft':'ArrowRight'" /></el-icon>
Groovy 脚本
</span>
<template v-if="scriptExpanded && selectedConfigId">
<el-select v-model="historyVersion" size="small" style="width:140px" placeholder="历史版本" clearable @change="loadHistoryScript">
<el-option v-for="v in allConfigVersions" :key="v.id" :label="'v'+v.version+' '+v.status" :value="v.id" />
</el-select>
<el-button size="small" @click="revertScript">回滚</el-button>
<el-button size="small" type="primary" @click="saveScript" :loading="saving">保存</el-button>
<el-button size="small" type="danger" @click="deleteScript" :loading="deleting">删除</el-button>
<template v-if="selectedConfig?.status==='pending'">
<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 v-if="selectedConfig?.status!=='pending'" size="small" type="success" @click="approveConfig" :loading="approving">发布</el-button>
</template>
<el-button v-if="!scriptExpanded && selectedConfigId" size="small" type="primary" @click="saveScript" :loading="saving">保存</el-button>
</div>
<template v-if="scriptExpanded">
<div style="flex:1;min-height:0;border-radius:4px;overflow:hidden">
<CodeEditor v-model="scriptContent" />
</div>
<div style="font-size:10px;color:#909399;margin-top:2px">
内置: <code>content</code> <code>url</code> <code>jsoup</code> <code>json</code> <code>utils</code>
</div>
</template>
</div>
<!-- 右侧:URL卡片 -->
<div style="flex:1;overflow:auto;display:flex;flex-direction:column;min-height:0">
<!-- 搜索 + 全部收起 -->
<div style="display:flex;gap:8px;align-items:center;margin-bottom:4px;flex-shrink:0">
<el-input v-model="urlFilter" size="small" placeholder="搜索URL..." clearable style="flex:1" />
<el-button v-if="expanded.size>0" size="small" @click="expanded.clear()">全部收起</el-button>
</div>
<div style="display:flex;flex-direction:column;gap:8px;overflow:auto">
<el-card v-for="page in filteredPages" :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">✓</span>
<span v-else-if="page.parsed" style="color:#e6a23c">⚠</span>
<span v-else style="color:#c0c4cc">○</span>
<strong style="font-size:13px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer" :title="page.url">{{ shortUrl(page.url) }}</strong>
<el-tag v-if="page.statusCode" size="small" :type="statusTag(page.statusCode)">{{ page.statusCode }}</el-tag>
<el-tag v-if="page.parsed?.matched" size="small" type="success">{{ page.parsed.pageName }}</el-tag>
<!-- 选择历史结果 -->
<el-select v-if="page.historyIds?.length>1" v-model="page.activeId" size="small" style="width:140px" @change="v=>parseHistory(page,v)">
<el-option v-for="h in page.historyIds" :key="h.id" :label="'#'+h.id+' '+h.time" :value="h.id" />
</el-select>
<el-tag size="small" type="info">×{{ page.totalCount }}</el-tag>
</div>
<div style="display:flex;gap:4px">
<el-button size="small" @click="viewRaw(page)">原始</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>
<!-- 解析结果摘要 -->
<div v-if="page.parsed?.matched && page.parsed.extracted?.length">
<div style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:4px 0"
@click="toggleExpand(page)">
<el-icon><component :is="expanded.has(page.url)?'ArrowDown':'ArrowRight'" /></el-icon>
<span class="text-xs">{{ page.parsed.rowCount }}行 · {{ page.parsed.fieldCount }}字段</span>
<span v-if="page.nullCount>0" class="text-xs" style="color:#e6a23c">{{ page.nullCount }} 个空值</span>
<span v-else class="text-xs" style="color:#67c23a">全部有效</span>
<span class="text-xs" style="color:#909399;margin-left:auto">{{ page.parsed.message || '' }}</span>
</div>
<el-table v-if="expanded.has(page.url)" :data="page.parsed.extracted" border stripe size="small" max-height="280" style="margin-top:4px">
<el-table-column type="index" label="#" width="40" />
<el-table-column v-for="col in Object.keys(page.parsed.extracted[0])" :key="col"
:prop="col" :label="col" min-width="90" 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-if="page.parsed" style="background:#fef0f0;color:#f56c6c;font-size:12px;padding:6px 8px;border-radius:4px;white-space:pre-wrap;word-break:break-all">
{{ page.parsed.message || '解析失败' }}
</div>
<div v-else style="color:#909399;font-size:12px;padding:4px 0">未解析</div>
</el-card>
<div v-if="filteredPages.length===0 && !loading" style="text-align:center;padding:60px;color:#909399">暂无访问结果</div>
</div>
</div>
</div>
<!-- 原始内容弹窗 -->
<el-dialog v-model="rawVisible" title="原始内容" width="900px" :close-on-click-modal="false">
<div v-if="rawLoading" style="text-align:center;padding:40px">加载中...</div>
<pre v-else class="raw-content">{{ rawContent }}</pre>
<template #footer><el-button @click="rawVisible=false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router'
import api from '../api'
import CodeEditor from '../components/CodeEditor.vue'
import { Loading } from '@element-plus/icons-vue'
const route = useRoute()
const siteId = route.params.siteId
const loading = ref(false), parseLoading = ref(false), scriptExpanded = ref(true), historyVersion = ref(null), urlFilter = ref(''), deleting = ref(false)
const selectedConfigId = ref(null), selectedConfig = ref(null)
const scriptContent = ref(''), saving = ref(false), approving = ref(false), rejecting = ref(false)
const allConfigVersions = ref([]), allResults = ref([])
const pageParsed = ref({}), reparsingUrls = reactive(new Set()), expanded = reactive(new Set())
const rawVisible = ref(false), rawLoading = ref(false), rawContent = ref('')
const totalResults = computed(() => allResults.value.length)
const filteredPages = computed(() => {
const f = urlFilter.value.toLowerCase()
return f ? pages.value.filter(p => p.url.toLowerCase().includes(f)) : pages.value
})
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, activeId: r.id, accessResultId: r.id,
statusCode: r.statusCode, responseTimeMs: r.responseTimeMs,
createdAt: r.createdAt, totalCount: 0, historyIds: [], parsed: null })
}
const p = map.get(key)
p.totalCount++
p.historyIds.push({ id: r.id, time: (r.createdAt||'').substring(5,16) })
}
for (const [url, parsed] of Object.entries(pageParsed.value)) {
if (map.has(url)) {
const p = map.get(url)
p.parsed = parsed
// 计算空值数
if (parsed?.extracted?.length) {
let nulls = 0
for (const row of parsed.extracted) {
for (const v of Object.values(row)) { if (v == null) nulls++ }
}
p.nullCount = nulls
}
}
}
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)])
allConfigVersions.value = [...(pr.status==='fulfilled'?pr.value.data||[]:[]), ...(ar.status==='fulfilled'?ar.value.data||[]:[])]
if (allConfigVersions.value.length) selectedConfigId.value = allConfigVersions.value[0].id
} catch {}
}
const onConfigChange = () => {
const cfg = allConfigVersions.value.find(v => v.id===selectedConfigId.value)
if (cfg) selectedConfig.value = cfg
}
const loadScript = async () => {
if (!selectedConfig.value) return
try { const r = await api.adminConfigs.getScript(selectedConfig.value.id); scriptContent.value = r.data?.script||'' }
catch { scriptContent.value = '' }
}
watch(selectedConfig, (v) => { if (v) loadScript() })
const loadHistoryScript = async (configId) => {
if (!configId) return
try { const r = await api.adminConfigs.getScript(configId); scriptContent.value = r.data?.script || '' }
catch { ElMessage.error('加载失败') }
}
const revertScript = async () => {
if (!selectedConfig.value) return
try { await ElMessageBox.confirm('确认回滚?未保存的修改将丢失。','回滚脚本',{confirmButtonText:'确认',type:'warning'}) } catch{return}
try {
const r = await api.adminConfigs.getScript(selectedConfig.value.id)
scriptContent.value = r.data?.script || ''
ElMessage.success('已回滚到线上版本')
} catch { ElMessage.error('回滚失败') }
}
const saveScript = async () => {
if (!selectedConfig.value) return
saving.value = true
try { await api.adminConfigs.updateScript(selectedConfig.value.id, scriptContent.value); ElMessage.success('已保存') }
catch(e) { ElMessage.error(e.response?.data?.message||'保存失败') }
finally { saving.value = false }
}
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 reparseUrl = async (page) => {
if (!selectedConfigId.value) return
reparsingUrls.add(page.url)
try {
const r = await api.adapter.parse(selectedConfigId.value, page.activeId)
if (r.data) {
const np = { ...pageParsed.value }; np[page.url] = r.data; pageParsed.value = np
}
} catch {}
finally { reparsingUrls.delete(page.url) }
}
const parseHistory = async (page, newId) => {
page.accessResultId = newId
await reparseUrl(page)
}
const refresh = async () => {
loading.value = true
try { allResults.value = (await api.accessResults.bySite(siteId)).data||[] }
catch { ElMessage.error('获取访问结果失败') }
finally { loading.value = false }
}
const toggleExpand = (page) => {
expanded.has(page.url) ? expanded.delete(page.url) : expanded.add(page.url)
}
const viewRaw = async (page) => {
rawVisible.value = true; rawLoading.value = true; rawContent.value = ''
try { rawContent.value = (await api.accessResults.get(page.activeId)).data?.content || '(空)' }
catch { rawContent.value = '加载失败' }
finally { rawLoading.value = false }
}
const deleteResult = async (page) => {
if (!page.accessResultId) return
try { await api.accessResults.delete(page.accessResultId); ElMessage.success('已删除'); refresh() }
catch { ElMessage.error('删除失败') }
}
const deleteScript = async () => {
try { await ElMessageBox.confirm('确认删除自定义脚本?删除后站点将使用默认模板','确认',{type:'warning',confirmButtonText:'确认删除'}) } catch { return }
deleting.value = true
try { await api.raw.delete('/admin/configs/'+siteId+'/script'); ElMessage.success('已删除,将使用默认模板'); loadConfigs() }
catch(e) { ElMessage.error(e.response?.data?.message||'删除失败') }
finally { deleting.value = false }
}
const approveConfig = async () => {
if (!selectedConfig.value) return
const label = selectedConfig.value.status==='pending' ? '确认审批?版本号将自动+1' : '确认发布脚本?版本号将+1'
try { await ElMessageBox.confirm(label,'确认',{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 fmtTime = (t) => t ? t.substring(5,16).replace('T',' ') : '-'
const shortUrl = (u) => {
try { return new URL(u).pathname + new URL(u).search } catch { return u }
}
const statusTag = (c) => c>=200&&c<300?'success':c>=300&&c<400?'warning':'danger'
onMounted(async () => {
await refresh()
await loadConfigs()
if (selectedConfigId.value) {
selectedConfig.value = allConfigVersions.value.find(v=>v.id===selectedConfigId.value)
}
})
</script>
<style scoped>
:deep(.el-card) { overflow: visible !important }
:deep(.el-card__body) { overflow: visible !important }
:deep(.el-card__header) { overflow: visible !important }
.raw-content { background:#1e1e1e; color:#d4d4d4; padding:12px; border-radius:4px;
font-size:11px; line-height:1.4; margin:0; white-space:pre-wrap; word-break:break-all; max-height:550px; overflow:auto }
</style>