refactor: 左脚本编辑器+右URL卡片+历史结果选择,三合一布局

This commit is contained in:
mediabot-pt
2026-07-01 13:37:01 +08:00
parent 4bf792fc97
commit 621e436eb0

View File

@@ -1,141 +1,110 @@
<template>
<div>
<div style="display:flex;flex-direction:column;height:calc(100vh - 80px)">
<!-- 顶栏 -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<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>
<!-- 控制栏 -->
<el-card shadow="never" style="margin-bottom:12px">
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<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>
<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>
<span class="text-xs">{{ pages.length }}页 {{ totalResults }}次</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>
</template>
<span class="text-xs"><strong>{{ pages.length }}</strong> 页面</span>
<span class="text-xs"><strong>{{ totalResults }}</strong> 次提交</span>
<el-button size="small" @click="refresh" :loading="loading">刷新</el-button>
<el-button size="small" @click="doBatchParse" :loading="parseLoading" v-if="selectedConfigId">全部重解析</el-button>
<el-divider direction="vertical" />
<!-- 审批/拒绝 -->
<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 size="small" type="success" @click="approveConfig" :loading="approving" v-if="selectedConfigId && selectedConfig?.status!=='pending'">发布脚本</el-button>
</div>
<!-- 解析摘要 -->
<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>
<el-button size="small" type="success" @click="approveConfig" :loading="approving" v-if="selectedConfigId && selectedConfig?.status!=='pending'">发布脚本</el-button>
<!-- 主体:脚本 + 结果 -->
<div style="display:flex;gap:12px;flex:1;min-height:0">
<!-- 左侧:脚本编辑器 -->
<div style="flex:0 0 420px;display:flex;flex-direction:column;min-height:0">
<div style="font-size:13px;font-weight:500;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center">
<span>Groovy 脚本</span>
<el-button size="small" type="primary" @click="saveScript" :loading="saving" v-if="selectedConfigId">保存</el-button>
</div>
<el-input v-model="scriptContent" type="textarea" :rows="1" style="flex:1;font-family:monospace;font-size:11px;line-height:1.4"
placeholder="// 先选配置版本,再编辑脚本&#10;// 内置变量: content, url, jsoup, json, utils" />
<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>
· 返回 Map{pageName,extracted} 或 List
</div>
</div>
</el-card>
<!-- 右侧:URL卡片 -->
<div style="flex:1;overflow:auto;display:flex;flex-direction:column;gap:8px;min-height:0">
<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">✓</span>
<span v-else-if="page.parsed" style="color:#e6a23c">⚠</span>
<span v-else style="color:#c0c4cc">○</span>
<strong style="font-size:13px;word-break:break-all">{{ 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">
<el-table :data="page.parsed.extracted" border stripe size="small" max-height="280">
<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="color:#e6a23c;font-size:12px">
{{ page.parsed.message || '解析失败' }}
</div>
<div v-else style="color:#909399;font-size:12px">未解析</div>
</el-card>
<div v-if="pages.length===0 && !loading" style="text-align:center;padding:60px;color:#909399">暂无访问结果</div>
</div>
</div>
<!-- 原始内容弹窗 -->
<el-dialog v-model="rawVisible" :title="'原始内容 · ' + rawUrl" width="900px" :close-on-click-modal="false">
<div v-if="rawLoading" style="text-align:center;padding:40px;color:#909399">加载中...</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>
<template #footer><el-button @click="rawVisible=false">关闭</el-button></template>
</el-dialog>
<!-- 脚本编辑弹窗 -->
<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)&#10;def rows = []&#10;doc.select('table tr').each { row ->&#10; rows &lt;&lt; [&#10; title: utils.text(utils.selectFirst(row,'td.name a')),&#10; size: utils.text(utils.selectFirst(row,'td.size'))&#10; ]&#10;}&#10;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="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" style="margin-top:4px">
<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-if="page.parsed" style="color:#e6a23c;font-size:12px;margin-top:4px">
{{ page.parsed.message || '解析失败' }}
</div>
</el-card>
<div v-if="pages.length===0 && !loading" style="text-align:center;padding:60px;color:#909399">
暂无访问结果
</div>
</div>
</div>
</template>
@@ -147,37 +116,27 @@ 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 rawVisible = ref(false), rawLoading = ref(false), rawUrl = ref(''), rawContent = ref('')
const loading = ref(false), parseLoading = 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())
const rawVisible = ref(false), rawLoading = ref(false), rawContent = ref('')
const totalResults = computed(() => allResults.value.length)
// 始终从 allResults 按 URL 分组
const pages = computed(() => {
const map = new Map()
const map = new LinkedHashMap()
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.set(key, { url: key, activeId: r.id, accessResultId: r.id,
statusCode: r.statusCode, responseTimeMs: r.responseTimeMs,
createdAt: r.createdAt, totalCount: 0, historyIds: [], parsed: null })
}
map.get(key).totalCount++
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)) map.get(url).parsed = parsed
@@ -185,35 +144,41 @@ const pages = computed(() => {
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++
}
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]
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 = async () => {
const onConfigChange = () => {
const cfg = allConfigVersions.value.find(v => v.id===selectedConfigId.value)
if (!cfg) return
selectedConfig.value = cfg
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 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 () => {
@@ -222,46 +187,56 @@ const doBatchParse = async () => {
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
}
for (const p of (r.data?.pages||[])) { if (p.parsed) map[p.url] = p.parsed }
pageParsed.value = map
} catch { /* 非关键 */ }
} 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 {
const r = await api.accessResults.bySite(siteId)
allResults.value = r.data||[]
if (selectedConfigId.value) await doBatchParse()
} catch { ElMessage.error('获取访问结果失败') }
try { allResults.value = (await api.accessResults.bySite(siteId)).data||[] }
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 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 saveScript = async () => {
saving.value = true
try {
await api.adminConfigs.updateScript(selectedConfig.value.id, scriptContent.value)
configVisible.value = false
ElMessage.success('脚本已保存')
} catch(e) { ElMessage.error(e.response?.data?.message||'保存失败') }
finally { saving.value = false }
const deleteResult = async (page) => {
if (!page.accessResultId) return
try { await api.accessResults.delete(page.accessResultId); ElMessage.success('已删除'); refresh() }
catch { ElMessage.error('删除失败') }
}
const approveConfig = async () => {
try { await ElMessageBox.confirm('确认审批?版本号将自动+1','审批配置',{confirmButtonText:'确认'}) } catch{return}
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||'审批失败') }
try { await api.adminConfigs.approve(selectedConfig.value.id); ElMessage.success('已发布'); loadConfigs() }
catch(e) { ElMessage.error(e.response?.data?.message||'失败') }
finally { approving.value = false }
}
@@ -269,81 +244,22 @@ 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||'拒绝失败') }
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 viewRaw = async (page) => {
rawUrl.value = page.url
rawVisible.value = true
rawLoading.value = true
rawContent.value = ''
try {
const r = await api.accessResults.get(page.accessResultId)
rawContent.value = r.data?.content || '(空)'
} catch { rawContent.value = '加载失败' }
finally { rawLoading.value = false }
}
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'
}
const statusTag = (c) => c>=200&&c<300?'success':c>=300&&c<400?'warning':'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
selectedConfig.value = allConfigVersions.value.find(v=>v.id===selectedConfigId.value)
}
})
</script>
<style scoped>
.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;
}
.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 }
.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>