349 lines
17 KiB
Vue
349 lines
17 KiB
Vue
<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) }} | 发布: {{ fmtTime(selectedConfig.createdAt) }}
|
||
| {{ 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>
|