Merge branch 'main' of http://1.117.67.95:3000/ZhangRongHong/tyapi-frontend
This commit is contained in:
582
src/pages/admin/certification-reviews/index.vue
Normal file
582
src/pages/admin/certification-reviews/index.vue
Normal file
@@ -0,0 +1,582 @@
|
||||
<template>
|
||||
<div class="certification-reviews-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">企业审核</h1>
|
||||
<p class="page-subtitle">审核用户提交的企业信息,通过后可进入企业认证流程</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
v-model="filterStatus"
|
||||
placeholder="认证状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="loadList"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="待审核" value="info_pending_review" />
|
||||
<el-option label="已通过" value="info_submitted" />
|
||||
<el-option label="已拒绝" value="info_rejected" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="filterCompanyName"
|
||||
placeholder="企业名称"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="loadList"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filterLegalPersonPhone"
|
||||
placeholder="法人手机号"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@keyup.enter="loadList"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filterLegalPersonName"
|
||||
placeholder="法人姓名"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@keyup.enter="loadList"
|
||||
/>
|
||||
<el-button type="primary" @click="loadList">查询</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
class="reviews-table"
|
||||
>
|
||||
<el-table-column prop="submit_at" label="提交时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.submit_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="user_id" label="用户ID" width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="company_name" label="企业名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="unified_social_code" label="统一社会信用代码" width="200" />
|
||||
<el-table-column prop="legal_person_name" label="法人姓名" width="100" />
|
||||
<el-table-column prop="certification_status" label="认证状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row)" size="small">
|
||||
{{ certificationStatusDisplay(row?.certification_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row.id)">查看详情</el-button>
|
||||
<template v-if="canShowApproveReject(row)">
|
||||
<el-button link type="success" @click="handleApprove(row)">通过</el-button>
|
||||
<el-button link type="danger" @click="handleReject(row)">拒绝</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="loadList"
|
||||
@current-change="loadList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
title="企业信息详情"
|
||||
size="560"
|
||||
direction="rtl"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<template #header>
|
||||
<span class="drawer-title">企业信息详情</span>
|
||||
<div class="drawer-actions">
|
||||
<template v-if="detail && canShowApproveReject(detail)">
|
||||
<el-button type="success" size="small" @click="approveFromDrawer">通过</el-button>
|
||||
<el-button type="danger" size="small" @click="rejectFromDrawer">拒绝</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="detail" class="detail-content">
|
||||
<section class="detail-section">
|
||||
<h4 class="detail-section-title">基本信息</h4>
|
||||
<dl class="detail-dl">
|
||||
<dt>企业名称</dt>
|
||||
<dd>{{ detail.company_name }}</dd>
|
||||
<dt>统一社会信用代码</dt>
|
||||
<dd class="detail-mono">{{ detail.unified_social_code }}</dd>
|
||||
<dt>法人姓名</dt>
|
||||
<dd>{{ detail.legal_person_name }}</dd>
|
||||
<dt>法人身份证号</dt>
|
||||
<dd class="detail-mono">{{ detail.legal_person_id }}</dd>
|
||||
<dt>法人手机号</dt>
|
||||
<dd>{{ detail.legal_person_phone }}</dd>
|
||||
<dt>企业地址</dt>
|
||||
<dd class="detail-long">{{ detail.enterprise_address || '-' }}</dd>
|
||||
<dt>提交时间</dt>
|
||||
<dd>{{ formatDate(detail.submit_at) }}</dd>
|
||||
<dt>认证状态</dt>
|
||||
<dd>
|
||||
<el-tag :type="statusTagType(detail)" size="small">
|
||||
{{ certificationStatusDisplay(detail?.certification_status) }}
|
||||
</el-tag>
|
||||
</dd>
|
||||
<template v-if="detail.failure_reason">
|
||||
<dt>失败原因</dt>
|
||||
<dd class="detail-long detail-error">{{ detail.failure_reason }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h4 class="detail-section-title">授权代表</h4>
|
||||
<dl class="detail-dl">
|
||||
<dt>姓名</dt>
|
||||
<dd>{{ (detail.authorized_rep_name ?? detail.authorizedRepName) || '-' }}</dd>
|
||||
<dt>身份证号</dt>
|
||||
<dd class="detail-mono">{{ (detail.authorized_rep_id ?? detail.authorizedRepId) || '-' }}</dd>
|
||||
<dt>手机号</dt>
|
||||
<dd>{{ (detail.authorized_rep_phone ?? detail.authorizedRepPhone) || '-' }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h4 class="detail-section-title">应用场景说明</h4>
|
||||
<div class="detail-long-block">{{ detail.api_usage || '无' }}</div>
|
||||
</section>
|
||||
|
||||
<div class="image-section">
|
||||
<h4>营业执照</h4>
|
||||
<div v-if="detail.business_license_image_url" class="image-list">
|
||||
<a :href="detail.business_license_image_url" target="_blank" rel="noopener" class="image-link">
|
||||
<img v-if="isImageUrl(detail.business_license_image_url)" :src="detail.business_license_image_url" alt="营业执照" class="thumb" />
|
||||
<span v-else>查看链接</span>
|
||||
</a>
|
||||
</div>
|
||||
<p v-else class="text-gray-500 text-sm">无</p>
|
||||
</div>
|
||||
|
||||
<div class="image-section">
|
||||
<h4>办公场地照片</h4>
|
||||
<div v-if="officePlaceUrls.length" class="image-list">
|
||||
<a v-for="(url, i) in officePlaceUrls" :key="i" :href="url" target="_blank" rel="noopener" class="image-link">
|
||||
<img v-if="isImageUrl(url)" :src="url" :alt="`场地${i + 1}`" class="thumb" />
|
||||
<span v-else>链接{{ i + 1 }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<p v-else class="text-gray-500 text-sm">无</p>
|
||||
</div>
|
||||
|
||||
<div class="image-section">
|
||||
<h4>应用场景附件</h4>
|
||||
<div v-if="scenarioUrls.length" class="image-list">
|
||||
<a v-for="(url, i) in scenarioUrls" :key="i" :href="url" target="_blank" rel="noopener" class="image-link">
|
||||
<img v-if="isImageUrl(url)" :src="url" :alt="`场景${i + 1}`" class="thumb" />
|
||||
<span v-else>链接{{ i + 1 }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<p v-else class="text-gray-500 text-sm">无</p>
|
||||
</div>
|
||||
|
||||
<div class="image-section">
|
||||
<h4>授权代表身份证</h4>
|
||||
<div v-if="authorizedRepIdUrls.length" class="image-list">
|
||||
<a v-for="(url, i) in authorizedRepIdUrls" :key="i" :href="url" target="_blank" rel="noopener" class="image-link">
|
||||
<img v-if="isImageUrl(url)" :src="url" :alt="i === 0 ? '人像面' : '国徽面'" class="thumb" />
|
||||
<span v-else>{{ i === 0 ? '人像面' : '国徽面' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<p v-else class="text-gray-500 text-sm">无</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 通过弹窗 -->
|
||||
<el-dialog v-model="approveDialogVisible" title="审核通过" width="400px">
|
||||
<el-form label-width="80">
|
||||
<el-form-item label="审核备注">
|
||||
<el-input v-model="approveRemark" type="textarea" :rows="3" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="approveDialogVisible = false">取消</el-button>
|
||||
<el-button type="success" :loading="actionLoading" @click="confirmApprove">确认通过</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 拒绝弹窗 -->
|
||||
<el-dialog v-model="rejectDialogVisible" title="审核拒绝" width="400px">
|
||||
<el-form label-width="80">
|
||||
<el-form-item label="拒绝原因" required>
|
||||
<el-input v-model="rejectRemark" type="textarea" :rows="3" placeholder="必填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="actionLoading" :disabled="!rejectRemark.trim()" @click="confirmReject">确认拒绝</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { certificationApi } from '@/api/index.js'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const filterStatus = ref('')
|
||||
const filterCompanyName = ref('')
|
||||
const filterLegalPersonPhone = ref('')
|
||||
const filterLegalPersonName = ref('')
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const detail = ref(null)
|
||||
const approveDialogVisible = ref(false)
|
||||
const rejectDialogVisible = ref(false)
|
||||
const approveRemark = ref('')
|
||||
const rejectRemark = ref('')
|
||||
const actionLoading = ref(false)
|
||||
const pendingRecordId = ref('')
|
||||
const pendingUserId = ref('')
|
||||
|
||||
function formatDate(val) {
|
||||
if (!val) return '-'
|
||||
try {
|
||||
const d = new Date(val)
|
||||
return Number.isNaN(d.getTime()) ? val : d.toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// 以状态机为准:认证状态展示与是否可操作(全流程口径)
|
||||
const CERTIFICATION_STATUS_LABELS = {
|
||||
pending: '待认证',
|
||||
info_pending_review: '待审核',
|
||||
info_submitted: '已通过',
|
||||
info_rejected: '已拒绝',
|
||||
enterprise_verified: '已企业认证',
|
||||
contract_applied: '已申请合同',
|
||||
contract_signed: '已签署合同',
|
||||
contract_rejected: '合同拒签',
|
||||
contract_expired: '合同超时',
|
||||
completed: '已完成'
|
||||
}
|
||||
|
||||
function certificationStatusDisplay(certificationStatus) {
|
||||
if (!certificationStatus) return '-'
|
||||
return CERTIFICATION_STATUS_LABELS[certificationStatus] || certificationStatus
|
||||
}
|
||||
|
||||
function statusTagType(row) {
|
||||
const status = row?.certification_status
|
||||
const m = {
|
||||
pending: 'info',
|
||||
info_pending_review: 'warning',
|
||||
info_submitted: 'success',
|
||||
info_rejected: 'danger',
|
||||
enterprise_verified: 'success',
|
||||
contract_applied: 'info',
|
||||
contract_signed: 'success',
|
||||
contract_rejected: 'danger',
|
||||
contract_expired: 'warning',
|
||||
completed: 'success'
|
||||
}
|
||||
return m[status] || 'info'
|
||||
}
|
||||
|
||||
function canShowApproveReject(row) {
|
||||
if (!row) return false
|
||||
return row.certification_status === 'info_pending_review'
|
||||
}
|
||||
|
||||
// 判断是否可作为图片展示(含七牛云等无扩展名的 CDN URL)
|
||||
function isImageUrl(url) {
|
||||
if (!url || typeof url !== 'string') return false
|
||||
if (url.startsWith('blob:') || url.startsWith('data:')) return true
|
||||
if (url.startsWith('https://file.tianyuanapi.com')) return true
|
||||
return /\.(jpe?g|png|webp|gif)(\?|$)/i.test(url)
|
||||
}
|
||||
|
||||
const officePlaceUrls = computed(() => {
|
||||
if (!detail.value?.office_place_image_urls) return []
|
||||
try {
|
||||
const v = detail.value.office_place_image_urls
|
||||
return typeof v === 'string' ? JSON.parse(v) : v
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const scenarioUrls = computed(() => {
|
||||
if (!detail.value?.scenario_attachment_urls) return []
|
||||
try {
|
||||
const v = detail.value.scenario_attachment_urls
|
||||
return typeof v === 'string' ? JSON.parse(v) : v
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const authorizedRepIdUrls = computed(() => {
|
||||
if (!detail.value?.authorized_rep_id_image_urls) return []
|
||||
try {
|
||||
const v = detail.value.authorized_rep_id_image_urls
|
||||
return typeof v === 'string' ? JSON.parse(v) : v
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await certificationApi.adminListSubmitRecords({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
certification_status: filterStatus.value || undefined,
|
||||
company_name: filterCompanyName.value || undefined,
|
||||
legal_person_phone: filterLegalPersonPhone.value || undefined,
|
||||
legal_person_name: filterLegalPersonName.value || undefined
|
||||
})
|
||||
const data = res?.data
|
||||
list.value = data?.items ?? []
|
||||
total.value = data?.total ?? 0
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '加载列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(id) {
|
||||
try {
|
||||
const res = await certificationApi.adminGetSubmitRecord(id)
|
||||
detail.value = res?.data ?? res
|
||||
drawerVisible.value = true
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '加载详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleApprove(row) {
|
||||
pendingRecordId.value = row.id
|
||||
pendingUserId.value = row.user_id
|
||||
approveRemark.value = ''
|
||||
approveDialogVisible.value = true
|
||||
}
|
||||
|
||||
function approveFromDrawer() {
|
||||
if (!detail.value?.id) return
|
||||
pendingRecordId.value = detail.value.id
|
||||
pendingUserId.value = detail.value.user_id
|
||||
approveRemark.value = ''
|
||||
approveDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
if (!pendingUserId.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await certificationApi.adminTransitionCertificationStatus({
|
||||
user_id: pendingUserId.value,
|
||||
target_status: 'info_submitted',
|
||||
remark: approveRemark.value || ''
|
||||
})
|
||||
ElMessage.success('已通过')
|
||||
approveDialogVisible.value = false
|
||||
drawerVisible.value = false
|
||||
detail.value = null
|
||||
pendingRecordId.value = ''
|
||||
pendingUserId.value = ''
|
||||
loadList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReject(row) {
|
||||
pendingRecordId.value = row.id
|
||||
pendingUserId.value = row.user_id
|
||||
rejectRemark.value = ''
|
||||
rejectDialogVisible.value = true
|
||||
}
|
||||
|
||||
function rejectFromDrawer() {
|
||||
if (!detail.value?.id) return
|
||||
pendingRecordId.value = detail.value.id
|
||||
pendingUserId.value = detail.value.user_id
|
||||
rejectRemark.value = ''
|
||||
rejectDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
if (!pendingUserId.value || !rejectRemark.value?.trim()) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await certificationApi.adminTransitionCertificationStatus({
|
||||
user_id: pendingUserId.value,
|
||||
target_status: 'info_rejected',
|
||||
remark: rejectRemark.value.trim()
|
||||
})
|
||||
ElMessage.success('已拒绝')
|
||||
rejectDialogVisible.value = false
|
||||
drawerVisible.value = false
|
||||
detail.value = null
|
||||
pendingRecordId.value = ''
|
||||
pendingUserId.value = ''
|
||||
loadList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.certification-reviews-page {
|
||||
padding: 24px;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pagination-wrap {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.drawer-title {
|
||||
margin-right: 12px;
|
||||
}
|
||||
.drawer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.detail-content {
|
||||
padding-right: 12px;
|
||||
max-height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.detail-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin: 0 0 10px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.detail-dl {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 8px 16px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-dl dt {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.detail-dl dd {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.detail-long {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-long-block {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #334155;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.detail-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
.image-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.image-section h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin: 0 0 8px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.image-link {
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
line-height: 100px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
.image-link .thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,17 @@
|
||||
<template>
|
||||
<div class="list-page-card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-sm text-gray-500">统计视图切换</div>
|
||||
<el-segmented
|
||||
v-model="viewMode"
|
||||
:options="[
|
||||
{ label: '统计仪表盘', value: 'dashboard' },
|
||||
{ label: '请求流可视化', value: 'request-globe' }
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<RequestFlowGlobe v-if="viewMode === 'request-globe'" />
|
||||
<template v-else>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<el-icon size="32" class="animate-spin">
|
||||
@@ -466,6 +478,7 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -480,6 +493,7 @@ import {
|
||||
adminGetUserCallRanking,
|
||||
adminGetUserDomainStatistics
|
||||
} from '@/api/statistics'
|
||||
import RequestFlowGlobe from '@/pages/admin/statistics/components/RequestFlowGlobe.vue'
|
||||
import DanmakuBar from '@/components/common/DanmakuBar.vue'
|
||||
import { Check, Loading, Money, Refresh, TrendCharts, User } from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
@@ -488,6 +502,7 @@ import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const viewMode = ref('dashboard')
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
337
src/pages/admin/statistics/components/RequestFlowGlobe.vue
Normal file
337
src/pages/admin/statistics/components/RequestFlowGlobe.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="request-flow-page">
|
||||
<div class="toolbar">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
class="toolbar-item"
|
||||
/>
|
||||
<el-input-number v-model="topN" :min="10" :max="1000" :step="10" class="toolbar-item" />
|
||||
<el-button type="primary" :loading="loading" @click="loadData">刷新请求流</el-button>
|
||||
<el-button v-if="selectedIP || selectedPath" @click="clearFilter">清空筛选</el-button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div ref="chartRef" class="chart"></div>
|
||||
<div class="side-list">
|
||||
<h4>TOP 可疑来源</h4>
|
||||
<div v-if="rows.length === 0" class="empty">暂无数据</div>
|
||||
<div
|
||||
v-for="(item, index) in rows.slice(0, 20)"
|
||||
:key="`${item.ip}-${item.path}-${index}`"
|
||||
class="row clickable"
|
||||
@click="selectFlow(item)"
|
||||
>
|
||||
<div class="name">{{ item.from_name }} -> {{ item.path }}</div>
|
||||
<div class="value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="table-title">
|
||||
可疑IP明细
|
||||
<span v-if="selectedIP || selectedPath" class="hint">
|
||||
(筛选:{{ selectedIP || '-' }} / {{ selectedPath || '-' }})
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="listRows" border size="small" height="360">
|
||||
<el-table-column prop="ip" label="IP" width="150" />
|
||||
<el-table-column prop="path" label="接口路径" min-width="240" />
|
||||
<el-table-column prop="method" label="方法" width="90" />
|
||||
<el-table-column prop="request_count" label="次数" width="90" />
|
||||
<el-table-column prop="trigger_reason" label="触发原因" width="160" />
|
||||
<el-table-column prop="created_at" label="时间" width="180" />
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="loadList"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { adminGetSuspiciousIPGeoStream, adminGetSuspiciousIPList } from '@/api/statistics'
|
||||
import * as echarts from 'echarts'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const topN = ref(200)
|
||||
const dateRange = ref([])
|
||||
const rows = ref([])
|
||||
const listRows = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const selectedIP = ref('')
|
||||
const selectedPath = ref('')
|
||||
const chartRef = ref(null)
|
||||
let chart = null
|
||||
|
||||
const getDefaultRange = () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
const format = (d) => {
|
||||
const y = d.getFullYear()
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${d.getDate()}`.padStart(2, '0')
|
||||
const h = `${d.getHours()}`.padStart(2, '0')
|
||||
const mi = `${d.getMinutes()}`.padStart(2, '0')
|
||||
const s = `${d.getSeconds()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${day} ${h}:${mi}:${s}`
|
||||
}
|
||||
return [format(start), format(now)]
|
||||
}
|
||||
|
||||
const renderChart = () => {
|
||||
if (!chartRef.value) return
|
||||
if (!chart) chart = echarts.init(chartRef.value)
|
||||
|
||||
const lineData = rows.value.map(item => ({
|
||||
coords: [
|
||||
[item.from_lng, item.from_lat],
|
||||
[item.to_lng, item.to_lat]
|
||||
],
|
||||
value: item.value
|
||||
}))
|
||||
|
||||
const pointData = rows.value.map(item => ({
|
||||
name: item.from_name,
|
||||
value: [item.from_lng, item.from_lat, item.value]
|
||||
}))
|
||||
|
||||
chart.setOption({
|
||||
backgroundColor: '#040b1b',
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: -180,
|
||||
max: 180,
|
||||
axisLabel: { color: '#6b7280' },
|
||||
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: -90,
|
||||
max: 90,
|
||||
axisLabel: { color: '#6b7280' },
|
||||
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '请求流',
|
||||
type: 'lines',
|
||||
coordinateSystem: 'cartesian2d',
|
||||
zlevel: 2,
|
||||
effect: {
|
||||
show: true,
|
||||
period: 4,
|
||||
symbol: 'arrow',
|
||||
symbolSize: 6
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#4cc9f0',
|
||||
curveness: 0.2
|
||||
},
|
||||
data: lineData
|
||||
},
|
||||
{
|
||||
name: '来源点',
|
||||
type: 'scatter',
|
||||
coordinateSystem: 'cartesian2d',
|
||||
symbolSize: (val) => Math.max(6, Math.min(18, (val[2] || 1) / 2)),
|
||||
itemStyle: {
|
||||
color: '#f72585'
|
||||
},
|
||||
data: pointData
|
||||
}
|
||||
]
|
||||
})
|
||||
chart.off('click')
|
||||
chart.on('click', (params) => {
|
||||
if (params.seriesName === '来源点' && params.dataIndex >= 0) {
|
||||
const item = rows.value[params.dataIndex]
|
||||
if (item) selectFlow(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectFlow = (item) => {
|
||||
selectedIP.value = item.ip || ''
|
||||
selectedPath.value = item.path || ''
|
||||
page.value = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
const clearFilter = () => {
|
||||
selectedIP.value = ''
|
||||
selectedPath.value = ''
|
||||
page.value = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
try {
|
||||
if (!dateRange.value || dateRange.value.length !== 2) {
|
||||
dateRange.value = getDefaultRange()
|
||||
}
|
||||
const res = await adminGetSuspiciousIPList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
start_time: dateRange.value[0],
|
||||
end_time: dateRange.value[1],
|
||||
ip: selectedIP.value || undefined,
|
||||
path: selectedPath.value || undefined
|
||||
})
|
||||
listRows.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载可疑IP明细失败:', error)
|
||||
ElMessage.error('加载可疑IP明细失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size) => {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
if (!dateRange.value || dateRange.value.length !== 2) {
|
||||
dateRange.value = getDefaultRange()
|
||||
}
|
||||
const res = await adminGetSuspiciousIPGeoStream({
|
||||
start_time: dateRange.value[0],
|
||||
end_time: dateRange.value[1],
|
||||
top_n: topN.value
|
||||
})
|
||||
rows.value = res.data || []
|
||||
await nextTick()
|
||||
renderChart()
|
||||
loadList()
|
||||
} catch (error) {
|
||||
console.error('加载请求流失败:', error)
|
||||
ElMessage.error('加载请求流失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
dateRange.value = getDefaultRange()
|
||||
loadData()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) {
|
||||
chart.dispose()
|
||||
chart = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.request-flow-page {
|
||||
width: 100%;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.toolbar-item {
|
||||
min-width: 220px;
|
||||
}
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 12px;
|
||||
}
|
||||
.chart {
|
||||
height: 620px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.side-list {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
max-height: 620px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.clickable:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.name {
|
||||
color: #374151;
|
||||
word-break: break-all;
|
||||
}
|
||||
.value {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.table-card {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.table-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hint {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user