diff --git a/.eslintrc-auto-import.json b/.eslintrc-auto-import.json index 666770a..55a19f9 100644 --- a/.eslintrc-auto-import.json +++ b/.eslintrc-auto-import.json @@ -366,6 +366,7 @@ "useThrottleFn": true, "useThrottledRefHistory": true, "useTimeAgo": true, + "useTimeAgoIntl": true, "useTimeout": true, "useTimeoutFn": true, "useTimeoutPoll": true, diff --git a/auto-imports.d.ts b/auto-imports.d.ts index 1af4034..516d1ce 100644 --- a/auto-imports.d.ts +++ b/auto-imports.d.ts @@ -817,6 +817,7 @@ declare module 'vue' { readonly useThrottleFn: UnwrapRef readonly useThrottledRefHistory: UnwrapRef readonly useTimeAgo: UnwrapRef + readonly useTimeAgoIntl: UnwrapRef readonly useTimeout: UnwrapRef readonly useTimeoutFn: UnwrapRef readonly useTimeoutPoll: UnwrapRef diff --git a/components.d.ts b/components.d.ts index 4db8f92..06a2b1e 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/src/api/index.js b/src/api/index.js index 0930975..2f4b01e 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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相关接口 diff --git a/src/api/statistics/index.js b/src/api/statistics/index.js index e7e96c6..9ec2ce1 100644 --- a/src/api/statistics/index.js +++ b/src/api/statistics/index.js @@ -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 + }) +} diff --git a/src/constants/menu.js b/src/constants/menu.js index 1c8ba00..0401563 100644 --- a/src/constants/menu.js +++ b/src/constants/menu.js @@ -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 }, diff --git a/src/pages/admin/certification-reviews/index.vue b/src/pages/admin/certification-reviews/index.vue new file mode 100644 index 0000000..8898bc9 --- /dev/null +++ b/src/pages/admin/certification-reviews/index.vue @@ -0,0 +1,582 @@ + + + + + diff --git a/src/pages/admin/statistics/SystemStatisticsPage.vue b/src/pages/admin/statistics/SystemStatisticsPage.vue index 8b6f043..0ba9c26 100644 --- a/src/pages/admin/statistics/SystemStatisticsPage.vue +++ b/src/pages/admin/statistics/SystemStatisticsPage.vue @@ -1,5 +1,17 @@ @@ -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('') diff --git a/src/pages/admin/statistics/components/RequestFlowGlobe.vue b/src/pages/admin/statistics/components/RequestFlowGlobe.vue new file mode 100644 index 0000000..9e33de8 --- /dev/null +++ b/src/pages/admin/statistics/components/RequestFlowGlobe.vue @@ -0,0 +1,337 @@ + + + + + + diff --git a/src/pages/api/ApiDebugger.vue b/src/pages/api/ApiDebugger.vue index f30a589..8117f39 100644 --- a/src/pages/api/ApiDebugger.vue +++ b/src/pages/api/ApiDebugger.vue @@ -181,17 +181,17 @@ * - -
+ +
- 上传图片(JPG/BMP/PNG) + {{ getUploadButtonTextByField(field) }} @@ -423,7 +423,7 @@
-
@@ -749,12 +749,12 @@ watch( if (newProductId === oldProductId) { return } - + // 如果正在选择产品,不重复执行 if (isSelectingProduct.value) { return } - + if (newProductId && userProducts.value.length > 0) { await autoSelectProduct(newProductId) } else if (!newProductId && userProducts.value.length > 0 && !selectedProduct.value) { @@ -771,13 +771,13 @@ const autoSelectProduct = async (productId) => { console.log('正在选择产品,跳过重复请求') return } - + // 如果已经选择了相同的产品,不重复选择 if (lastSelectedProductId.value === productId && selectedProduct.value) { console.log('产品已选择,跳过重复选择:', productId) return } - + // 如果用户产品列表为空,等待加载完成 if (!userProducts.value.length) { console.log('等待用户产品列表加载完成...') @@ -883,13 +883,13 @@ const loadApiKeys = async () => { const selectProduct = async (product) => { // 防止重复选择相同产品 const productId = product.product_id || product.id - if (selectedProduct.value && + if (selectedProduct.value && (selectedProduct.value.id === productId || selectedProduct.value.product_id === productId) && !isSelectingProduct.value) { console.log('产品已选择,跳过重复加载:', productId) return } - + // 确保API密钥已经加载 if (!debugForm.accessId || !debugForm.secretKey) { ElMessage.warning('正在加载API密钥,请稍候...') @@ -934,24 +934,51 @@ 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 } - + // 读取文件并转换为base64 const reader = new FileReader() reader.onload = (e) => { @@ -959,13 +986,13 @@ 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) - + return false // 阻止自动上传 } @@ -1142,7 +1169,7 @@ const convertFieldTypes = (data) => { if (!formFields.value || formFields.value.length === 0) { return data } - + const processedData = { ...data } formFields.value.forEach(field => { const value = processedData[field.name] @@ -1150,7 +1177,7 @@ const convertFieldTypes = (data) => { if (value === '' || value === null || value === undefined) { return } - + // 根据字段类型进行转换 if (field.type === 'number') { // 将字符串转换为数字(整数) @@ -1160,7 +1187,7 @@ const convertFieldTypes = (data) => { } } }) - + return processedData } @@ -1197,7 +1224,7 @@ const encryptWithAES = async (data, secretKey) => { // 解析JSON字符串(如果是字符串) let parsedData = typeof data === 'string' ? JSON.parse(data) : data - + // 根据字段类型进行类型转换 parsedData = convertFieldTypes(parsedData) @@ -1282,7 +1309,7 @@ const handleDebug = async () => { debugResult.value = null decryptedData.value = null await nextTick() // 确保DOM更新 - + const startTime = new Date() try { diff --git a/src/pages/certification/components/EnterpriseInfo.vue b/src/pages/certification/components/EnterpriseInfo.vue index 3f7dcff..0f26be1 100644 --- a/src/pages/certification/components/EnterpriseInfo.vue +++ b/src/pages/certification/components/EnterpriseInfo.vue @@ -22,7 +22,7 @@ label-width="10em" class="enterprise-form-content" > -
+

基本信息

@@ -42,21 +42,7 @@
- OCR识别 - - - - 上传营业执照 - - + 可进行OCR识别,请在下方上传营业执照
@@ -84,6 +70,7 @@ + @@ -96,21 +83,9 @@ /> - - - - - - - - + + + + + + + + +
上传清晰可辨的营业执照图片
+
+
+
+
+ + + + + + + + + + + + + + +
@@ -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 数组,优先用服务器 URL(response.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; diff --git a/src/pages/certification/components/ManualReviewPending.vue b/src/pages/certification/components/ManualReviewPending.vue new file mode 100644 index 0000000..9ad4cd0 --- /dev/null +++ b/src/pages/certification/components/ManualReviewPending.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/pages/certification/index.vue b/src/pages/certification/index.vue index 64f2c7f..2196a6d 100644 --- a/src/pages/certification/index.vue +++ b/src/pages/certification/index.vue @@ -68,6 +68,15 @@ @submit="handleEnterpriseSubmit" /> + + + { // 步骤特定元数据 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 diff --git a/src/router/index.js b/src/router/index.js index ff20bee..a520d62 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -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: '企业审核' } } ] },