import React, { useEffect, useState } from "react"; import { Connect } from "@cqsjjb/jjb-dva-runtime"; import { message, Empty } from "antd"; import { NS_REGISTER } from "~/enumerate/namespace"; import auditLookImage from "~/enumerate/img/auditLook.png"; import auditBkImage from "~/enumerate/img/audit.png"; import successIcon from "~/enumerate/img/successIcon.png"; import { normalizeUrl } from "~/utils/validators"; import { resolveRegisterQueryParams } from "~/utils/resolveRegisterQueryParams"; import "./index.less"; // ===== 2026-08-08 依据原型 V1.6 register.html「认证信息填报页」1:1 还原 ===== // 变更原因:原型为原生 HTML/CSS 页面,需按原型的样式、布局、边框、字体、颜色、按钮、响应式做 1:1 还原。 // 变更逻辑: // 1) 前端改用原生受控表单(formData state + 原生 input/textarea/checkbox + 自定义校验), // 字段名直接对齐后端 OrgInfo 字段,规避二次映射; // 2) 布局采用原型 .grid 两列 + .wide 全宽字段,营业执照/拟申请业务范围/单位基本情况介绍单独成行居左; // 3) 所有字段均加红星必填标注(含资质证书编号); // 4) 边框容器 .card 居中、内容居左;保留 Steps 三步流程与 orgInfoSave/syncUserToGBS 提交逻辑。 // 变更理由:最小侵入,样式与原型一一对应;响应式 @media(max-width:760px) 单列适配。 // 业务范围选项(与后端枚举 code 严格匹配:OrgBusinessScopeDeviceRefDO 等定义的行业 code) // 2026-08-08 改为 {value,label}:value 用后端枚举 code(存储/渲染的值),label 仅作展示, // 不再以中文名作为存储值,保证与后端枚举一致。 const BUSINESS_SCOPE_OPTIONS = [ { value: "COAL_MINING", label: "煤炭开采业" }, { value: "METAL_NONMETAL_MINING", label: "金属、非金属矿及其他矿采选业" }, { value: "ONSHORE_OIL_GAS", label: "陆地石油和天然气开采业" }, { value: "ONSHORE_OIL_GAS_PIPELINE", label: "陆上油气管道运输业" }, { value: "PETROCHEMICAL_CHEMICAL_PHARMACEUTICAL", label: "石油加工业,化学原料、化学品及医药制造业", }, { value: "FIREWORKS_FIRECRACKERS", label: "烟花爆竹制造业" }, { value: "METAL_SMELTING", label: "金属冶炼" }, ]; // 2026-08-08 业务范围值清洗:兼容历史已存的中文 label,提交/回显时统一转换为后端枚举 code。 // 原因:早期数据 apply_business_scope 存储的是中文名,现改为存 code;若历史中文与 code 混存, // 提交前必须清洗为 code,避免请求体中出现 label 导致后端只应接收枚举值。 function normalizeBusinessScope(arr) { if (!Array.isArray(arr)) return []; const labelToValue = Object.fromEntries( BUSINESS_SCOPE_OPTIONS.map((opt) => [opt.label, opt.value]), ); return arr .filter((v) => v != null && String(v).trim() !== "") .map((v) => { const trimmed = String(v).trim(); return labelToValue[trimmed] || trimmed; }); } // 初始表单(字段名对齐后端 OrgInfo) const INITIAL_FORM = { unitName: "", creditCode: "", attachmentUrls: [], // 营业执照(数组,提交时 JSON.stringify) registerAddress: "", businessAddress: "", infoDisclosureUrl: "", qualificationCertNo: "", legalRepresentative: "", legalRepresentativePhone: "", fax: "", contactNamePhone: "", fixedAssetsTotal: "", workplaceArea: "", archiveRoomArea: "", fulltimeEvaluatorCount: "", registeredEngineerCount: "", safetyIndustryCategoryCode: [], // 多选,提交时传数组 orgIntro: "", id: undefined, }; // ===== 自定义校验(不依赖 antd rules,贴合原型原生表单)===== // 2026-08-08 校验规则与后端库表字段类型/长度对齐(见 OrgInfoAddCmd 的 Bean Validation 注解): // 字符串字段长度取库 varchar 长度;decimal 字段整数/小数位取库精度;int 字段取 32 位有符号上限。 const PHONE_RE = /^(1[3-9]\d{9}|0\d{2,3}-?\d{7,8})$/; // 2026-08-08 联系人及电话为「联系人姓名 + 电话」组合字段,要求串中包含合法手机/座机号 const HAS_PHONE_RE = /(1[3-9]\d{9}|0\d{2,3}-?\d{7,8})/; // 2026-08-08 严格 URL 校验:必须为 http(s):// 开头且含「域名.后缀」结构(如 https://www.example.com), // 拦截纯数字/无域名等非法输入(如 "123" 经 normalizeUrl 补全后为 "https://123",无 "." 域名,不匹配)。 const URL_RE = /^https?:\/\/[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+([\/?#][\w\-._~:/?#[\]@!$&'()*+,;=.\-%]*)?$/; const CREDIT_RE = /^[0-9A-Z]{18}$/; const NUMBER_RE = /^\d+(\.\d+)?$/; const INT_RE = /^\d+$/; // 2026-08-08 联系人与电话采用「单输入框 + "/" 分隔」录入模式:用户输入 "联系人/电话"。 // 提交时按首个 "/" 拆分为 contactName / contactPhone 两个后端字段(与 OrgInfoDO 拆分字段对齐)。 // 兼容无 "/" 的情形:整段视为联系人、电话置空,由校验拦截提示。 function splitContact(input) { if (!input) return { name: "", phone: "" }; const idx = input.indexOf("/"); if (idx === -1) return { name: input.trim(), phone: "" }; return { name: input.slice(0, idx).trim(), phone: input.slice(idx + 1).trim(), }; } // 各数字字段的取值范围上限(与库 decimal/int 精度对齐,详见 OrgInfoAddCmd 注解) const MAX_AREA_INT = 9999999999; // decimal(12,2) 整数最多10位 const MAX_AREA_FRAC = 2; // decimal(12,2) 小数最多2位 const MAX_FIXED_INT = 99999999999999; // decimal(16,4) 整数最多12位 const MAX_FIXED_FRAC = 4; // decimal(16,4) 小数最多4位 const MAX_INT32 = 2147483647; // int 32位有符号上限 // 小数位数校验:拆分整数/小数部分分别与库精度比对 const exceedsScale = (val, maxFrac) => { const s = String(val); const dot = s.indexOf("."); if (dot === -1) return false; return s.length - dot - 1 > maxFrac; }; const validate = (formData, registerUserId) => { const errors = {}; const required = (f, msg) => { const v = formData[f]; if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) { errors[f] = msg; return true; } return false; }; const len = (f, max, msg) => { if (String(formData[f]).length > max) errors[f] = msg; }; if (required("unitName", "请输入单位名称")) { } else len("unitName", 200, "单位名称不能超过200字"); if (required("creditCode", "请输入统一社会信用代码")) { } else if (!CREDIT_RE.test(formData.creditCode)) errors.creditCode = "统一社会信用代码为18位字母或数字"; if (required("attachmentUrls", "请上传营业执照")) { } if (required("registerAddress", "请输入注册地址")) { } else len("registerAddress", 500, "注册地址不能超过500字"); // varchar(500) if (required("businessAddress", "请输入办公地址")) { } else len("businessAddress", 500, "办公地址不能超过500字"); // varchar(500) if (required("infoDisclosureUrl", "请输入信息公开网址")) { } else if (!URL_RE.test(normalizeUrl(formData.infoDisclosureUrl))) errors.infoDisclosureUrl = "请输入合法网址(如 https://www.example.com)"; else len("infoDisclosureUrl", 500, "网址不能超过500字"); // varchar(500) if (required("qualificationCertNo", "请输入资质证书编号")) { } else len("qualificationCertNo", 100, "资质证书编号不能超过100字"); // varchar(100) if (required("legalRepresentative", "请输入法定代表人")) { } else len("legalRepresentative", 50, "法定代表人不能超过50字"); // varchar(50) if (required("legalRepresentativePhone", "请输入法定代表人电话")) { } else if (!PHONE_RE.test(formData.legalRepresentativePhone)) errors.legalRepresentativePhone = "请输入正确的电话(手机或座机)"; else len("legalRepresentativePhone", 20, "电话不能超过20字"); // varchar(20) if (required("fax", "请输入传真")) { } else if (!PHONE_RE.test(formData.fax)) errors.fax = "请输入正确的传真(手机或座机)"; else len("fax", 20, "传真不能超过20字"); // varchar(20) // 2026-08-08 联系人与电话:单输入框 "联系人/电话",提交时拆分;此处按 "/" 分割后分别校验。 // contactName varchar(50),contactPhone varchar(20)(与 OrgInfoDO / OrgInfoAddCmd 一致)。 if (required("contactNamePhone", "请输入联系人及电话(用/分隔联系人和电话)")) { } else { const { name, phone } = splitContact(formData.contactNamePhone); if (!name) errors.contactNamePhone = "请输入联系人(/前)"; else if (name.length > 50) errors.contactNamePhone = "联系人不能超过50字"; // varchar(50) else if (!phone) errors.contactNamePhone = "请输入联系电话(/后)"; else if (!HAS_PHONE_RE.test(phone)) errors.contactNamePhone = "请输入正确的联系电话(手机或座机)"; else if (phone.length > 20) errors.contactNamePhone = "联系电话不能超过20字"; // varchar(20) } // 2026-08-08 数值范围/精度校验:与后端库表 decimal 精度对齐,避免提交后数据库写入时 // 因整数位或小数位超出 decimal 精度而抛 "Out of range" 异常。 if (required("fixedAssetsTotal", "请输入固定资产总值")) { } else if (!NUMBER_RE.test(formData.fixedAssetsTotal) || Number(formData.fixedAssetsTotal) < 0) errors.fixedAssetsTotal = "请输入非负数字"; else if (Number(formData.fixedAssetsTotal) > MAX_FIXED_INT) errors.fixedAssetsTotal = "数值过大,整数部分不能超过12位"; // decimal(16,4) else if (exceedsScale(formData.fixedAssetsTotal, MAX_FIXED_FRAC)) errors.fixedAssetsTotal = "小数位最多4位"; // decimal(16,4) if (required("workplaceArea", "请输入工作场所建筑面积")) { } else if (!NUMBER_RE.test(formData.workplaceArea) || Number(formData.workplaceArea) < 0) errors.workplaceArea = "请输入非负数字"; else if (Number(formData.workplaceArea) > MAX_AREA_INT) errors.workplaceArea = "数值过大,整数部分不能超过10位"; // decimal(12,2) else if (exceedsScale(formData.workplaceArea, MAX_AREA_FRAC)) errors.workplaceArea = "小数位最多2位"; // decimal(12,2) if (required("archiveRoomArea", "请输入档案室面积")) { } else if (!NUMBER_RE.test(formData.archiveRoomArea) || Number(formData.archiveRoomArea) < 0) errors.archiveRoomArea = "请输入非负数字"; else if (Number(formData.archiveRoomArea) > MAX_AREA_INT) errors.archiveRoomArea = "数值过大,整数部分不能超过10位"; // decimal(12,2) else if (exceedsScale(formData.archiveRoomArea, MAX_AREA_FRAC)) errors.archiveRoomArea = "小数位最多2位"; // decimal(12,2) if (required("fulltimeEvaluatorCount", "请输入专职安全评价师数量")) { } else if (!INT_RE.test(formData.fulltimeEvaluatorCount)) errors.fulltimeEvaluatorCount = "请输入非负整数"; else if (Number(formData.fulltimeEvaluatorCount) > MAX_INT32) errors.fulltimeEvaluatorCount = "数值过大"; // int 上限 if (required("registeredEngineerCount", "请输入注册安全工程师数量")) { } else if (!INT_RE.test(formData.registeredEngineerCount)) errors.registeredEngineerCount = "请输入非负整数"; else if (Number(formData.registeredEngineerCount) > MAX_INT32) errors.registeredEngineerCount = "数值过大"; // int 上限 // 2026-08-08 校验前先清洗为 code(兼容历史中文 label 混存),避免以 label 长度误判 const normalizedScope = normalizeBusinessScope(formData.safetyIndustryCategoryCode); if (normalizedScope.length === 0) { errors.safetyIndustryCategoryCode = "请至少选择一项业务范围"; } else if (normalizedScope.join(",").length > 500) errors.safetyIndustryCategoryCode = "业务范围总字数不能超过500字"; // varchar(500) if (required("orgIntro", "请输入单位基本情况介绍")) { } else if (formData.orgIntro.length > 2000) errors.orgIntro = "单位基本情况介绍不能超过2000字"; // 2026-08-08 注册用户标识校验:来自统一解析(路由参数或会话缓存),后端 registerUserId 为 Long, // 此处校验其存在且为数字字符串,避免非数字值(如测试占位符)提交后端时 // 触发 JSON 反序列化异常(Jackson 无法将非数字字符串解析为 Long)。 if (!registerUserId || !/^\d+$/.test(String(registerUserId).trim())) errors.registerUserId = "注册用户标识缺失或非法"; return errors; }; const RegisterMore = (props) => { const { orgInfoSave, register, syncUserToGBS } = props; const [current, setCurrent] = useState(0); const [formData, setFormData] = useState(INITIAL_FORM); const [errors, setErrors] = useState({}); const [uploading, setUploading] = useState(false); const { registerLoading, syncUserToGBSLoading } = register; // ===== 2026-08-08 注册用户标识统一解析(变更原因) ===== // 变更逻辑:registerUserId 仅从"路由参数"或"会话缓存"获取(resolveRegisterQueryParams), // 禁止直接取 router.query 散落值,避免手改 URL 瞎写非法 id 落库。 // 变更理由:此前出现过 create_id 与真实账户脱节(前端传了错误的 registerUserId), // 现统一来源 + 保存前校验(前端拦截 + 后端 account 存在性校验)双保险。 const resolvedParams = resolveRegisterQueryParams(); const resolvedAccount = resolvedParams.account; const resolvedRegisterUserId = resolvedParams.registerUserId; const hasRegisterContext = Boolean(resolvedAccount && resolvedRegisterUserId); const setField = (field, value) => { setFormData((prev) => ({ ...prev, [field]: value })); setErrors((prev) => ({ ...prev, [field]: undefined })); }; const loadDetail = async () => { const res = await props.orgInfoGet({ data: resolvedAccount, }); if (res?.data) { // 2026-08-08 回显:safetyIndustryCategoryCode 拆为数组,attachmentUrls JSON.parse // 同时把后端主键 id 写回 formData,供"缓存/提交"更新时精确匹配记录(规避二次映射)。 setFormData({ ...INITIAL_FORM, ...res.data, // 2026-08-08 主键 id 以字符串保留:id 为后端 Long(19位),超过 JS Number.MAX_SAFE_INTEGER, // 用 Number() 转换会丢失末位精度(如 ...87938 → ...88000),故保持字符串,由后端 Long 反序列化解析。 id: res.data.id != null ? String(res.data.id) : undefined, // 写回主键,使保存走更新分支更精确(后端仍按 registerUserId 兜底) // 2026-08-08 回显时清洗历史中文 label 为 code,保证 formData 中只存枚举值 safetyIndustryCategoryCode: normalizeBusinessScope( res.data.safetyIndustryCategoryCode ? String(res.data.safetyIndustryCategoryCode).split(",").filter(Boolean) : [], ), attachmentUrls: res.data.attachmentUrls ? (() => { try { const p = JSON.parse(res.data.attachmentUrls); return Array.isArray(p) ? p : []; } catch { return []; } })() : [], fixedAssetsTotal: res.data.fixedAssetsTotal ?? "", workplaceArea: res.data.workplaceArea ?? "", archiveRoomArea: res.data.archiveRoomArea ?? "", fulltimeEvaluatorCount: res.data.fulltimeEvaluatorCount ?? "", registeredEngineerCount: res.data.registeredEngineerCount ?? "", // 2026-08-08 后端拆分字段 contactName/contactPhone 合并回单输入框("/" 分隔)回显 contactNamePhone: [res.data.contactName, res.data.contactPhone] .filter(Boolean) .join("/"), }); } }; useEffect(() => { loadDetail(); }, []); // ===== 营业执照上传(原生 input,对接后端 /safetyEval/images/upload)===== const handleFileUpload = async (e) => { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; const accept = [".pdf", ".jpg", ".jpeg", ".png"]; const ext = "." + file.name.split(".").pop().toLowerCase(); if (!accept.includes(ext)) { message.error("仅支持 pdf/jpg/jpeg/png 格式文件"); return; } if (file.size / 1024 / 1024 > 10) { message.error("文件大小不能超过 10MB"); return; } setUploading(true); try { const fd = new FormData(); fd.append("file", file); const host = window.process?.env?.app?.API_HOST || ""; const resp = await fetch(`${host}/safetyEval/images/upload`, { method: "POST", body: fd, }); const json = await resp.json(); const url = json?.data?.url; if (!url) throw new Error("上传失败"); setFormData((prev) => ({ ...prev, attachmentUrls: [ ...prev.attachmentUrls, { url, name: file.name, uid: `${Date.now()}`, status: "done" }, ], })); setErrors((prev) => ({ ...prev, attachmentUrls: undefined })); } catch { message.error("上传失败,请重试"); } finally { setUploading(false); } }; const removeAttachment = (uid) => { setFormData((prev) => ({ ...prev, attachmentUrls: prev.attachmentUrls.filter((f) => f.uid !== uid), })); }; const toggleScope = (val) => { setFormData((prev) => { const set = new Set(prev.safetyIndustryCategoryCode); if (set.has(val)) set.delete(val); else set.add(val); return { ...prev, safetyIndustryCategoryCode: Array.from(set) }; }); setErrors((prev) => ({ ...prev, safetyIndustryCategoryCode: undefined })); }; // 2026-08-08 暂存(draft)与提交(submit)区分流转: // - 两者都会先校验(必填+常见格式:网址/电话等)并调用 orgInfoSave 落库; // - draft 仅保存草稿,停留当前页可继续编辑,不调用 syncUserToGBS、不进入下一步; // - submit 才调用 syncUserToGBS 同步并 setCurrent(1) 进入认证审核步骤。 const handleSave = async (type) => { // ===== 2026-08-08 提交前拦截:registerUserId 必须存在且为数字(变更原因) ===== // 变更理由:registerUserId 缺失/非法时直接拦截,禁止把错误 id 提交后端落库, // 与后端 account 存在性校验形成前后端双保险。 if (!resolvedRegisterUserId || !/^\d+$/.test(String(resolvedRegisterUserId).trim())) { message.error("注册用户标识缺失或非法,请从注册页重新进入"); return; } if (!hasRegisterContext) { message.error("缺少注册信息,请从注册页重新进入"); return; } const errs = validate(formData, resolvedRegisterUserId); setErrors(errs); if (Object.keys(errs).length > 0) { message.error("请完善表单必填项或检查格式(网址/电话等)"); return; } // 2026-08-08 数字字段转 number,业务范围 join(","),附件 JSON.stringify // 联系人与电话:单输入框 contactNamePhone 按 "/" 拆分为 contactName + contactPhone 两个后端字段。 const { name: contactName, phone: contactPhone } = splitContact( formData.contactNamePhone, ); const { contactNamePhone, ...formRest } = formData; const values = { ...formRest, // 2026-08-08 后端 registerUserId 为 Long 类型(19位,超过 Number.MAX_SAFE_INTEGER), // 用 Number() 转换会丢失末位精度(如 2085936982110887938 → 2085936982110888000)导致落库 id 与真实账户脱节。 // 故统一以字符串传递,由后端 Long 反序列化正确解析(与 syncUserToGBS 传参保持一致)。 registerUserId: String(resolvedRegisterUserId), contactName, contactPhone, // 2026-08-08 提交前清洗历史中文 label 为 code,以数组形式提交 safetyIndustryCategoryCode: normalizeBusinessScope(formData.safetyIndustryCategoryCode), attachmentUrls: formData.attachmentUrls.length ? JSON.stringify(formData.attachmentUrls) : null, infoDisclosureUrl: formData.infoDisclosureUrl ? normalizeUrl(formData.infoDisclosureUrl) : undefined, fixedAssetsTotal: Number(formData.fixedAssetsTotal), workplaceArea: Number(formData.workplaceArea), archiveRoomArea: Number(formData.archiveRoomArea), fulltimeEvaluatorCount: Number(formData.fulltimeEvaluatorCount), registeredEngineerCount: Number(formData.registeredEngineerCount), authStatusCode: type === "draft" ? 0 : 1, authStatusName: type === "draft" ? "草稿" : "已提交", }; const res1 = await orgInfoSave(values); if (!res1.success) { message.error(res1.message || "保存失败"); return; } if (type === "submit") { // 提交:同步用户到 GBS 并进入下一步骤 const res2 = await syncUserToGBS({ account: resolvedAccount, registerUserId: resolvedRegisterUserId, }); if (res2.success) { message.success("提交成功,进入认证审核"); setCurrent(1); } } else { // 暂存:仅保存草稿,停留在当前页,保留表单可继续编辑,不跳转、不同步 GBS message.success("已暂存草稿,可继续编辑"); } }; const handleBack = () => { if (window.history.length > 1) window.history.back(); }; const handleLogin = () => { message.success("即将跳转到登录页面,请稍候..."); setTimeout(() => { window.location.href = "https://gbs-gateway.qhdsafety.com/login/client/ZQAQPJ"; }, 2000); }; useEffect(() => { if (current === 1) { window.setTimeout(() => { setCurrent(2); }, 3000); } if (current === 2) { handleLogin(); } }, [current]); const stepTitles = ["填写信息", "认证审核中", "认证通过"]; return (
{/* hero 头图区 */}

认证信息

{/* 步骤条 */}
{stepTitles.map((t, i) => (
{i + 1} {t}
))}
{current === 0 && (

申请单位基本情况

{!hasRegisterContext && ( )}
{/* 单位名称 */} setField("unitName", e.target.value)} /> {/* 统一社会信用代码 */} setField("creditCode", e.target.value)} /> {/* 营业执照(两列,与单位名称/信用代码同列;控件内联显示红星与提示) */}
请上传营业执照扫描件或清晰照片,支持 PDF、JPG、PNG
{formData.attachmentUrls.map((f) => ( {f.name || "营业执照"} ))}
{/* 注册地址(全宽单独成行) */}