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/src/api/index.js b/src/api/index.js index 0930975..2abe03b 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -194,8 +194,25 @@ 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), + + // 管理端企业审核:列表、详情、通过、拒绝 + 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) } // API相关接口 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..4d949ed --- /dev/null +++ b/src/pages/admin/certification-reviews/index.vue @@ -0,0 +1,441 @@ + + + + + diff --git a/src/pages/certification/components/EnterpriseInfo.vue b/src/pages/certification/components/EnterpriseInfo.vue index 3f7dcff..7b876ce 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 @@ /> - - - - - - - - + + + + + + + + +
上传清晰可辨的营业执照图片
+
+
+
+
+ + + + +
+ 请在非 IE 浏览器下上传大小不超过 3M 的图片,最多 10 张,需体现门楣 LOGO、办公设备与工作人员。 +
+
+ +
+ +
上传办公场地环境照片
+
+
+
+
+
+
+ + + + + + + + +

授权代表信息

+

授权代表信息用于证明该人员已获得企业授权,请确保姓名、身份证号、手机号及身份证正反面照片真实有效。

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
上传授权代表身份证人像面
+
+
+
+ + + + +
上传授权代表身份证国徽面
+
+
+
+
+ +

应用场景填写

+

请描述您调用接口的具体业务场景

+ + + + + + + + + + + + + + + + + +
+ 请在非IE浏览器下上传大小不超过3M的图片,要求:不超过10张后台应用截图 +
+
+ +
+ +
上传业务场景相关截图或证明材料
+
+
+
+
+
+
@@ -190,7 +378,7 @@ import { CheckIcon, DocumentIcon } from '@heroicons/vue/24/outline' -import { ElMessage, ElMessageBox } from 'element-plus' +import { ElMessage, ElMessageBox, genFileId } from 'element-plus' import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha' const props = defineProps({ @@ -203,7 +391,17 @@ const props = defineProps({ legalPersonID: '', legalPersonPhone: '', enterpriseAddress: '', - legalPersonCode: '' + legalPersonCode: '', + // 扩展:营业执照 & 办公场地 & 场景 + businessLicenseImageURL: '', + officePlaceImageURLs: [], + apiUsage: '', + scenarioAttachmentURLs: [], + // 授权代表信息 + authorizedRepName: '', + authorizedRepID: '', + authorizedRepPhone: '', + authorizedRepIDImageURLs: [] }) } }) @@ -224,7 +422,17 @@ const form = ref({ legalPersonID: '', legalPersonPhone: '', enterpriseAddress: '', - legalPersonCode: '' + legalPersonCode: '', + // 扩展:营业执照 & 办公场地 & 场景 + businessLicenseImageURL: '', + officePlaceImageURLs: [], + apiUsage: '', + scenarioAttachmentURLs: [], + // 授权代表信息 + authorizedRepName: '', + authorizedRepID: '', + authorizedRepPhone: '', + authorizedRepIDImageURLs: [] }) // 验证码相关状态 @@ -239,6 +447,17 @@ const submitting = ref(false) const ocrLoading = ref(false) const ocrResult = ref(false) const uploadRef = ref() +const officePlaceUploadRef = ref() +const scenarioUploadRef = ref() +const officePlaceDragover = ref(false) +const scenarioDragover = ref(false) + +// 上传文件列表(前端展示用) +const officePlaceFileList = ref([]) +const scenarioFileList = ref([]) +const businessLicenseFileList = ref([]) +const authorizedRepIDFrontFileList = ref([]) +const authorizedRepIDBackFileList = ref([]) // 计算属性 const canSendCode = computed(() => { @@ -326,6 +545,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' } ] } @@ -395,7 +638,7 @@ const beforeUpload = (file) => { return true } -// 处理文件变化 +// 处理文件变化:触发 OCR,并保存营业执照原图 URL(若有上传地址) const handleFileChange = async (file) => { if (!beforeUpload(file.raw)) { return @@ -419,6 +662,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 +680,211 @@ const handleFileChange = async (file) => { } } +// 判断是否为前端 blob 预览地址(需上传到服务器后替换为真实 URL) +const isBlobUrl = (url) => typeof url === 'string' && url.startsWith('blob:') + +// 上传单张图片到七牛云,返回可访问 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 +} + +// 提交前将 blob 图片全部上传到七牛云,并更新表单中的 URL +const uploadAllBlobFilesAndFillForm = async () => { + const tasks = [] + + // 营业执照:若当前是 blob 则上传 + if (isBlobUrl(form.value.businessLicenseImageURL) && businessLicenseFileList.value.length > 0 && businessLicenseFileList.value[0].raw) { + tasks.push( + uploadFileToServer(businessLicenseFileList.value[0].raw).then((url) => { + form.value.businessLicenseImageURL = url + }) + ) + } + + // 办公场地多图:按顺序上传 + if (officePlaceFileList.value.length > 0) { + const files = officePlaceFileList.value.filter((f) => f.raw).map((f) => f.raw) + if (files.length > 0) { + tasks.push( + Promise.all(files.map((f) => uploadFileToServer(f))).then((urls) => { + form.value.officePlaceImageURLs = urls + }) + ) + } + } + + // 应用场景附件多图:按顺序上传 + if (scenarioFileList.value.length > 0) { + const files = scenarioFileList.value.filter((f) => f.raw).map((f) => f.raw) + if (files.length > 0) { + tasks.push( + Promise.all(files.map((f) => uploadFileToServer(f))).then((urls) => { + form.value.scenarioAttachmentURLs = urls + }) + ) + } + } + + // 授权代表身份证正反面:人像面 + 国徽面 + const frontRaw = authorizedRepIDFrontFileList.value[0]?.raw + const backRaw = authorizedRepIDBackFileList.value[0]?.raw + if (frontRaw || backRaw) { + tasks.push( + (async () => { + const urls = [] + if (frontRaw) urls.push(await uploadFileToServer(frontRaw)) + if (backRaw) urls.push(await uploadFileToServer(backRaw)) + form.value.authorizedRepIDImageURLs = urls + })() + ) + } + + await Promise.all(tasks) +} + +// 从 el-upload 的 fileList 中提取 URL 数组(后端接好上传接口后可用 response.url) +const extractUrls = (fileList) => { + return fileList + .map(f => f.url || f.response?.url || f.name) + .filter(Boolean) +} + +// 营业执照图片变更(同时触发 OCR 识别) +const handleBusinessLicenseChange = async (file, fileList) => { + businessLicenseFileList.value = fileList + const urls = extractUrls(fileList) + form.value.businessLicenseImageURL = urls[0] || '' + + // 使用当前选择的营业执照图片触发 OCR 识别 + if (file && file.raw) { + await handleFileChange(file) + } +} + +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 = (file, fileList) => { + authorizedRepIDFrontFileList.value = fileList + updateAuthorizedRepIDImageURLs() +} + +const handleAuthorizedRepIDFrontRemove = (file, fileList) => { + authorizedRepIDFrontFileList.value = fileList + updateAuthorizedRepIDImageURLs() +} + +// 授权代表身份证国徽面图片变更 +const handleAuthorizedRepIDBackChange = (file, fileList) => { + authorizedRepIDBackFileList.value = fileList + 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 +} + +// 办公场地:拖放文件时通过 handleStart 加入列表 +const onOfficePlaceDrop = (e) => { + officePlaceDragover.value = false + const upload = officePlaceUploadRef.value + if (!upload) return + const files = Array.from(e.dataTransfer?.files || []).filter( + (f) => f.type && /^image\/(jpeg|jpg|png|webp)$/i.test(f.type) + ) + const limit = 10 + const remain = Math.max(0, limit - officePlaceFileList.value.length) + const toAdd = files.slice(0, remain) + for (const file of toAdd) { + if (!beforeUpload(file)) continue + if (typeof file.uid === 'undefined') file.uid = genFileId() + upload.handleStart(file) + } + if (files.length > remain && remain > 0) ElMessage.warning(`最多上传 ${limit} 张,已忽略多余文件`) +} + +// 应用场景:拖放文件时通过 handleStart 加入列表 +const onScenarioDrop = (e) => { + scenarioDragover.value = false + const upload = scenarioUploadRef.value + if (!upload) return + const files = Array.from(e.dataTransfer?.files || []).filter( + (f) => f.type && /^image\/(jpeg|jpg|png|webp)$/i.test(f.type) + ) + const limit = 10 + const remain = Math.max(0, limit - scenarioFileList.value.length) + const toAdd = files.slice(0, remain) + for (const file of toAdd) { + if (!beforeUpload(file)) continue + if (typeof file.uid === 'undefined') file.uid = genFileId() + upload.handleStart(file) + } + if (files.length > remain && remain > 0) ElMessage.warning(`最多上传 ${limit} 张,已忽略多余文件`) +} + +// 办公场地图片变更 +const handleOfficePlaceChange = (file, fileList) => { + officePlaceFileList.value = fileList + form.value.officePlaceImageURLs = extractUrls(fileList) +} + +const handleOfficePlaceRemove = (file, fileList) => { + officePlaceFileList.value = fileList + form.value.officePlaceImageURLs = extractUrls(fileList) +} + +// 应用场景附件图片变更 +const handleScenarioChange = (file, fileList) => { + scenarioFileList.value = fileList + 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 +901,42 @@ const submitForm = async () => { } ) - submitting.value = true + // 先将所有 blob 图片上传到七牛云,再提交企业信息 + try { + await uploadAllBlobFilesAndFillForm() + } catch (err) { + ElMessage.error(err?.message || '图片上传失败,请重试') + 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 +1090,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 +1121,45 @@ onUnmounted(() => { padding: 8px 12px; } +/* 拖放区域:包裹 picture-card 上传,支持拖拽图片到整块区域 */ +.upload-drop-zone { + width: 100%; + border-radius: 8px; + transition: background 0.2s, border-color 0.2s; +} +.upload-drop-zone.is-dragover { + background: var(--el-color-primary-light-9, #ecf5ff); + outline: 2px dashed var(--el-color-primary); + outline-offset: -2px; +} + +/* 上传区域基础样式 */ +.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..24b3e77 --- /dev/null +++ b/src/pages/certification/components/ManualReviewPending.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/src/pages/certification/index.vue b/src/pages/certification/index.vue index 64f2c7f..eca7688 100644 --- a/src/pages/certification/index.vue +++ b/src/pages/certification/index.vue @@ -68,6 +68,14 @@ @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 +216,32 @@ const enterpriseForm = ref({ enterpriseEmail: '', }) -// 开发模式控制 +// const isDevelopment = ref(false) const devCurrentStep = ref('enterprise_info') // 合同签署加载状态 const contractSignLoading = ref(false) -// 事件处理 -const handleEnterpriseSubmit = async (formData) => { +// 事件处理:优先用提交接口返回的认证数据更新步骤,确保进入「人工审核」页,避免依赖二次请求 +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() + } else { + await getCertificationDetails() } - await certificationApi.submitEnterpriseInfo(payload) - ElMessage.success('企业信息提交成功') - // 提交成功后刷新认证详情 - await getCertificationDetails() } catch (error) { - ElMessage.error(error?.message || '提交失败,请检查表单信息') - // 提交失败时不刷新认证详情,保持用户填写的信息 + ElMessage.error(error?.message || '获取认证状态失败,请刷新页面') } finally { loading.value = false } @@ -355,6 +380,9 @@ const setCurrentStepByStatus = async () => { case 'pending': currentStep.value = 'enterprise_info' break + case 'info_pending_review': + currentStep.value = 'manual_review' + 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: '企业审核' } } ] },