feat: 新增适配验证接口,mediabot 可提交站点访问结果,管理员可校验适配是否正确

This commit is contained in:
mediabot-pt
2026-06-30 22:36:45 +08:00
parent c947ce56d2
commit 83e150b8c9
12 changed files with 613 additions and 0 deletions

View File

@@ -66,5 +66,11 @@ export default {
emailStats: () => api.get('/admin/logs/stats/email'),
pathStats: () => api.get('/admin/logs/stats/path'),
},
accessResults: {
list: () => api.get('/admin/access-results'),
bySite: (siteId) => api.get(`/admin/access-results/by-site/${siteId}`),
get: (id) => api.get(`/admin/access-results/${id}`),
validate: (id, isValid, note) => api.post(`/admin/access-results/${id}/validate`, { isValid, note: note || '' }),
},
health: () => api.get('/health', { baseURL: '' })
}

View File

@@ -53,6 +53,12 @@ const routes = [
name: 'ApiLogs',
component: () => import('../views/ApiLogs.vue'),
meta: { title: '访问日志' }
},
{
path: 'access-results',
name: 'AccessResults',
component: () => import('../views/AccessResults.vue'),
meta: { title: '适配验证' }
}
]
}

View File

@@ -0,0 +1,235 @@
<template>
<div>
<h2>适配验证</h2>
<el-card style="margin-top: 20px">
<div style="margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center">
<div>
<el-select v-model="filterSiteId" placeholder="按站点筛选" clearable @change="fetchAll" style="width: 200px">
<el-option v-for="s in siteOptions" :key="s.siteId" :label="s.siteId" :value="s.siteId" />
</el-select>
<el-select v-model="filterValid" placeholder="验证状态" clearable @change="fetchAll" style="width: 140px; margin-left: 8px">
<el-option label="已验证-有效" value="1" />
<el-option label="已验证-无效" value="0" />
<el-option label="未验证" value="null" />
</el-select>
</div>
<el-button @click="fetchAll" :icon="Refresh">刷新</el-button>
</div>
<el-table :data="filteredList" v-loading="loading" stripe size="small" empty-text="暂无访问记录">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="siteId" label="站点ID" width="130" />
<el-table-column prop="configVersion" label="配置版本" width="100">
<template #default="scope">
<span v-if="scope.row.configVersion" class="text-xs">{{ scope.row.configVersion }}</span>
<span v-else class="text-xs" style="color:#909399">-</span>
</template>
</el-table-column>
<el-table-column prop="clientId" label="来源" width="120">
<template #default="scope">
<span class="text-xs">{{ scope.row.clientId || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="url" label="访问URL" min-width="200">
<template #default="scope">
<span class="text-xs" style="word-break: break-all">{{ scope.row.url || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="statusCode" label="HTTP" width="70">
<template #default="scope">
<el-tag v-if="scope.row.statusCode" size="small" :type="statusTag(scope.row.statusCode)">
{{ scope.row.statusCode }}
</el-tag>
<span v-else class="text-xs" style="color:#909399">-</span>
</template>
</el-table-column>
<el-table-column prop="responseTimeMs" label="耗时" width="80">
<template #default="scope">
<span class="text-xs">{{ scope.row.responseTimeMs != null ? scope.row.responseTimeMs + 'ms' : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="验证" width="110">
<template #default="scope">
<el-tag v-if="scope.row.isValid === true" size="small" type="success">有效</el-tag>
<el-tag v-else-if="scope.row.isValid === false" size="small" type="danger">无效</el-tag>
<el-tag v-else size="small" type="info">未验证</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="时间" width="160" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button size="small" @click="handleView(scope.row)">查看内容</el-button>
<el-button v-if="scope.row.isValid == null" size="small" type="success" @click="handleValidate(scope.row, true)">有效</el-button>
<el-button v-if="scope.row.isValid == null" size="small" type="danger" @click="handleValidate(scope.row, false)">无效</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 查看内容对话框 -->
<el-dialog v-model="detailVisible" title="访问结果详情" width="900px" :close-on-click-modal="false">
<el-descriptions :column="2" border size="small" style="margin-bottom: 16px">
<el-descriptions-item label="站点">{{ detail.siteId }}</el-descriptions-item>
<el-descriptions-item label="配置版本">{{ detail.configVersion || '-' }}</el-descriptions-item>
<el-descriptions-item label="来源">{{ detail.clientId || '-' }}</el-descriptions-item>
<el-descriptions-item label="数据类型">{{ detail.contentType }}</el-descriptions-item>
<el-descriptions-item label="访问URL" :span="2">{{ detail.url || '-' }}</el-descriptions-item>
<el-descriptions-item label="HTTP状态码">
<el-tag v-if="detail.statusCode" size="small" :type="statusTag(detail.statusCode)">{{ detail.statusCode }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="响应耗时">{{ detail.responseTimeMs != null ? detail.responseTimeMs + 'ms' : '-' }}</el-descriptions-item>
<el-descriptions-item label="验证结果">
<el-tag v-if="detail.isValid === true" size="small" type="success">有效</el-tag>
<el-tag v-else-if="detail.isValid === false" size="small" type="danger">无效</el-tag>
<el-tag v-else size="small" type="info">未验证</el-tag>
</el-descriptions-item>
<el-descriptions-item label="验证备注">{{ detail.validationNote || '-' }}</el-descriptions-item>
<el-descriptions-item label="提交时间" :span="2">{{ detail.createdAt }}</el-descriptions-item>
</el-descriptions>
<div style="margin-bottom: 4px; font-size: 13px; color: #606266; font-weight: 500">返回内容:</div>
<pre v-if="detail.contentType === 'json'" class="content-json" v-html="highlightedJson"></pre>
<pre v-else class="content-html" v-html="highlightedHtml"></pre>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
<el-button v-if="detail.isValid == null" type="success" @click="handleValidate(detail, true); detailVisible = false">标记有效</el-button>
<el-button v-if="detail.isValid == null" type="danger" @click="handleValidate(detail, false); detailVisible = false">标记无效</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import api from '../api'
import { Refresh } from '@element-plus/icons-vue'
const loading = ref(false)
const list = ref([])
const filterSiteId = ref('')
const filterValid = ref('')
const siteOptions = ref([])
const detailVisible = ref(false)
const detail = ref({})
const filteredList = computed(() => {
let items = list.value
if (filterSiteId.value) {
items = items.filter(r => r.siteId === filterSiteId.value)
}
if (filterValid.value === '1') {
items = items.filter(r => r.isValid === true)
} else if (filterValid.value === '0') {
items = items.filter(r => r.isValid === false)
} else if (filterValid.value === 'null') {
items = items.filter(r => r.isValid == null)
}
return items
})
const highlightedJson = computed(() => {
try {
const json = typeof detail.value.content === 'string' ? JSON.parse(detail.value.content) : detail.value.content
return syntaxHighlight(JSON.stringify(json, null, 2))
} catch {
return escapeHtml(detail.value.content || '')
}
})
const highlightedHtml = computed(() => {
return escapeHtml(detail.value.content || '')
.replace(/(&lt;\/?)(\w+)([\s\S]*?)(\/?&gt;)/g,
'<span class="html-tag">$1</span><span class="html-name">$2</span><span class="html-tag">$3$4</span>')
.replace(/(\w+)=(&quot;.*?&quot;)/g,
'<span class="html-attr">$1</span>=<span class="html-value">$2</span>')
})
const fetchAll = async () => {
loading.value = true
try {
const r = await api.accessResults.list()
list.value = r.data || []
// 提取去重的站点列表用于筛选
const siteIds = [...new Set(list.value.map(r => r.siteId))]
siteOptions.value = siteIds.map(id => ({ siteId: id }))
} catch { ElMessage.error('获取访问结果失败') }
finally { loading.value = false }
}
const handleView = (row) => {
detail.value = { ...row }
detailVisible.value = true
}
const handleValidate = async (row, isValid) => {
try {
await api.accessResults.validate(row.id, isValid)
ElMessage.success(`已标记为${isValid ? '有效' : '无效'}`)
fetchAll()
} catch { ElMessage.error('操作失败') }
}
const statusTag = (code) => {
if (code >= 200 && code < 300) return 'success'
if (code >= 300 && code < 400) return 'warning'
return 'danger'
}
const escapeHtml = (str) => {
if (!str) return ''
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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) => {
let cls = 'number'
if (/^"/.test(match)) cls = /:$/.test(match) ? 'key' : 'string'
else if (/true|false/.test(match)) cls = 'boolean'
else if (/null/.test(match)) cls = 'null'
return `<span class="${cls}">${match}</span>`
}
)
onMounted(fetchAll)
</script>
<style scoped>
.content-json {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
max-height: 500px;
overflow: auto;
}
.content-html {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
max-height: 500px;
overflow: auto;
}
.content-json :deep(.key) { color: #9cdcfe; }
.content-json :deep(.string) { color: #ce9178; }
.content-json :deep(.number) { color: #b5cea8; }
.content-json :deep(.boolean) { color: #569cd6; }
.content-json :deep(.null) { color: #569cd6; }
.content-html :deep(.html-tag) { color: #808080; }
.content-html :deep(.html-name) { color: #569cd6; }
.content-html :deep(.html-attr) { color: #9cdcfe; }
.content-html :deep(.html-value) { color: #ce9178; }
</style>

View File

@@ -38,6 +38,10 @@
<el-icon><Document /></el-icon>
<span>访问日志</span>
</el-menu-item>
<el-menu-item index="/access-results">
<el-icon><Connection /></el-icon>
<span>适配验证</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>