fix: 签约相关

This commit is contained in:
whitechiina 2026-09-22 15:00:28 +08:00
parent dfba786129
commit d4045fcd8b
32 changed files with 2560 additions and 36 deletions

BIN
.DS_Store vendored

Binary file not shown.

478
api/bmt.js Normal file
View File

@ -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);
}

View File

@ -48,6 +48,12 @@ const serviceConfig = {
pointsConvertRecordDetail: "/api/integral/transferInfo", pointsConvertRecordDetail: "/api/integral/transferInfo",
bmtrMonthPoints: "/api/mxj/api/get_month_points", bmtrMonthPoints: "/api/mxj/api/get_month_points",
bmtrChargeList: "/api/mxj/api/get_charge_list", 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",
}, },
}; };

View File

@ -106,6 +106,27 @@
"navigationStyle": "custom", "navigationStyle": "custom",
"backgroundColor": "#191E32" "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": { "globalStyle": {

View File

@ -0,0 +1,425 @@
<template>
<view class="asset-page asset-theme bmt-agree-page">
<asset-page-shell title="签约协议" />
<view v-if="signStatus">
<view class="bmt-agree-content glass-panel">
<scroll-view class="bmt-agree-scroll" scroll-y :style="{ height: scrollViewHeight + 'px' }"
:scroll-with-animation="false" :show-scrollbar="false" @scroll="handleScroll">
<text class="bmt-agree-title">{{ signData.name }}</text>
<view class="bmt-agree-desc">
<text class="bmt-agree-text">{{ signData.content || "暂无协议内容" }}</text>
<view v-if="!isReachBottom" class="bmt-agree-tip">请阅读到底部后签约</view>
<view class="bmt-agree-padding"></view>
</view>
</scroll-view>
</view>
<view class="bmt-agree-bottom">
<text class="bmt-agree-bottom__hint">{{ isReachBottom ? "已阅读到底部" : "向下滑动阅读签约" }}</text>
<view class="bmt-agree-bottom__row">
<view class="bmt-agree-btn bmt-agree-btn--ghost" @click="cancel">取消</view>
<view class="bmt-agree-btn bmt-agree-btn--primary"
:class="{ 'is-disabled': !isReachBottom || signing }" @click="toSign">
{{ signing ? "签约中..." : "确认签约" }}
</view>
</view>
<view class="bmt-agree-copy">
<image class="bmt-agree-copy__icon" src="https://imgs.agrimedia.cn/copy-icon-sign.png" mode="aspectFit" />
<text class="bmt-agree-copy__text">易保全在线签约系统</text>
</view>
</view>
</view>
<view v-else class="bmt-agree-success">
<image class="bmt-agree-success__image" src="https://imgs.agrimedia.cn/bm-bmt/success.png" mode="aspectFit" />
<text class="bmt-agree-success__text">恭喜签约完成</text>
<view class="bmt-agree-success__btn" @click="compute">完成</view>
</view>
</view>
</template>
<script>
import AssetPageShell from "../../components/asset-page-shell.vue";
import { checkBmtSignCondition, startBmtSigning } from "../../api/bmt";
const READ_BOTTOM_THRESHOLD = 15;
export default {
components: {
AssetPageShell,
},
data() {
return {
signData: {
name: "",
content: "",
},
signStatus: true,
signing: false,
isReachBottom: false,
scrollViewHeight: 0,
scrollTimer: null,
contractId: "",
};
},
onLoad() {
this.loadAgreement();
},
onReady() {
this.$nextTick(() => {
setTimeout(() => {
this.calcScrollViewHeight();
}, 100);
});
},
onShow() {
this.$nextTick(() => {
setTimeout(() => {
this.calcScrollViewHeight();
}, 300);
});
},
onUnload() {
if (this.scrollTimer) {
clearTimeout(this.scrollTimer);
}
},
methods: {
async loadAgreement() {
try {
// 协议内容与签约 id 都取自 go_to_sign_check_bmt
const data =
(await checkBmtSignCondition({}, {
showLoading: true,
loadingText: "加载中",
})) || {};
this.contractId = String(data.id || "");
this.signData = {
name: String(data.name || "签约协议"),
content: String(data.content || ""),
};
this.$nextTick(() => {
setTimeout(() => {
this.calcScrollViewHeight();
setTimeout(() => {
this.calcScrollViewHeight();
}, 200);
}, 100);
});
} catch (error) {
uni.showToast({
title: (error && error.message) || "获取签约信息失败,请重试",
icon: "none",
});
}
},
calcScrollViewHeight() {
const systemInfo = uni.getSystemInfoSync();
const windowHeight = Number(systemInfo.windowHeight || 0);
const query = uni.createSelectorQuery().in(this);
query.select(".bmt-agree-content").boundingClientRect();
query.select(".bmt-agree-desc").boundingClientRect();
query.select(".bmt-agree-bottom").boundingClientRect();
query.exec((res) => {
const contentRect = res && res[0];
const descRect = res && res[1];
const bottomRect = res && res[2];
const top = contentRect && contentRect.top ? Number(contentRect.top) : 0;
const bottomHeight =
bottomRect && bottomRect.height ? Number(bottomRect.height) : 150;
const available = windowHeight - top - bottomHeight - 16;
this.scrollViewHeight = Math.max(available, 200);
// 内容不足以滚动时,直接视为已读到底部,避免短协议无法签约
const descHeight = descRect && descRect.height ? Number(descRect.height) : 0;
if (descHeight && descHeight <= this.scrollViewHeight + READ_BOTTOM_THRESHOLD) {
this.isReachBottom = true;
}
});
},
handleScroll(event) {
if (this.scrollTimer) {
clearTimeout(this.scrollTimer);
}
this.scrollTimer = setTimeout(() => {
this.checkScrollBottom(event);
}, 50);
},
checkScrollBottom(event) {
if (this.isReachBottom) {
return;
}
const detail = (event && event.detail) || {};
const scrollTop = Number(detail.scrollTop || 0);
const scrollHeight = Number(detail.scrollHeight || 0);
const clientHeight = this.scrollViewHeight;
const scrollPercent = scrollHeight
? (scrollTop + clientHeight) / scrollHeight
: 1;
if (
scrollTop + clientHeight >= scrollHeight - READ_BOTTOM_THRESHOLD ||
scrollPercent >= 0.95
) {
this.isReachBottom = true;
this.$nextTick(() => {
setTimeout(() => {
this.calcScrollViewHeight();
}, 50);
});
uni.showToast({
title: "已阅读到底部",
icon: "none",
duration: 1000,
});
}
},
cancel() {
uni.navigateBack({
delta: 1,
});
},
async toSign() {
if (!this.isReachBottom || this.signing) {
return;
}
this.signing = true;
try {
// 签约 id 取自 go_to_sign_check_bmt
const result =
(await startBmtSigning(
{
id: this.contractId,
},
{
showLoading: true,
loadingText: "签约中",
},
)) || {};
console.log("[BMT签约] 协议页 start_signing_bmt_v2 返回:", result.raw);
if (Number(result.status) === 0 && result.saasShortLink) {
this.navigateToExternalUrl(result.saasShortLink);
return;
}
this.signStatus = false;
} catch (error) {
this.signing = false;
uni.showToast({
title: (error && error.message) || "签约失败,请重试",
icon: "none",
});
}
},
navigateToExternalUrl(targetUrl) {
if (!targetUrl) {
return;
}
if (typeof window !== "undefined" && window.location) {
window.location.href = targetUrl;
return;
}
if (
typeof plus !== "undefined" &&
plus.webview &&
plus.webview.currentWebview
) {
const currentWebview = plus.webview.currentWebview();
if (currentWebview && typeof currentWebview.loadURL === "function") {
currentWebview.loadURL(targetUrl);
return;
}
}
uni.navigateTo({
url: targetUrl,
});
},
compute() {
uni.navigateBack({
delta: 1,
});
},
},
};
</script>
<style lang="scss" scoped>
@import "../../styles/tokens.scss";
@import "../../styles/common.scss";
.bmt-agree-page {
min-height: 100vh;
}
.bmt-agree-content {
margin: 12rpx 24rpx;
padding: 28rpx 26rpx;
}
.bmt-agree-scroll {
box-sizing: border-box;
}
.bmt-agree-title {
display: block;
margin-bottom: 30rpx;
font-size: 34rpx;
font-weight: 700;
text-align: center;
color: $asset-text-main;
}
.bmt-agree-desc {
box-sizing: border-box;
}
.bmt-agree-text {
display: block;
font-size: 26rpx;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
color: $asset-text-muted;
}
.bmt-agree-tip {
margin-top: 24rpx;
padding-top: 30rpx;
border-top: 1px solid rgba(255, 255, 255, 0.08);
text-align: center;
font-size: 24rpx;
color: $asset-text-subtle;
}
.bmt-agree-padding {
height: 1rpx;
opacity: 0;
}
.bmt-agree-bottom {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 22rpx 32rpx calc(22rpx + env(safe-area-inset-bottom));
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: #191e32;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.18);
z-index: 30;
}
.bmt-agree-bottom__hint {
display: block;
margin-bottom: 20rpx;
text-align: center;
font-size: 24rpx;
color: $asset-text-subtle;
}
.bmt-agree-bottom__row {
display: flex;
align-items: center;
}
.bmt-agree-btn {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
height: 80rpx;
border-radius: 40rpx;
font-size: 30rpx;
font-weight: 600;
}
.bmt-agree-btn+.bmt-agree-btn {
margin-left: 20rpx;
}
.bmt-agree-btn--ghost {
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.06);
color: $asset-text-main;
}
.bmt-agree-btn--primary {
background: linear-gradient(135deg, #7e6cff 0%, #6b56f6 100%);
box-shadow: 0 14rpx 24rpx rgba(93, 79, 225, 0.22);
color: #ffffff;
}
.bmt-agree-btn--primary.is-disabled {
opacity: 0.5;
}
.bmt-agree-copy {
display: flex;
align-items: center;
justify-content: center;
margin-top: 16rpx;
}
.bmt-agree-copy__icon {
width: 112rpx;
height: 32rpx;
margin-right: 10rpx;
}
.bmt-agree-copy__text {
font-size: 24rpx;
color: $asset-text-subtle;
}
.bmt-agree-success {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 220rpx;
}
.bmt-agree-success__image {
width: 180rpx;
height: 180rpx;
}
.bmt-agree-success__text {
margin-top: 48rpx;
font-size: 36rpx;
font-weight: 500;
color: $asset-text-main;
}
.bmt-agree-success__btn {
display: flex;
align-items: center;
justify-content: center;
width: 328rpx;
height: 80rpx;
margin-top: 60rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #7e6cff 0%, #6b56f6 100%);
box-shadow: 0 14rpx 24rpx rgba(93, 79, 225, 0.22);
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
</style>

View File

@ -0,0 +1,945 @@
<template>
<view class="asset-page asset-theme bmt-info-page">
<asset-page-shell title="补充签约信息" />
<view class="asset-scroll bmt-info-scroll">
<view class="glass-panel bmt-info-notice">
<text class="bmt-info-notice__title">温馨提示</text>
<text class="bmt-info-notice__text">如核对后仍然审核失败,可联系客服处理。</text>
<text v-if="noticeText" class="bmt-info-notice__error">审核失败:{{ noticeText }}</text>
</view>
<view class="glass-panel bmt-info-section">
<text class="bmt-info-section__title">身份证认证</text>
<text class="bmt-info-section__desc">请上传本人身份证正反面照片,并核对识别后的身份信息。</text>
<view class="bmt-upload-row">
<view v-for="item in uploadList" :key="item.key" class="bmt-upload-col">
<view class="bmt-upload-box" @click="handleUpload(item.key)">
<image class="bmt-upload-image" :src="item.image || item.placeholder" mode="aspectFit" />
<view v-if="item.uploading" class="bmt-upload-mask">{{ item.loadingText || "上传中..." }}</view>
</view>
<text class="bmt-upload-label">{{ item.label }}</text>
</view>
</view>
</view>
<view class="glass-panel bmt-info-section">
<text class="bmt-info-section__title">身份信息</text>
<view class="bmt-form">
<view class="bmt-form-item">
<text class="bmt-form-label">姓名</text>
<input class="bmt-form-input" type="text" v-model="form.sign_name" placeholder="识别后自动回填"
placeholder-class="bmt-form-placeholder" />
</view>
<view class="bmt-form-item">
<text class="bmt-form-label">身份证号</text>
<input class="bmt-form-input" type="text" v-model="form.id_card" maxlength="18" placeholder="识别后自动回填"
placeholder-class="bmt-form-placeholder" />
</view>
<view class="bmt-form-item">
<text class="bmt-form-label">手机号</text>
<input class="bmt-form-input" type="number" v-model="form.sign_mobile" maxlength="11" placeholder="请输入手机号"
placeholder-class="bmt-form-placeholder" />
</view>
<view class="bmt-form-item bmt-form-item--textarea">
<text class="bmt-form-label">身份证地址</text>
<textarea class="bmt-form-textarea" v-model="form.sign_address" auto-height maxlength="120"
placeholder="识别后自动回填" placeholder-class="bmt-form-placeholder" />
</view>
<view class="bmt-shoot">
<text class="bmt-shoot__title">拍摄身份证要求:</text>
<text class="bmt-shoot__item">1、持有的本人有效二代身份证;</text>
<text class="bmt-shoot__item">2、拍摄时确保身份证边框完整、字体清晰、亮度均匀。</text>
</view>
</view>
</view>
</view>
<view class="bmt-info-bottom">
<view class="bmt-info-bottom__row">
<view class="bmt-info-button bmt-info-button--ghost" @click="handleCancel">取消</view>
<view class="bmt-info-button bmt-info-button--primary" :class="{ 'is-disabled': isBusy }"
@click="handleSubmit">提交</view>
</view>
</view>
</view>
</template>
<script>
import AssetPageShell from "../../components/asset-page-shell.vue";
import {
uploadBmtImage,
fetchBmtOcrResult,
submitBmtSignInfo,
fetchBmtContractStatus,
checkBmtSignCondition,
startBmtSigning,
} from "../../api/bmt";
const ID_CARD_REG = /^(\d{15}|\d{17}[\dXx])$/;
const OCR_TYPE_ID_CARD = "idcard_ocr";
export default {
components: {
AssetPageShell,
},
data() {
return {
info: {},
form: {
back_img: "",
id_card: "",
positive_img: "",
sign_address: "",
sign_mobile: "",
sign_name: "",
},
uploadList: [
{
key: "positive_img",
label: "人像面",
placeholder: "https://imgs.agrimedia.cn/shop/sfz-2.png",
image: "",
uploading: false,
loadingText: "",
ocrValid: false,
},
{
key: "back_img",
label: "国徽面",
placeholder: "https://imgs.agrimedia.cn/shop/sfz-1.png",
image: "",
uploading: false,
loadingText: "",
ocrValid: false,
},
],
isSubmitting: false,
isIdCardExpired: false,
};
},
computed: {
noticeText() {
return String(this.info.jzq_audit_msg || "");
},
isBusy() {
return (
this.isSubmitting ||
this.uploadList.some((item) => item.uploading)
);
},
},
onLoad(options) {
if (options && options.data) {
try {
this.info = JSON.parse(decodeURIComponent(options.data)) || {};
} catch (error) {
this.info = {};
}
}
this.initForm();
},
methods: {
initForm() {
const data = this.info || {};
this.form = {
back_img: data.back_img || data.reverse_img || "",
id_card: data.id_card || data.card_id || data.number || "",
positive_img: data.positive_img || data.front_img || "",
sign_address:
data.sign_address ||
data.card_address ||
data.id_card_address ||
data.address ||
"",
sign_mobile:
data.sign_mobile || data.mobile || data.realname_mobile || "",
sign_name:
data.sign_name ||
data.legal_representative_name ||
data.legal_person ||
data.name ||
"",
};
this.syncUploadList();
},
syncUploadList() {
this.uploadList.forEach((item) => {
item.image = this.form[item.key] || "";
item.loadingText = "";
item.ocrValid = !!item.image;
});
},
getUploadItem(key) {
return this.uploadList.find((item) => item.key === key);
},
getRequiredUploads() {
return ["positive_img", "back_img"];
},
showTip(title) {
uni.showToast({
title: title,
icon: "none",
});
},
formatError(error, fallback) {
if (!error) {
return fallback;
}
if (typeof error === "string") {
return error;
}
if (error.message) {
return error.message;
}
if (error.msg) {
return error.msg;
}
if (error.errMsg) {
return error.errMsg;
}
if (error.data && error.data.msg) {
return error.data.msg;
}
return fallback;
},
handleUpload(key) {
if (this.isBusy) {
return;
}
const target = this.getUploadItem(key);
if (!target || target.uploading) {
return;
}
uni.chooseImage({
count: 1,
sizeType: ["original", "compressed"],
sourceType: ["album", "camera"],
success: (result) => {
const filePath =
result && result.tempFilePaths && result.tempFilePaths[0];
const fileSize =
result && result.tempFiles && result.tempFiles[0]
? Number(result.tempFiles[0].size || 0)
: 0;
if (!filePath) {
return;
}
this.uploadAndRecognize(key, filePath, fileSize);
},
});
},
async uploadAndRecognize(key, filePath, fileSize) {
const target = this.getUploadItem(key);
if (!target) {
return;
}
const previous = {
image: target.image || "",
value: this.form[key] || "",
ocrValid: target.ocrValid,
};
target.uploading = true;
target.loadingText = "上传中...";
try {
// 超出 2MB 时会先压缩再上传
const uploadResult = await uploadBmtImage(filePath, {
size: fileSize,
});
const url = uploadResult && uploadResult.url ? uploadResult.url : "";
if (!url) {
throw new Error("上传失败,请重试");
}
target.image = url;
target.ocrValid = false;
this.form[key] = url;
target.loadingText = "识别中...";
console.log("[BMT签约] 图片上传成功,开始 OCR:", url);
// OCR:/api/merchant/ocr_img,type 固定 idcard_ocr
const ocrResult = await fetchBmtOcrResult({
type: OCR_TYPE_ID_CARD,
url: url,
});
console.log("[BMT签约] OCR 返回:", ocrResult);
if (!this.handleOcrResult(key, ocrResult)) {
this.restoreUpload(key, previous);
}
} catch (error) {
console.error("[BMT签约] 上传/识别失败", error);
this.restoreUpload(key, previous);
this.showTip(this.formatError(error, "识别失败,请重新上传清晰照片"));
} finally {
target.uploading = false;
target.loadingText = "";
}
},
restoreUpload(key, previous) {
const target = this.getUploadItem(key);
if (!target) {
return;
}
target.image = previous.image || "";
target.ocrValid = !!previous.ocrValid;
this.form[key] = previous.value || "";
},
getOcrData(result) {
if (!result || typeof result !== "object") {
return {};
}
if (result.data && result.data.data && result.data.data.info) {
return result.data.data.info;
}
if (result.data && result.data.info) {
return result.data.info;
}
if (result.data && result.data.data) {
return result.data.data;
}
if (result.data) {
return result.data;
}
return result;
},
handleOcrResult(key, result) {
if (key === "positive_img") {
return this.handleIdCardFrontOcr(result);
}
if (key === "back_img") {
return this.handleIdCardBackOcr(result);
}
return true;
},
handleIdCardFrontOcr(result) {
const info = this.getOcrData(result);
const name = String(info.name || info.sign_name || "").trim();
const number = String(
info.number || info.id_card || info.card_id || "",
).trim();
const address = String(
info.address || info.sign_address || info.card_address || "",
).trim();
const target = this.getUploadItem("positive_img");
if (!name && !number) {
if (target) {
target.ocrValid = false;
}
this.showTip("身份证人像面识别失败,请重新上传清晰照片");
return false;
}
if (!name) {
if (target) {
target.ocrValid = false;
}
this.showTip("未识别到姓名,请重新上传清晰照片");
return false;
}
if (!number) {
if (target) {
target.ocrValid = false;
}
this.showTip("未识别到身份证号,请重新上传清晰照片");
return false;
}
if (!ID_CARD_REG.test(number)) {
if (target) {
target.ocrValid = false;
}
this.showTip("身份证号识别异常,请重新上传清晰照片");
return false;
}
this.form.sign_name = name;
this.form.id_card = number;
if (address) {
this.form.sign_address = address;
}
if (target) {
target.ocrValid = true;
}
this.showTip("身份证识别成功,请核对信息");
return true;
},
handleIdCardBackOcr(result) {
const info = this.getOcrData(result);
const timelimit = String(
info.timelimit || info.valid_date || info.validity || "",
).trim();
const validity = this.checkIdCardTimelimit(timelimit);
const target = this.getUploadItem("back_img");
this.isIdCardExpired = false;
if (!validity.valid) {
if (target) {
target.ocrValid = false;
}
this.showTip("身份证国徽面识别失败,请重新上传清晰照片");
return false;
}
if (validity.expired) {
this.isIdCardExpired = true;
if (target) {
target.ocrValid = false;
}
this.showTip("身份证已过期,请上传最新身份证");
return false;
}
if (target) {
target.ocrValid = true;
}
this.showTip("身份证国徽面识别成功");
return true;
},
checkIdCardTimelimit(timelimit) {
const text = String(timelimit || "").trim();
if (!text) {
return { valid: false, expired: true, text: "" };
}
if (text.indexOf("长期") > -1) {
return { valid: true, expired: false, text: text };
}
const digits = text.replace(/\D/g, "");
if (digits.length < 16) {
return { valid: false, expired: true, text: text };
}
const parseDate = (value) => {
if (!value || value.length < 8) {
return null;
}
const year = parseInt(value.slice(0, 4), 10);
const month = parseInt(value.slice(4, 6), 10) - 1;
const day = parseInt(value.slice(6, 8), 10);
const dateObj = new Date(year, month, day);
return isNaN(dateObj.getTime()) ? null : dateObj;
};
const startDate = parseDate(digits.slice(0, 8));
const endDate = parseDate(digits.slice(digits.length - 8));
if (!startDate || !endDate) {
return { valid: false, expired: true, text: text };
}
const currentDate = new Date();
return {
valid: true,
expired: currentDate > endDate || currentDate < startDate,
text: text,
};
},
validateUploads() {
const labels = {
positive_img: "身份证人像面",
back_img: "身份证国徽面",
};
const required = this.getRequiredUploads();
for (let i = 0; i < required.length; i += 1) {
const key = required[i];
const target = this.getUploadItem(key);
if (!this.form[key]) {
this.showTip(`请上传${labels[key]}`);
return false;
}
if (!target || !target.ocrValid) {
this.showTip(`请重新上传${labels[key]}`);
return false;
}
}
return true;
},
validateForm() {
if (!this.validateUploads()) {
return false;
}
const validations = [
{ field: "sign_name", message: "请输入姓名" },
{ field: "id_card", message: "请输入身份证号" },
{ field: "sign_mobile", message: "请输入手机号" },
{ field: "sign_address", message: "请输入身份证地址" },
];
for (let i = 0; i < validations.length; i += 1) {
const item = validations[i];
if (!String(this.form[item.field] || "").trim()) {
this.showTip(item.message);
return false;
}
}
if (!ID_CARD_REG.test(String(this.form.id_card || "").trim())) {
this.showTip("请输入正确的身份证号");
return false;
}
return true;
},
buildPayload() {
const address = String(this.form.sign_address || "").trim();
return {
sign_name: String(this.form.sign_name || "").trim(),
id_card: String(this.form.id_card || "").trim(),
sign_mobile: String(this.form.sign_mobile || "").trim(),
sign_address: address,
contact_address: address,
positive_img: this.form.positive_img || "",
back_img: this.form.back_img || "",
};
},
handleCancel() {
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.reLaunch({
url: "/pages/index/index",
});
},
navigateToExternalUrl(targetUrl) {
if (!targetUrl) {
return;
}
if (typeof window !== "undefined" && window.location) {
window.location.href = targetUrl;
return;
}
if (
typeof plus !== "undefined" &&
plus.webview &&
plus.webview.currentWebview
) {
const currentWebview = plus.webview.currentWebview();
if (currentWebview && typeof currentWebview.loadURL === "function") {
currentWebview.loadURL(targetUrl);
return;
}
}
uni.navigateTo({
url: targetUrl,
});
},
backAfterSubmit() {
setTimeout(() => {
this.handleCancel();
}, 800);
},
async handleSubmit() {
if (this.isBusy) {
return;
}
if (!this.validateForm()) {
return;
}
this.isSubmitting = true;
try {
await submitBmtSignInfo(this.buildPayload(), {
showLoading: true,
loadingText: "提交中",
});
uni.showToast({
title: "提交成功",
icon: "none",
});
await this.handleAfterSignInfoSubmit();
} catch (error) {
uni.showToast({
title: this.formatError(error, "提交失败"),
icon: "none",
});
} finally {
this.isSubmitting = false;
}
},
/**
* 提交成功后复查签约状态:仍未签约则自动发起签约,
* 拿到 saasShortLink 后跳转去认证;否则回到上一页。
*/
async handleAfterSignInfoSubmit() {
let saasShortLink = "";
try {
const status = (await fetchBmtContractStatus()) || {};
const contractStatus = Number(status.contract_status || 0);
if (contractStatus !== 0) {
this.backAfterSubmit();
return;
}
let contractType = Number(status.contract_type || 0);
let contractId = "";
let recordId = String(status.id || "");
try {
const checkData =
(await checkBmtSignCondition(
{},
{
showLoading: true,
loadingText: "校验中",
},
)) || {};
contractId = String(checkData.c_id || "");
recordId = String(checkData.id || recordId || "");
const checkType = checkData.contract_type;
if (
checkType !== undefined &&
checkType !== null &&
checkType !== ""
) {
contractType = Number(checkType) === 1 ? 1 : 0;
}
} catch (error) {
// 前置校验失败不阻断,继续尝试发起签约
}
const result =
(await startBmtSigning(
{
contract_type: contractType,
c_id: contractId,
id: recordId,
},
{
showLoading: true,
loadingText: "发起签约",
},
)) || {};
console.log("[BMT签约] start_signing_bmt_v2 返回:", result.raw);
if (Number(result.status) === 0 && result.saasShortLink) {
saasShortLink = String(result.saasShortLink);
} else {
uni.showToast({
title: result.message || "发起签约失败,请稍后重试",
icon: "none",
});
}
} catch (error) {
console.error("[BMT签约] 提交后自动签约失败", error);
}
if (saasShortLink) {
this.navigateToExternalUrl(saasShortLink);
return;
}
this.backAfterSubmit();
},
},
};
</script>
<style lang="scss" scoped>
@import "../../styles/tokens.scss";
@import "../../styles/common.scss";
.bmt-info-page {
min-height: 100vh;
}
.bmt-info-scroll {
padding: 12rpx 24rpx calc(env(safe-area-inset-bottom) + 180rpx);
}
.bmt-info-notice {
padding: 24rpx 26rpx;
}
.bmt-info-notice__title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: $asset-warning;
}
.bmt-info-notice__text {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.7;
color: $asset-text-muted;
}
.bmt-info-notice__error {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.7;
color: $asset-danger;
}
.bmt-info-section {
margin-top: 24rpx;
padding: 28rpx 26rpx;
}
.bmt-info-section__title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: $asset-text-main;
}
.bmt-info-section__desc {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: $asset-text-subtle;
}
.bmt-upload-row {
display: flex;
align-items: flex-start;
margin-top: 24rpx;
}
.bmt-upload-col {
flex: 1;
min-width: 0;
text-align: center;
}
.bmt-upload-col+.bmt-upload-col {
margin-left: 16rpx;
}
.bmt-upload-box {
position: relative;
width: 100%;
height: 176rpx;
border-radius: 14rpx;
overflow: hidden;
background: rgba(18, 25, 48, 0.72);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
.bmt-upload-image {
width: 100%;
height: 100%;
}
.bmt-upload-mask {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5, 10, 22, 0.62);
font-size: 24rpx;
color: #ffffff;
}
.bmt-upload-label {
display: block;
margin-top: 12rpx;
min-height: 44rpx;
font-size: 22rpx;
line-height: 32rpx;
color: $asset-text-muted;
}
.bmt-form {
margin-top: 16rpx;
}
.bmt-form-item {
display: flex;
align-items: center;
min-height: 96rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.bmt-form-item:last-child {
border-bottom: 0;
}
.bmt-form-item--textarea {
align-items: flex-start;
padding: 26rpx 0;
}
.bmt-form-label {
width: 200rpx;
flex-shrink: 0;
font-size: 28rpx;
color: $asset-text-muted;
}
.bmt-form-input {
flex: 1;
height: 72rpx;
padding: 0;
border: 0;
text-align: right;
font-size: 30rpx;
color: $asset-text-main;
background: transparent;
}
.bmt-form-textarea {
flex: 1;
width: auto;
min-height: 84rpx;
padding: 0;
border: 0;
text-align: right;
font-size: 28rpx;
line-height: 42rpx;
color: $asset-text-main;
background: transparent;
}
.bmt-form-placeholder {
font-size: 26rpx;
color: rgba(143, 167, 207, 0.72);
}
.bmt-shoot {
margin-top: 26rpx;
}
.bmt-shoot__title {
display: block;
font-size: 26rpx;
color: $asset-text-muted;
}
.bmt-shoot__item {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.7;
color: $asset-text-subtle;
}
.bmt-info-bottom {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: #191e32;
z-index: 30;
}
.bmt-info-bottom__row {
display: flex;
align-items: center;
}
.bmt-info-button {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
height: 84rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 600;
}
.bmt-info-button+.bmt-info-button {
margin-left: 20rpx;
}
.bmt-info-button--ghost {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: $asset-text-main;
}
.bmt-info-button--primary {
background: linear-gradient(135deg, #7e6cff 0%, #6b56f6 100%);
box-shadow: 0 14rpx 24rpx rgba(93, 79, 225, 0.22);
color: #ffffff;
}
.bmt-info-button.is-disabled {
opacity: 0.6;
}
</style>

432
pages/assets/bmt-sign.vue Normal file
View File

@ -0,0 +1,432 @@
<template>
<view class="asset-page asset-theme bmt-sign-page">
<asset-page-shell title="BMT签约" />
<view class="asset-scroll bmt-sign-scroll">
<view class="bmt-sign-hero glass-panel">
<view class="bmt-sign-hero__head">
<view class="bmt-sign-hero__badge">
<text class="bmt-sign-hero__badge-text">R</text>
</view>
<text class="bmt-sign-hero__title">BMT 电子签约</text>
</view>
<text class="bmt-sign-hero__desc">
签约完成后即可正常使用 BMT 相关服务
</text>
</view>
<view class="bmt-sign-card glass-panel">
<view class="bmt-sign-card__row">
<text class="bmt-sign-card__label">签约信息</text>
<text class="bmt-sign-card__value" :class="status.signInfoComplete === 1 ? 'success-text' : 'warning-text'">{{
status.signInfoComplete === 1 ? "已完善" : "待补充"
}}</text>
</view>
<view class="bmt-sign-card__row">
<text class="bmt-sign-card__label">合同状态</text>
<text class="bmt-sign-card__value" :class="contractToneClass">{{
contractStatusText
}}</text>
</view>
<text v-if="isLoading" class="bmt-sign-card__loading">正在查询签约状态…</text>
<view class="bmt-sign-card__actions">
<view v-if="status.signInfoComplete !== 1" class="bmt-sign-button bmt-sign-button--primary"
@click="goToSignInfo">
去补充签约信息
</view>
<view class="bmt-sign-button bmt-sign-button--ghost" @click="refresh">
重新查询
</view>
</view>
</view>
</view>
<asset-confirm-popup :visible="signPopupVisible" title="BMT签约" :message="signTips"
:confirm-text="signButtonText" cancel-text="取消" :show-close="false" @cancel="handleCancel"
@confirm="handleStartSigning" />
</view>
</template>
<script>
import AssetPageShell from "../../components/asset-page-shell.vue";
import AssetConfirmPopup from "../../components/asset-confirm-popup.vue";
import {
fetchBmtContractStatus,
checkBmtSignCondition,
startBmtSigning,
} from "../../api/bmt";
const SIGN_INFO_PAGE = "/pages/assets/bmt-sign-info";
const TIPS_FIELD_KEYS = ["tips", "tip", "sign_tips"];
const CONTRACT_TYPE_KEYS = ["contract_type"];
const SIGN_URL_KEYS = [
"url",
"sign_url",
"redirect_url",
"h5_url",
"link",
"jump_url",
];
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 "";
}
export default {
components: {
AssetPageShell,
AssetConfirmPopup,
},
data() {
return {
isLoading: true,
isFetching: false,
isStarting: false,
hasShown: false,
hasAutoRedirected: false,
signPopupVisible: false,
signTips: "",
signButtonText: "签约新协议",
signContractType: 0,
rawStatus: {},
status: {
signInfoComplete: 1,
contractStatus: 1,
contractType: 0,
tips: "",
},
};
},
computed: {
contractStatusText() {
return Number(this.status.contractStatus) === 0 ? "待签约" : "已签约";
},
contractToneClass() {
return Number(this.status.contractStatus) === 0
? "warning-text"
: "success-text";
},
},
onLoad() {
this.loadStatus(true, true);
},
onShow() {
// 从「补充签约信息」页返回时刷新状态,首次进入不重复请求
if (this.hasShown) {
this.loadStatus(false, false);
return;
}
this.hasShown = true;
},
methods: {
normalizeStatus(payload) {
const source = payload && typeof payload === "object" ? payload : {};
const rawComplete = pickFirstValue(source, ["sign_info_complete"]);
const signInfoComplete =
rawComplete === "" ? 1 : Number(rawComplete) === 1 ? 1 : 0;
return {
signInfoComplete: signInfoComplete,
contractStatus: Number(
pickFirstValue(source, ["contract_status"]) || 0,
),
contractType: Number(pickFirstValue(source, CONTRACT_TYPE_KEYS) || 0),
tips: String(pickFirstValue(source, TIPS_FIELD_KEYS) || ""),
};
},
async loadStatus(showLoading, isInitial) {
if (this.isFetching) {
return;
}
this.isFetching = true;
if (showLoading) {
this.isLoading = true;
}
try {
const response = await fetchBmtContractStatus(
showLoading
? {
showLoading: true,
loadingText: "查询中",
}
: null,
);
this.rawStatus = response || {};
const status = this.normalizeStatus(response);
this.status = status;
// sign_info_complete 为 0:签约信息不完整,首次进入自动去补充信息
if (status.signInfoComplete !== 1) {
if (isInitial && !this.hasAutoRedirected) {
this.hasAutoRedirected = true;
this.goToSignInfo();
}
return;
}
// contract_status 为 0:需要签约,弹框引导
if (Number(status.contractStatus) === 0) {
await this.openSignPopup(status);
}
} catch (error) {
uni.showToast({
title: error.message || "签约状态查询失败",
icon: "none",
});
} finally {
this.isFetching = false;
this.isLoading = false;
}
},
refresh() {
this.loadStatus(true, false);
},
goToSignInfo() {
const data = encodeURIComponent(JSON.stringify(this.rawStatus || {}));
uni.navigateTo({
url: SIGN_INFO_PAGE + "?data=" + data,
});
},
async openSignPopup(status) {
let tips = status.tips;
let contractType = status.contractType;
let checkData = {};
// 签约前置校验,失败时退回状态接口返回的提示,不阻断弹框
try {
checkData = await checkBmtSignCondition(
{},
{
showLoading: true,
loadingText: "校验中",
},
);
} catch (error) {
checkData = {};
}
const checkTips = String(pickFirstValue(checkData, TIPS_FIELD_KEYS) || "");
const checkType = pickFirstValue(checkData, CONTRACT_TYPE_KEYS);
if (checkTips) {
tips = checkTips;
}
if (checkType !== "") {
contractType = Number(checkType) === 1 ? 1 : 0;
}
this.signTips = tips || "您尚未签署 BMT 相关协议,请完成签约后继续使用。";
this.signContractType = contractType;
this.signButtonText = contractType === 1 ? "更新协议" : "签约新协议";
this.signPopupVisible = true;
},
async handleStartSigning() {
if (this.isStarting) {
return;
}
this.isStarting = true;
try {
const result = await startBmtSigning(
{
contract_type: this.signContractType,
},
{
showLoading: true,
loadingText: "发起签约",
},
);
this.signPopupVisible = false;
const targetUrl = String(pickFirstValue(result, SIGN_URL_KEYS) || "");
if (targetUrl) {
this.openSignUrl(targetUrl);
return;
}
uni.showToast({
title: "签约已发起",
icon: "none",
});
this.loadStatus(false, false);
} catch (error) {
uni.showToast({
title: error.message || "发起签约失败",
icon: "none",
});
} finally {
this.isStarting = false;
}
},
openSignUrl(targetUrl) {
if (typeof window !== "undefined" && window.location) {
window.location.href = targetUrl;
return;
}
if (
typeof plus !== "undefined" &&
plus.webview &&
plus.webview.currentWebview
) {
const currentWebview = plus.webview.currentWebview();
if (currentWebview && typeof currentWebview.loadURL === "function") {
currentWebview.loadURL(targetUrl);
return;
}
}
uni.navigateTo({
url: targetUrl,
});
},
handleCancel() {
this.signPopupVisible = false;
if (getCurrentPages().length > 1) {
uni.navigateBack();
}
},
},
};
</script>
<style lang="scss" scoped>
@import "../../styles/tokens.scss";
@import "../../styles/common.scss";
.bmt-sign-page {
min-height: 100vh;
}
.bmt-sign-scroll {
padding: 12rpx 24rpx calc(env(safe-area-inset-bottom) + 36rpx);
}
.bmt-sign-hero {
padding: 32rpx;
}
.bmt-sign-hero__head {
display: flex;
align-items: center;
}
.bmt-sign-hero__badge {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
margin-right: 16rpx;
border-radius: 50%;
background: #ff5a5a;
}
.bmt-sign-hero__badge-text {
font-size: 32rpx;
font-weight: 800;
color: #ffffff;
}
.bmt-sign-hero__title {
font-size: 34rpx;
font-weight: 700;
color: $asset-text-main;
}
.bmt-sign-hero__desc {
display: block;
margin-top: 18rpx;
font-size: 26rpx;
line-height: 1.6;
color: $asset-text-subtle;
}
.bmt-sign-card {
margin-top: 24rpx;
padding: 12rpx 28rpx 28rpx;
}
.bmt-sign-card__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.bmt-sign-card__label {
font-size: 28rpx;
color: $asset-text-muted;
}
.bmt-sign-card__value {
font-size: 30rpx;
font-weight: 600;
}
.bmt-sign-card__loading {
display: block;
margin-top: 24rpx;
font-size: 24rpx;
color: $asset-text-subtle;
}
.bmt-sign-card__actions {
display: flex;
flex-direction: column;
margin-top: 32rpx;
}
.bmt-sign-card__actions .bmt-sign-button+.bmt-sign-button {
margin-top: 20rpx;
}
.bmt-sign-button {
display: flex;
align-items: center;
justify-content: center;
height: 84rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 600;
}
.bmt-sign-button--primary {
background: linear-gradient(135deg, #7e6cff 0%, #6b56f6 100%);
box-shadow: 0 14rpx 24rpx rgba(93, 79, 225, 0.22);
color: #ffffff;
}
.bmt-sign-button--ghost {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: $asset-text-main;
}
</style>

View File

@ -125,6 +125,10 @@
</image> </image>
</view> </view>
</view> </view>
<asset-confirm-popup :visible="signPopupVisible" :title="signPopupTitle" :message="signPopupMessage"
:confirm-text="signPopupConfirmText" cancel-text="取消" @cancel="handleSignPopupCancel"
@confirm="handleSignPopupConfirm" />
</view> </view>
</template> </template>
@ -133,16 +137,51 @@ import {
fetchAssetHome, fetchAssetHome,
fetchCouponRedeemLinkData, fetchCouponRedeemLinkData,
} from "../../api/assets"; } from "../../api/assets";
import {
fetchBmtContractStatus,
checkBmtSignCondition,
startBmtSigning,
} from "../../api/bmt";
import AssetConfirmPopup from "../../components/asset-confirm-popup.vue";
import serviceConfig from "../../config/service"; import serviceConfig from "../../config/service";
import { truncateToTwo } from "../../utils/index.js"; import { truncateToTwo } from "../../utils/index.js";
// H5 下点击弹框「取消」后固定跳转的推广页
const BMT_CANCEL_REDIRECT_URL =
`${window.location.origin}/JXH5/pages/users/user_spread_user/index`;
/** personal_auth_status 可能是对象或标量,统一取出 status 值(1 表示需要先走协议签约) */
function resolvePersonalAuthStatus(source) {
const raw = source && source.personal_auth_status;
if (raw && typeof raw === "object") {
return Number(raw.status) === 1 ? 1 : 0;
}
return Number(raw) === 1 ? 1 : 0;
}
export default { export default {
components: {
AssetConfirmPopup,
},
data() { data() {
return { return {
isHomeLoading: true, isHomeLoading: true,
hasLoadedOnce: false, hasLoadedOnce: false,
isFetchingHome: false, isFetchingHome: false,
isOpeningCouponRedeem: false, isOpeningCouponRedeem: false,
signPopupVisible: false,
signPopupMode: "",
signPopupTitle: "BMT签约",
signPopupMessage: "",
signPopupConfirmText: "确认",
signContractType: 0,
signContractId: "",
signRecordId: "",
signPersonalAuthStatus: 0,
signInfoData: {},
isSigning: false,
overview: { overview: {
title: "数字资产", title: "数字资产",
heroTitle: "BM数字资产管理", heroTitle: "BM数字资产管理",
@ -159,11 +198,209 @@ export default {
}); });
}, },
onShow() { onShow() {
// 首次进入首页校验 BMT 签约状态,命中条件时弹框引导,不自动跳页
this.checkBmtContract();
this.loadPage({ this.loadPage({
showSkeleton: !this.hasLoadedOnce, showSkeleton: !this.hasLoadedOnce,
}); });
}, },
methods: { methods: {
async checkBmtContract() {
try {
const status = (await fetchBmtContractStatus()) || {};
// status === 2:无需签约提示,不再做后续判断与接口请求
if (Number(status.status) === 2) {
return;
}
const rawComplete = status.sign_info_complete;
const signInfoComplete =
rawComplete === undefined || rawComplete === null || rawComplete === ""
? 1
: Number(rawComplete);
const contractStatus = Number(status.contract_status || 0);
// 签约信息未完善:弹框引导,用户点击后再进认证页
if (signInfoComplete !== 1) {
this.openSignInfoPopup(status);
return;
}
// 未签约:弹框引导,点击后直接发起签约
if (contractStatus === 0) {
await this.openSigningPopup(status);
}
} catch (error) {
// 签约状态查询失败不阻断首页加载
}
},
openSignInfoPopup(status) {
this.signPopupMode = "info";
this.signPopupTitle = "BMT签约";
this.signPopupMessage =
"您的签约信息尚未完善,请先补充签约信息后再进行签约。";
this.signPopupConfirmText = "去补充信息";
this.signInfoData = status || {};
this.signPopupVisible = true;
},
async openSigningPopup(status) {
let tips = String((status && status.tips) || "");
let contractType = Number((status && status.contract_type) || 0);
let checkData = {};
// 提示签约时先请求前置校验接口,取 tips / contract_type / c_id
try {
checkData =
(await checkBmtSignCondition(
{},
{
showLoading: true,
loadingText: "校验中",
},
)) || {};
} catch (error) {
checkData = {};
}
const checkTips = String(checkData.tips || "");
const checkType = checkData.contract_type;
if (checkTips) {
tips = checkTips;
}
if (checkType !== undefined && checkType !== null && checkType !== "") {
contractType = Number(checkType) === 1 ? 1 : 0;
}
// c_id / id 来自前置校验接口,点击「去签约」时随请求一并带上
this.signContractId = String(checkData.c_id || "");
this.signRecordId = String(checkData.id || (status && status.id) || "");
this.signPersonalAuthStatus = resolvePersonalAuthStatus(checkData);
this.signPopupMode = "sign";
this.signPopupTitle = "BMT签约";
this.signPopupMessage =
tips || "您尚未签署 BMT 相关协议,请完成签约后继续使用。";
this.signPopupConfirmText = contractType === 1 ? "更新协议" : "签约新协议";
this.signContractType = contractType;
this.signPopupVisible = true;
},
handleSignPopupConfirm() {
if (this.signPopupMode === "info") {
const data = encodeURIComponent(
JSON.stringify(this.signInfoData || {}),
);
this.signPopupVisible = false;
uni.navigateTo({
url: "/pages/assets/bmt-sign-info?data=" + data,
});
return;
}
this.handleGoSigning();
},
handleSignPopupCancel() {
this.signPopupVisible = false;
this.exitToHostPage();
},
/** 退出当前页:H5 固定跳转推广页;其它端返回上一页 / 关闭 webview */
exitToHostPage() {
const ua = navigator.userAgent.toLowerCase();
const isAppWebview = ua.indexOf('html5plus') > -1 || window.plus;
// App内嵌webview:跳约定的uniwebview协议,外层拦截
if (isAppWebview) {
window.location.href = "uniwebview://redirectTo?url=/pages/users/user_spread_user/index";
return;
}
// 普通浏览器H5,跳转推广页
if (typeof window !== "undefined") {
window.location.href = BMT_CANCEL_REDIRECT_URL;
return;
}
// uni-app原生App页面(非webview)
if (typeof getCurrentPages === "function" && getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
if (typeof plus !== "undefined") {
const currentWebview = plus.webview.currentWebview();
currentWebview?.close?.();
}
},
async handleGoSigning() {
if (this.isSigning) {
return;
}
// personal_auth_status.status === 1:先到协议页阅读并签约(协议页用 id 签约)
if (this.signPersonalAuthStatus === 1) {
this.isSigning = true;
try {
this.signPopupVisible = false;
uni.navigateTo({
url: "/pages/assets/bmt-sign-agreement",
});
} finally {
this.isSigning = false;
}
return;
}
if (!this.signContractId) {
uni.showToast({
title: "签约信息获取失败,请稍后重试",
icon: "none",
});
return;
}
this.isSigning = true;
try {
const result = await startBmtSigning(
{
contract_type: this.signContractType,
c_id: this.signContractId,
id: this.signRecordId,
},
{
showLoading: true,
loadingText: "发起签约",
},
);
// 输出原始返回,便于确认字段实际层级(排查完可删)
console.log("[BMT签约] start_signing_bmt_v2 返回:", result.raw);
// status 为 0:直接跳转 saasShortLink 去认证
if (Number(result.status) === 0 && result.saasShortLink) {
this.signPopupVisible = false;
this.navigateToExternalUrl(result.saasShortLink);
return;
}
uni.showToast({
title: result.message || "发起签约失败,请稍后重试",
icon: "none",
});
} catch (error) {
uni.showToast({
title: error.message || "发起签约失败",
icon: "none",
});
} finally {
this.isSigning = false;
}
},
formatAssetValue(value) { formatAssetValue(value) {
return truncateToTwo(value); return truncateToTwo(value);
}, },

2
reasonix.toml Normal file
View File

@ -0,0 +1,2 @@
[permissions]
allow = ["Bash=cd \"/Users/white/Desktop/BMT/白马交易所\" && sed -n '240,300p' App.vue && echo \"=== grep asset-scroll 全库 ===\" && grep -rn \"asset-scroll\" --include=*.vue --include=*.scss . | grep -v node_modules | head", "Edit"]

View File

@ -1,2 +0,0 @@
<!DOCTYPE html><html lang=zh-CN><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><title>白马交易所</title><script>var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')</script><link rel=stylesheet href=/bmt/static/index.883130ca.css></head><body><noscript><strong>Please enable JavaScript to continue.</strong></noscript><div id=app></div><script src=/bmt/static/js/chunk-vendors.a58c62f3.js></script><script src=/bmt/static/js/index.47f364de.js></script></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -51,14 +51,22 @@ export default function request(options) {
? "application/x-www-form-urlencoded; charset=UTF-8" ? "application/x-www-form-urlencoded; charset=UTF-8"
: "application/json"; : "application/json";
const token = getCurrentWebviewToken(); const token = getCurrentWebviewToken();
const ua = navigator.userAgent.toLowerCase();
// 判断是否uni-app打包APP的webview环境
const isAppWebview = ua.includes('html5plus');
const formType = isAppWebview ? "app" : "gzh";
const requestHeader = Object.assign( const requestHeader = Object.assign(
{},
options.header || {},
{ {
"Content-Type": defaultContentType, "Content-Type": defaultContentType,
"Form-type": "gzh", "Form-type": formType,
}, }
options.header || {},
); );
if (token) { if (token) {
const bearerToken = "Bearer " + token; const bearerToken = "Bearer " + token;
requestHeader["Authori-zation"] = bearerToken; requestHeader["Authori-zation"] = bearerToken;