Files
lucky_shop/pages_tool/form/form.vue
ZF sun b2211edeab refactor(formdata.vue): update default styles and config merging logic
- 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.
2026-09-03 17:50:11 +08:00

747 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>