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 @@
+
+