- Adjusted the merging order of form configuration to prioritize frontend defaults when backend configurations are absent. - Clarified comments regarding the purpose of new visual styles and their application. - Modified the hideInnerBanner logic to ensure consistent behavior with the updated form styles.
747 lines
30 KiB
Vue
747 lines
30 KiB
Vue
<template>
|
||
<view :style="themeColor">
|
||
<!-- 顶部图条:位于表单卡片之上,宽度 calc(100% - 2*containerInset) 水平居中。
|
||
图片由后台 form_config.bg_image / bgImage 下发;未配置时不渲染。 -->
|
||
<view v-if="bgImage" class="formdata-bg" :style="bgLayerStyle"></view>
|
||
<block v-if="detail">
|
||
<view class="page">
|
||
<view class="system-form-wrap" :style="containerStyle">
|
||
<!-- 复用 diy-form(与 formdata.vue 同构):banner / tip / 表单字段 / 提交按钮全部由其内部渲染。
|
||
两种表单风格(classic 平铺 / card 卡片浮起)及全部 formConfig 样式属性(tip 四边内边距/边框/圆角/阴影、
|
||
card_overlap/overlap_top/radius/side_pad/bg_color/shadow/border、信息卡片等)由此链路统一支持。
|
||
:onSubmit 接管提交逻辑;create 在 created 阶段 bind this,防止 this 漂移到子组件实例。 -->
|
||
<diy-form ref="form" :value="diyValue" dataMode="raw" :showSubmit="!showCaptcha" :onSubmit="handleSubmit" :container-inset="containerInset" />
|
||
<!-- 行为设置:提交验证码开启时,隐藏 diy-form 内置按钮,由本页渲染验证码+提交按钮 -->
|
||
<view v-if="showCaptcha" class="form-extra-wrap">
|
||
<view class="captcha-wrap">
|
||
<input class="captcha-input" type="text" v-model="captcha.code" :placeholder="$lang('form.form.captchaPlaceholder')" />
|
||
<!-- #ifdef H5 -->
|
||
<img v-if="captcha.img" class="captcha-img" :src="captcha.img" @click="getCaptcha()">
|
||
<!-- #endif -->
|
||
<!-- #ifndef H5 -->
|
||
<image v-if="captcha.img" class="captcha-img" :src="captcha.img" mode="aspectFill" @click="getCaptcha()"></image>
|
||
<!-- #endif -->
|
||
<view class="captcha-btn" @click="getCaptcha()">{{ captcha.img ? '换一张' : '获取验证码' }}</view>
|
||
</view>
|
||
<view class="custom-submit" @click="submitFromPage">{{ submitBtnText }}</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</block>
|
||
<!-- 空态:除 ns-empty 文案外,追加一个可手动点的登录按钮——解决一切自动弹窗时序问题后的最后兜底 -->
|
||
<block v-else>
|
||
<ns-empty :text="emptyText"></ns-empty>
|
||
<view v-if="!complete" style="padding: 20rpx 40rpx 40rpx; display: flex; justify-content: center;">
|
||
<button type="primary" size="mini" @click="forceOpenLogin">点击登录查看表单</button>
|
||
</view>
|
||
</block>
|
||
|
||
<loading-cover ref="loadingCover"></loading-cover>
|
||
<ns-login ref="login"></ns-login>
|
||
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import scroll from '@/common/js/scroll-view.js';
|
||
|
||
export default {
|
||
mixins: [scroll],
|
||
data() {
|
||
return {
|
||
id: 0,
|
||
detail: null,
|
||
isRepeat: false,
|
||
complete: false,
|
||
scroll: true,
|
||
urlParams: {},
|
||
// 登录后回跳地址(供 ns-login / H5 重定向使用)
|
||
backUrl: '',
|
||
// create 的 this 绑定版本,作为 :onSubmit 传给 diy-form(见 created)
|
||
handleSubmit: null,
|
||
// 行为设置(后端 __form_config__.behavior 下发,含默认值)
|
||
behavior: {},
|
||
// 非成功状态的空态文案(表单未开放/需登录/加载失败等)
|
||
formError: '',
|
||
// 图形验证码(behavior.captcha=1 时展示)
|
||
captcha: { id: '', img: '', code: '' },
|
||
// 是否已加载过验证码(避免 onShow 重复刷新导致 session 错乱)
|
||
captchaLoaded: false,
|
||
// 登录拦截时暂存待提交数据:组件内存(MP 不丢态)+ 本地存储(H5 登录整页回跳后从存储恢复)
|
||
pendingSubmit: null,
|
||
// onLoad→getData 比 ns-login 挂载早时,用此标志在 mounted 再兜底弹登录(避免 ref 空报错)
|
||
pendingLoginRequired: false
|
||
};
|
||
},
|
||
created() {
|
||
// 预绑定 this:create 重度依赖本组件 this(id/memberInfo/$api/$lang/$util 等)。
|
||
// 若直接传 :onSubmit="create",diy-form 通过 this.onSubmit(formData) 调用时,
|
||
// 普通函数调用的 this 会指向调用者(子组件实例),导致字段全部读不到。
|
||
this.handleSubmit = this.create.bind(this);
|
||
},
|
||
computed: {
|
||
// 构造 diy-form raw 模式期望的 value 形态(与 formdata.vue 对齐):
|
||
// - jsonData:透传给 ns-newform([{ controller, value, val }],含 InfoCard 展示组件)
|
||
// - formConfig:后端返回 camelCase(convertFormConfigToCamel),diy-form 内部双键兼容
|
||
// - show_banner / form_banner:交给 diy-form 渲染 banner(card 风格浮起 / classic 平铺)
|
||
// - show_tip / form_tip:交给 diy-form 渲染提示语(四边内边距/边框/圆角/阴影/对齐等新样式生效)
|
||
// 注意:小程序端模板引用的 computed 会进入 setData 数据流,detail 置空瞬间也要求值,必须做空值守卫
|
||
diyValue() {
|
||
// 前端视觉默认:仅作兜底(后端 form_config 未配置的字段才生效)。
|
||
// 合并顺序:detail.form_config 在后,后台显式配置(submit_bg_color / input_radius / form_layout 等)
|
||
// 必须覆盖前端默认,否则商家端样式配置不生效。
|
||
const styleDefaults = {
|
||
formLayout: 'vertical',
|
||
formPaddingX: 28,
|
||
rowPaddingY: 24,
|
||
labelGap: 20,
|
||
labelFontSize: 32,
|
||
labelFontWeight: 700,
|
||
labelColor: '#1f2937',
|
||
inputHeight: 84,
|
||
inputRadius: 16,
|
||
inputBgColor: '#ffffff',
|
||
inputBorderColor: '#e5e7eb',
|
||
inputFocusBorderColor: '#3b82f6',
|
||
inputFocusBgColor: '#ffffff',
|
||
placeholderColor: '#9ca3af',
|
||
placeholderFontSize: 28,
|
||
checkedColor: '#3b82f6',
|
||
showRowDivider: false,
|
||
submitColor: '#ffffff',
|
||
submitBgColor: '#3b82f6'
|
||
};
|
||
if (!this.detail) {
|
||
return {
|
||
jsonData: null,
|
||
formConfig: Object.assign({}, styleDefaults),
|
||
show_banner: 0,
|
||
form_banner: '',
|
||
show_tip: 0,
|
||
form_tip: ''
|
||
};
|
||
}
|
||
return {
|
||
jsonData: this.detail.json_data,
|
||
formConfig: Object.assign({}, styleDefaults, this.detail.form_config || {}),
|
||
// 全屏底图模式下隐藏卡片内 banner,避免与底图重复(见 hideInnerBanner)
|
||
show_banner: this.hideInnerBanner ? 0 : this.detail.show_banner,
|
||
form_banner: this.detail.form_banner || 'public/uniapp/form/banner.png',
|
||
show_tip: this.detail.show_tip,
|
||
form_tip: this.detail.form_tip || this.$lang('form.formdata.formTipPlaceholder')
|
||
};
|
||
},
|
||
// 顶部图条:后台 form_config.bg_image / bgImage 下发(兼容 snake_case / camelCase)
|
||
bgImage() {
|
||
if (!this.detail) return '';
|
||
const fc = this.detail.form_config || {};
|
||
const raw = fc.bgImage || fc.bg_image || fc.backgroundImage || fc.background_image || '';
|
||
return raw ? this.$util.img(raw) : '';
|
||
},
|
||
// 容器左右内缩(rpx)。后台 form_config.container_inset / containerInset 可调;默认 30rpx
|
||
containerInset() {
|
||
const fc = (this.detail && this.detail.form_config) || {};
|
||
const raw = fc.containerInset !== undefined ? fc.containerInset : fc.container_inset;
|
||
const n = parseInt(raw);
|
||
return isNaN(n) ? 30 : Math.min(150, Math.max(0, n));
|
||
},
|
||
// 顶部图条高度(rpx)。后台 form_config.bg_image_height 可调;clamp 80-600
|
||
bgImageHeight() {
|
||
const fc = (this.detail && this.detail.form_config) || {};
|
||
const raw = fc.bgImageHeight !== undefined ? fc.bgImageHeight : fc.bg_image_height;
|
||
const n = parseInt(raw);
|
||
return isNaN(n) ? 200 : Math.min(600, Math.max(80, n));
|
||
},
|
||
// 图条与表单卡片之间的间距(rpx)。后台 form_config.card_gap 可调;clamp 0-80
|
||
cardGap() {
|
||
const fc = (this.detail && this.detail.form_config) || {};
|
||
const raw = fc.cardGap !== undefined ? fc.cardGap : fc.card_gap;
|
||
const n = parseInt(raw);
|
||
return isNaN(n) ? 24 : Math.min(80, Math.max(0, n));
|
||
},
|
||
// 图条内联样式:水平居中 + 高度 + 与卡片的间距
|
||
bgLayerStyle() {
|
||
const inset = this.containerInset;
|
||
return 'width:calc(100% - ' + (inset * 2) + 'rpx);margin:0 ' + inset + 'rpx ' + this.cardGap + 'rpx;height:' + this.bgImageHeight + 'rpx;background-image:url(' + this.bgImage + ');';
|
||
},
|
||
// 表单容器内联样式:左右内缩可配,覆盖 .system-form-wrap 默认 padding
|
||
containerStyle() {
|
||
return 'padding-left:' + this.containerInset + 'rpx;padding-right:' + this.containerInset + 'rpx;';
|
||
},
|
||
// 表单风格(与 diy-form.vue hasBannerMode 对齐)
|
||
// - card :卡片浮起需要 banner 作为被压对象 → 保留内联 banner(与后端预览一致);仅 bgImage 全屏底图时隐藏避免重复
|
||
// - classic:保留内联 banner(与原行为一致),仅当后台显式配 bgImage 才隐藏
|
||
hideInnerBanner() {
|
||
return !!this.bgImage;
|
||
},
|
||
// 行为设置:提交验证码开启时 diy-form 隐藏内置提交按钮,本页渲染验证码 + 自有提交按钮
|
||
showCaptcha() {
|
||
return this.behavior.captcha == 1;
|
||
},
|
||
// 自有提交按钮文案:跟随表单配置的提交按钮文字(form_config 为 camelCase)
|
||
submitBtnText() {
|
||
return (this.detail && this.detail.form_config && this.detail.form_config.submitText) || this.$lang('common.submit');
|
||
},
|
||
emptyText() {
|
||
return this.complete ? this.$lang('form.form.submitSuccess') : (this.formError || this.$lang('form.form.noFormInfo'));
|
||
}
|
||
},
|
||
onLoad(data) {
|
||
// #ifdef MP-ALIPAY
|
||
let options = my.getLaunchOptionsSync();
|
||
options.query && Object.assign(data, options.query)
|
||
// #endif
|
||
|
||
this.id = data.id || 0;
|
||
|
||
// 通用 URL 参数收集:除 id/scene 外的所有参数都透传给后端
|
||
// 后端会做卫生过滤并广播事件,供 recommend 等插件消费
|
||
const reservedKeys = ['uniacid', 'id', 'scene'];
|
||
this.urlParams = {};
|
||
for (let key in data) {
|
||
if (reservedKeys.indexOf(key) !== -1) continue;
|
||
this.urlParams[key] = data[key];
|
||
}
|
||
|
||
if (data.scene) {
|
||
var sceneParams = decodeURIComponent(data.scene);
|
||
sceneParams = sceneParams.split('&');
|
||
if (sceneParams.length) {
|
||
sceneParams.forEach(item => {
|
||
if (!item) return;
|
||
// 兼容标准 key=value(id=13&uniacid=1)与旧式 id-13 两种 scene 格式
|
||
let pair = item.split('=');
|
||
if (pair.length === 2) {
|
||
const key = pair[0];
|
||
const val = pair[1];
|
||
if (key === 'id') this.id = val;
|
||
else if (key === 'uniacid') this.uniacid = val;
|
||
else this.urlParams[key] = val;
|
||
} else if (item.indexOf('id') === 0 && item.indexOf('-') !== -1) {
|
||
// 旧格式兜底:id-13
|
||
this.id = item.split('-')[1];
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// #ifdef H5
|
||
// H5:从 window.location.href 解析参数,兼容 history 路由 (?key=val) 与 hash 路由 (#/path?key=val)
|
||
// 保证 recommend_code / uniacid 等参数不丢失
|
||
try {
|
||
const href = window.location.href;
|
||
let queryStr = '';
|
||
const hashIdx = href.indexOf('#');
|
||
const qIdx = href.indexOf('?');
|
||
if (qIdx !== -1 && (hashIdx === -1 || qIdx < hashIdx)) {
|
||
// history 模式:? 在 # 之前(或无 #)
|
||
queryStr = href.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : href.length);
|
||
} else if (hashIdx !== -1) {
|
||
// hash 模式:# 之后可能还有 ?
|
||
const afterHash = href.substring(hashIdx + 1);
|
||
const hq = afterHash.indexOf('?');
|
||
if (hq !== -1) queryStr = afterHash.substring(hq + 1);
|
||
}
|
||
if (queryStr) {
|
||
const pairs = queryStr.split('&');
|
||
for (const p of pairs) {
|
||
if (!p) continue;
|
||
const eq = p.indexOf('=');
|
||
let k, v;
|
||
if (eq === -1) { k = decodeURIComponent(p); v = ''; }
|
||
else { k = decodeURIComponent(p.substring(0, eq)); v = decodeURIComponent(p.substring(eq + 1)); }
|
||
if (k === 'id') { if (!this.id) this.id = v; }
|
||
else if (k === 'uniacid') { /* reserved, 透传到后端时不进 urlParams */ }
|
||
else if (k === 'scene') { continue; }
|
||
else { if (this.urlParams[k] === undefined) this.urlParams[k] = v; }
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
// #endif
|
||
|
||
// 构建登录回跳地址:包含 id + 所有 urlParams(尤其 recommend_code),
|
||
// 供 ns-login 弹窗登录成功或 H5 整页重定向登录后回到本页
|
||
const backQuery = [];
|
||
if (this.id) backQuery.push('id=' + encodeURIComponent(this.id));
|
||
for (const k in this.urlParams) {
|
||
const v = this.urlParams[k];
|
||
if (v !== undefined && v !== null && v !== '') {
|
||
backQuery.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
|
||
}
|
||
}
|
||
this.backUrl = '/pages_tool/form/form' + (backQuery.length ? '?' + backQuery.join('&') : '');
|
||
},
|
||
onShow() {
|
||
// 打开是否需要登录由后端 behavior 决定(info 返回 FORM_LOGIN_REQUIRED 时弹登录)
|
||
this.getData();
|
||
},
|
||
mounted() {
|
||
// 1) 兜底 A:onLoad→getData 回调比 ns-login 挂载还早时,pendingLoginRequired=true
|
||
// 挂载完成再最后尝试一次自动弹窗
|
||
if (this.pendingLoginRequired) {
|
||
const tryOpen = () => {
|
||
try {
|
||
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
|
||
this.$refs.login.open(this.backUrl);
|
||
}
|
||
} catch (e) { console.warn('[FORM] mounted login.open failed:', e); }
|
||
};
|
||
this.$nextTick(tryOpen);
|
||
this.pendingLoginRequired = false;
|
||
}
|
||
// 2) 兜底 B:登录态在组件创建之前就已写入(token 提前 race),但 info 因时序没拿到数据
|
||
// 此时用户实际上已经登录,再拉一次 info 就能拿到真实表单数据
|
||
if (this.storeToken && !this.detail) {
|
||
this.$nextTick(() => { this.getData(); });
|
||
}
|
||
},
|
||
watch: {
|
||
storeToken: function(nVal, oVal) {
|
||
if (nVal) {
|
||
this.getData();
|
||
// 填写需登录:弹窗登录成功后自动续提交
|
||
this.resumeSubmitAfterLogin();
|
||
}
|
||
}
|
||
},
|
||
methods: {
|
||
getData() {
|
||
this.$api.sendRequest({
|
||
url: '/form/api/form/info',
|
||
data: {
|
||
form_id: this.id
|
||
},
|
||
success: res => { this._handleInfoResponse(res); },
|
||
fail: err => { this._handleInfoFailure(err); }
|
||
});
|
||
},
|
||
// ====== 表单 info 响应统一处理(剥离自 getData,便于严格判定与复用) ======
|
||
// 成功分支必须同时满足:code==0 + data 是对象 + 内含 json_data 或 form_config
|
||
// 只要不满足就是"没拿到有效表单",然后用最宽的条件判定是否是登录拦截:
|
||
// - 消息包含 FORM_LOGIN_REQUIRED / LOGIN_REQUIRED / 请先登录 / 登录相关中文
|
||
// - 或者 data 无效(null/undefined/''/{}/非对象)+ 用户未登录 + 有 form_id
|
||
// 命中任一 → 一律弹登录 + 友好文案"请先登录",不再误显示"未获取到表单信息"
|
||
_handleInfoResponse(res) {
|
||
try {
|
||
const payload = (res && typeof res === 'object') ? res : {};
|
||
const dataObj = payload.data;
|
||
|
||
// —— 1) 严格判定:是否拿到了真正的表单结构? ——
|
||
const hasValidFormData =
|
||
payload.code == 0 &&
|
||
dataObj !== null && dataObj !== undefined && dataObj !== '' &&
|
||
typeof dataObj === 'object' &&
|
||
(dataObj.json_data !== undefined || dataObj.form_config !== undefined);
|
||
|
||
if (hasValidFormData) {
|
||
this.detail = dataObj;
|
||
this.formError = '';
|
||
this.behavior = (dataObj.form_config && dataObj.form_config.behavior) || {};
|
||
if (this.behavior.captcha == 1 && !this.captchaLoaded) this.getCaptcha();
|
||
this.resumeSubmitAfterLogin();
|
||
// 本地缓存一份字段 JSON:下次再打开时如果仍被登录拦截,可作为 skeleton 兜底
|
||
try { uni.setStorageSync('form_fields_cache_' + this.id, dataObj.json_data); } catch (e) {}
|
||
// 成功分支同样必须关闭 loading 遮罩
|
||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||
return;
|
||
}
|
||
|
||
// —— 2) 没拿到有效结构 → 宽网判定:是否属于"需要登录"场景? ——
|
||
const msg = (payload.message || '').toString();
|
||
const isFormNotOpen = (msg === 'FORM_NOT_OPEN');
|
||
|
||
const isLoginMsg =
|
||
msg === 'FORM_LOGIN_REQUIRED' ||
|
||
msg === 'LOGIN_REQUIRED' ||
|
||
msg.indexOf('请先登录') !== -1 ||
|
||
(msg.indexOf('登录') !== -1 && msg.indexOf('请') !== -1) ||
|
||
(msg.toLowerCase().indexOf('login') !== -1 && msg.toLowerCase().indexOf('required') !== -1);
|
||
|
||
const dataIsEmpty =
|
||
!dataObj ||
|
||
dataObj === '' ||
|
||
dataObj === null ||
|
||
dataObj === undefined ||
|
||
(typeof dataObj === 'object' && !dataObj.json_data && !dataObj.form_config &&
|
||
((Array.isArray(dataObj) && dataObj.length === 0) ||
|
||
(!Array.isArray(dataObj) && Object.keys(dataObj).length === 0)));
|
||
|
||
// 最宽判定:只要不是"表单未开放",且(显式登录消息 OR 空数据+未登录+有 id)都按登录拦截处理
|
||
const shouldTriggerLogin =
|
||
!isFormNotOpen &&
|
||
(isLoginMsg || (dataIsEmpty && !this.storeToken && this.id));
|
||
|
||
if (shouldTriggerLogin && this.storeToken) {
|
||
// 已登录(storeToken 存在)但后端仍返回登录拦截:
|
||
// 登录一次后就不再自动弹登录/跳登录页,仅展示空态文案 + 手动登录按钮
|
||
this.formError = this.$lang('form.form.loginRequired');
|
||
} else if (shouldTriggerLogin) {
|
||
this.formError = this.$lang('form.form.loginRequired');
|
||
this._openLoginWithRetry();
|
||
} else if (isFormNotOpen) {
|
||
this.formError = this.$lang('form.form.notOpen');
|
||
} else {
|
||
this.formError = msg || this.$lang('form.form.noFormInfo');
|
||
}
|
||
} catch (e) {
|
||
console.warn('[FORM] _handleInfoResponse exception:', e && e.message ? e.message : e);
|
||
this.formError = this.$lang('form.form.noFormInfo');
|
||
}
|
||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||
},
|
||
// info 接口网络失败时也做同样判定:未登录+有 id 就尝试启动登录流程
|
||
_handleInfoFailure(err) {
|
||
try {
|
||
if (this.id && !this.storeToken) {
|
||
this.formError = this.$lang('form.form.loginRequired');
|
||
this._openLoginWithRetry();
|
||
} else {
|
||
this.formError = this.$lang('form.form.noFormInfo');
|
||
}
|
||
} catch (e) {
|
||
this.formError = this.$lang('form.form.noFormInfo');
|
||
}
|
||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||
},
|
||
// 打开登录:3 次 setTimeout 重试(0ms / 60ms / 320ms)
|
||
_openLoginWithRetry() {
|
||
// 防重复触发:3 秒内只允许自动弹一次登录
|
||
const now = Date.now();
|
||
if (this._lastAutoLoginAt && (now - this._lastAutoLoginAt) < 3000) return;
|
||
this._lastAutoLoginAt = now;
|
||
const url = this.backUrl;
|
||
let attempts = 0;
|
||
const delays = [0, 60, 320];
|
||
const tryOnce = () => {
|
||
try {
|
||
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
|
||
this.$refs.login.open(url);
|
||
this.pendingLoginRequired = false;
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
console.warn('[FORM] login.open attempt ' + (attempts + 1) + ' failed:', e);
|
||
}
|
||
attempts++;
|
||
if (attempts < delays.length) {
|
||
setTimeout(tryOnce, delays[attempts]);
|
||
} else {
|
||
// 三次全失败:交给 mounted 钩子最后兜底
|
||
this.pendingLoginRequired = true;
|
||
}
|
||
return false;
|
||
};
|
||
tryOnce();
|
||
},
|
||
// 模板空态按钮的手动入口:用户主动点击也能打开登录弹窗(最后一道防线)
|
||
forceOpenLogin() {
|
||
this._openLoginWithRetry();
|
||
},
|
||
// 图形验证码(点击图片/按钮刷新;提交失败后自动刷新)
|
||
getCaptcha() {
|
||
this.captchaLoaded = true;
|
||
this.$api.sendRequest({
|
||
url: '/api/captcha/captcha',
|
||
data: {
|
||
captcha_id: this.captcha.id
|
||
},
|
||
success: res => {
|
||
if (res.code == 0 && res.data) {
|
||
this.captcha.id = res.data.id;
|
||
this.captcha.img = res.data.img ? res.data.img.replace(/\r\n/g, '') : '';
|
||
this.captcha.code = '';
|
||
}
|
||
}
|
||
});
|
||
},
|
||
// 登录拦截时保存待提交数据(组件内存 + 本地存储,H5 登录会整页回跳)
|
||
savePendingSubmit(formData) {
|
||
const data = formData || (this.$refs.form && this.$refs.form.getFormData && this.$refs.form.getFormData()) || null;
|
||
const payload = {
|
||
formId: this.id,
|
||
urlParams: this.urlParams,
|
||
formData: data,
|
||
captchaId: this.captcha.id,
|
||
captchaCode: this.captcha.code,
|
||
ts: Date.now()
|
||
};
|
||
this.pendingSubmit = payload;
|
||
try { uni.setStorageSync('form_pending_submit', payload); } catch (e) {}
|
||
// H5 整页回跳标记:仅当确由登录拦截触发时才允许自动续提交,避免陈旧数据被误提交
|
||
try { uni.setStorageSync('form_pending_from_login', 1); } catch (e) {}
|
||
},
|
||
// 仅在已登录时取回并消费待提交数据(未登录 / 非登录回跳 / 过期不消费,避免误提交);取后清除
|
||
takePendingSubmit() {
|
||
let payload = this.pendingSubmit;
|
||
const fromStorage = !payload;
|
||
if (fromStorage) {
|
||
try { payload = uni.getStorageSync('form_pending_submit'); } catch (e) {}
|
||
}
|
||
if (!payload || !payload.formData) return null;
|
||
if (!this.storeToken) return null;
|
||
// H5 存储路径:必须是因登录拦截触发的回跳,否则视为陈旧数据,清理后不提交
|
||
if (fromStorage) {
|
||
let fromLogin = false;
|
||
try { fromLogin = uni.getStorageSync('form_pending_from_login'); } catch (e) {}
|
||
if (!fromLogin) {
|
||
this.pendingSubmit = null;
|
||
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
|
||
return null;
|
||
}
|
||
}
|
||
// 仅消费 10 分钟内的待提交,避免极旧数据被自动提交
|
||
if (payload.ts && Date.now() - payload.ts > 10 * 60 * 1000) {
|
||
this.pendingSubmit = null;
|
||
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
|
||
try { uni.removeStorageSync('form_pending_from_login'); } catch (e) {}
|
||
return null;
|
||
}
|
||
this.pendingSubmit = null;
|
||
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
|
||
try { uni.removeStorageSync('form_pending_from_login'); } catch (e) {}
|
||
return payload;
|
||
},
|
||
// 登录成功后:回填已填数据并自动续提交
|
||
resumeSubmitAfterLogin() {
|
||
const pending = this.takePendingSubmit();
|
||
if (!pending) return;
|
||
// 回填表单字段(nextTick 确保 diy-form 内部 ns-newform 已挂载)
|
||
this.$nextTick(() => this.restoreFormData(pending.formData));
|
||
// 验证码:MP 登录不丢态可直接提交;H5 回跳后验证码失效,需重新获取并让用户重填
|
||
if (this.behavior.captcha == 1 && !this.captcha.code) {
|
||
this.getCaptcha();
|
||
this.$util.showToast({ title: '请重新输入验证码后提交' });
|
||
return;
|
||
}
|
||
this.create(pending.formData);
|
||
},
|
||
// 将保存的 formData 回填到 diy-form 内部的 ns-newform
|
||
restoreFormData(formData) {
|
||
try {
|
||
const ns = this.$refs.form && this.$refs.form.$refs && this.$refs.form.$refs.nsNewform;
|
||
if (ns && Array.isArray(formData)) {
|
||
ns.formData = JSON.parse(JSON.stringify(formData));
|
||
if (typeof ns.processFormData === 'function') ns.processFormData();
|
||
}
|
||
} catch (e) {}
|
||
},
|
||
// 验证码模式下本页自有提交按钮:先走 diy-form 校验与取数,再进 create
|
||
submitFromPage() {
|
||
if (this.isRepeat) return;
|
||
if (!this.$refs.form || !this.$refs.form.verify()) return;
|
||
const formData = this.$refs.form.getFormData();
|
||
this.create(formData);
|
||
},
|
||
// 填完跳转:消费统一链接选择器的完整对象(wap_url 外链/页面路径、外部小程序、拨打电话)
|
||
doRedirect(link) {
|
||
const fallback = () => { this.complete = true; this.detail = null; };
|
||
if (!link) return fallback();
|
||
if (link.appid) {
|
||
// 跳转其他微信小程序(仅小程序端可用;H5 场景直接回退完成态)
|
||
// #ifdef MP-WEIXIN
|
||
wx.navigateToMiniProgram({ appId: link.appid, path: link.page || '', fail: fallback });
|
||
return;
|
||
// #endif
|
||
fallback();
|
||
} else if (link.mobile) {
|
||
uni.makePhoneCall({ phoneNumber: String(link.mobile), fail: fallback });
|
||
fallback();
|
||
} else if (link.wap_url) {
|
||
if (/^https?:\/\//i.test(link.wap_url)) {
|
||
uni.navigateTo({ url: '/pages_tool/webview/webview?src=' + encodeURIComponent(link.wap_url), fail: fallback });
|
||
} else {
|
||
uni.navigateTo({ url: link.wap_url, fail: fallback });
|
||
}
|
||
} else {
|
||
fallback();
|
||
}
|
||
},
|
||
// 由 diy-form 内置提交按钮触发:diy-form handleSubmit 已先做 verify(),
|
||
// 仅在通过时才通过 :onSubmit 函数 prop 调用本方法;验证码模式下由 submitFromPage 调用。
|
||
// 本方法通过 created 中 this.handleSubmit = this.create.bind(this) 预绑定 this。
|
||
create(formData) {
|
||
if (this.isRepeat) return;
|
||
const behavior = this.behavior || {};
|
||
|
||
// 填写需登录 / 限填(后端强制,前端提前拦截优化体验)
|
||
if ((behavior.require_login_submit == 1 || behavior.submit_limit > 0) && !this.storeToken) {
|
||
// 保存已填写数据,登录成功(MP 弹窗 / H5 回跳)后自动续提交,避免数据丢失
|
||
this.savePendingSubmit(formData);
|
||
this.$refs.login && this.$refs.login.open(this.backUrl);
|
||
return;
|
||
}
|
||
// 验证码必填
|
||
if (behavior.captcha == 1 && !this.captcha.code) {
|
||
this.$util.showToast({ title: this.$lang('form.form.captchaRequired') });
|
||
return;
|
||
}
|
||
|
||
this.isRepeat = true;
|
||
const submitCaptchaId = String(this.captcha.id || '');
|
||
const submitCaptchaCode = String(this.captcha.code || '').toUpperCase();
|
||
this.$api.sendRequest({
|
||
url: '/form/api/form/create',
|
||
data: {
|
||
form_id: this.id,
|
||
form_data: typeof formData === 'string' ? formData : JSON.stringify(formData),
|
||
url_params: JSON.stringify(this.urlParams),
|
||
member_id: this.memberInfo ? this.memberInfo.id : 0,
|
||
captcha_id: submitCaptchaId,
|
||
captcha_code: submitCaptchaCode
|
||
},
|
||
success: res => {
|
||
if (res.code == 0) {
|
||
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') });
|
||
const link = behavior.redirect;
|
||
setTimeout(() => {
|
||
link ? this.doRedirect(link)
|
||
: (this.complete = true, this.detail = null);
|
||
}, link ? 800 : 1500);
|
||
} else {
|
||
this.isRepeat = false;
|
||
if (res.message == 'FORM_LOGIN_REQUIRED') {
|
||
// 提交时后端仍要求登录(如登录态过期):暂存数据,重登后自动续提交
|
||
this.savePendingSubmit(formData);
|
||
this.$refs.login && this.$refs.login.open(this.backUrl);
|
||
} else if (res.message == 'CAPTCHA_ERROR' || res.message == 'CAPTCHA_FAILURE') {
|
||
this.$util.showToast({ title: this.$lang('form.form.captchaError') });
|
||
if (behavior.captcha == 1) this.getCaptcha();
|
||
} else if (res.message == 'FORM_SUBMIT_LIMIT') {
|
||
this.$util.showToast({ title: this.$lang('form.form.submitLimit') });
|
||
this.complete = true;
|
||
this.detail = null;
|
||
} else if (res.message == 'FORM_NOT_OPEN') {
|
||
this.$util.showToast({ title: this.$lang('form.form.notOpen') });
|
||
} else {
|
||
this.$util.showToast({ title: res.message });
|
||
}
|
||
}
|
||
},
|
||
fail: () => {
|
||
// 网络失败时复位防重复标记,允许用户重试
|
||
this.isRepeat = false;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
page {
|
||
background: #fff;
|
||
}
|
||
|
||
/*
|
||
* ===== 顶部图条 =====
|
||
* 位于表单卡片之上。宽度由内联 bgLayerStyle 注入(calc(100% - 2*inset)),
|
||
* 与下方表单卡片等宽、左右各留 inset 对称留白。圆角 + overflow 防止图片溢出。
|
||
* 未配置 form_config.bg_image 时该节点不渲染(v-if="bgImage")。
|
||
*/
|
||
.formdata-bg {
|
||
position: relative;
|
||
background-size: cover;
|
||
background-position: center;
|
||
background-repeat: no-repeat;
|
||
border-radius: 16rpx;
|
||
overflow: hidden;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.page {
|
||
min-height: 100vh;
|
||
overflow-y: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.system-form-wrap {
|
||
overflow: visible;
|
||
margin: 0 0 60rpx 0;
|
||
/* ===== 表单卡片:白底圆角、左右内缩居中 =====
|
||
⚠️ 此 padding 仅为首屏兜底(computed 尚未就绪时生效),
|
||
运行中会被 containerStyle 内联样式覆盖,实际值由 containerInset computed 决定 */
|
||
padding: 0 30rpx;
|
||
box-sizing: border-box;
|
||
background-color: #ffffff;
|
||
border-radius: 16rpx;
|
||
padding-bottom: env(safe-area-inset-bottom, 40rpx);
|
||
}
|
||
|
||
// 穿透 diy-form 包装层:消除其自带的圆角与背景,
|
||
// 让 form 内容区域与 system-form-wrap 卡片视觉融合
|
||
.system-form-wrap ::v-deep .diy-from {
|
||
background: transparent;
|
||
border-radius: 0;
|
||
}
|
||
|
||
// ===== 行为设置:验证码行 + 自有提交按钮(diy-form 内置按钮被 showSubmit=false 隐藏时接管) =====
|
||
.form-extra-wrap {
|
||
padding: 0 28rpx 20rpx;
|
||
}
|
||
.captcha-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
background: #fff;
|
||
border-radius: 12rpx;
|
||
padding: 16rpx 20rpx;
|
||
margin-top: 24rpx;
|
||
|
||
.captcha-input {
|
||
flex: 1;
|
||
height: 84rpx;
|
||
line-height: 84rpx;
|
||
font-size: 28rpx;
|
||
border: 1rpx solid #e5e7eb;
|
||
border-radius: 16rpx;
|
||
padding: 0 24rpx;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.captcha-img {
|
||
width: 200rpx;
|
||
height: 72rpx;
|
||
margin-left: 20rpx;
|
||
border-radius: 12rpx;
|
||
border: 2rpx solid #e5e7eb;
|
||
}
|
||
|
||
.captcha-btn {
|
||
margin-left: 20rpx;
|
||
height: 72rpx;
|
||
line-height: 72rpx;
|
||
padding: 0 24rpx;
|
||
font-size: 26rpx;
|
||
color: #ff4544;
|
||
background: #fff;
|
||
border: 2rpx solid #ff4544;
|
||
border-radius: 12rpx;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
// 提交按钮:视觉对齐 diy-form 内置按钮(蓝底白字圆角,medium 内边距)
|
||
.custom-submit {
|
||
margin-top: 30rpx;
|
||
padding: 24rpx 0;
|
||
text-align: center;
|
||
color: #ffffff;
|
||
background-color: #3b82f6;
|
||
border-radius: 12rpx;
|
||
font-size: 30rpx;
|
||
}
|
||
</style>
|
||
|
||
<style lang="scss" scoped>
|
||
::v-deep .mescroll-totop {
|
||
/* #ifdef H5 */
|
||
right: 27rpx !important;
|
||
bottom: 120rpx !important;
|
||
/* #endif */
|
||
/* #ifdef MP */
|
||
right: 27rpx !important;
|
||
bottom: 180rpx !important;
|
||
/* #endif */
|
||
}
|
||
</style>
|