diff --git a/.DS_Store b/.DS_Store index 0fd10a9..a5a21e2 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/api/bmt.js b/api/bmt.js new file mode 100644 index 0000000..eab0d66 --- /dev/null +++ b/api/bmt.js @@ -0,0 +1,478 @@ +/** + * BMT 签约相关接口 + * 状态查询 / 前置校验 / 发起签约 / 图片上传 / OCR 识别 / 补充信息提交 + * + * 与 api/assets.js 保持同一套请求约定: + * { status: 200, data: {...} } => 返回 data,否则抛出 { message } + */ +import serviceConfig from "../config/service"; +import request from "../utils/request"; +import { getCurrentWebviewToken } from "../utils/webview-token"; + +function createError(message, raw) { + return { + message: message || "接口请求失败", + raw: raw, + }; +} + +function isSuccessPayload(payload) { + if (!payload || typeof payload !== "object") { + return false; + } + + const statusCode = + payload.code !== undefined ? Number(payload.code) : Number(payload.status); + + return statusCode === 200; +} + +function unwrapPayload(payload, fallbackMessage) { + if (isSuccessPayload(payload)) { + return payload.data; + } + + if (payload && typeof payload === "object") { + throw createError( + payload.msg || payload.message || fallbackMessage || "接口请求失败", + payload, + ); + } + + throw createError(fallbackMessage || "接口返回异常", payload); +} + +async function fetchPayload(options, fallbackMessage) { + try { + const payload = await request(options); + return unwrapPayload(payload, fallbackMessage); + } catch (error) { + if (error && error.message) { + throw error; + } + + throw createError(fallbackMessage || "接口请求失败", error); + } +} + +function createRequestOptions(baseOptions, requestOptions) { + return Object.assign({}, baseOptions, requestOptions || {}); +} + +function pickFirstValue(source, keys) { + if (!source || typeof source !== "object") { + return ""; + } + + for (let i = 0; i < keys.length; i += 1) { + const value = source[keys[i]]; + + if (value !== undefined && value !== null && value !== "") { + return value; + } + } + + return ""; +} + +/** 接口返回体可能存在 info / data / result 一层或多层嵌套,统一摊平 */ +function normalizeBmtSource(payload) { + if (!payload || typeof payload !== "object") { + return {}; + } + + let merged = {}; + + ["info", "data", "result"].forEach(function (key) { + const nested = payload[key]; + + if (nested && typeof nested === "object" && !Array.isArray(nested)) { + merged = Object.assign(merged, nested); + } + }); + + return Object.assign(merged, payload); +} + +function buildAuthHeader() { + const token = getCurrentWebviewToken(); + + if (!token) { + return {}; + } + + const bearerToken = "Bearer " + token; + return { + "Authori-zation": bearerToken, + Authorization: bearerToken, + }; +} + +const UPLOAD_MAX_SIZE = 2 * 1024 * 1024; // 2MB 以内不压缩 +const COMPRESS_MAX_SIDE = 800; // 压缩后最长边 +const COMPRESS_QUALITY = 0.8; // 起始压缩质量 +const COMPRESS_MIN_QUALITY = 0.1; // 最低压缩质量 +const COMPRESS_MAX_KB = 500; // 压缩目标大小 +const UPLOAD_FIELD_NAME = "pics"; // 后端接收文件的字段名(与参考实现一致) + +// #ifdef H5 +/** H5:用原生 canvas 压缩,按质量递减直到小于 COMPRESS_MAX_KB */ +function compressImageForH5(filePath) { + return new Promise(function (resolve, reject) { + if (typeof document === "undefined") { + reject(new Error("当前环境不支持图片压缩")); + return; + } + + const img = new Image(); + img.src = filePath; + + img.onload = function () { + let width = img.width; + let height = img.height; + + if (width > height) { + if (width > COMPRESS_MAX_SIDE) { + height = height * (COMPRESS_MAX_SIDE / width); + width = COMPRESS_MAX_SIDE; + } + } else if (height > COMPRESS_MAX_SIDE) { + width = width * (COMPRESS_MAX_SIDE / height); + height = COMPRESS_MAX_SIDE; + } + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + canvas.width = width; + canvas.height = height; + ctx.drawImage(img, 0, 0, width, height); + + const compress = function (quality) { + const dataUrl = canvas.toDataURL("image/jpeg", quality); + const sizeKb = (dataUrl.length * 3) / 4 / 1024; + + if (sizeKb > COMPRESS_MAX_KB && quality > COMPRESS_MIN_QUALITY) { + return compress(quality - 0.05); + } + + return dataUrl; + }; + + resolve(compress(COMPRESS_QUALITY)); + }; + + img.onerror = function () { + reject(new Error("图片读取失败")); + }; + }); +} +// #endif + +// #ifndef H5 +/** 小程序 / App:优先用 uni.compressImage 压缩 */ +function compressImageForNative(filePath) { + return new Promise(function (resolve) { + if (typeof uni.compressImage !== "function") { + resolve(filePath); + return; + } + + uni.compressImage({ + src: filePath, + quality: Math.round(COMPRESS_QUALITY * 100), + success: function (result) { + resolve(result && result.tempFilePath ? result.tempFilePath : filePath); + }, + fail: function () { + resolve(filePath); + }, + }); + }); +} +// #endif + +/** 上传前压缩:2MB 以内原样返回,超出则压缩;压缩失败退回原文件,不阻断上传 */ +async function prepareUploadFile(filePath, fileSize) { + const size = Number(fileSize || 0); + + if (size > 0 && size <= UPLOAD_MAX_SIZE) { + return filePath; + } + + try { + // #ifdef H5 + return await compressImageForH5(filePath); + // #endif + + // #ifndef H5 + return await compressImageForNative(filePath); + // #endif + } catch (error) { + console.warn("[BMT上传] 图片压缩失败,改用原图", error); + return filePath; + } +} + +/** + * 上传认证图片(身份证) + * 上传前先压缩(参考 uploadImageChange:2MB 以内直传,超出压到 800px / 500KB 以内); + * 响应解析只要 HTTP 2xx 且能取到图片地址就算成功,不依赖业务 status/code + * @param {string} filePath 本地临时路径 + * @param {{ size?: number, header?: object }} options + * @returns {Promise<{url: string}>} + */ +export async function uploadBmtImage(filePath, options) { + const path = String(filePath || "").trim(); + + if (!path) { + throw createError("图片上传失败"); + } + + const fileSize = Number((options && options.size) || 0); + const uploadPath = await prepareUploadFile(path, fileSize); + + console.log( + "[BMT上传] 原始大小", + fileSize, + "上传来源", + String(uploadPath).slice(0, 48), + ); + + const header = Object.assign( + { + "Form-type": "gzh", + // #ifdef MP + "Content-Type": "multipart/form-data", + // #endif + }, + buildAuthHeader(), + (options && options.header) || {}, + ); + + const url = serviceConfig.BASE_URL + serviceConfig.ENDPOINTS.bmtUploadImage; + + return new Promise(function (resolve, reject) { + uni.uploadFile({ + url: url, + filePath: uploadPath, + name: UPLOAD_FIELD_NAME, + formData: { + filename: UPLOAD_FIELD_NAME, + }, + header: header, + success: function (response) { + const statusCode = Number(response && response.statusCode); + + console.log("[BMT上传] 响应", statusCode, response && response.data); + + if (!(statusCode >= 200 && statusCode < 300)) { + reject( + createError("图片上传失败(HTTP " + statusCode + ")", response), + ); + return; + } + + let payload = response && response.data; + + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch (error) { + // 有些实现直接返回图片地址字符串 + const text = String(response.data || "").trim(); + + if (/^https?:\/\//i.test(text)) { + resolve({ url: text }); + return; + } + + reject(createError("图片上传失败", response)); + return; + } + } + + if ( + typeof payload === "string" && + /^https?:\/\//i.test(payload.trim()) + ) { + resolve({ url: payload.trim() }); + return; + } + + // url 可能落在 payload / payload.data / payload.data.data 任意一层 + const layers = []; + let cursor = payload; + + for (let i = 0; i < 3 && cursor && typeof cursor === "object"; i += 1) { + layers.push(cursor); + cursor = cursor.data; + } + + for (let i = 0; i < layers.length; i += 1) { + const found = pickFirstValue(layers[i], [ + "url", + "src", + "path", + "file_url", + "media_url", + "image_url", + ]); + + if (found) { + resolve({ url: String(found) }); + return; + } + } + + reject(createError("图片上传失败:响应中未找到图片地址", payload)); + }, + fail: function (error) { + console.error("[BMT上传] 失败", error); + reject(createError("图片上传失败", error)); + }, + }); + }); +} + +/** + * BMT 签约状态查询 + * 关键字段:sign_info_complete(0 待补充 / 1 已完善)、contract_status(0 待签约)、 + * contract_type(0 签约新协议 / 1 更新协议)、tips(签约提示文案) + */ +export async function fetchBmtContractStatus(requestOptions) { + const data = await fetchPayload( + createRequestOptions( + { + url: serviceConfig.ENDPOINTS.bmtContractStatus, + }, + requestOptions, + ), + "签约状态查询失败", + ); + + return normalizeBmtSource(data); +} + +/** BMT 签约前置校验 */ +export async function checkBmtSignCondition(payload, requestOptions) { + const data = await fetchPayload( + createRequestOptions( + { + url: serviceConfig.ENDPOINTS.bmtSignCheck, + method: "GET", + data: payload || {}, + }, + requestOptions, + ), + "签约前置校验失败", + ); + + return normalizeBmtSource(data); +} + +/** + * BMT 发起签约 + * 注意:该接口用业务码 status === 0 表示成功,与项目通用的 status/code === 200 约定不同, + * 所以这里不走 fetchPayload 的通用解包,单独解析,返回 { status, message, saasShortLink }。 + */ +export async function startBmtSigning(payload, requestOptions) { + let raw; + + try { + raw = await request( + createRequestOptions( + { + url: serviceConfig.ENDPOINTS.bmtSignStart, + method: "POST", + data: payload || {}, + }, + requestOptions, + ), + ); + } catch (error) { + throw createError((error && error.message) || "发起签约失败", error); + } + + const source = raw && typeof raw === "object" ? raw : {}; + + // 字段可能落在 raw / raw.data / raw.data.data 任意一层,逐层收集 + const layers = []; + let cursor = source; + + for (let i = 0; i < 3 && cursor && typeof cursor === "object"; i += 1) { + layers.push(cursor); + cursor = cursor.data; + } + + const pickText = (keys) => { + for (let i = 0; i < layers.length; i += 1) { + for (let j = 0; j < keys.length; j += 1) { + const value = layers[i][keys[j]]; + + if (value !== undefined && value !== null && value !== "") { + return String(value); + } + } + } + + return ""; + }; + + // status:优先 data 层的业务状态,其次顶层 status,最后顶层 code + let statusValue; + + for (let i = layers.length - 1; i >= 1; i -= 1) { + if (layers[i].status !== undefined && layers[i].status !== null) { + statusValue = layers[i].status; + break; + } + } + + if (statusValue === undefined && source.status !== undefined) { + statusValue = source.status; + } + + if (statusValue === undefined) { + statusValue = source.code; + } + + return { + status: Number(statusValue), + message: pickText(["msg", "message"]), + saasShortLink: pickText(["saasShortLink", "saas_short_link"]), + raw: source, + }; +} + +/** 图片 OCR 识别:type = idcard_ocr(身份证) */ +export async function fetchBmtOcrResult(payload, requestOptions) { + return fetchPayload( + createRequestOptions( + { + url: serviceConfig.ENDPOINTS.bmtOcrImage, + method: "POST", + data: payload || {}, + }, + requestOptions, + ), + "图片识别失败", + ); +} + +/** 提交补充签约信息:/api/jz_contract/update_user_sign_info */ +export async function submitBmtSignInfo(payload, requestOptions) { + const data = await fetchPayload( + createRequestOptions( + { + url: serviceConfig.ENDPOINTS.bmtUpdateUserSignInfo, + method: "POST", + data: payload || {}, + }, + requestOptions, + ), + "提交失败", + ); + + return normalizeBmtSource(data); +} diff --git a/config/service.js b/config/service.js index a1be9c8..95943d5 100644 --- a/config/service.js +++ b/config/service.js @@ -48,6 +48,12 @@ const serviceConfig = { pointsConvertRecordDetail: "/api/integral/transferInfo", bmtrMonthPoints: "/api/mxj/api/get_month_points", bmtrChargeList: "/api/mxj/api/get_charge_list", + bmtContractStatus: "/api/user/bmt_contract_status", + bmtSignCheck: "/api/jz_contract/go_to_sign_check_bmt", + bmtSignStart: "/api/jz_contract/start_signing_bmt_v2", + bmtUpdateUserSignInfo: "/api/jz_contract/update_user_sign_info", + bmtOcrImage: "/api/merchant/ocr_img", + bmtUploadImage: "/api/upload/image", }, }; diff --git a/pages.json b/pages.json index 4c231b0..331c0a5 100644 --- a/pages.json +++ b/pages.json @@ -106,6 +106,27 @@ "navigationStyle": "custom", "backgroundColor": "#191E32" } + }, + { + "path": "pages/assets/bmt-sign", + "style": { + "navigationStyle": "custom", + "backgroundColor": "#191E32" + } + }, + { + "path": "pages/assets/bmt-sign-info", + "style": { + "navigationStyle": "custom", + "backgroundColor": "#191E32" + } + }, + { + "path": "pages/assets/bmt-sign-agreement", + "style": { + "navigationStyle": "custom", + "backgroundColor": "#191E32" + } } ], "globalStyle": { diff --git a/pages/assets/bmt-sign-agreement.vue b/pages/assets/bmt-sign-agreement.vue new file mode 100644 index 0000000..02a8693 --- /dev/null +++ b/pages/assets/bmt-sign-agreement.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/pages/assets/bmt-sign-info.vue b/pages/assets/bmt-sign-info.vue new file mode 100644 index 0000000..aad2f50 --- /dev/null +++ b/pages/assets/bmt-sign-info.vue @@ -0,0 +1,945 @@ +