Files
lucky_shop/pages_tool/form/formdata.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

1123 lines
45 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>
<!-- #ifdef MP-HARMONY -->
<view :style="themeColor" class="harmony-root">
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<view :style="themeColor">
<!-- #endif -->
<!-- 顶部图条:位于 navbar 之下、表单卡片之上。宽度 calc(100% - 2*containerInset) 水平居中,
留下与卡片等宽的左右对称留白(默认容器内缩 30rpx ≈ 屏宽 1.3%,即内容宽 97.3%,与参考图一致)。
图片由后台 form_config.bg_image / bgImage 下发;未配置时不渲染,保持原来的纯白布局。 -->
<view v-if="bgImage" class="formdata-bg" :style="bgLayerStyle"></view>
<!--
自绘导航栏(仅在 navigationStyle:custom 的端渲染:APP / 微信小程序 / 华为快应用 / 鸿蒙元服务)
- pages.json 已为这几端开启 navigationStyle:custom,系统不再提供原生 navbar
- 左侧:hasBackStack 为 true 时显示返回箭头(点击 navigateBack),showHomeBtn=true 时再叠加 Home 胶囊
- 中央:表单标题(form_name)
- 整体高度 = 状态栏高度 + 44px 内容区,由 customNavbarStyle 动态算
H5 端保留系统原生 navbar,不渲染此块
-->
<!-- #ifndef H5 -->
<view class="form-home-navbar" :style="customNavbarStyle">
<view class="form-home-navbar__left">
<view v-if="hasBackStack" class="form-home-navbar__back" @tap="goBack" hover-class="form-home-navbar__btn--hover">
<text class="form-home-navbar__back-icon">‹</text>
</view>
<view v-if="showHomeBtn" class="form-home-capsule" @tap="goHome" hover-class="form-home-capsule--hover">
<text class="iconfont icon-shouye1 form-home-capsule__icon"></text>
</view>
</view>
<view class="form-home-navbar__title">{{ navbarTitle }}</view>
<view class="form-home-navbar__placeholder" />
</view>
<!-- #endif -->
<block v-if="detail">
<!-- 鸿蒙元服务:用原生 scroll-view 包裹表单,替代 CSS overflow 滚动(ArkUI 不识别 WebKit overflow 语义)。
根容器 harmony-root 占满 100vh 并 flex 列布局,navbar 占顶部固定高度,page flex:1 占剩余,scroll-view 在其内滚动。
提交按钮由 diy-form 内部渲染,处于 scroll-view 内,用户可滚到末尾点击。 -->
<!-- #ifdef MP-HARMONY -->
<view class="page harmony-page">
<scroll-view scroll-y="true" :show-scrollbar="false" class="form-scroll">
<view class="system-form-wrap" :style="containerStyle">
<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>
</scroll-view>
</view>
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<view class="page">
<view class="system-form-wrap" :style="containerStyle">
<!-- 复用 diy-form:banner / tip / 表单字段 / 提交按钮 全部由其内部渲染,样式(bannerPaddingX/tipColor/submitBgColor 等)由 form_config 控制。
:onSubmit 接管提交逻辑(带 uniacid + showModal);跨 H5 / MP 一致,不依赖 $listeners。
注意:create 重度依赖本组件 this(id/uniacid/memberInfo/$api 等),必须在 created 阶段 bind this,
否则 diy-form 通过 this.onSubmit(formData) 调用时 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>
<!-- #endif -->
</block>
<block v-else>
<ns-empty :text="emptyText" :isIndex="false"></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,
uniacid: 0,
isFromMpWebView: false, // 是否是从微信小程序的WebView跳转过来的
hideNavbar: false, // 查询参数中支持隐藏Navbar
showHomeBtn: false, // 查询参数 show_home=1 时,在系统标题栏左侧显示 Home 按钮
urlParams: {}, // 通用 URL 透传参数收集
handleSubmit: null, // create 的 this 绑定版本,作为 :onSubmit 传给 diy-form(见 created)
// 行为设置(后端 __form_config__.behavior 下发,含默认值)
behavior: {},
// 非成功状态的空态文案(表单未开放/需登录/加载失败等)
formError: '',
// 图形验证码(behavior.captcha=1 时展示,由本页渲染而非 diy-form)
captcha: { id: '', img: '', code: '' },
// 是否已加载过验证码(避免 onShow 重复刷新导致 session 错乱)
captchaLoaded: false,
// 登录后回跳地址(onLoad 时由原始 options 重建)
backUrl: '',
// 登录拦截时暂存待提交数据:组件内存(MP 不丢态)+ 本地存储(H5 登录整页回跳后从存储恢复)
pendingSubmit: null,
// onLoad→getData 比 ns-login 挂载早时,用此标志在 mounted 再兜底弹登录(避免 ref 空报错)
pendingLoginRequired: false,
// #ifndef H5
navbarHeight: 44, // 自绘 navbar 内容区高度(不含状态栏),跨端兜底 44px
statusBarHeight: 20, // 系统状态栏高度,自绘 navbar 时需要预留顶部空隙
navbarTitle: '', // 自绘 navbar 中央显示的标题
hasBackStack: false, // 自绘 navbar 左上角是否显示返回箭头:页面栈 > 1 时显示
// #endif
// #ifdef H5
formTitle: '', // 查询参数传入的Title
enableSetTitle: false, // 查询参数传入的是否可以强制设置Title
// #endif
}
},
created() {
// 预绑定 this:create 重度依赖本组件 this(id/uniacid/memberInfo/$api/$lang/$util 等)。
// 若直接传 :onSubmit="create",diy-form 通过 this.onSubmit(formData) 调用时,
// 普通函数调用的 this 会指向调用者(子组件实例),导致字段全部读不到。
// bind 后无论子组件如何调用,this 始终是 formdata 实例。
this.handleSubmit = this.create.bind(this);
},
computed: {
// #ifndef H5
// 自绘 navbar 的高度样式
// - 内容区高度 = navbarHeight(兜底 44px)
// - 整体高度 = 状态栏高度 + 内容区高度
// - padding-top 把内容区下移到状态栏之下,避免被刘海/水滴遮挡
customNavbarStyle() {
try {
const sys = uni.getSystemInfoSync();
const sb = sys.statusBarHeight || 0;
const h = this.navbarHeight || 44;
return `height: ${sb + h}px; padding-top: ${sb}px;`;
} catch (e) {
return 'height: 64px; padding-top: 20px;';
}
},
// #endif
// 构造 diy-form raw 模式期望的 value 形态:
// - jsonData:透传给 ns-newform(已是 ns-newform 期望的 [{ controller, value, val }])
// - formConfig:后端返回 camelCase(如 formPaddingX / formBgColor / labelFontSize),
// diy-form 内部 mergedFormConfig 同时兼容 snake_case 后再交给 ns-newform
// - show_banner / form_banner:交给 diy-form 渲染 banner,样式走 form_config.bannerPaddingX;form_banner 给保底图
// - show_tip / form_tip:交给 diy-form 渲染 tip,样式走 form_config;form_tip 给保底文案
// 注:小程序端模板引用的 computed 会进入 setData 数据流、每次更新都重新求值,
// 即使消费节点都在 v-if="detail" 内,detail 置空瞬间(提交成功/登录回跳等)也会求值,
// 必须做空值守卫,否则真机抛 Cannot read properties of null (reading 'json_data') 导致渲染中断
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')
}
},
// ===== 容器左右内缩(默认开启,不依赖 bgImage) =====
// 图条图片:后台 form_config.bg_image / bgImage 下发(兼容 snake_case / camelCase)。
// 未配置时不渲染图条,但表单卡片本身已有内缩效果(见 .system-form-wrap 默认 padding)
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,在 750rpx 屏宽下左右各留 30rpx ≈ 4%,内容宽 ≈ 92%。
// ⚠️ 改这里才生效:本 computed 的返回值通过 containerStyle 内联样式作用于 .system-form-wrap,
// 内联样式优先级高于 SCSS 类选择器,会覆盖下方 SCSS 里的 padding(SCSS 仅作首屏兜底)。
// ⚠️ 后台优先级最高:若 form_config.containerInset / container_inset 下发了数值,会覆盖此默认值。
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));
},
// 图条内联样式:水平居中(width=100%-2*inset + 左右各 inset 边距),高度由配置控制
bgLayerStyle() {
const inset = this.containerInset;
return 'width:calc(100% - ' + (inset * 2) + 'rpx);margin:0 ' + inset + 'rpx;height:' + this.bgImageHeight + 'rpx;background-image:url(' + this.bgImage + ');';
},
// 表单容器内联样式:覆盖 .system-form-wrap 默认 padding,使后台可配。始终返回有效样式(含默认值),
// 不再依赖 bgImage 开关 —— 表单默认就有左右内缩居中(不需后台任何配置)
containerStyle() {
return 'padding-left:' + this.containerInset + 'rpx;padding-right:' + this.containerInset + 'rpx;';
},
// 表单风格(与 diy-form.vue hasBannerMode 对齐):优先读显式 form_style,未设置时回退到原 bgImage 隐式判定
// - card :卡片浮起需要 banner 作为被压对象 → 保留内联 banner(与后端预览一致);仅 bgImage 全屏底图时隐藏避免重复
// - classic:保留内联 banner(与原行为一致),仅当后台显式配 bgImage 才隐藏
// 老表单无 form_style 字段时 → undefined → 走原隐式判定,零迁移风险
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.formdata.submitSuccess')
: (this.formError || this.$lang('form.formdata.noFormInfo'));
}
},
onLoad(options) {
console.log("=====表单页面====", options)
if (!options.uniacid) {
uni.showModal({
title: this.$lang('form.formdata.tip'),
content: this.$lang('form.formdata.paramError'),
showCancel: false,
success: () => { }
})
return false
}
uni.setStorageSync('skipPrivacyCheck', true);
// #ifdef H5
this.formTitle = options?.title ?? '';
this.enableSetTitle = options?.enable_set_title == 1;
// #endif
this.uniacid = options.uniacid
this.id = options.id || 0
this.isFromMpWebView = options.from === 'mp-webview';
this.hideNavbar = this.$util.coerceBool(options?.hide_navbar);
// show_home=1 时在系统标题栏左侧显示 Home 按钮,点击回到首页 INDEX_PAGE_URL
// 解析规则统一走 this.$util.coerceBool:支持 '1' / 'true' / 1 / true 四种写法
// 仅在 navigationStyle:custom 端生效(APP / 微信小程序 / 华为快应用 / 鸿蒙元服务);
// H5 保留系统原生 navbar,不支持 show_home,参数被忽略(这是预期行为)
// 注:H5 嵌入微信小程序 webview(from=mp-webview)的场景也属于 H5 范畴,
// 此场景下也没有 Home 入口——后续如需支持,需要单独处理(导航靠宿主小程序接管)
this.showHomeBtn = this.$util.coerceBool(options?.show_home);
// #ifndef H5
// 自绘 navbar 需要用到状态栏高度 / navbar 内容区高度(跨端兜底)
try {
const sys = uni.getSystemInfoSync();
if (sys.statusBarHeight) this.statusBarHeight = sys.statusBarHeight;
// uniapp 在不同端 menuButton / navigationBar 高度略有差异,给个安全兜底即可
if (typeof sys.navbarHeight === 'number' && sys.navbarHeight > 0) {
this.navbarHeight = sys.navbarHeight;
}
} catch (e) { /* keep defaults */ }
// 自绘 navbar 是否显示返回箭头:getCurrentPages().length > 1 即页面有栈
try {
const pages = getCurrentPages ? getCurrentPages() : [];
this.hasBackStack = Array.isArray(pages) && pages.length > 1;
} catch (e) { /* keep default */ }
// #endif
// 通用 URL 参数收集:除业务保留字段外,其余全部透传给后端
// 后端会做卫生过滤并广播事件,供 recommend 等插件消费
const reservedKeys = ['uniacid', 'id', 'scene', 'from', 'hide_navbar', 'show_home', 'title', 'enable_set_title'];
this.urlParams = {};
for (let key in options) {
if (reservedKeys.indexOf(key) !== -1) continue;
this.urlParams[key] = options[key];
}
if (options.scene) {
const scene = decodeURIComponent(options.scene)
const params = scene.split('&')
params.length && params.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('&');
const reservedH5 = ['scene', 'from', 'hide_navbar', 'show_home', 'title', 'enable_set_title'];
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') { if (!this.uniacid) this.uniacid = v; }
else if (reservedH5.indexOf(k) !== -1) {
// 导航/显示类参数:仅当 options 没有传时才从 href 补
if (options[k] === undefined && this[k] === undefined) {
if (k === 'from') this.isFromMpWebView = (v === 'mp-webview');
else if (k === 'hide_navbar') this.hideNavbar = this.$util.coerceBool(v);
else if (k === 'show_home') this.showHomeBtn = this.$util.coerceBool(v);
else if (k === 'title') this.formTitle = v;
else if (k === 'enable_set_title') this.enableSetTitle = (v == 1);
}
} else {
if (this.urlParams[k] === undefined) this.urlParams[k] = v;
}
}
}
} catch (e) {}
// #endif
// #ifdef H5
if (this.isFromMpWebView || this.hideNavbar) {
this.$nextTick(() => {
const navbar = document.querySelector('uni-page-head')
if (navbar) navbar.style.display = 'none'
})
}
// #endif
// 登录后回跳地址:基于最终状态(this.id / this.uniacid + urlParams + 导航类参数)重建,
// 确保 options/scene/H5 href 三个来源的参数都被带到登录回跳 URL 中(尤其 recommend_code)
const backQuery = [];
if (this.uniacid) backQuery.push('uniacid=' + encodeURIComponent(this.uniacid));
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));
}
}
// 导航类参数(不在 urlParams 中,单独收集)
if (options && options.from) backQuery.push('from=' + encodeURIComponent(options.from));
if (this.hideNavbar) backQuery.push('hide_navbar=1');
if (this.showHomeBtn) backQuery.push('show_home=1');
// #ifdef H5
if (this.formTitle) backQuery.push('title=' + encodeURIComponent(this.formTitle));
if (this.enableSetTitle) backQuery.push('enable_set_title=1');
// #endif
this.backUrl = '/pages_tool/form/formdata' + (backQuery.length ? '?' + backQuery.join('&') : '');
this.getData()
},
watch: {
storeToken(newVal, oldVal) {
if (newVal) {
this.getData();
// 填写需登录:弹窗登录成功后自动续提交
this.resumeSubmitAfterLogin();
}
}
},
onShow() {
this.updateNavbarTitle();
},
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('[FORMDATA] 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(); });
}
},
methods: {
// #ifndef H5
// 返回上一页;用 navigateBack,栈深度>1 时由 hasBackStack 控制按钮可见性
goBack() {
uni.navigateBack({ delta: 1 });
},
// #endif
// 返回首页;
// INDEX_PAGE_URL 是 TabBar 根页面,不能用裸 uni.redirectTo(会报"can not redirectTo a tabBar page")
// 必须用 this.$util.redirectTo,内部对 TabBar 页面自动走 uni.switchTab
// (鸿蒙元服务下还会用 reLaunch 兜底,见 common/js/util.js)
// H5 端保留 show_home / goHome(虽然模板里不渲染自绘 navbar,但保留方法调用不报错)
goHome() {
this.$util.redirectTo(this.$util.INDEX_PAGE_URL, {}, 'redirectTo');
},
getData() {
this.$api.sendRequest({
url: '/form/api/form/info',
data: {
form_id: this.id,
uniacid: this.uniacid
},
success: res => { this._handleInfoResponse(res); },
fail: err => { this._handleInfoFailure(err); }
})
},
// ====== 表单 info 响应统一处理(剥离自 getData,便于严格判定与复用) ======
// 成功必须同时满足:code==0 + data 是对象 + 内含 json_data 或 form_config
// 不满足 → 宽网判定是否是登录拦截:
// - 显式消息:FORM_LOGIN_REQUIRED / LOGIN_REQUIRED / 请先登录
// - 兜底:数据为空 + 用户未登录 + 有 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.updateNavbarTitle();
this.behavior = (dataObj.form_config && dataObj.form_config.behavior) || {};
if (this.behavior.captcha == 1 && !this.captchaLoaded) this.getCaptcha();
this.resumeSubmitAfterLogin();
// 本地缓存:下次再打开时若被登录拦截,可作为 skeleton 数据使用
try { uni.setStorageSync('form_fields_cache_' + this.id, dataObj.json_data); } catch (e) {}
// 成功分支同样必须关闭 loading 遮罩:
// 之前这里直接 return 跳过了函数底部的 hide(),导致登录后进入表单一直转圈
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 存在)但后端仍返回登录拦截:
// 登录一次后就不再自动弹登录/跳登录页(防止 H5 登录回跳后 token 未被
// 及时认可或双请求竞态时反复弹登录的死循环),仅展示空态文案 + 手动登录按钮
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 || '';
}
} catch (e) {
console.warn('[FORMDATA] _handleInfoResponse exception:', e && e.message ? e.message : e);
this.formError = '';
}
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 = '';
}
} catch (e) {
this.formError = '';
}
this.$refs.loadingCover && this.$refs.loadingCover.hide();
},
// 打开登录:3 次 setTimeout 重试(0ms / 60ms / 320ms)
// 彻底覆盖 onLoad→回调→ns-login 尚未挂载的所有时序问题
_openLoginWithRetry() {
// 防重复触发:3 秒内只允许自动弹一次登录
// (响应回调 / mounted 兜底 / watcher 同时命中时只生效一次,避免 H5 反复跳登录页)
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('[FORMDATA] login.open attempt ' + (attempts + 1) + ' failed:', e);
}
attempts++;
if (attempts < delays.length) {
setTimeout(tryOnce, delays[attempts]);
} else {
this.pendingLoginRequired = true;
}
return false;
};
tryOnce();
},
// 模板空态按钮的手动入口(最后一道防线)
forceOpenLogin() {
this._openLoginWithRetry();
},
updateNavbarTitle() {
if (!this.detail?.form_name) return;
let formName = this.detail.form_name;
// #ifdef H5
if (this.enableSetTitle && this.formTitle) {
formName = this.formTitle;
}
// #endif
// H5 端:系统原生 navbar —— uni.setNavigationBarTitle 改原生标题
// #ifdef H5
uni.setNavigationBarTitle({ title: formName });
// #endif
// 非 H5 端:custom 模式下没有原生 navbar,直接写 data.navbarTitle,
// 由模板里的 .form-home-navbar__title 显示
// #ifndef H5
this.navbarTitle = formName;
// #endif
},
// 图形验证码(点击图片/按钮刷新;提交失败后自动刷新)
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 payload = {
formId: this.id,
uniacid: this.uniacid,
urlParams: this.urlParams,
formData: formData,
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 已挂载),避免 H5 整页回跳后好友看到空白表单
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 调用本方法(跨 H5 / MP 一致,不走 $emit / $listeners)。
// 注意:本方法通过 created 中 this.handleSubmit = this.create.bind(this) 预绑定 this,
// 因此内部所有 this.xxx 都指向 formdata 实例,不会漂移到子组件。
create(formData) {
// 防重复提交:console 在拦截之前,避免日志噪声;拦截后立即置位
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();
console.log('[FORMDATA-SUBMIT-CAPTCHA]', { captcha_id: submitCaptchaId, captcha_code: submitCaptchaCode, raw_code: this.captcha.code });
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof formData === 'string' ? formData : JSON.stringify(formData),
uniacid: this.uniacid,
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0,
captcha_id: submitCaptchaId,
captcha_code: submitCaptchaCode
},
success: res => {
this.isRepeat = false;
if (res.code == 0) {
const link = behavior.redirect;
if (link) {
this.$util.showToast({ title: this.$lang('form.formdata.submitSuccess') });
setTimeout(() => this.doRedirect(link), 800);
} else {
this.complete = true
uni.showModal({
title: this.$lang('form.formdata.tip'),
content: this.$lang('form.formdata.submitSuccess'),
showCancel: false,
success: () => { }
})
}
} else {
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
} 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>
/*
* 自绘 navbar(非 H5 端:APP / 微信小程序 / 华为快应用 / 鸿蒙元服务)
* - pages.json 已为这几端开启 navigationStyle:custom,系统不再提供原生 navbar
* - 整体高度由 inline style 注入:statusBarHeight + 44px
* - 左侧 Home 胶囊 + 中央标题 + 右侧占位,标题过长自动省略
*/
/* #ifndef H5 */
/* --- 自绘 navbar 容器 --- */
.form-home-navbar {
position: relative;
width: 100%;
box-sizing: border-box;
background: #ffffff;
/* 高度由 inline style 通过 customNavbarStyle 注入:statusBarHeight + 44px */
/* padding-top 把内容区下移到状态栏之下,避免被刘海/水滴遮挡 */
/* 浮在表单内容之上,避免被 diy-form 等组件覆盖 */
z-index: 100;
/* 让内容靠 flex 自动落到 padding-top 之下的区域 */
display: flex;
flex-direction: row;
align-items: center;
}
.form-home-navbar__left {
/* 不再用 absolute,跟着 navbar 的 padding-top 下移 */
flex: none;
/* 宽度自适应:返回按钮 56rpx + Home 胶囊 56rpx + 间距 12rpx + 左 padding 16rpx = 最多 ~140rpx */
min-width: 88rpx;
max-width: 200rpx;
display: flex;
align-items: center;
justify-content: flex-start;
padding-left: 16rpx;
box-sizing: border-box;
gap: 12rpx;
}
.form-home-navbar__back {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
}
.form-home-navbar__back-icon {
font-size: 48rpx;
line-height: 1;
color: #000;
font-weight: 300;
}
.form-home-navbar__btn--hover {
background: rgba(0, 0, 0, 0.06);
}
.form-home-navbar__title {
flex: 1;
height: 44px;
line-height: 44px;
text-align: center;
font-size: 32rpx;
color: #000;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
box-sizing: border-box;
}
.form-home-navbar__placeholder {
/* 与 __left 对称,保持标题视觉居中 */
flex: none;
min-width: 88rpx;
max-width: 200rpx;
}
/* --- Home 胶囊主体 --- */
.form-home-capsule {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
}
.form-home-capsule--hover {
background: rgba(0, 0, 0, 0.06);
}
/*
* Home 图标 —— 使用项目统一的 iconfont icon-shouye1(unicode \e02d)
* 与 pages_goods/detail.vue 的首页按钮共用同一个图标,跨端视觉一致
*/
.form-home-capsule__icon {
font-size: 32rpx;
line-height: 1;
color: #000;
display: block;
}
/* #endif */
</style>
<style lang="scss" scoped>
page {
background: #fff;
}
// ===== 非鸿蒙端(H5 / 微信小程序 / App):保留原有 WebKit 滚动 / 安全区处理 =====
// 鸿蒙走下方 MP-HARMONY 专属的 scroll-view 方案,不编译这些 WebKit 属性
/*
* ===== 顶部图条 =====
* 位于 navbar 之下、表单卡片之上。宽度由内联 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;
}
/* #ifdef H5 || MP-WEIXIN || APP-PLUS */
.page {
min-height: 100vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
/* 页面容器不再垫白底 —— 只有红色框区域(.system-form-wrap 表单卡片)保留白底,
图条上下、左右留白等区域透出页面默认底色 */
}
.system-form-wrap {
overflow: visible;
margin: 0 0 60rpx 0;
/* ===== 表单卡片:唯一保留白底的区域 =====
92% 宽居中(左右各留 30rpx ≈ 4%) + 16rpx 圆角 + 白底,对应红色框范围(从图条底部到页面底部)。
⚠️ 注意:此 padding 仅为首屏兜底(H5 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);
}
/* #endif */
// ===== 鸿蒙元服务:用原生 scroll-view 做滚动容器 =====
// ArkUI 不识别 WebKit 的 overflow / -webkit-overflow-scrolling,必须用 scroll-view 组件(编译为 ArkUI Scroll)。
// 根容器 harmony-root 占满 100vh 并 flex 列布局,navbar 占顶部固定高度,page flex:1 占剩余空间,
// scroll-view 在 page 内 flex:1 获得确定高度后内部滚动。提交按钮由 diy-form 渲染在 scroll-view 内,可滚动到。
/* #ifdef MP-HARMONY */
.harmony-root {
height: 100vh;
display: flex;
flex-direction: column;
}
.harmony-page {
flex: 1;
min-height: 0; // 允许内容收缩,内部 scroll-view 才能拿到确定高度并正确滚动
display: flex;
flex-direction: column;
/* 鸿蒙端与 .page 保持一致:去掉白底,只有 .system-form-wrap 是白底卡片 */
}
.form-scroll {
flex: 1;
height: 100%;
}
.system-form-wrap {
// 不再用 overflow:hidden 裁剪;提交按钮在 scroll-view 内,滚动可见
overflow: visible;
margin: 0 0 60rpx 0;
// ===== 表单卡片:默认就是「白底圆角、左右内缩居中」(参考图效果),不依赖后台配置 =====
// 左右各留 30rpx(≈ 4% 屏宽),与 H5/MP/APP 端保持一致;后台可通过 form_config.container_inset 调整
// ⚠️ 注意:此 padding 仅为首屏兜底,运行中会被 containerStyle 内联样式覆盖(同 H5/MP/APP 端)
padding: 0 30rpx;
box-sizing: border-box;
background-color: #ffffff;
border-radius: 16rpx;
padding-bottom: 40rpx; // 鸿蒙不识别 env(safe-area-inset-bottom),固定值兜底
}
/* #endif */
/*
* ===== .form-card 兼容占位(旧模板/旧调用残留) =====
* 旧实现用 .form-card 做条件类加强化,现已将白底/圆角合并到 .system-form-wrap 默认样式中。
* 保留此类名是为了不破坏潜在的 :class="formCardClass" 引用(已从模板移除,但旧页面缓存可能用到);
* 这里只做 noop,不影响任何视觉。
*/
.form-card {}
// 穿透 diy-form 包装层:消除其自带的 30rpx 圆角与白色背景,
// 让 form 内容区域保持与原始 ns-newform 接入时一致的视觉
.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;
}
::v-deep .mescroll-totop {
/* #ifdef H5 */
right: 230rpx !important;
bottom: 120rpx !important;
/* #endif */
/* #ifdef MP */
right: 230rpx !important;
bottom: 180rpx !important;
/* #endif */
}
</style>