This commit is contained in:
2026-03-21 14:33:11 +08:00
14 changed files with 1746 additions and 89 deletions

View File

@@ -366,6 +366,7 @@
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeAgoIntl": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,

1
auto-imports.d.ts vendored
View File

@@ -817,6 +817,7 @@ declare module 'vue' {
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
readonly useTimeAgoIntl: UnwrapRef<typeof import('@vueuse/core')['useTimeAgoIntl']>
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>

1
components.d.ts vendored
View File

@@ -55,6 +55,7 @@ declare module 'vue' {
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']

View File

@@ -194,8 +194,27 @@ export const certificationApi = {
}
}),
// 上传认证图片到七牛云(企业信息中的营业执照、办公场地、场景附件、授权代表身份证等)
uploadFile: (file) => {
const formData = new FormData()
formData.append('file', file)
return request.post('/certifications/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 管理员代用户完成认证(暂不关联合同)
adminCompleteWithoutContract: (data) => request.post('/certifications/admin/complete-without-contract', data)
adminCompleteWithoutContract: (data) => request.post('/certifications/admin/complete-without-contract', data),
// 管理端企业审核:列表(按状态机 certification_status 筛选)、详情、通过、拒绝、按用户变更状态
adminListSubmitRecords: (params) => request.get('/certifications/admin/submit-records', { params }),
adminGetSubmitRecord: (id) => request.get(`/certifications/admin/submit-records/${id}`),
adminApproveSubmitRecord: (id, data) => request.post(`/certifications/admin/submit-records/${id}/approve`, data || {}),
adminRejectSubmitRecord: (id, data) => request.post(`/certifications/admin/submit-records/${id}/reject`, data),
// 管理端按用户变更认证状态以状态机为准info_submitted=通过 / info_rejected=拒绝)
adminTransitionCertificationStatus: (data) => request.post('/certifications/admin/transition-status', data)
}
// API相关接口

View File

@@ -659,3 +659,31 @@ export function adminGetTodayCertifiedEnterprises(params = {}) {
params
})
}
// ================ 管理员安全可视化接口 ================
/**
* 获取可疑IP列表
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function adminGetSuspiciousIPList(params = {}) {
return request({
url: '/admin/security/suspicious-ip/list',
method: 'get',
params
})
}
/**
* 获取可疑IP地球请求流
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function adminGetSuspiciousIPGeoStream(params = {}) {
return request({
url: '/admin/security/suspicious-ip/geo-stream',
method: 'get',
params
})
}

View File

@@ -116,6 +116,7 @@ export const getUserAccessibleMenuItems = (userType = 'user') => {
icon: Setting,
children: [
{ name: '系统统计', path: '/admin/statistics', icon: ChartBar },
{ name: '企业审核', path: '/admin/certification-reviews', icon: ShieldCheck },
{ name: '产品管理', path: '/admin/products', icon: Cube },
{ name: '用户管理', path: '/admin/users', icon: Users },
{ name: '分类管理', path: '/admin/categories', icon: Tag },

View 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>

View File

@@ -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('')

View 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>

View File

@@ -181,17 +181,17 @@
<span v-if="field.required" class="text-red-500 ml-1">*</span>
</label>
<!-- 图片上传字段photo_data -->
<div v-if="field.name === 'photo_data' || field.name === 'vlphoto_data' && field.type === 'textarea'" class="space-y-2">
<!-- Base64字段上传根据后端校验规则自动识别 -->
<div v-if="isBase64UploadField(field)" class="space-y-2">
<div class="flex gap-2 mb-2">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept="image/jpeg,image/jpg,image/png,image/bmp"
:on-change="(file) => handleImageUpload(file, field.name)"
:accept="getUploadAcceptByField(field)"
:on-change="(file) => handleImageUpload(file, field.name, field)"
class="flex-1">
<el-button type="primary" size="small">
<i class="el-icon-upload"></i> 上传图片JPG/BMP/PNG
<i class="el-icon-upload"></i> {{ getUploadButtonTextByField(field) }}
</el-button>
</el-upload>
<el-button v-if="formData[field.name]" type="danger" size="small" @click="clearImageData(field.name)">
@@ -934,21 +934,48 @@ const loadFormConfig = async (apiCode) => {
}
}
// 处理图片上传并转换为base64
const handleImageUpload = (file, fieldName) => {
const getFieldValidationText = (field) => {
return typeof field?.validation === 'string' ? field.validation : ''
}
const isBase64ImageOnlyField = (field) => {
return getFieldValidationText(field).includes('Base64图片')
}
const isBase64UploadField = (field) => {
if (field?.type !== 'textarea') return false
const validationText = getFieldValidationText(field)
return validationText.includes('Base64图片') || validationText.includes('Base64编码') || validationText.toLowerCase().includes('base64')
}
const getUploadAcceptByField = (field) => {
if (isBase64ImageOnlyField(field)) {
return 'image/jpeg,image/jpg,image/png,image/bmp'
}
return 'image/jpeg,image/jpg,image/png,image/bmp,application/pdf,.pdf'
}
const getUploadButtonTextByField = (field) => {
return isBase64ImageOnlyField(field) ? '上传图片JPG/BMP/PNG' : '上传文件JPG/BMP/PNG/PDF'
}
// 处理文件上传并转换为base64支持按字段规则限制类型
const handleImageUpload = (file, fieldName, field) => {
const fileObj = file.raw || file
// 验证文件类型
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp']
const allowedTypes = isBase64ImageOnlyField(field)
? ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp']
: ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp', 'application/pdf']
if (!allowedTypes.includes(fileObj.type)) {
ElMessage.error('只支持 JPG、BMP、PNG 格式的图片')
ElMessage.error(isBase64ImageOnlyField(field) ? '只支持 JPG、BMP、PNG 格式的图片' : '只支持 JPG、BMP、PNG、PDF 格式的文件')
return false
}
// 验证文件大小限制为5MB
const maxSize = 5 * 1024 * 1024 // 5MB
if (fileObj.size > maxSize) {
ElMessage.error('图片大小不能超过 5MB')
ElMessage.error('文件大小不能超过 5MB')
return false
}
@@ -959,10 +986,10 @@ const handleImageUpload = (file, fieldName) => {
// 移除 data:image/xxx;base64, 前缀只保留纯base64数据
const base64Data = base64String.includes(',') ? base64String.split(',')[1] : base64String
formData.value[fieldName] = base64Data
ElMessage.success('图片上传成功已转换为base64')
ElMessage.success('文件上传成功已转换为base64')
}
reader.onerror = () => {
ElMessage.error('图片读取失败,请重试')
ElMessage.error('文件读取失败,请重试')
}
reader.readAsDataURL(fileObj)

View File

@@ -22,7 +22,7 @@
label-width="10em"
class="enterprise-form-content"
>
<div class="form-section">
<div class="form-section">
<h3 class="section-title">基本信息</h3>
<!-- 企业名称和OCR识别区域 -->
@@ -42,21 +42,7 @@
<div class="ocr-compact">
<div class="ocr-header-compact">
<el-icon class="text-green-600"><DocumentIcon /></el-icon>
<span class="ocr-title-compact">OCR识别</span>
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="false"
:on-change="handleFileChange"
:before-upload="beforeUpload"
accept="image/jpeg,image/jpg,image/png,image/webp"
class="ocr-uploader-compact"
>
<el-button type="success" size="small" plain>
<el-icon><ArrowUpTrayIcon /></el-icon>
上传营业执照
</el-button>
</el-upload>
<span class="ocr-title-compact">可进行OCR识别请在下方上传营业执照</span>
</div>
<div v-if="ocrLoading" class="ocr-status-compact">
<el-icon class="is-loading"><Loading /></el-icon>
@@ -84,6 +70,7 @@
</el-col>
</el-row>
<el-row :gutter="16" class="mb-4">
<el-col :span="12">
<el-form-item label="法人姓名" prop="legalPersonName">
@@ -96,21 +83,9 @@
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="法人身份证号" prop="legalPersonID">
<el-input
v-model="form.legalPersonID"
placeholder="请输入法人身份证号"
clearable
size="default"
class="form-input"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16" class="mb-4">
<el-col :span="24">
<el-col :span="12">
<el-form-item label="企业地址" prop="enterpriseAddress">
<el-input
v-model="form.enterpriseAddress"
@@ -122,8 +97,70 @@
</el-form-item>
</el-col>
</el-row>
<!-- 营业执照图片上传保留 -->
<el-row :gutter="16" class="mb-4">
<el-col :span="24">
<el-form-item label="营业执照图片" prop="businessLicenseImageURL">
<el-upload
class="upload-area single-upload-area"
action="#"
list-type="picture-card"
:auto-upload="false"
:file-list="businessLicenseFileList"
:limit="1"
:on-change="handleBusinessLicenseChange"
:on-remove="handleBusinessLicenseRemove"
:before-upload="beforeUpload"
>
<el-icon class="upload-icon"><ArrowUpTrayIcon /></el-icon>
<div class="el-upload__text">上传清晰可辨的营业执照图片</div>
</el-upload>
</el-form-item>
</el-col>
</el-row>
<!-- 暂时隐藏办公场地图片上传 -->
<!-- <el-row :gutter="16" class="mb-4">
<el-col :span="24">
<el-form-item label="办公场地照片">
<div class="text-xs mb-1 text-blue-500">
请在非 IE 浏览器下上传大小不超过 1M 的图片最多 10 需体现门楣 LOGO办公设备与工作人员
</div>
<el-upload
ref="officePlaceUploadRef"
class="upload-area"
action="#"
list-type="picture-card"
:auto-upload="false"
v-model:file-list="officePlaceFileList"
accept="image/jpeg,image/jpg,image/png,image/webp"
multiple
:limit="10"
:on-change="handleOfficePlaceChange"
:on-remove="handleOfficePlaceRemove"
:before-upload="beforeUpload"
>
<div class="upload-trigger-inner">
<el-icon class="upload-icon"><ArrowUpTrayIcon /></el-icon>
<div class="el-upload__text">上传办公场地环境照片</div>
</div>
</el-upload>
</el-form-item>
</el-col>
</el-row> -->
<el-row :gutter="16" class="mb-4">
<el-col :span="12">
<el-form-item label="法人身份证号" prop="legalPersonID">
<el-input
v-model="form.legalPersonID"
placeholder="请输入法人身份证号"
clearable
size="default"
class="form-input"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="法人手机号" prop="legalPersonPhone">
<el-input
@@ -161,6 +198,137 @@
</el-form-item>
</el-col>
</el-row>
<!-- 暂时隐藏授权代表信息姓名身份证号手机号授权代表身份证 -->
<!-- <h3 class="section-title">授权代表信息</h3>
<p class="section-desc">授权代表信息用于证明该人员已获得企业授权请确保姓名身份证号手机号及身份证正反面照片真实有效</p>
<el-row :gutter="16" class="mb-4">
<el-col :span="8">
<el-form-item label="授权代表姓名" prop="authorizedRepName">
<el-input
v-model="form.authorizedRepName"
placeholder="请输入授权代表姓名"
clearable
size="default"
class="form-input"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="授权代表身份证号" prop="authorizedRepID">
<el-input
v-model="form.authorizedRepID"
placeholder="请输入授权代表身份证号"
clearable
size="default"
class="form-input"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="授权代表手机号" prop="authorizedRepPhone">
<el-input
v-model="form.authorizedRepPhone"
placeholder="请输入授权代表手机号"
clearable
size="default"
maxlength="11"
class="form-input"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16" class="mb-4">
<el-col :span="12">
<el-form-item label="身份证人像面" prop="authorizedRepIDImageURLs">
<el-upload
class="upload-area single-upload-area"
action="#"
list-type="picture-card"
:auto-upload="false"
:file-list="authorizedRepIDFrontFileList"
:limit="1"
:on-change="handleAuthorizedRepIDFrontChange"
:on-remove="handleAuthorizedRepIDFrontRemove"
:before-upload="beforeUpload"
>
<el-icon class="upload-icon"><ArrowUpTrayIcon /></el-icon>
<div class="el-upload__text">上传授权代表身份证人像面</div>
</el-upload>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="身份证国徽面" prop="authorizedRepIDImageURLs">
<el-upload
class="upload-area single-upload-area"
action="#"
list-type="picture-card"
:auto-upload="false"
:file-list="authorizedRepIDBackFileList"
:limit="1"
:on-change="handleAuthorizedRepIDBackChange"
:on-remove="handleAuthorizedRepIDBackRemove"
:before-upload="beforeUpload"
>
<el-icon class="upload-icon"><ArrowUpTrayIcon /></el-icon>
<div class="el-upload__text">上传授权代表身份证国徽面</div>
</el-upload>
</el-form-item>
</el-col>
</el-row> -->
<!-- 暂时隐藏应用场景说明应用场景附件 -->
<!-- <h3 class="section-title">应用场景填写</h3>
<p class="section-desc">请描述您调用接口的具体业务场景</p>
<el-row :gutter="16" class="mb-4">
<el-col :span="24">
<el-form-item label="应用场景" prop="apiUsage">
<el-input
v-model="form.apiUsage"
type="textarea"
:rows="4"
placeholder="请描述您调用接口的具体业务场景和用途"
maxlength="500"
show-word-limit
class="form-input"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16" class="mb-4">
<el-col :span="24">
<el-form-item label="应用场景附件">
<div class="text-xs mb-1 text-blue-500">
请在非 IE 浏览器下上传大小不超过 1M 的图片最多 10 张后台应用截图
</div>
<el-upload
ref="scenarioUploadRef"
class="upload-area"
action="#"
list-type="picture-card"
:auto-upload="false"
v-model:file-list="scenarioFileList"
accept="image/jpeg,image/jpg,image/png,image/webp"
multiple
:limit="10"
:on-change="handleScenarioChange"
:on-remove="handleScenarioRemove"
:before-upload="beforeUpload"
>
<div class="upload-trigger-inner">
<el-icon class="upload-icon"><ArrowUpTrayIcon /></el-icon>
<div class="el-upload__text">上传业务场景相关截图或证明材料</div>
</div>
</el-upload>
</el-form-item>
</el-col>
</el-row> -->
</div>
<div class="form-actions">
@@ -203,7 +371,17 @@ const props = defineProps({
legalPersonID: '',
legalPersonPhone: '',
enterpriseAddress: '',
legalPersonCode: ''
legalPersonCode: '',
// 扩展:营业执照 & 办公场地 & 场景
businessLicenseImageURL: '',
officePlaceImageURLs: [],
apiUsage: '',
scenarioAttachmentURLs: [],
// 授权代表信息
authorizedRepName: '',
authorizedRepID: '',
authorizedRepPhone: '',
authorizedRepIDImageURLs: []
})
}
})
@@ -224,7 +402,17 @@ const form = ref({
legalPersonID: '',
legalPersonPhone: '',
enterpriseAddress: '',
legalPersonCode: ''
legalPersonCode: '',
// 扩展:营业执照 & 办公场地 & 场景
businessLicenseImageURL: '',
officePlaceImageURLs: [],
apiUsage: '',
scenarioAttachmentURLs: [],
// 授权代表信息
authorizedRepName: '',
authorizedRepID: '',
authorizedRepPhone: '',
authorizedRepIDImageURLs: []
})
// 验证码相关状态
@@ -239,6 +427,15 @@ const submitting = ref(false)
const ocrLoading = ref(false)
const ocrResult = ref(false)
const uploadRef = ref()
const officePlaceUploadRef = ref()
const scenarioUploadRef = ref()
// 上传文件列表(前端展示用)
const officePlaceFileList = ref([])
const scenarioFileList = ref([])
const businessLicenseFileList = ref([])
const authorizedRepIDFrontFileList = ref([])
const authorizedRepIDBackFileList = ref([])
// 计算属性
const canSendCode = computed(() => {
@@ -326,7 +523,30 @@ const enterpriseRules = {
legalPersonCode: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '验证码应为6位数字', trigger: 'blur' }
]
],
businessLicenseImageURL: [
{ required: true, message: '请上传营业执照图片', trigger: 'change' }
],
// 暂时隐藏的表单项,校验已关闭,恢复显示时请还原
// apiUsage: [
// { required: true, message: '请填写接口用途', trigger: 'blur' },
// { min: 5, max: 500, message: '接口用途长度应在5-500个字符之间', trigger: 'blur' }
// ],
// authorizedRepName: [
// { required: true, message: '请输入授权代表姓名', trigger: 'blur' },
// { min: 2, max: 20, message: '授权代表姓名长度应在2-20个字符之间', trigger: 'blur' }
// ],
// authorizedRepID: [
// { required: true, message: '请输入授权代表身份证号', trigger: 'blur' },
// { validator: validateIDCard, trigger: 'blur' }
// ],
// authorizedRepPhone: [
// { required: true, message: '请输入授权代表手机号', trigger: 'blur' },
// { validator: validatePhone, trigger: 'blur' }
// ],
// authorizedRepIDImageURLs: [
// { required: true, message: '请上传授权代表身份证正反面图片', trigger: 'change' }
// ]
}
// 监听props变化
@@ -382,20 +602,21 @@ const startCountdown = () => {
const beforeUpload = (file) => {
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const isValidType = allowedTypes.includes(file.type)
const isValidSize = file.size / 1024 / 1024 < 5
const maxSizeMB = 1
const isValidSize = file.size / 1024 / 1024 < maxSizeMB
if (!isValidType) {
ElMessage.error('只支持 JPG、PNG、WEBP 格式的图片')
return false
}
if (!isValidSize) {
ElMessage.error('图片大小不能超过 5MB')
ElMessage.error(`图片大小不能超过 ${maxSizeMB}MB`)
return false
}
return true
}
// 处理文件变化
// 处理文件变化:触发 OCR并保存营业执照原图 URL若有上传地址
const handleFileChange = async (file) => {
if (!beforeUpload(file.raw)) {
return
@@ -419,6 +640,11 @@ const handleFileChange = async (file) => {
form.value.legalPersonID = ocrData.legal_person_id || ''
form.value.enterpriseAddress = ocrData.address || ''
// 如果后端返回了已保存的营业执照图片URL可以直接写入
if (ocrData.license_image_url) {
form.value.businessLicenseImageURL = ocrData.license_image_url
}
ocrResult.value = true
ElMessage.success('营业执照识别成功,已自动填充表单')
} else {
@@ -432,8 +658,180 @@ const handleFileChange = async (file) => {
}
}
// 上传单张图片到七牛云,返回可访问 URL
const uploadFileToServer = async (file) => {
const res = await certificationApi.uploadFile(file)
if (!res?.success || !res?.data?.url) {
throw new Error(res?.error?.message || '图片上传失败')
}
return res.data.url
}
// 选择后立即上传:服务器 URL 存到 response.url保留 file.url 为 blob 以便预览(避免服务器证书等问题导致预览失败)
const uploadFileOnceSelected = async (file) => {
if (!file?.raw) return null
if (file.response?.url) return file.response.url // 已上传过,不重复上传
file.status = 'uploading'
try {
const url = await uploadFileToServer(file.raw)
file.status = 'success'
if (file.response === undefined) file.response = {}
file.response.url = url
// 不覆盖 file.url保留 blob 预览地址,避免服务器证书无效时预览失败
return url
} catch (err) {
file.status = 'fail'
ElMessage.error(err?.message || '图片上传失败')
return null
}
}
// 提交前仅从 fileList 同步 URL 到表单,并检查是否全部已上传(选择即上传,提交时不再批量上传)
// 注:营业执照、办公场地、应用场景、授权代表身份证等表单项已暂时隐藏,仅同步 URL不再强制校验
const syncFormUrlsAndCheckReady = () => {
form.value.businessLicenseImageURL = extractUrls(businessLicenseFileList.value)[0] || ''
form.value.officePlaceImageURLs = extractUrls(officePlaceFileList.value)
form.value.scenarioAttachmentURLs = extractUrls(scenarioFileList.value)
updateAuthorizedRepIDImageURLs()
const hasUploading = (list) => list.some((f) => f.status === 'uploading')
const hasUnfinished = (list) => list.some((f) => f.raw && !f.response?.url)
if (hasUploading(businessLicenseFileList.value) || hasUnfinished(businessLicenseFileList.value)) return false
// 以下上传项已暂时隐藏,不再参与“未上传完成”的拦截
// if (hasUploading(officePlaceFileList.value) || hasUnfinished(officePlaceFileList.value)) return false
// if (hasUploading(scenarioFileList.value) || hasUnfinished(scenarioFileList.value)) return false
// if (hasUploading(authorizedRepIDFrontFileList.value) || hasUnfinished(authorizedRepIDFrontFileList.value)) return false
// if (hasUploading(authorizedRepIDBackFileList.value) || hasUnfinished(authorizedRepIDBackFileList.value)) return false
return true
}
// 从 el-upload 的 fileList 中提取 URL 数组,优先用服务器 URLresponse.url提交用
const extractUrls = (fileList) => {
return fileList
.map(f => f.response?.url || f.url || f.name)
.filter(Boolean)
}
// 营业执照图片变更:先 OCR 识别,再选择即上传
const handleBusinessLicenseChange = async (file, fileList) => {
businessLicenseFileList.value = fileList
const urls = extractUrls(fileList)
form.value.businessLicenseImageURL = urls[0] || ''
if (file && file.raw) {
await handleFileChange(file)
// OCR 若未返回服务器 URL则选择后立即上传未上传过才上传
if (!file.response?.url) {
const url = await uploadFileOnceSelected(file)
if (url) form.value.businessLicenseImageURL = url
}
}
}
const handleBusinessLicenseRemove = (file, fileList) => {
businessLicenseFileList.value = fileList
const urls = extractUrls(fileList)
form.value.businessLicenseImageURL = urls[0] || ''
}
// 手动清除营业执照图片(预览区域中的“删除”按钮)
const clearBusinessLicense = () => {
businessLicenseFileList.value = []
form.value.businessLicenseImageURL = ''
}
// 授权代表身份证人像面图片变更:选择即上传
const handleAuthorizedRepIDFrontChange = async (file, fileList) => {
authorizedRepIDFrontFileList.value = fileList
if (file?.raw && !file.response?.url) {
const url = await uploadFileOnceSelected(file)
if (url) {
authorizedRepIDFrontFileList.value = authorizedRepIDFrontFileList.value.map((f) =>
f.uid === file.uid ? { ...f, status: 'success', response: { url }, url: f.url } : f
)
}
}
updateAuthorizedRepIDImageURLs()
}
const handleAuthorizedRepIDFrontRemove = (file, fileList) => {
authorizedRepIDFrontFileList.value = fileList
updateAuthorizedRepIDImageURLs()
}
// 授权代表身份证国徽面图片变更:选择即上传
const handleAuthorizedRepIDBackChange = async (file, fileList) => {
authorizedRepIDBackFileList.value = fileList
if (file?.raw && !file.response?.url) {
const url = await uploadFileOnceSelected(file)
if (url) {
authorizedRepIDBackFileList.value = authorizedRepIDBackFileList.value.map((f) =>
f.uid === file.uid ? { ...f, status: 'success', response: { url }, url: f.url } : f
)
}
}
updateAuthorizedRepIDImageURLs()
}
const handleAuthorizedRepIDBackRemove = (file, fileList) => {
authorizedRepIDBackFileList.value = fileList
updateAuthorizedRepIDImageURLs()
}
// 手动清除授权代表身份证人像面
const clearAuthorizedRepFront = () => {
authorizedRepIDFrontFileList.value = []
updateAuthorizedRepIDImageURLs()
}
// 手动清除授权代表身份证国徽面
const clearAuthorizedRepBack = () => {
authorizedRepIDBackFileList.value = []
updateAuthorizedRepIDImageURLs()
}
// 汇总授权代表身份证正反面图片URL到一个数组字段
const updateAuthorizedRepIDImageURLs = () => {
const frontUrl = extractUrls(authorizedRepIDFrontFileList.value)[0] || ''
const backUrl = extractUrls(authorizedRepIDBackFileList.value)[0] || ''
const urls = []
if (frontUrl) urls.push(frontUrl)
if (backUrl) urls.push(backUrl)
form.value.authorizedRepIDImageURLs = urls
}
// 办公场地图片变更:选择即上传
const handleOfficePlaceChange = async (file, fileList) => {
officePlaceFileList.value = fileList
if (file?.raw && !file.response?.url) {
await uploadFileOnceSelected(file)
}
form.value.officePlaceImageURLs = extractUrls(fileList)
}
const handleOfficePlaceRemove = (file, fileList) => {
officePlaceFileList.value = fileList
form.value.officePlaceImageURLs = extractUrls(fileList)
}
// 应用场景附件图片变更:选择即上传
const handleScenarioChange = async (file, fileList) => {
scenarioFileList.value = fileList
if (file?.raw && !file.response?.url) {
await uploadFileOnceSelected(file)
}
form.value.scenarioAttachmentURLs = extractUrls(fileList)
}
const handleScenarioRemove = (file, fileList) => {
scenarioFileList.value = fileList
form.value.scenarioAttachmentURLs = extractUrls(fileList)
}
// 提交表单
const submitForm = async () => {
if (submitting.value) return
submitting.value = true
try {
await enterpriseFormRef.value.validate()
@@ -450,12 +848,40 @@ const submitForm = async () => {
}
)
submitting.value = true
// 选择即上传:提交时不再上传,仅同步 URL 并校验是否均已上传完成
if (!syncFormUrlsAndCheckReady()) {
ElMessage.warning('请等待所有图片上传完成后再提交')
submitting.value = false
return
}
// Mock API 调用
await new Promise(resolve => setTimeout(resolve, 2000))
// 调用后端提交接口
const payload = {
company_name: form.value.companyName,
unified_social_code: form.value.unifiedSocialCode,
legal_person_name: form.value.legalPersonName,
legal_person_id: form.value.legalPersonID,
legal_person_phone: form.value.legalPersonPhone,
enterprise_address: form.value.enterpriseAddress,
verification_code: form.value.legalPersonCode,
// 扩展字段
business_license_image_url: form.value.businessLicenseImageURL,
office_place_image_urls: form.value.officePlaceImageURLs,
api_usage: form.value.apiUsage,
scenario_attachment_urls: form.value.scenarioAttachmentURLs,
// 授权代表信息
authorized_rep_name: form.value.authorizedRepName,
authorized_rep_id: form.value.authorizedRepID,
authorized_rep_phone: form.value.authorizedRepPhone,
authorized_rep_id_image_urls: form.value.authorizedRepIDImageURLs
}
emit('submit', form.value)
const res = await certificationApi.submitEnterpriseInfo(payload)
if (!res.success) {
throw new Error(res?.error?.message || '提交企业信息失败')
}
emit('submit', { formData: form.value, response: res })
} catch (error) {
// 用户点击取消或关闭对话框,不处理
@@ -609,6 +1035,13 @@ onUnmounted(() => {
border-radius: 2px;
}
.section-desc {
font-size: 14px;
color: #64748b;
margin: 0 0 20px 0;
line-height: 1.6;
}
/* 表单输入框 */
.form-input :deep(.el-input__wrapper) {
border-radius: 8px;
@@ -633,6 +1066,33 @@ onUnmounted(() => {
padding: 8px 12px;
}
/* 上传区域基础样式 */
.upload-area {
width: 100%;
}
/* 保证 picture-card 触发区域整块可点击、可拖拽 */
.upload-trigger-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 148px;
cursor: pointer;
gap: 8px;
}
.upload-icon {
font-size: 28px;
}
/* 当已有一张图片时,隐藏单图上传的“+ 选择文件”入口 */
.single-upload-area :deep(.el-upload-list__item + .el-upload--picture-card) {
display: none;
}
/* 验证码按钮 */
.code-btn {
min-width: 100px;

View File

@@ -0,0 +1,115 @@
<template>
<el-card class="step-card">
<template #header>
<div class="card-header">
<div class="header-icon">
<el-icon class="text-amber-600">
<ClockIcon />
</el-icon>
</div>
<div class="header-content">
<h2 class="header-title">人工审核</h2>
<p class="header-subtitle">您的企业信息已提交请等待管理员审核</p>
</div>
</div>
</template>
<div class="manual-review-content">
<div class="review-status-box">
<el-icon class="status-icon"><ClockIcon /></el-icon>
<p class="status-text">我们正在审核您提交的企业信息请耐心等待</p>
<p v-if="submitTime" class="submit-time">提交时间{{ submitTime }}</p>
<p v-if="companyName" class="company-name">企业名称{{ companyName }}</p>
</div>
<el-button type="primary" class="refresh-btn" :loading="refreshing" @click="handleRefresh">
刷新状态
</el-button>
</div>
</el-card>
</template>
<script setup>
import { ClockIcon } from '@heroicons/vue/24/outline'
defineProps({
certificationData: {
type: Object,
default: null
},
submitTime: {
type: String,
default: ''
},
companyName: {
type: String,
default: ''
}
})
const emit = defineEmits(['refresh'])
const refreshing = ref(false)
const pollTimer = ref(null)
const handleRefresh = async () => {
refreshing.value = true
try {
emit('refresh')
} finally {
refreshing.value = false
}
}
onMounted(() => {
// 人工审核阶段:自动轮询状态,审核通过后会在父组件中自动切换步骤
pollTimer.value = window.setInterval(() => {
emit('refresh')
}, 5000)
})
onUnmounted(() => {
if (pollTimer.value) {
window.clearInterval(pollTimer.value)
pollTimer.value = null
}
})
</script>
<style scoped>
.manual-review-content {
padding: 24px 0;
text-align: center;
}
.review-status-box {
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 12px;
padding: 32px 24px;
margin-bottom: 24px;
}
.status-icon {
font-size: 48px;
color: #d97706;
margin-bottom: 16px;
}
.status-text {
font-size: 16px;
color: #92400e;
margin: 0 0 12px;
line-height: 1.6;
}
.submit-time,
.company-name {
font-size: 14px;
color: #b45309;
margin: 4px 0;
}
.refresh-btn {
min-width: 120px;
}
</style>

View File

@@ -68,6 +68,15 @@
@submit="handleEnterpriseSubmit"
/>
<!-- 暂时隐藏第二步人工审核 -->
<!-- <ManualReviewPending
v-if="currentStep === 'manual_review'"
:certification-data="certificationData"
:submit-time="manualReviewSubmitTime"
:company-name="enterpriseForm.companyName"
@refresh="getCertificationDetails"
/> -->
<EnterpriseVerify
v-if="currentStep === 'enterprise_verify'"
:enterprise-data="enterpriseForm"
@@ -120,6 +129,7 @@ import { useUserStore } from '@/stores/user'
import {
BuildingOfficeIcon,
CheckCircleIcon,
ClockIcon,
CodeBracketIcon,
DocumentTextIcon,
UserIcon,
@@ -133,9 +143,10 @@ import ContractRejected from './components/ContractRejected.vue'
import ContractSign from './components/ContractSign.vue'
import EnterpriseInfo from './components/EnterpriseInfo.vue'
import EnterpriseVerify from './components/EnterpriseVerify.vue'
import ManualReviewPending from './components/ManualReviewPending.vue'
const router = useRouter()
const userStore = useUserStore()
// 认证步骤配置
// 认证步骤配置(暂时隐藏第二步「人工审核」,恢复时取消注释 manual_review 并还原 setCurrentStepByStatus 中 info_pending_review 分支)
const certificationSteps = [
{
key: 'enterprise_info',
@@ -143,6 +154,12 @@ const certificationSteps = [
description: '填写企业基本信息和法人信息',
icon: BuildingOfficeIcon,
},
// {
// key: 'manual_review',
// title: '人工审核',
// description: '等待管理员审核企业信息',
// icon: ClockIcon,
// },
{
key: 'enterprise_verify',
title: '企业认证',
@@ -176,6 +193,18 @@ const currentStepIndex = computed(() => {
// 步骤特定元数据
const stepMeta = ref({}) // 用于存储当前步骤的metadata
// 人工审核步骤的提交时间展示
const manualReviewSubmitTime = computed(() => {
const at = certificationData.value?.metadata?.enterprise_info?.submit_at ?? certificationData.value?.info_submitted_at
if (!at) return ''
try {
const d = new Date(at)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString('zh-CN')
} catch {
return ''
}
})
// 表单数据
const enterpriseForm = ref({
companyName: '',
@@ -188,35 +217,66 @@ const enterpriseForm = ref({
enterpriseEmail: '',
})
// 开发模式控制
//
const isDevelopment = ref(false)
const devCurrentStep = ref('enterprise_info')
// 合同签署加载状态
const contractSignLoading = ref(false)
// 事件处理
const handleEnterpriseSubmit = async (formData) => {
// 只补 enterprise_verify 所需 auth_url不改动原有页面展示数据结构
// 轮询策略:最多 5 秒,每秒 1 次(最多 5 次)
const pollAuthUrlOnly = async (maxTries = 5) => {
for (let i = 0; i < maxTries; i++) {
const res = await certificationApi.getCertificationDetails()
const status = res?.data?.status
const authUrl = res?.data?.metadata?.auth_url
// 仅同步状态,避免覆盖已有展示字段
if (status && certificationData.value) {
certificationData.value.status = status
await setCurrentStepByStatus()
}
if (authUrl) {
// 保留原 metadata仅更新链接字段
stepMeta.value = {
...(stepMeta.value || {}),
auth_url: authUrl,
}
return authUrl
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
return ''
}
// 事件处理:优先用提交接口返回的认证数据更新步骤,确保进入「人工审核」页,避免依赖二次请求
const handleEnterpriseSubmit = async (payload) => {
try {
loading.value = true
// 字段映射
const payload = {
company_name: formData.companyName,
unified_social_code: formData.unifiedSocialCode,
legal_person_name: formData.legalPersonName,
legal_person_id: formData.legalPersonID,
legal_person_phone: formData.legalPersonPhone,
enterprise_address: formData.enterpriseAddress,
enterprise_email: formData.enterpriseEmail,
verification_code: formData.legalPersonCode,
const nextAction = payload?.response?.data?.metadata?.next_action
if (nextAction) {
ElMessage.success(nextAction)
} else {
ElMessage.success('企业信息提交成功,请等待管理员审核')
}
if (payload?.response?.data?.status) {
certificationData.value = payload.response.data
stepMeta.value = payload.response.data?.metadata || {}
await setCurrentStepByStatus()
if (currentStep.value === 'enterprise_verify' && !stepMeta.value?.auth_url) {
await pollAuthUrlOnly()
}
} else {
await getCertificationDetails()
if (currentStep.value === 'enterprise_verify' && !stepMeta.value?.auth_url) {
await pollAuthUrlOnly()
}
}
await certificationApi.submitEnterpriseInfo(payload)
ElMessage.success('企业信息提交成功')
// 提交成功后刷新认证详情
await getCertificationDetails()
} catch (error) {
ElMessage.error(error?.message || '提交失败,请检查表单信息')
// 提交失败时不刷新认证详情,保持用户填写的信息
ElMessage.error(error?.message || '获取认证状态失败,请刷新页面')
} finally {
loading.value = false
}
@@ -355,6 +415,10 @@ const setCurrentStepByStatus = async () => {
case 'pending':
currentStep.value = 'enterprise_info'
break
case 'info_pending_review':
// 暂时跳过人工审核展示,直接进入企业认证步骤
currentStep.value = 'enterprise_verify'
break
case 'info_submitted':
currentStep.value = 'enterprise_verify'
break

View File

@@ -304,6 +304,12 @@ const routes = [
name: 'AdminPurchaseRecords',
component: () => import('@/pages/admin/purchase-records/index.vue'),
meta: { title: '购买记录管理' }
},
{
path: 'certification-reviews',
name: 'AdminCertificationReviews',
component: () => import('@/pages/admin/certification-reviews/index.vue'),
meta: { title: '企业审核' }
}
]
},