Compare commits
20 Commits
release/v1
...
c1407a84bb
| Author | SHA1 | Date | |
|---|---|---|---|
| c1407a84bb | |||
| 6ffbae0f33 | |||
| f6038575cc | |||
| 5cdafb6f7f | |||
| 7ebbdaf996 | |||
| ecb0789780 | |||
| e693e4273c | |||
| e3171ef7ad | |||
| 3ca1ca8cd7 | |||
| fcbbb05afe | |||
| dd94e53d81 | |||
| b8ff67aaa3 | |||
| 40a6f739d0 | |||
| 58a5a7d535 | |||
| 0e8db6f821 | |||
| e6a45f6b7d | |||
| 1b7cc4b679 | |||
| 876f639bb5 | |||
| 02c9fa602e | |||
| b5b27039ee |
@@ -185,6 +185,10 @@ const localDevConfig = ({
|
||||
domain: 'http://192.168.8.118:8050/',
|
||||
// domain: 'http://localhost:8050/',
|
||||
},
|
||||
'5g-59': { // 合成孔径雷达,一级分类背景字体颜色
|
||||
uniacid: 59,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
|
||||
@@ -1225,6 +1225,7 @@
|
||||
}
|
||||
|
||||
.popup-layer {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
.head-wrap {
|
||||
font-size: $font-size-toolbar;
|
||||
|
||||
@@ -1644,59 +1644,118 @@ export default {
|
||||
},
|
||||
/**
|
||||
* 获取小程序分享内容数据
|
||||
*
|
||||
* 重要:本函数保证永远 resolve(绝不 reject)。
|
||||
* 原因:外部 8 处调用(category.vue / diy.js / promote_code.vue / contact.vue 等)
|
||||
* 全部只写了 .then(res => {}) 没有 .catch(),一旦 reject 就会变成 Unhandled Promise Rejection,
|
||||
* 微信小程序框架会把它包成 Error: MiniProgramError 并以红框形式打印到控制台,污染日志且易被
|
||||
* 误判为业务崩溃。后台未配置某路径的分享(data:null)、接口返回 code<0、甚至 HTTP 网络失败,
|
||||
* 全部退化为"从传入 path 构造最小分享对象",保证调用方始终能拿到可消费的结构。
|
||||
*/
|
||||
getMpShare(path) {
|
||||
//如果没有特别指定 则获取当前页面的路由
|
||||
// 如果没有特别指定 则获取当前页面的路由
|
||||
if (!path) {
|
||||
let route = this.getCurrentRoute();
|
||||
path = route.path;
|
||||
if (path == '/pages_tool/member/index') {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve) => {
|
||||
resolve({})
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
// 从传入 path 生成的兜底分享(后台未配置 / 接口异常时使用)
|
||||
const buildFallback = (p) => {
|
||||
const fallbackPath = p || '/pages/index/index';
|
||||
const fallback = {
|
||||
title: '小程序分享',
|
||||
path: fallbackPath,
|
||||
imageUrl: ''
|
||||
};
|
||||
const appMessage = {
|
||||
title: fallback.title,
|
||||
path: fallback.path,
|
||||
imageUrl: fallback.imageUrl,
|
||||
success: () => {},
|
||||
fail: () => {}
|
||||
};
|
||||
const query = (fallback.path.indexOf('?') > 0) ? fallback.path.split('?')[1] : '';
|
||||
const timeLine = {
|
||||
title: fallback.title,
|
||||
query: fallback.path,
|
||||
imageUrl: fallback.imageUrl
|
||||
};
|
||||
return { appMessage, timeLine, _fallback: true, query };
|
||||
};
|
||||
// 通用构建:从后端返回的 {title,path,imageUrl} 拼成标准结构
|
||||
const buildFromConfig = (shareConfig) => {
|
||||
const appMessage = {
|
||||
title: shareConfig.title,
|
||||
path: shareConfig.path,
|
||||
imageUrl: shareConfig.imageUrl || '',
|
||||
success: () => {},
|
||||
fail: () => {}
|
||||
};
|
||||
const query = (shareConfig.path && shareConfig.path.indexOf('?') > 0)
|
||||
? shareConfig.path.split('?')[1]
|
||||
: '';
|
||||
const timeLine = {
|
||||
title: shareConfig.title,
|
||||
query: shareConfig.path,
|
||||
imageUrl: shareConfig.imageUrl || ''
|
||||
};
|
||||
return { appMessage, timeLine, query };
|
||||
};
|
||||
const fallback = buildFallback(path);
|
||||
return new Promise((resolve) => {
|
||||
Http.sendRequest({
|
||||
url: "/weapp/api/weapp/share",
|
||||
data: {
|
||||
path: path
|
||||
},
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
let shareConfig = res.data.data;
|
||||
if (shareConfig) {
|
||||
//分享给好友
|
||||
let appMessageData = {
|
||||
title: shareConfig.title,
|
||||
path: shareConfig.path,
|
||||
imageUrl: shareConfig.imageUrl,
|
||||
success: res => {
|
||||
},
|
||||
fail: res => {
|
||||
try {
|
||||
if (!res || typeof res !== 'object') {
|
||||
resolve(fallback);
|
||||
return;
|
||||
}
|
||||
if (res.code >= 0) {
|
||||
// 响应可能是两种形态:
|
||||
// A) { code, message, data: { title, path, imageUrl } }
|
||||
// B) { code, message, permission:{...}, data: null }
|
||||
// 统一安全解包:外层 payload 可能在 res.data 里,也可能直接在 res 里
|
||||
const outer = res.data !== undefined ? res.data : res;
|
||||
let shareConfig = null;
|
||||
if (outer && typeof outer === 'object') {
|
||||
// 后端在 outer 内嵌真正的配置对象(A 形态):outer.data = {title, path, ...}
|
||||
if (outer.data && typeof outer.data === 'object' && (outer.data.title || outer.data.path)) {
|
||||
shareConfig = outer.data;
|
||||
} else if (outer.title || outer.path) {
|
||||
// 极少数后端版本把配置平铺在 outer 上(扁平形态)
|
||||
shareConfig = outer;
|
||||
}
|
||||
}
|
||||
//分享到朋友圈
|
||||
let query = '';
|
||||
if (shareConfig.path.indexOf('?') > 0) {
|
||||
query = shareConfig.path.split('?')[1];
|
||||
if (shareConfig) {
|
||||
resolve(buildFromConfig(shareConfig));
|
||||
} else {
|
||||
// 后台未配置该路径(B 形态 / data=null / 空对象)→ 不 reject,直接兜底
|
||||
// 防止 8 处无 catch 的调用方变成 Unhandled Promise Rejection
|
||||
resolve(fallback);
|
||||
}
|
||||
let timeLineData = {
|
||||
title: shareConfig.title,
|
||||
query: shareConfig.path,
|
||||
imageUrl: shareConfig.imageUrl,
|
||||
}
|
||||
resolve({
|
||||
appMessage: appMessageData,
|
||||
timeLine: timeLineData,
|
||||
})
|
||||
} else {
|
||||
reject(res.data);
|
||||
// 业务 code<0:视为"无可用分享配置",兜底 resolve,不抛错
|
||||
resolve(fallback);
|
||||
}
|
||||
} else {
|
||||
reject(res.data)
|
||||
} catch (e) {
|
||||
// 任何解析异常(响应结构异常/字段缺失/后端改版)一律兜底,绝不 reject
|
||||
console.warn('[UTIL] getMpShare parse error, use fallback:', e && e.message ? e.message : e);
|
||||
resolve(fallback);
|
||||
}
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 网络失败:同样兜底(之前这里没写 fail 回调,Promise 会 hang 住)
|
||||
resolve(fallback);
|
||||
},
|
||||
complete: () => {}
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<ns-newform
|
||||
:data="formComponents"
|
||||
:customAttr="value"
|
||||
:mode="mergedFormConfig.formLayout || 'horizontal'"
|
||||
:mode="mergedFormConfig.formLayout || 'vertical'"
|
||||
:formConfig="mergedFormConfig"
|
||||
ref="nsNewform"
|
||||
></ns-newform>
|
||||
@@ -149,7 +149,7 @@ export default {
|
||||
width: widthPct + '%',
|
||||
padding: `${submitPaddingY}rpx 0`,
|
||||
color: cfg.submitColor || '#ffffff',
|
||||
'background-color': cfg.submitBgColor || '#ff4544',
|
||||
'background-color': cfg.submitBgColor || '#3b82f6',
|
||||
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
|
||||
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
|
||||
});
|
||||
@@ -237,9 +237,33 @@ export default {
|
||||
delete base[k];
|
||||
}
|
||||
});
|
||||
// 前端视觉默认:强制覆盖后端 formConfig 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
|
||||
// 与 formdata.vue 的 styleDefaults 保持一致,使所有 diy-form(含 diy-group 内的表单)统一呈现新设计稿样式。
|
||||
// 后端仍保留行为/文案类配置(behavior / submitText / banner / tip 等),本合并仅覆盖视觉字段。
|
||||
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'
|
||||
};
|
||||
// 平台差异化配置(terminal 块在 formConfig 子对象内)
|
||||
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
|
||||
const result = Object.assign({}, base);
|
||||
const result = Object.assign({}, base, styleDefaults);
|
||||
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
|
||||
if (termBlock && typeof termBlock === 'object') {
|
||||
Object.keys(termBlock).forEach(function(platform) {
|
||||
|
||||
@@ -87,41 +87,25 @@
|
||||
<!-- ============ 推荐码卡片:仅在非 consent 且非首次加载时渲染 ============ -->
|
||||
<block v-if="!showConsent && !(loadingCode && !code)">
|
||||
<!-- ============ 预设 1:Gradient 渐变风格(默认)全部内联样式 ============ -->
|
||||
<view v-if="stylePreset === 'gradient'" :style="$util.objToStyle({ width: '100%', boxSizing: 'border-box', marginTop: '24rpx', background: '#FFFFFF', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(0,0,0,0.06)' })">
|
||||
<!-- 标题行 + 已获得推荐码标签 -->
|
||||
<view v-if="stylePreset === 'gradient'" :style="$util.objToStyle({ width: 'calc(100% - 28rpx)', boxSizing: 'border-box', marginTop: '24rpx', marginLeft: 'auto', marginRight: 'auto', background: '#FFFFFF', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(0,0,0,0.06)' })">
|
||||
<!-- 标题行 -->
|
||||
<view style="display:flex;align-items:center;gap:12rpx;position:relative;z-index:1;">
|
||||
<text style="font-size:30rpx;">🔑</text>
|
||||
<text :style="$util.objToStyle({ fontSize: px2rpx(value.titleFontSize || 17) + 'px', fontWeight: '800', color: value.titleColor || '#1A1A1A' })">{{ value.title || '我的推荐码' }}</text>
|
||||
<view style="display:inline-flex;align-items:center;background:#52C41A;border-radius:16rpx;padding:4rpx 14rpx;gap:4rpx;">
|
||||
<text style="font-size:18rpx;color:#FFFFFF;">✓</text>
|
||||
<text style="font-size:20rpx;color:#FFFFFF;font-weight:600;">已获得推荐码</text>
|
||||
</view>
|
||||
<!-- 推荐码区域:浅蓝虚线框 + 右侧蓝色方形复制按钮 -->
|
||||
<view style="margin-top:24rpx;display:flex;align-items:center;gap:16rpx;position:relative;z-index:1;">
|
||||
<view style="flex:1;display:flex;align-items:center;justify-content:center;height:124rpx;background:#F0F7FF;border:4rpx dashed #3B82F6;border-radius:24rpx;box-sizing:border-box;">
|
||||
<text :style="$util.objToStyle({ fontSize: px2rpx(Math.max(20, Math.min(40, Number(value.codeFontSize) || 28))) + 'px', fontWeight: '900', color: value.codeColor || '#2563EB', letterSpacing: '2rpx', fontFamily: 'Courier New, monospace' })">{{ code || value.placeholderCode || 'HK-8888' }}</text>
|
||||
</view>
|
||||
<view v-if="value.showCopyButton !== false" @tap="copyRecommendCode" style="flex-shrink:0;width:96rpx;height:96rpx;display:flex;align-items:center;justify-content:center;background:linear-gradient(180deg,#3B82F6 0%,#2563EB 100%);border-radius:20rpx;box-shadow:0 4rpx 12rpx rgba(37,99,235,0.3);">
|
||||
<text style="font-size:44rpx;color:#FFFFFF;">📋</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 推荐码+复制按钮区域:浅米黄色背景,圆角紧凑 -->
|
||||
<view style="margin-top:24rpx;background:#FFF8F0;border-radius:16rpx;padding:32rpx 20rpx 24rpx;position:relative;z-index:1;">
|
||||
<!-- 推荐码居中显示 -->
|
||||
<view style="text-align:center;">
|
||||
<text :style="$util.objToStyle({ fontSize: px2rpx(Math.max(16, Math.min(48, (Number(value.codeFontSize) || 28) * 0.93))) + 'px', fontWeight: '900', color: value.codeColor || '#DC143C', letterSpacing: '2rpx', fontFamily: 'Courier New, monospace' })">{{ code || value.placeholderCode || 'ABCD1234' }}</text>
|
||||
</view>
|
||||
<!-- 复制推荐码按钮:白色小按钮,红边框,在推荐码下方居中 -->
|
||||
<view v-if="value.showCopyButton !== false" style="margin-top:16rpx;text-align:center;">
|
||||
<view @tap="copyRecommendCode" :style="$util.objToStyle(copyBtnStyleObj({display:'inline-flex',alignItems:'center',borderRadius:'15px',padding:'0 16px',height:'30px',gap:'4px'}))">
|
||||
<text style="font-size:22rpx;">📋</text>
|
||||
<text :style="$util.objToStyle(copyBtnStyleObj({}, true))">{{ nc.copyButtonText || '复制推荐码' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 立即分享链接按钮:黄橙渐变,胶囊形圆角,紧凑布局
|
||||
固定走 linear-gradient,不读 diyData.buttonBgColor(避免被后端默认色覆盖)
|
||||
H5 端用 view 触发(避免 button 默认样式),小程序/APP 端保留 button 以触发 open-type=share -->
|
||||
<button v-if="value.showShareButton !== false && !isH5Platform" open-type="share" @tap="onShareClick" style="position:relative;left:0;right:0;margin:16rpx 0 0 0;width:100%;text-align:left;background:transparent;border:0;padding:0;line-height:normal;display:block;box-sizing:border-box;">
|
||||
<view style="display:block;width:100%;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
|
||||
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ value.buttonText || '立即分享' }}</text>
|
||||
</view>
|
||||
</button>
|
||||
<view v-else-if="value.showShareButton !== false" @tap="onShareClick" style="margin-top:16rpx;width:100%;">
|
||||
<view style="display:block;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
|
||||
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ nc.buttonText || '立即分享' }}</text>
|
||||
</view>
|
||||
<!-- 提示文字 -->
|
||||
<view style="margin-top:16rpx;display:flex;align-items:flex-start;gap:8rpx;position:relative;z-index:1;">
|
||||
<text style="font-size:24rpx;color:#F59E0B;flex-shrink:0;">💡</text>
|
||||
<text style="font-size:24rpx;color:#999999;line-height:1.5;flex:1;">{{ value.tipText || '复制推荐码分享给合作企业,对方填表时填入即可绑定' }}</text>
|
||||
</view>
|
||||
<!-- showLink -->
|
||||
<view v-if="isFeatureOn(value.showLink)" style="margin-top:20rpx;text-align:center;position:relative;z-index:1;">
|
||||
@@ -135,6 +119,29 @@
|
||||
<text style="font-size:22rpx;color:#999;margin-top:12rpx;">{{ value.posterQrTip || '扫码进入并填写入驻信息' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 分享推荐链接按钮:深蓝渐变,左侧图标 + 文字,右侧“复制链接”小按钮(独立于白色卡片外) -->
|
||||
<button v-if="stylePreset === 'gradient' && value.showShareButton !== false && !isH5Platform" open-type="share" @tap="onShareClick" style="position:relative;left:0;right:0;margin:24rpx auto 0 auto;width:calc(100% - 28rpx);text-align:left;background:transparent;border:0;padding:0;line-height:normal;display:block;box-sizing:border-box;">
|
||||
<view style="display:flex;align-items:center;justify-content:space-between;width:100%;height:100rpx;background:linear-gradient(90deg,#3B82F6 0%,#1E40AF 100%);border-radius:20rpx;padding:0 24rpx;box-shadow:0 4rpx 16rpx rgba(29,78,216,0.35);box-sizing:border-box;">
|
||||
<view style="display:flex;align-items:center;gap:12rpx;">
|
||||
<text style="font-size:36rpx;color:#FFFFFF;">📤</text>
|
||||
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">{{ value.buttonText || '分享推荐链接给企业' }}</text>
|
||||
</view>
|
||||
<view @tap.stop="copyLink" style="flex-shrink:0;height:52rpx;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.2);border-radius:26rpx;padding:0 20rpx;">
|
||||
<text style="font-size:24rpx;color:#FFFFFF;">复制链接</text>
|
||||
</view>
|
||||
</view>
|
||||
</button>
|
||||
<view v-else-if="stylePreset === 'gradient' && value.showShareButton !== false" @tap="onShareClick" style="margin:24rpx auto 0 auto;width:calc(100% - 28rpx);">
|
||||
<view style="display:flex;align-items:center;justify-content:space-between;width:100%;height:100rpx;background:linear-gradient(90deg,#3B82F6 0%,#1E40AF 100%);border-radius:20rpx;padding:0 24rpx;box-shadow:0 4rpx 16rpx rgba(29,78,216,0.35);box-sizing:border-box;">
|
||||
<view style="display:flex;align-items:center;gap:12rpx;">
|
||||
<text style="font-size:36rpx;color:#FFFFFF;">📤</text>
|
||||
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">{{ nc.buttonText || '分享推荐链接给企业' }}</text>
|
||||
</view>
|
||||
<view @tap.stop="copyLink" style="flex-shrink:0;height:52rpx;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.2);border-radius:26rpx;padding:0 20rpx;">
|
||||
<text style="font-size:24rpx;color:#FFFFFF;">复制链接</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ============ 预设 2:Minimal 极简风格(全部内联样式) ============ -->
|
||||
<view v-if="stylePreset === 'minimal'" :style="$util.objToStyle({ width: '100%', boxSizing: 'border-box', marginTop: '24rpx', background: 'linear-gradient(90deg,#FFFFFF 0%,#FFF8F8 30%,#FFF5F5 60%,#FFF0F0 100%)', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(255,107,107,0.08)' })">
|
||||
@@ -323,6 +330,56 @@
|
||||
<text style="font-size:22rpx;color:#999;margin-top:8rpx;">{{ value.posterQrTip || '扫码进入并填写入驻信息' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ============ 预设 5(样式5):直接采用用户提供的代码,原样新增 ============ -->
|
||||
<view v-if="stylePreset === 'style5'" :style="$util.objToStyle({ width: '100%', boxSizing: 'border-box', marginTop: '24rpx', background: '#FFFFFF', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(0,0,0,0.06)' })">
|
||||
<!-- 标题行 + 已获得推荐码标签 -->
|
||||
<view style="display:flex;align-items:center;gap:12rpx;position:relative;z-index:1;">
|
||||
<text :style="$util.objToStyle({ fontSize: px2rpx(value.titleFontSize || 17) + 'px', fontWeight: '800', color: value.titleColor || '#1A1A1A' })">{{ value.title || '我的推荐码' }}</text>
|
||||
<view style="display:inline-flex;align-items:center;background:#52C41A;border-radius:16rpx;padding:4rpx 14rpx;gap:4rpx;">
|
||||
<text style="font-size:18rpx;color:#FFFFFF;">✓</text>
|
||||
<text style="font-size:20rpx;color:#FFFFFF;font-weight:600;">已获得推荐码</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 推荐码+复制按钮区域:浅米黄色背景,圆角紧凑 -->
|
||||
<view style="margin-top:24rpx;background:#FFF8F0;border-radius:16rpx;padding:32rpx 20rpx 24rpx;position:relative;z-index:1;">
|
||||
<!-- 推荐码居中显示 -->
|
||||
<view style="text-align:center;">
|
||||
<text :style="$util.objToStyle({ fontSize: px2rpx(Math.max(16, Math.min(48, (Number(value.codeFontSize) || 28) * 0.93))) + 'px', fontWeight: '900', color: value.codeColor || '#DC143C', letterSpacing: '2rpx', fontFamily: 'Courier New, monospace' })">{{ code || value.placeholderCode || 'ABCD1234' }}</text>
|
||||
</view>
|
||||
<!-- 复制推荐码按钮:白色小按钮,红边框,在推荐码下方居中 -->
|
||||
<view v-if="value.showCopyButton !== false" style="margin-top:16rpx;text-align:center;">
|
||||
<view @tap="copyRecommendCode" :style="$util.objToStyle(copyBtnStyleObj({display:'inline-flex',alignItems:'center',borderRadius:'15px',padding:'0 16px',height:'30px',gap:'4px'}))">
|
||||
<text style="font-size:22rpx;">📋</text>
|
||||
<text :style="$util.objToStyle(copyBtnStyleObj({}, true))">{{ nc.copyButtonText || '复制推荐码' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 立即分享链接按钮:黄橙渐变,胶囊形圆角,紧凑布局
|
||||
固定走 linear-gradient,不读 diyData.buttonBgColor(避免被后端默认色覆盖)
|
||||
H5 端用 view 触发(避免 button 默认样式),小程序/APP 端保留 button 以触发 open-type=share -->
|
||||
<button v-if="stylePreset === 'style5' && value.showShareButton !== false && !isH5Platform" open-type="share" @tap="onShareClick" style="position:relative;left:0;right:0;margin:16rpx 0 0 0;width:100%;text-align:left;background:transparent;border:0;padding:0;line-height:normal;display:block;box-sizing:border-box;">
|
||||
<view style="display:block;width:100%;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
|
||||
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ value.buttonText || '立即分享' }}</text>
|
||||
</view>
|
||||
</button>
|
||||
<view v-else-if="stylePreset === 'style5' && value.showShareButton !== false" @tap="onShareClick" style="margin-top:16rpx;width:100%;">
|
||||
<view style="display:block;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
|
||||
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ nc.buttonText || '立即分享' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- showLink -->
|
||||
<view v-if="isFeatureOn(value.showLink)" style="margin-top:20rpx;text-align:center;position:relative;z-index:1;">
|
||||
<text @tap="copyLink" style="font-size:22rpx;color:#999;text-decoration:underline;">{{ formatShareLink }}</text>
|
||||
</view>
|
||||
<!-- showQR -->
|
||||
<view v-if="isFeatureOn(value.showQR)" style="margin-top:20rpx;display:flex;flex-direction:column;align-items:center;position:relative;z-index:1;">
|
||||
<view :style="$util.objToStyle({ width: px2rpx(nc.qrSize || 200) + 'px', height: px2rpx(nc.qrSize || 200) + 'px', background: nc.qrBoxBackground || 'transparent', border: nc.qrBoxBorderWidth && nc.qrBoxBorderColor ? (px2rpx(nc.qrBoxBorderWidth) + 'px solid ' + nc.qrBoxBorderColor) : 'none', borderRadius: px2rpx(nc.qrBoxBorderRadius || 0) + 'px', padding: px2rpx(nc.qrBoxPadding || 0) + 'px', boxSizing: 'border-box' })">
|
||||
<image :src="qrcodeRenderUrl" mode="aspectFit" style="width:100%;height:100%;display:block;" />
|
||||
</view>
|
||||
<text style="font-size:22rpx;color:#999;margin-top:12rpx;">{{ value.posterQrTip || '扫码进入并填写入驻信息' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
<!-- 登录组件占位(ns-login 全局 mixin 已注册) -->
|
||||
@@ -382,6 +439,7 @@ function rcFallbackCopy(text, cb) {
|
||||
/* =========================== 组件本体 =========================== */
|
||||
import DiyMinx from './minx.js';
|
||||
import golbalConfig from '@/common/js/golbalConfig.js';
|
||||
import { extractInternalAppPath } from '@/common/js/util-navigate.js';
|
||||
export default {
|
||||
name: 'DiyRecommendCode',
|
||||
mixins: [DiyMinx, golbalConfig],
|
||||
@@ -429,6 +487,13 @@ export default {
|
||||
qrBoxBackground: '', // 容器背景色;空=透明
|
||||
qrBoxPadding: 0, // 容器内边距(rpx)
|
||||
|
||||
/* ============== 分享目标配置(后台可配,避免写死 pages_tool/form/formdata) ============== */
|
||||
// H5 完整分享链接模板,必须包含 {code} 占位符;为空时走默认兜底链接
|
||||
customShareLink: '',
|
||||
// 微信小程序分享路径模板,必须包含 {code} 占位符;为空时自动从 customShareLink 还原
|
||||
// 示例:'/pages_tool/index/diy?id=123&recommend_code={code}'
|
||||
miniProgramPath: '',
|
||||
|
||||
/* ============== 兼容旧字段(不破坏之前商家已经配置过的卡片) ============== */
|
||||
// 外间距
|
||||
marginTop: 20, marginBottom: 20,
|
||||
@@ -478,7 +543,7 @@ export default {
|
||||
},
|
||||
stylePreset() {
|
||||
const s = String(this.nc.stylePreset || 'gradient').toLowerCase();
|
||||
if (['minimal', 'card', 'bookgold', 'horizontal'].includes(s)) return s;
|
||||
if (['minimal', 'card', 'bookgold', 'horizontal', 'style5'].includes(s)) return s;
|
||||
return 'gradient';
|
||||
},
|
||||
requireConsentComputed() {
|
||||
@@ -651,9 +716,13 @@ export default {
|
||||
return 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&margin=0&data=' + url;
|
||||
},
|
||||
formatShareLink() {
|
||||
// 读取后台配置完整带{code}的模板
|
||||
const template = this.nc.customShareLink || "http://192.168.8.118/hwappx/common/pages_tool/form/formdata?id=13&uniacid=1&recommend_code={code}";
|
||||
// 把模板里 {code} 整体替换成 {推荐码}
|
||||
// 读取后台配置完整带 {code} 的 H5 链接模板,未配置时兜底到原表单链接
|
||||
// 兼容后台传对象 { wap_url, url } 或字符串的情况
|
||||
let template = this._pickShareLinkTemplate(this.nc.customShareLink);
|
||||
if (!template) {
|
||||
template = "http://192.168.8.118/hwappx/common/pages_tool/form/formdata?id=13&uniacid=1&recommend_code={code}";
|
||||
}
|
||||
// 把模板里 {code} 整体替换成当前推荐码
|
||||
return template.replace(/\{code\}/g, this.code);
|
||||
},
|
||||
},
|
||||
@@ -768,13 +837,13 @@ export default {
|
||||
*/
|
||||
onShareTimeline() {
|
||||
const v = this.nc;
|
||||
// 从H5分享链接中提取完整查询参数,与分享给好友保持一致
|
||||
// 从小程序分享路径中提取完整查询参数,与分享给好友保持一致
|
||||
var query = '';
|
||||
try {
|
||||
var shareLink = this.formatShareLink;
|
||||
var queryIndex = shareLink.indexOf('?');
|
||||
var miniPath = this.extractMiniProgramPath();
|
||||
var queryIndex = miniPath.indexOf('?');
|
||||
if (queryIndex >= 0) {
|
||||
query = shareLink.substring(queryIndex + 1); // id=13&uniacid=1&recommend_code=XXX
|
||||
query = miniPath.substring(queryIndex + 1); // id=13&uniacid=1&recommend_code=XXX
|
||||
}
|
||||
} catch(e) {}
|
||||
if (!query && this.code) query = 'recommend_code=' + encodeURIComponent(this.code);
|
||||
@@ -1248,20 +1317,55 @@ onShareClick() {
|
||||
});
|
||||
},
|
||||
extractMiniProgramPath() {
|
||||
// 从H5分享链接中提取完整查询参数,保持小程序分享与H5链接参数一致
|
||||
// H5链接如: http://xxx/hwappx/common/pages_tool/form/formdata?id=13&uniacid=1&recommend_code=XXX
|
||||
// 小程序路径如: /pages_tool/form/formdata?id=13&uniacid=1&recommend_code=XXX
|
||||
// 1) 优先使用后台单独配置的小程序路径模板(支持 {code} 占位符)
|
||||
const pathTemplate = this._pickShareLinkTemplate(this.nc.miniProgramPath);
|
||||
if (pathTemplate) {
|
||||
return this._ensureRecommendCode(pathTemplate.replace(/\{code\}/g, this.code || ''));
|
||||
}
|
||||
|
||||
// 2) 否则从 H5 分享链接(后台「从平台链接中选择」或「自定义 URL」)中还原小程序内部路径
|
||||
const shareLink = this.formatShareLink;
|
||||
let miniPath = '';
|
||||
try {
|
||||
var queryIndex = shareLink.indexOf('?');
|
||||
if (queryIndex >= 0) {
|
||||
var query = shareLink.substring(queryIndex); // ?id=13&uniacid=1&recommend_code=XXX
|
||||
return '/pages_tool/form/formdata' + query;
|
||||
}
|
||||
// 强制按内部链接还原:截取 /pages 或 /pages_xxx 起始段
|
||||
miniPath = extractInternalAppPath(shareLink, true);
|
||||
} catch(e) {}
|
||||
// 兜底:只带recommend_code
|
||||
if (!this.code) return '/pages_tool/form/formdata';
|
||||
return '/pages_tool/form/formdata?recommend_code=' + encodeURIComponent(this.code);
|
||||
|
||||
// 如果是相对路径且未被上面识别,也视为内部路径
|
||||
if (!miniPath && shareLink && shareLink.charAt(0) === '/') {
|
||||
miniPath = shareLink;
|
||||
}
|
||||
|
||||
// 兜底:原表单页面
|
||||
if (!miniPath) {
|
||||
miniPath = '/pages_tool/form/formdata';
|
||||
}
|
||||
|
||||
return this._ensureRecommendCode(miniPath);
|
||||
},
|
||||
/**
|
||||
* 确保小程序路径携带 recommend_code 参数
|
||||
*/
|
||||
_ensureRecommendCode(miniPath) {
|
||||
if (!this.code) return miniPath;
|
||||
try {
|
||||
const hasCode = /[?&]recommend_code=/.test(miniPath);
|
||||
if (hasCode) return miniPath;
|
||||
const sep = miniPath.indexOf('?') >= 0 ? '&' : '?';
|
||||
return miniPath + sep + 'recommend_code=' + encodeURIComponent(this.code);
|
||||
} catch(e) { return miniPath; }
|
||||
},
|
||||
/**
|
||||
* 统一解析后台配置的链接字段:支持字符串 / { wap_url } / { url }
|
||||
*/
|
||||
_pickShareLinkTemplate(raw) {
|
||||
if (!raw) return '';
|
||||
if (typeof raw === 'string') return raw.trim();
|
||||
if (raw && typeof raw === 'object') {
|
||||
if (typeof raw.wap_url === 'string' && raw.wap_url.trim()) return raw.wap_url.trim();
|
||||
if (typeof raw.url === 'string' && raw.url.trim()) return raw.url.trim();
|
||||
}
|
||||
return '';
|
||||
},
|
||||
_persistRecommendInfo() {
|
||||
try {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<input
|
||||
@@ -22,8 +22,8 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
|
||||
<view class="order-cell flex-box textarea" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
|
||||
@@ -48,11 +48,11 @@
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<view :class="['box', 'box-flex']">
|
||||
<text v-if="item.val != ''">{{ item.val }}</text>
|
||||
<text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
</picker>
|
||||
@@ -60,8 +60,8 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'check-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
|
||||
@@ -80,8 +80,8 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'radio-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
|
||||
@@ -100,8 +100,8 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box img-boxs">
|
||||
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
|
||||
@@ -116,23 +116,23 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box box-flex">
|
||||
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<view :class="['box', 'box-flex']">
|
||||
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box date-boxs">
|
||||
<view class="date-box">
|
||||
@@ -157,23 +157,23 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box box-flex">
|
||||
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<view :class="['box', 'box-flex']">
|
||||
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box date-boxs">
|
||||
<view class="date-box">
|
||||
@@ -196,20 +196,20 @@
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
|
||||
<view class="order-cell box-flex" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex']">
|
||||
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
|
||||
<view class="select-address " :class="{ empty: !item.val, 'color-tip': !item.val }">
|
||||
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
|
||||
</view>
|
||||
</pick-regions>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="box">
|
||||
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
|
||||
<view class="select-address " :class="{ empty: !item.val, 'color-tip': !item.val }">
|
||||
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
|
||||
</view>
|
||||
</pick-regions>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
@@ -263,22 +263,22 @@
|
||||
config() {
|
||||
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
|
||||
const defaults = {
|
||||
formPaddingX: 0,
|
||||
formPaddingX: 28,
|
||||
formBgColor: '#ffffff',
|
||||
rowPaddingY: 20,
|
||||
rowPaddingY: 24,
|
||||
rowMinHeight: 60,
|
||||
labelGap: 16,
|
||||
inputHeight: 60,
|
||||
inputRadius: 0,
|
||||
labelWidthMode: 'fixed', // 旧版 width: 80vw 用 fixed 180rpx 等价替代
|
||||
labelGap: 20,
|
||||
inputHeight: 84,
|
||||
inputRadius: 16,
|
||||
labelWidthMode: 'auto', // 与 ns-newform 对齐:标签在上、输入框在下,整行宽度
|
||||
labelFixedWidth: 180,
|
||||
labelFontSize: 28,
|
||||
labelFontSize: 32,
|
||||
labelColor: '#1f2937',
|
||||
labelFontWeight: 600,
|
||||
labelFontWeight: 700,
|
||||
labelAutoMaxWidth: 200,
|
||||
requiredColor: '#ef4444',
|
||||
checkedColor: '#ff4544',
|
||||
placeholderColor: '#909399',
|
||||
checkedColor: '#3b82f6',
|
||||
placeholderColor: '#9ca3af',
|
||||
placeholderFontSize: 28,
|
||||
// textarea 显示行数 / 行高(与 ns-newform 对齐)
|
||||
// 改用 :rows + line-height 驱动高度:rows 给原生 <textarea>(H5/微信原生层/鸿蒙 ArkUI 都识别),
|
||||
@@ -404,13 +404,10 @@
|
||||
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
||||
});
|
||||
},
|
||||
// 标签宽度:根据 labelWidthMode 决定;fixed 用 --label-fixed-width,auto 用 maxLabelWidth
|
||||
// 标签宽度:垂直布局下标签整行显示,宽度交由 CSS(.name { width:100% })控制;
|
||||
// 字号 / 颜色 / 字重已由 .form-wrap 注入的 --label-* 变量统一驱动,无需 inline 注入。
|
||||
nameWidthStyle() {
|
||||
const cfg = this.config;
|
||||
if (cfg.labelWidthMode === 'fixed') {
|
||||
return { width: this.$util.rpx(cfg.labelFixedWidth) };
|
||||
}
|
||||
return this.maxLabelWidth ? { width: this.$util.rpx(this.maxLabelWidth) } : { width: 'auto' };
|
||||
return {};
|
||||
},
|
||||
// 输入框样式(input / textarea 共用)
|
||||
inputStyle() {
|
||||
@@ -948,31 +945,31 @@
|
||||
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
|
||||
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
|
||||
.form-wrap {
|
||||
--form-padding-x: 0rpx;
|
||||
--form-padding-x: 28rpx;
|
||||
--form-bg-color: #ffffff;
|
||||
--row-padding-y: 20rpx;
|
||||
--row-padding-y: 24rpx;
|
||||
--row-min-height: 60rpx;
|
||||
--label-gap: 16rpx;
|
||||
--input-height: 60rpx;
|
||||
--input-radius: 0rpx;
|
||||
--label-gap: 20rpx;
|
||||
--input-height: 84rpx;
|
||||
--input-radius: 16rpx;
|
||||
--label-fixed-width: 180rpx;
|
||||
--label-font-size: 28rpx;
|
||||
--label-font-size: 32rpx;
|
||||
--label-color: #1f2937;
|
||||
--label-font-weight: 600;
|
||||
--label-font-weight: 700;
|
||||
--required-color: #ef4444;
|
||||
--placeholder-font-size: 28rpx;
|
||||
--checked-color: #ff4544;
|
||||
--placeholder-color: #909399;
|
||||
--checked-color: #3b82f6;
|
||||
--placeholder-color: #9ca3af;
|
||||
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
|
||||
--textarea-rows: 4;
|
||||
--textarea-line-height: 44rpx;
|
||||
--textarea-line-height: 48rpx;
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
--input-border-color: #e5e7eb;
|
||||
--input-bg-color: #f9fafb;
|
||||
--input-focus-border-color: #ff4544;
|
||||
--input-bg-color: #ffffff;
|
||||
--input-focus-border-color: #3b82f6;
|
||||
--input-focus-bg-color: #ffffff;
|
||||
--row-divider-color: #f0f0f0;
|
||||
--show-row-divider: 1;
|
||||
--show-row-divider: 0;
|
||||
}
|
||||
|
||||
.order-wrap {
|
||||
@@ -990,7 +987,8 @@
|
||||
|
||||
.order-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column; // 与 ns-newform vertical 对齐:标签在上、输入框在下
|
||||
align-items: flex-start;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
min-height: var(--row-min-height, 60rpx);
|
||||
@@ -1023,18 +1021,23 @@
|
||||
}
|
||||
|
||||
.name {
|
||||
width: var(--label-fixed-width, 180rpx);
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: var(--label-gap, 16rpx);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--input-height, 60rpx);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--label-gap, 20rpx);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.tit {
|
||||
text-align: left;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
font-weight: var(--label-font-weight, 600);
|
||||
font-weight: var(--label-font-weight, 700);
|
||||
|
||||
text {
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
@@ -1044,15 +1047,17 @@
|
||||
.required {
|
||||
color: var(--required-color, #ef4444);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
margin-left: 4rpx;
|
||||
width: 14rpx;
|
||||
margin-right: 6rpx;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.box {
|
||||
flex: 1;
|
||||
padding: 0 10rpx;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
line-height: inherit;
|
||||
text-align: left;
|
||||
|
||||
@@ -1064,26 +1069,27 @@
|
||||
color: var(--label-color, #1f2937);
|
||||
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
|
||||
// 避免 placeholder / 文字下半部分被裁切
|
||||
line-height: var(--input-height, 60rpx);
|
||||
height: var(--input-height, 60rpx);
|
||||
min-height: var(--input-height, 60rpx);
|
||||
border-radius: var(--input-radius, 0rpx);
|
||||
line-height: var(--input-height, 84rpx);
|
||||
height: var(--input-height, 84rpx);
|
||||
min-height: var(--input-height, 84rpx);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
box-sizing: border-box;
|
||||
padding: 0 12rpx;
|
||||
width: 100%;
|
||||
padding: 0 24rpx;
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
background: var(--input-bg-color, #f9fafb);
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus {
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
border-color: var(--input-focus-border-color, #ff4544);
|
||||
border-color: var(--input-focus-border-color, #3b82f6);
|
||||
}
|
||||
|
||||
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
|
||||
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #909399);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
@@ -1091,9 +1097,9 @@
|
||||
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
|
||||
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
|
||||
.placeholder {
|
||||
color: var(--placeholder-color, #909399);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
line-height: var(--input-height, 84rpx);
|
||||
}
|
||||
|
||||
::v-deep textarea,
|
||||
@@ -1101,36 +1107,36 @@
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
width: 100%;
|
||||
color: var(--label-color, #1f2937);
|
||||
// 行高由 formConfig.textareaLineHeight(兜底 44rpx)控制;
|
||||
// 行高由 formConfig.textareaLineHeight(兜底 48rpx)控制;
|
||||
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
|
||||
// 真正做到了"rows 改 → 高度自动改"。
|
||||
line-height: var(--textarea-line-height, 44rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
line-height: var(--textarea-line-height, 48rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border-radius: var(--input-radius, 0rpx);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
box-sizing: border-box;
|
||||
padding: 12rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
background: var(--input-bg-color, #f9fafb);
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus {
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
border-color: var(--input-focus-border-color, #ff4544);
|
||||
border-color: var(--input-focus-border-color, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
.uni-input {
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
min-height: var(--input-height, 60rpx);
|
||||
line-height: var(--input-height, 84rpx);
|
||||
min-height: var(--input-height, 84rpx);
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
background: var(--input-bg-color, #f9fafb);
|
||||
border-radius: var(--input-radius, 0rpx);
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -1140,26 +1146,36 @@
|
||||
}
|
||||
|
||||
.color-tip {
|
||||
color: var(--placeholder-color, #909399);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
}
|
||||
|
||||
checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
// 让原生 picker / 自定义 pick-regions 撑满父容器(纵向布局下 label 在上、控件在下)
|
||||
picker,
|
||||
.pick-regions {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// 在 box-flex(带右侧箭头)场景下,picker 不应占满,留出右侧箭头空间
|
||||
&.box-flex {
|
||||
picker,
|
||||
.pick-regions {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.iconfont.icon-right {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
|
||||
.option-layout-vertical {
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
@@ -1169,6 +1185,39 @@
|
||||
margin-right: 30rpx;
|
||||
}
|
||||
|
||||
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
|
||||
// 注意:option-layout-* 是 .box 自身的类,必须用 & 嵌套,否则编译成 .box .option-layout-* 会选不中
|
||||
&.option-layout-vertical {
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.option-layout-wrap {
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-right: 30rpx;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.img-boxs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1244,10 +1293,11 @@
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
|
||||
.iconfont {
|
||||
font-size: 32rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 40rpx;
|
||||
margin-right: 10rpx;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1261,10 +1311,11 @@
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
|
||||
.iconfont {
|
||||
font-size: 32rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 40rpx;
|
||||
margin-right: 10rpx;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -325,14 +325,69 @@ export default {
|
||||
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
this.getCode(authData => {
|
||||
this.authLogin(authData, 'authOnlyLogin');
|
||||
}, (err) => {
|
||||
callback && callback(err);
|
||||
// 真机 __usePrivacyCheck__ 开启时 getPhoneNumber 属于微信隐私接口,
|
||||
// 必须先完成平台级隐私协议授权,否则点击"授权手机号"按钮会直接报
|
||||
// "privacy permission is not authorized or buttonId is invalid"。
|
||||
// 这里在登录链路前主动检查并弹隐私弹窗,授权完成后再继续登录。
|
||||
this.ensureWxPrivacyAuthorized(() => {
|
||||
this.getCode(authData => {
|
||||
this.authLogin(authData, 'authOnlyLogin');
|
||||
}, (err) => {
|
||||
callback && callback(err);
|
||||
});
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 微信小程序: 确保平台级隐私协议已授权后再执行回调
|
||||
ensureWxPrivacyAuthorized(done) {
|
||||
try {
|
||||
if (typeof wx === 'undefined' || !wx.getPrivacySetting) {
|
||||
// 低版本基础库无隐私 API, 直接放行
|
||||
done();
|
||||
return;
|
||||
}
|
||||
wx.getPrivacySetting({
|
||||
success: (res) => {
|
||||
if (!res || !res.needAuthorization) {
|
||||
// 已授权或无隐私指引配置, 直接放行
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// 需要授权: 优先弹自建隐私弹窗(内含 agreePrivacyAuthorization 按钮,
|
||||
// 点击同意即完成平台级授权); 用户同意后通过 privacy:agreed 事件继续
|
||||
const popup = this.$refs.privacyPopup;
|
||||
if (popup && typeof popup.showPrivacyPopup === 'function') {
|
||||
const onAgreed = () => {
|
||||
uni.$off('privacy:agreed', onAgreed);
|
||||
uni.$off('privacy:disagreed', onDisagreed);
|
||||
done();
|
||||
};
|
||||
const onDisagreed = () => {
|
||||
uni.$off('privacy:agreed', onAgreed);
|
||||
uni.$off('privacy:disagreed', onDisagreed);
|
||||
// 用户拒绝隐私协议: 不继续登录链路(隐私接口也不可用)
|
||||
};
|
||||
uni.$off('privacy:agreed', onAgreed);
|
||||
uni.$off('privacy:disagreed', onDisagreed);
|
||||
uni.$on('privacy:agreed', onAgreed);
|
||||
uni.$on('privacy:disagreed', onDisagreed);
|
||||
popup.showPrivacyPopup();
|
||||
} else if (typeof wx.requirePrivacyAuthorize === 'function') {
|
||||
// 无 privacy-popup 实例时退回微信官方隐私弹窗
|
||||
wx.requirePrivacyAuthorize({
|
||||
success: () => done(),
|
||||
fail: () => { }
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: () => done()
|
||||
});
|
||||
} catch (e) {
|
||||
done();
|
||||
}
|
||||
},
|
||||
|
||||
// 跳转去登录页
|
||||
toLoginH5Page(mode = 'navigateTo') {
|
||||
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
|
||||
@@ -614,8 +669,24 @@ export default {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const errMsg = (e.detail && e.detail.errMsg) || '';
|
||||
// #ifdef MP-WEIXIN
|
||||
// 隐私协议未授权时 getPhoneNumber 会直接失败:
|
||||
// "getPhoneNumber:fail privacy permission is not authorized or buttonId is invalid"
|
||||
// 此时不再抛原始错误, 引导用户同意隐私弹窗后重新点授权按钮
|
||||
if (errMsg.indexOf('privacy') !== -1) {
|
||||
this.$util.showToast({
|
||||
title: this.$lang('components.ns-login.pleaseAgreePrivacy')
|
||||
});
|
||||
const popup = this.$refs.privacyPopup;
|
||||
if (popup && typeof popup.showPrivacyPopup === 'function') {
|
||||
popup.showPrivacyPopup();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
this.$util.showToast({
|
||||
title: e.detail.errMsg
|
||||
title: errMsg
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<view v-if="item.controller == 'Text'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
|
||||
<input
|
||||
@@ -23,8 +23,8 @@
|
||||
<view v-if="item.controller == 'Textarea'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', 'flex-box', 'textarea', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
|
||||
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
|
||||
@@ -51,8 +51,8 @@
|
||||
>
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex', 'align-right']">
|
||||
<text v-if="item.val != ''">{{ item.val }}</text>
|
||||
@@ -67,22 +67,23 @@
|
||||
<view v-if="item.controller == 'Checkbox'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'check-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||
<view :class="['box', 'check-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'vertical')]">
|
||||
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
|
||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||
<checkbox :value="v.value" :checked="v.checked"></checkbox>
|
||||
<view class="checkbox">
|
||||
<text
|
||||
:class="[
|
||||
'iconfont',
|
||||
!v.checked ? 'icon-fuxuankuang2' : '',
|
||||
v.checked ? 'icon-fuxuankuang1' : ''
|
||||
]"
|
||||
:style="{ color: v.checked ? checkedColor : '' }"
|
||||
></text>
|
||||
<text
|
||||
:class="[
|
||||
'iconfont',
|
||||
'check-icon',
|
||||
!v.checked ? 'icon-fuxuankuang2' : '',
|
||||
v.checked ? 'icon-fuxuankuang1' : ''
|
||||
]"
|
||||
:style="{ color: v.checked ? checkedColor : '' }"
|
||||
></text>
|
||||
{{ v.value }}
|
||||
</view>
|
||||
</label>
|
||||
@@ -95,22 +96,23 @@
|
||||
<view v-if="item.controller == 'Radio'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'radio-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||
<view :class="['box', 'radio-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'vertical')]">
|
||||
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
|
||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||
<radio :value="v.value" :checked="item.val == v.value"></radio>
|
||||
<view class="radio-box">
|
||||
<text
|
||||
:class="[
|
||||
'iconfont',
|
||||
item.val != v.value ? 'icon-yuan_checkbox' : '',
|
||||
item.val == v.value ? 'icon-yuan_checked' : ''
|
||||
]"
|
||||
:style="{ color: item.val == v.value ? checkedColor : '' }"
|
||||
></text>
|
||||
<text
|
||||
:class="[
|
||||
'iconfont',
|
||||
'radio-icon',
|
||||
item.val != v.value ? 'icon-yuan_checkbox' : '',
|
||||
item.val == v.value ? 'icon-yuan_checked' : ''
|
||||
]"
|
||||
:style="{ color: item.val == v.value ? checkedColor : '' }"
|
||||
></text>
|
||||
{{ v.value }}
|
||||
</view>
|
||||
</label>
|
||||
@@ -123,8 +125,8 @@
|
||||
<view v-if="item.controller == 'Img'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'img-boxs', 'align-left']" :style="item._boxAlignStyle">
|
||||
<view v-for="(v, k) in item.img_lists" :key="k" class="img-box" @tap="uploadImg(index)">
|
||||
@@ -142,8 +144,8 @@
|
||||
<view v-if="item.controller == 'Date'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex', 'align-right']">
|
||||
<picker
|
||||
@@ -164,8 +166,8 @@
|
||||
<view v-if="item.controller == 'Datelimit'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'date-boxs', 'align-center']" :style="item._boxAlignStyle">
|
||||
<view class="date-box">
|
||||
@@ -203,8 +205,8 @@
|
||||
<view v-if="item.controller == 'Time'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex', 'align-right']">
|
||||
<picker
|
||||
@@ -225,8 +227,8 @@
|
||||
<view v-if="item.controller == 'Timelimit'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'date-boxs', 'align-center']" :style="item._boxAlignStyle">
|
||||
<view class="date-box">
|
||||
@@ -264,8 +266,8 @@
|
||||
<view v-if="item.controller == 'City'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex', 'align-right']">
|
||||
<pick-regions
|
||||
@@ -302,7 +304,7 @@ export default {
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'horizontal',
|
||||
default: 'vertical',
|
||||
validator: (value) => !value || ['horizontal', 'vertical'].includes(value)
|
||||
},
|
||||
// 表单配置(按平台差异化)
|
||||
@@ -362,40 +364,40 @@ export default {
|
||||
config() {
|
||||
// 内置默认值(最低优先级)- 与后端 D:\projects\shop-projects\backend\src\app\component\view\form\js\design.js getDefaultFormData().formConfig 对齐
|
||||
const defaults = {
|
||||
formPaddingX: 14,
|
||||
formPaddingX: 28,
|
||||
bannerPaddingX: 0,
|
||||
tipPaddingX: 14,
|
||||
rowPaddingY: 4,
|
||||
tipPaddingX: 28,
|
||||
rowPaddingY: 24,
|
||||
rowMinHeight: 60,
|
||||
labelGap: 16,
|
||||
labelGap: 20,
|
||||
formBgColor: '#ffffff',
|
||||
rowDividerColor: '#f0f0f0',
|
||||
showRowDivider: true,
|
||||
inputHeight: 60,
|
||||
inputRadius: 10,
|
||||
showRowDivider: false,
|
||||
inputHeight: 84,
|
||||
inputRadius: 16,
|
||||
labelWidthMode: 'auto',
|
||||
labelFixedWidth: 180,
|
||||
labelFontSize: 28,
|
||||
labelFontSize: 32,
|
||||
labelColor: '#1f2937',
|
||||
labelFontWeight: 600,
|
||||
labelFontWeight: 700,
|
||||
labelAutoMaxWidth: 200, // auto 模式下 label 宽度上限(rpx),超过后 ellipsis 截断
|
||||
requiredColor: '#ef4444',
|
||||
checkedColor: '#ff4544',
|
||||
placeholderColor: '#ddd',
|
||||
checkedColor: '#3b82f6',
|
||||
placeholderColor: '#9ca3af',
|
||||
placeholderFontSize: 28,
|
||||
// textarea 显示行数 / 行高(与 ns-form 保持一致,由 formConfig 覆盖)
|
||||
// 改用 :rows + line-height 驱动高度,rows 给原生 <textarea>,line-height 在 textareaStyle 算 px 高度
|
||||
textareaRows: 4,
|
||||
textareaLineHeight: 44,
|
||||
textareaLineHeight: 48,
|
||||
// 输入框视觉增强(可由后端 form_config 覆盖)
|
||||
inputBorderColor: '#e5e7eb',
|
||||
inputBgColor: '#f9fafb',
|
||||
inputFocusBorderColor: '#ff4544',
|
||||
inputBgColor: '#ffffff',
|
||||
inputFocusBorderColor: '#3b82f6',
|
||||
inputFocusBgColor: '#ffffff',
|
||||
labelFontWeight: 600,
|
||||
labelFontWeight: 700,
|
||||
// 提交按钮
|
||||
submitColor: '#ffffff',
|
||||
submitBgColor: '#ff4544',
|
||||
submitBgColor: '#3b82f6',
|
||||
submitMarginTop: 0, // 提交按钮顶部外边距(rpx):调整表单与按钮之间的距离
|
||||
// 提示语
|
||||
tipFontSize: 32,
|
||||
@@ -588,22 +590,23 @@ export default {
|
||||
const align = itemValue.input_align;
|
||||
this.$set(item, '_boxAlignStyle', align ? { 'text-align': align } : {});
|
||||
|
||||
// 选项排列样式:vertical 时给 checkbox-group / radio-group 加 flex-direction
|
||||
// 走 inline style(不依赖 dev 编译产物的 SCSS 类,跨端兼容更稳)
|
||||
// 直接写 flex-direction 等 CSS 属性,inline style 优先级最高
|
||||
const optLayout = itemValue.option_layout;
|
||||
const optStyle = {};
|
||||
if (optLayout === 'vertical') {
|
||||
optStyle['flex-direction'] = 'column';
|
||||
optStyle['align-items'] = 'flex-start';
|
||||
optStyle['gap'] = '8px';
|
||||
} else {
|
||||
// wrap 模式:用 display: flex 基础属性兜底(不强制,让浏览器按 SCSS 默认渲染)
|
||||
optStyle['flex-direction'] = 'row';
|
||||
optStyle['align-items'] = 'center';
|
||||
optStyle['gap'] = '6px';
|
||||
}
|
||||
this.$set(item, '_optionLayoutStyle', optStyle);
|
||||
// 选项排列样式:默认 vertical(每项一行),仅当后端显式配置 wrap 时横向换行
|
||||
// 走 inline style(不依赖 dev 编译产物的 SCSS 类,跨端兼容更稳)
|
||||
// 直接写 flex-direction 等 CSS 属性,inline style 优先级最高
|
||||
const optLayout = itemValue.option_layout;
|
||||
const optStyle = {};
|
||||
if (optLayout === 'wrap') {
|
||||
// wrap 模式:横向自动换行
|
||||
optStyle['flex-direction'] = 'row';
|
||||
optStyle['align-items'] = 'center';
|
||||
optStyle['gap'] = '12rpx';
|
||||
} else {
|
||||
// vertical 模式(默认):每项独立一行
|
||||
optStyle['flex-direction'] = 'column';
|
||||
optStyle['align-items'] = 'flex-start';
|
||||
optStyle['gap'] = '20rpx';
|
||||
}
|
||||
this.$set(item, '_optionLayoutStyle', optStyle);
|
||||
});
|
||||
},
|
||||
// 容器样式(提供给外部 diy-form 使用)
|
||||
@@ -1012,38 +1015,38 @@ export default {
|
||||
.form-wrap {
|
||||
background: #fff;
|
||||
/* 默认值兜底:与 ns-newform.config 默认值一致 */
|
||||
--form-padding-x: 14rpx;
|
||||
--form-padding-x: 28rpx;
|
||||
--banner-padding-x: 0rpx;
|
||||
--tip-padding-x: 14rpx;
|
||||
--tip-font-size: 24rpx;
|
||||
--tip-padding-x: 28rpx;
|
||||
--tip-font-size: 32rpx;
|
||||
--tip-color: #666666;
|
||||
--tip-bg-color: #ffffff;
|
||||
--row-padding-y: 4rpx;
|
||||
--row-padding-y: 24rpx;
|
||||
--row-min-height: 60rpx;
|
||||
--label-gap: 16rpx;
|
||||
--label-gap: 20rpx;
|
||||
--form-bg-color: #ffffff;
|
||||
--row-divider-color: #f0f0f0;
|
||||
--input-height: 60rpx;
|
||||
--input-radius: 10rpx;
|
||||
--input-height: 84rpx;
|
||||
--input-radius: 16rpx;
|
||||
--label-fixed-width: 180rpx;
|
||||
--label-font-size: 28rpx;
|
||||
--label-font-size: 32rpx;
|
||||
--label-color: #1f2937;
|
||||
--required-color: #ef4444;
|
||||
--checked-color: #ff4544;
|
||||
--checked-color: #3b82f6;
|
||||
--submit-color: #ffffff;
|
||||
--submit-bg-color: #ff4544;
|
||||
--placeholder-color: #ddd;
|
||||
--submit-bg-color: #3b82f6;
|
||||
--placeholder-color: #9ca3af;
|
||||
--placeholder-font-size: 28rpx;
|
||||
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
|
||||
--textarea-rows: 4;
|
||||
--textarea-line-height: 44rpx;
|
||||
--textarea-line-height: 48rpx;
|
||||
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
|
||||
--input-border-color: #e5e7eb;
|
||||
--input-bg-color: #f9fafb;
|
||||
--input-focus-border-color: #ff4544;
|
||||
--input-bg-color: #ffffff;
|
||||
--input-focus-border-color: #3b82f6;
|
||||
--input-focus-bg-color: #ffffff;
|
||||
--label-font-weight: 600;
|
||||
--show-row-divider: 1;
|
||||
--label-font-weight: 700;
|
||||
--show-row-divider: 0;
|
||||
|
||||
// 与后端 form-preview-wrap.no-divider-all 对齐:整组隐藏所有行分割线
|
||||
&.no-divider-all {
|
||||
@@ -1097,7 +1100,8 @@ export default {
|
||||
|
||||
.name {
|
||||
width: 100%;
|
||||
margin-bottom: var(--label-gap, 16rpx);
|
||||
min-height: auto;
|
||||
margin-bottom: var(--label-gap, 20rpx);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
@@ -1161,7 +1165,7 @@ export default {
|
||||
.required {
|
||||
color: var(--required-color, #ef4444);
|
||||
font-size: var(--item-label-font-size, var(--label-font-size, 28rpx));
|
||||
margin-left: 4rpx;
|
||||
margin-right: 6rpx;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
@@ -1223,12 +1227,12 @@ export default {
|
||||
::v-deep input {
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
text-align: left;
|
||||
height: var(--input-height, 60rpx);
|
||||
height: var(--input-height, 84rpx);
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
padding: 0 12rpx;
|
||||
background: var(--input-bg-color, #f9fafb);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
line-height: calc(var(--input-height, 84rpx) - 2rpx);
|
||||
padding: 0 24rpx;
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
color: var(--label-color, #1f2937);
|
||||
transition: all 0.3s ease;
|
||||
width: 100%;
|
||||
@@ -1236,11 +1240,11 @@ export default {
|
||||
|
||||
&:focus {
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
border-color: var(--input-focus-border-color, #ff4544);
|
||||
border-color: var(--input-focus-border-color, #3b82f6);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
@@ -1249,12 +1253,12 @@ export default {
|
||||
input {
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
text-align: left;
|
||||
height: var(--input-height, 60rpx);
|
||||
height: var(--input-height, 84rpx);
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
padding: 0 12rpx;
|
||||
background: var(--input-bg-color, #f9fafb);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
line-height: calc(var(--input-height, 84rpx) - 2rpx);
|
||||
padding: 0 24rpx;
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
color: var(--label-color, #1f2937);
|
||||
transition: all 0.3s ease;
|
||||
width: 100%;
|
||||
@@ -1262,65 +1266,68 @@ export default {
|
||||
|
||||
&:focus {
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
border-color: var(--input-focus-border-color, #ff4544);
|
||||
border-color: var(--input-focus-border-color, #3b82f6);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
// H5 端 <textarea> 被 uni-app 编译为 <uni-textarea> 自定义组件,
|
||||
// 真实 <textarea> 是其子节点,需要 ::v-deep 穿透 scoped 才能命中
|
||||
::v-deep textarea {
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
width: 100%;
|
||||
// 行高 / 高度全部由 formConfig 动态决定(CSS 兜底公式与 JS textareaStyle 一致)
|
||||
line-height: var(--textarea-line-height, 44rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
line-height: var(--textarea-line-height, 48rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
padding: 12rpx;
|
||||
background: var(--input-bg-color, #f9fafb);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
padding: 12rpx 24rpx;
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
color: var(--label-color, #1f2937);
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
border-color: var(--input-focus-border-color, #ff4544);
|
||||
border-color: var(--input-focus-border-color, #3b82f6);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
// 非 H5 端(小程序 / 鸿蒙 / App)<textarea> 是直接原生元素,普通选择器即可
|
||||
textarea {
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
width: 100%;
|
||||
// 行高 / 高度全部由 formConfig 动态决定(与 ::v-deep textarea 同步)
|
||||
line-height: var(--textarea-line-height, 44rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
line-height: var(--textarea-line-height, 48rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
padding: 12rpx;
|
||||
background: var(--input-bg-color, #f9fafb);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
padding: 12rpx 24rpx;
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
color: var(--label-color, #1f2937);
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
border-color: var(--input-focus-border-color, #ff4544);
|
||||
border-color: var(--input-focus-border-color, #3b82f6);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
@@ -1328,22 +1335,22 @@ export default {
|
||||
.uni-input, .select-address {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--label-color, #1f2937);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
transition: all 0.3s ease;
|
||||
min-height: var(--input-height, 60rpx);
|
||||
min-height: var(--input-height, 84rpx);
|
||||
box-sizing: border-box;
|
||||
line-height: var(--input-height, 60rpx);
|
||||
|
||||
line-height: var(--input-height, 84rpx);
|
||||
|
||||
&.color-tip {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
}
|
||||
|
||||
|
||||
&.empty {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1381,13 +1388,20 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
|
||||
.option-layout-vertical {
|
||||
// 选项排列样式:vertical = 每个选项独立一行;wrap = 横向自动换行
|
||||
// option-layout-* 是 .box 自身的类,必须用 & 嵌套,否则编译成 .box .option-layout-* 选不中实际 DOM
|
||||
&.option-layout-vertical {
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
width: 100%;
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 12rpx 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1416,7 +1430,14 @@ export default {
|
||||
.radio-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.check-icon,
|
||||
.radio-icon {
|
||||
font-size: 40rpx;
|
||||
line-height: 1;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
&.img-boxs {
|
||||
@@ -1509,7 +1530,7 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.placeholder {
|
||||
color: #909399;
|
||||
color: #9ca3af;
|
||||
font-size: 28rpx;
|
||||
|
||||
// #ifdef H5
|
||||
@@ -1518,6 +1539,6 @@ export default {
|
||||
}
|
||||
|
||||
.color-tip {
|
||||
color: #909399;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
@@ -281,18 +281,18 @@ export default {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (wx.onNeedPrivacyAuthorization) {
|
||||
wx.onNeedPrivacyAuthorization((resolve) => {
|
||||
// 检查是否已同意
|
||||
if (!PrivacyManager.needShow()) {
|
||||
// 已同意, 直接放行
|
||||
resolve({ event: 'agree' })
|
||||
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
|
||||
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
|
||||
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
|
||||
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
|
||||
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
|
||||
if (typeof this.privacyAuthorization === 'function') {
|
||||
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
|
||||
this.privacyAuthorization(resolve)
|
||||
} else {
|
||||
// 需要弹窗, 存 resolve 等用户操作
|
||||
if (typeof this.privacyAuthorization === 'function') {
|
||||
this.privacyAuthorization(resolve)
|
||||
} else {
|
||||
// 兜底: 组件未就绪, 直接放行避免 API 卡死
|
||||
resolve({ event: 'agree' })
|
||||
}
|
||||
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
|
||||
// (disagree 无需 buttonId, 不会产生非法 resolve)
|
||||
resolve({ event: 'disagree' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"loggingIn": "登录中",
|
||||
"loginFailed": "登录失败",
|
||||
"pleaseAuthorizeMobile": "请授权您的手机号码",
|
||||
"pleaseAgreePrivacy": "请先同意《隐私条款》后重试",
|
||||
"pleaseEnterNickname": "请输入昵称",
|
||||
"avatarPullFailed": "头像拉取失败"
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ export const lang = {
|
||||
loggingIn: 'Logging in',
|
||||
loginFailed: 'Login failed',
|
||||
pleaseAuthorizeMobile: 'Please authorize your mobile number',
|
||||
pleaseAgreePrivacy: 'Please agree to the Privacy Policy and retry',
|
||||
pleaseEnterNickname: 'Please enter nickname',
|
||||
avatarPullFailed: 'Avatar pull failed',
|
||||
}
|
||||
|
||||
@@ -7,5 +7,18 @@ export const lang = {
|
||||
submitSuccess: 'Submitted successfully',
|
||||
// Form information not available
|
||||
noFormInfo: 'Form information not available',
|
||||
// Behavior settings
|
||||
// Captcha input placeholder
|
||||
captchaPlaceholder: 'Please enter the captcha',
|
||||
// Captcha required
|
||||
captchaRequired: 'Please enter the captcha',
|
||||
// Captcha error
|
||||
captchaError: 'Incorrect captcha, please try again',
|
||||
// Submit limit reached
|
||||
submitLimit: 'Submission limit reached',
|
||||
// Form not open
|
||||
notOpen: 'The form is not open yet',
|
||||
// Login required
|
||||
loginRequired: 'Please log in first',
|
||||
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export const lang = {
|
||||
loggingIn: '登录中',
|
||||
loginFailed: '登录失败',
|
||||
pleaseAuthorizeMobile: '请授权您的手机号码',
|
||||
pleaseAgreePrivacy: '请先同意《隐私条款》后重试',
|
||||
pleaseEnterNickname: '请输入昵称',
|
||||
avatarPullFailed: '头像拉取失败',
|
||||
}
|
||||
|
||||
@@ -6,4 +6,17 @@ export const lang = {
|
||||
submitSuccess: '提交成功',
|
||||
// 未获取到表单信息
|
||||
noFormInfo: '未获取到表单信息',
|
||||
// 行为设置相关
|
||||
// 请输入验证码
|
||||
captchaPlaceholder: '请输入验证码',
|
||||
// 请输入验证码(校验提示)
|
||||
captchaRequired: '请输入验证码',
|
||||
// 验证码错误
|
||||
captchaError: '验证码错误,请重试',
|
||||
// 提交次数已达上限
|
||||
submitLimit: '提交次数已达上限',
|
||||
// 表单未开放
|
||||
notOpen: '表单未开放',
|
||||
// 请先登录
|
||||
loginRequired: '请先登录',
|
||||
}
|
||||
|
||||
@@ -2445,7 +2445,8 @@
|
||||
{
|
||||
"path": "file-preview/file-preview",
|
||||
"style": {
|
||||
"navigationBarTitleText": "文件预览"
|
||||
"navigationBarTitleText": "文件预览",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -384,5 +384,14 @@ export default {
|
||||
border-radius: 20rpx 20rpx 0 0;
|
||||
}
|
||||
|
||||
// 购物车页面SKU弹窗底部按钮上移,避开底部导航栏
|
||||
::v-deep .goods-sku .sku-info .footer {
|
||||
bottom: calc(30rpx + env(safe-area-inset-bottom) + 112rpx) !important;
|
||||
}
|
||||
|
||||
::v-deep .goods-sku .sku-info .body-item {
|
||||
padding-bottom: 112rpx;
|
||||
}
|
||||
|
||||
@import './public/css/cart.scss';
|
||||
</style>
|
||||
@@ -1,35 +1,93 @@
|
||||
<template>
|
||||
<view class="file-preview-container" :style="themeColor">
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- 微信小程序:web-view 是原生组件,必须用 cover-view 盖在上面;cover-view 不支持 iconfont,改用 cover-image PNG -->
|
||||
<cover-view class="mp-header">
|
||||
<cover-view class="mp-header-inner">
|
||||
<cover-view class="nav-btn back-btn" @click="goBack">
|
||||
<cover-image src="/static/img/icon-back.png" class="nav-icon"></cover-image>
|
||||
</cover-view>
|
||||
<cover-view class="header-title">{{ headerTitle }}</cover-view>
|
||||
<button class="nav-btn share-btn" open-type="share" hover-class="none">
|
||||
<cover-image src="/static/img/icon-share.png" class="nav-icon"></cover-image>
|
||||
</button>
|
||||
</cover-view>
|
||||
<!-- 状态栏占位(navigationStyle custom 时,避免被时间/电量遮挡) -->
|
||||
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
|
||||
</cover-view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<!-- 非小程序:普通 view + iconfont 即可 -->
|
||||
<view class="header">
|
||||
<view class="back-btn" @click="goBack">
|
||||
<view class="nav-btn back-btn" @click="goBack">
|
||||
<text class="iconfont icon-back"></text>
|
||||
</view>
|
||||
<view class="header-title">{{ fileName }}</view>
|
||||
<view class="placeholder"></view>
|
||||
<view class="header-title">{{ headerTitle }}</view>
|
||||
<button class="nav-btn share-btn" open-type="share" hover-class="none" @click.stop>
|
||||
<text class="iconfont icon-fenxiang"></text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- 微信小程序 web-view 是全屏原生组件,父容器 padding/margin 对它无效,必须在它前面放一个等高的占位 view 把它顶下去 -->
|
||||
<view class="mp-content-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="content">
|
||||
<!-- PDF 文件预览 -->
|
||||
<view v-if="fileType === 'pdf'" class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
<!-- H5 端:web-view 渲染为 iframe,若被 X-Frame-Options/CORS 拦截则显示空白;额外提供"新标签页打开"与"下载"按钮兜底 -->
|
||||
<view class="h5-action-bar">
|
||||
<view class="h5-action-tip">如页面空白,请使用下方按钮:</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="openInNewTab">新标签页打开</button>
|
||||
<button class="h5-action-btn" @click="triggerDownload">下载文件</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
<web-view v-if="fileUrl" :src="fileUrl"></web-view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- Word 文件预览 -->
|
||||
<view v-else-if="fileType === 'word'" class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
<view class="h5-action-bar">
|
||||
<view class="h5-action-tip">浏览器可能无法直接内嵌预览 Word,建议下载后查看:</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="triggerDownload">下载文件</button>
|
||||
<button class="h5-action-btn" @click="openInNewTab">新标签页打开</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
<web-view v-if="fileUrl" :src="fileUrl"></web-view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- 视频文件预览 -->
|
||||
<view v-else-if="fileType === 'video'" class="video-viewer">
|
||||
<video v-if="fileUrl" :src="fileUrl" controls autoplay class="video-player"></video>
|
||||
<video v-if="fileUrl" id="filePreviewVideo" :src="fileUrl" controls class="video-player"></video>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.videoUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- 其他文件类型 -->
|
||||
<view v-else class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
<view v-if="fileUrl" class="h5-action-bar">
|
||||
<view class="h5-action-tip">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="triggerDownload">下载文件</button>
|
||||
<button class="h5-action-btn" @click="openInNewTab">新标签页打开</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
<view class="error-message">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -41,32 +99,114 @@ export default {
|
||||
return {
|
||||
fileName: '',
|
||||
fileUrl: '',
|
||||
fileType: ''
|
||||
fileType: '',
|
||||
statusBarHeight: 20,
|
||||
windowWidth: 375
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 整个自定义 header 高度(状态栏 + 标题栏 88rpx),单位 px —— 用于占位 view 和内容区下移
|
||||
headerTotalHeightPx() {
|
||||
const rpxToPx = this.windowWidth / 750;
|
||||
return this.statusBarHeight + Math.round(88 * rpxToPx);
|
||||
},
|
||||
// #endif
|
||||
// 页面标题栏显示的短标题(避免超长挤压分享按钮;对用户无意义的tmp名统一显示通用"文件预览")
|
||||
headerTitle() {
|
||||
const name = (this.fileName || '').trim();
|
||||
// 如果是临时文件名(tmp_ 开头 或 带路径hash的长串),统一显示通用"文件预览"
|
||||
if (/^tmp_/i.test(name) || (name.length > 30 && /[a-f0-9]{20,}/i.test(name))) {
|
||||
return this.$lang('file-preview.file-preview.title');
|
||||
}
|
||||
// 否则显示原始文件名(列表里传入的,如"乐瑞固企业介绍",本身就短)
|
||||
if (name) {
|
||||
// JS 层保险截断(超过16字 + …),应对 cover-view CSS 省略号不生效
|
||||
if (name.length > 16) return name.slice(0, 16) + '…';
|
||||
return name;
|
||||
}
|
||||
return this.$lang('file-preview.file-preview.title');
|
||||
},
|
||||
// 分享的真实标题(好友收到的卡片显示真实文件名,不截断)
|
||||
shareTitle() {
|
||||
const name = (this.fileName || '').trim();
|
||||
if (name && !/^tmp_/i.test(name)) return name;
|
||||
return this.$lang('file-preview.file-preview.title');
|
||||
},
|
||||
// 分享路径:只带文件三要素,不挂推荐码/关系
|
||||
sharePath() {
|
||||
const query = [];
|
||||
if (this.fileName) query.push(`fileName=${encodeURIComponent(this.fileName)}`);
|
||||
if (this.fileUrl) query.push(`fileUrl=${encodeURIComponent(this.fileUrl)}`);
|
||||
if (this.fileType) query.push(`fileType=${encodeURIComponent(this.fileType)}`);
|
||||
return `/pages_tool/file-preview/file-preview${query.length ? '?' + query.join('&') : ''}`;
|
||||
},
|
||||
themeColor() {
|
||||
if (this.$shop && this.$shop.themeColor) {
|
||||
return `--theme-color: ${this.$shop.themeColor};`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
onLoad(option) {
|
||||
// 解析传递的参数
|
||||
if (option.fileName) {
|
||||
this.fileName = decodeURIComponent(option.fileName);
|
||||
}
|
||||
|
||||
if (option.fileUrl) {
|
||||
this.fileUrl = decodeURIComponent(option.fileUrl);
|
||||
}
|
||||
|
||||
if (option.fileType) {
|
||||
this.fileType = decodeURIComponent(option.fileType);
|
||||
} else {
|
||||
// 根据文件名后缀推断文件类型
|
||||
this.inferFileType();
|
||||
}
|
||||
// #ifdef MP-WEIXIN
|
||||
const sys = uni.getSystemInfoSync();
|
||||
this.statusBarHeight = sys.statusBarHeight || 20;
|
||||
this.windowWidth = sys.windowWidth || 375;
|
||||
// #endif
|
||||
},
|
||||
onReady() {
|
||||
// 视频加载后手动触发一次播放占位(去掉 autoplay 防自动播放被系统拦截)
|
||||
// #ifndef H5
|
||||
if (this.fileType === 'video' && this.fileUrl) {
|
||||
try {
|
||||
const ctx = uni.createVideoContext('filePreviewVideo', this);
|
||||
if (ctx && typeof ctx.play === 'function') { /* 留给用户点击,不自动播 */ }
|
||||
} catch (e) {}
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序:右上角胶囊"分享给朋友" / button[open-type=share] 触发
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: this.shareTitle,
|
||||
path: this.sharePath
|
||||
};
|
||||
},
|
||||
// 微信小程序:分享到朋友圈
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: this.shareTitle,
|
||||
query: this.sharePath.split('?')[1] || ''
|
||||
};
|
||||
},
|
||||
// #endif
|
||||
methods: {
|
||||
/**
|
||||
* 根据文件名后缀推断文件类型
|
||||
*/
|
||||
inferFileType() {
|
||||
if (this.fileName) {
|
||||
if (this.fileUrl) {
|
||||
const clean = String(this.fileUrl).split('?')[0];
|
||||
const ext = clean.split('.').pop().toLowerCase();
|
||||
if (['pdf'].includes(ext)) {
|
||||
this.fileType = 'pdf';
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
this.fileType = 'word';
|
||||
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
|
||||
this.fileType = 'video';
|
||||
}
|
||||
} else if (this.fileName) {
|
||||
const ext = this.fileName.split('.').pop().toLowerCase();
|
||||
if (['pdf'].includes(ext)) {
|
||||
this.fileType = 'pdf';
|
||||
@@ -77,13 +217,48 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 返回上一页
|
||||
*/
|
||||
goBack() {
|
||||
uni.navigateBack();
|
||||
const pages = getCurrentPages();
|
||||
if (pages && pages.length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
uni.switchTab({ url: '/pages/index/index' }).catch(() => {
|
||||
uni.navigateBack();
|
||||
});
|
||||
}
|
||||
},
|
||||
// #ifdef H5
|
||||
// H5 端:新标签页打开文件(绕过 iframe X-Frame-Options/CORS 限制)
|
||||
openInNewTab() {
|
||||
if (!this.fileUrl) return;
|
||||
try {
|
||||
window.open(this.fileUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '打开失败,请稍后重试', icon: 'none' });
|
||||
}
|
||||
},
|
||||
// H5 端:通过 <a download> 触发浏览器下载(最稳定的跨浏览器方案)
|
||||
triggerDownload() {
|
||||
if (!this.fileUrl) return;
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = this.fileUrl;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
if (this.fileName) {
|
||||
// 仅提示浏览器用这个名字;受 CORS 影响,跨域文件时 download 属性可能被忽略,
|
||||
// 此时浏览器会直接按服务器返回的文件名/直接显示,但不会丢失文件本身
|
||||
link.download = this.fileName;
|
||||
}
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (e) {
|
||||
// 兜底:新标签页打开
|
||||
this.openInNewTab();
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -92,7 +267,10 @@ export default {
|
||||
.file-preview-container {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* =================== 非 MP 端 header =================== */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -101,22 +279,34 @@ export default {
|
||||
padding: 0 30rpx;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.back-btn {
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
|
||||
.nav-btn {
|
||||
flex-shrink: 0;
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
margin: 0;
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
.icon-back {
|
||||
font-size: 32rpx;
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
@@ -124,17 +314,91 @@ export default {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0 20rpx;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 60rpx;
|
||||
|
||||
.share-btn {
|
||||
.icon-fenxiang {
|
||||
font-size: 34rpx;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =================== MP 端 header (cover-view 浮层) =================== */
|
||||
/* #ifdef MP-WEIXIN */
|
||||
.mp-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.mp-status-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mp-header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
padding: 0 20rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
margin: 0;
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin: 0 16rpx;
|
||||
/* cover-view 下 ellipsis 支持有限,JS 层(headerTitle computed)已经做了截断,这里再冗余防守 */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
/* =================== 内容区 =================== */
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
|
||||
/* #ifdef MP-WEIXIN */
|
||||
/* 给浮层 header(statusBar+88rpx)留位置,避免内容顶上去 */
|
||||
padding-top: calc(var(--mp-header-h, 120rpx));
|
||||
/* #endif */
|
||||
padding-left: 20rpx;
|
||||
padding-right: 20rpx;
|
||||
padding-bottom: 20rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.file-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
@@ -142,7 +406,7 @@ export default {
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
|
||||
.error-message {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
@@ -152,8 +416,52 @@ export default {
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
/* #ifdef H5 */
|
||||
/* H5 兜底操作栏(PDF/Word 被 X-Frame-Options 拦截时显示)*/
|
||||
.h5-action-bar {
|
||||
padding: 24rpx 28rpx 20rpx;
|
||||
background: linear-gradient(180deg, #fffbe6 0%, #ffffff 100%);
|
||||
border-bottom: 1rpx solid #f0e9c8;
|
||||
|
||||
.h5-action-tip {
|
||||
font-size: 26rpx;
|
||||
color: #8a6d1b;
|
||||
margin-bottom: 18rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.h5-action-btns {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
|
||||
.h5-action-btn {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
padding: 0;
|
||||
font-size: 28rpx;
|
||||
border-radius: 36rpx;
|
||||
background: #f2f3f5;
|
||||
color: #333;
|
||||
border: 1rpx solid #e2e4e8;
|
||||
margin: 0;
|
||||
|
||||
&::after { border: none; }
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(90deg, #ff7e47 0%, #f73131 100%);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4rpx 12rpx rgba(247, 49, 49, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
|
||||
.video-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
@@ -161,12 +469,12 @@ export default {
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
|
||||
.error-message {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
|
||||
@@ -13,12 +13,18 @@
|
||||
<view class="article-wrap">
|
||||
<ns-adv keyword="NS_ARTICLE" class-name="adv-wrap"></ns-adv>
|
||||
<block v-for="(item, index) in list" :key="index">
|
||||
<view class="item" @tap="toDetail(item.$orig)">
|
||||
<view class="article-img">
|
||||
<image class="cover-img" :src="item.g1" mode="widthFix" @error="imgError(index)" show-menu-by-longpress></image>
|
||||
<view class="item" @tap="toDetail(item)">
|
||||
<view class="pdf-icon">
|
||||
<text class="pdf-icon-p">P</text>
|
||||
<view class="pdf-icon-fold"></view>
|
||||
<view class="pdf-icon-lines">
|
||||
<view class="pdf-icon-line"></view>
|
||||
<view class="pdf-icon-line"></view>
|
||||
<view class="pdf-icon-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<view class="title">{{ item.$orig.files_title }}</view>
|
||||
<view class="title">{{ item.files_title }}</view>
|
||||
<view class="read-wrap">
|
||||
<text>{{ $lang('files.list.viewPdf') }}</text>
|
||||
</view>
|
||||
@@ -84,8 +90,10 @@ export default {
|
||||
if (res.code === 0 && res.data) {
|
||||
list = res.data.list
|
||||
if (!this.title && res.data.list.length > 0) {
|
||||
const first = res.data.list[0]
|
||||
const firstOrig = (first && first.$orig) ? first.$orig : first
|
||||
uni.setNavigationBarTitle({
|
||||
title: res.data.list[0].category_name
|
||||
title: firstOrig.category_name || ''
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -98,9 +106,18 @@ export default {
|
||||
this.list = []
|
||||
}
|
||||
this.list = this.list.concat(list.map(item => {
|
||||
const orig = item
|
||||
const g1 = this.$util.img("addon/personnel/shop/view/enterprise/fileicon.png")
|
||||
return { $orig: orig, g1: g1 }
|
||||
// 兼容两种后端结构:自带 $orig(如 cases 接口)或扁平结构
|
||||
const src = (item && item.$orig) ? item.$orig : item
|
||||
return {
|
||||
files_id: src.files_id,
|
||||
files_title: src.files_title,
|
||||
files_url: src.files_url,
|
||||
files_desc: src.files_desc,
|
||||
category_id: src.category_id,
|
||||
category_name: src.category_name,
|
||||
// 保留完整原对象引用,避免后续扩展字段丢失
|
||||
$orig: src
|
||||
}
|
||||
}))
|
||||
if (this.$refs.loadingCover) {
|
||||
this.$refs.loadingCover.hide()
|
||||
@@ -115,41 +132,121 @@ export default {
|
||||
})
|
||||
},
|
||||
toDetail(item) {
|
||||
uni.showLoading({
|
||||
title: this.$lang('files.list.loading')
|
||||
})
|
||||
try {
|
||||
uni.downloadFile({
|
||||
url: item.files_url,
|
||||
success: res => {
|
||||
if (res.statusCode === 200) {
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => {
|
||||
},
|
||||
fail: () => {
|
||||
},
|
||||
complete: () => {
|
||||
uni.hideLoading()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
},
|
||||
complete: res => {
|
||||
if (res.statusCode === 404) {
|
||||
uni.showToast({
|
||||
title: this.$lang('files.list.fileNotExist'),
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
const title = (item.files_title || '文件').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||
// 根据 URL 推断后缀(若无扩展名则默认 pdf)
|
||||
let ext = 'pdf';
|
||||
if (item.files_url) {
|
||||
const clean = item.files_url.split('?')[0];
|
||||
const matched = clean.match(/\.([a-zA-Z0-9]+)$/);
|
||||
if (matched) ext = matched[1].toLowerCase();
|
||||
}
|
||||
const fileName = `${title}.${ext}`;
|
||||
const fileType =
|
||||
['pdf'].includes(ext) ? 'pdf'
|
||||
: ['doc', 'docx'].includes(ext) ? 'word'
|
||||
: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext) ? 'video'
|
||||
: 'pdf';
|
||||
const previewPage = `/pages_tool/file-preview/file-preview?fileName=${encodeURIComponent(fileName)}&fileUrl=${encodeURIComponent(item.files_url)}&fileType=${encodeURIComponent(fileType)}`;
|
||||
|
||||
// ========== H5 端:跳转预览页(web-view 走 iframe);失败则新标签页打开原链接 ==========
|
||||
// #ifdef H5
|
||||
uni.navigateTo({
|
||||
url: previewPage,
|
||||
fail: () => {
|
||||
try { window.open(item.files_url, '_blank'); } catch (e) {
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
// #endif
|
||||
|
||||
// ========== 非 H5 端:下载文件后按平台分支打开 ==========
|
||||
uni.showLoading({ title: this.$lang('files.list.loading'), mask: true });
|
||||
uni.downloadFile({
|
||||
url: item.files_url,
|
||||
success: res => {
|
||||
if (res.statusCode !== 200) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- 微信小程序:saveFile 重命名 + openDocument(短文件名标题)-----
|
||||
// #ifdef MP-WEIXIN
|
||||
const fs = uni.getFileSystemManager();
|
||||
// 重命名临时文件到用户目录,用短文件名 —— openDocument 的标题会取 filePath 的文件名部分,从而显示短名"乐瑞固企业介绍"而非 tmp_xxx
|
||||
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
|
||||
try {
|
||||
// saveFile:复制临时文件到持久目录并改名(临时文件在某些基础库上禁止直接 rename)
|
||||
fs.saveFile({
|
||||
tempFilePath: res.tempFilePath,
|
||||
filePath: targetPath,
|
||||
success: r => {
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: r.savedFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => {
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
},
|
||||
complete: () => {
|
||||
// 预览结束后清理,避免占用持久空间(失败忽略)
|
||||
try { fs.removeSavedFile({ filePath: r.savedFilePath }); } catch (e) {}
|
||||
}
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
// saveFile 失败(配额等),直接用临时文件 openDocument 兜底
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// 极端异常兜底
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ----- App / 其他小程序:直接 openDocument 打开临时文件;失败兜底跳转预览页 -----
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => {
|
||||
uni.navigateTo({
|
||||
url: previewPage,
|
||||
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
|
||||
});
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
},
|
||||
complete: res => {
|
||||
if (res && res.statusCode === 404) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
imgError(index) {
|
||||
if (this.list[index]) {
|
||||
@@ -224,18 +321,70 @@ export default {
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.article-wrap .item .article-img {
|
||||
.article-wrap .item .pdf-icon {
|
||||
margin-right: 20rpx;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
flex-shrink: 0;
|
||||
background-color: #e64340;
|
||||
border-radius: 10rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
|
||||
}
|
||||
|
||||
.article-wrap .item .article-img image {
|
||||
.article-wrap .item .pdf-icon .pdf-icon-p {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 8rpx;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-fold {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
background: linear-gradient(135deg, transparent 50%, #ffeceb 50%);
|
||||
border-bottom-left-radius: 4rpx;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-lines {
|
||||
position: absolute;
|
||||
right: 6rpx;
|
||||
bottom: 6rpx;
|
||||
width: 36rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line {
|
||||
width: 100%;
|
||||
height: 3rpx;
|
||||
background-color: #ffffff;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(1) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(2) {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(3) {
|
||||
width: 60%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.article-wrap .item .info-wrap {
|
||||
@@ -243,16 +392,21 @@ export default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-wrap .item .info-wrap .title {
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 24rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.article-wrap .item .info-wrap .read-wrap {
|
||||
|
||||
@@ -6,11 +6,28 @@
|
||||
</view>
|
||||
<view class="system-form-wrap">
|
||||
<view class="form-title">{{ $lang('form.form.title') }}</view>
|
||||
<ns-form :data="detail.json_data" :formConfig="detail.form_config" ref="form"></ns-form>
|
||||
<ns-form :data="detail.json_data" :formConfig="mergedFormConfig" ref="form"></ns-form>
|
||||
<!-- 行为设置:提交验证码 -->
|
||||
<view class="captcha-wrap" v-if="behavior.captcha == 1">
|
||||
<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>
|
||||
<button type="primary" size="mini" class="button mini" @click="create()">{{ $lang('common.submit') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<ns-empty :text="complete ? $lang('form.form.submitSuccess') : $lang('form.form.noFormInfo')" v-else></ns-empty>
|
||||
<!-- 空态:除 ns-empty 文案外,追加一个可手动点的登录按钮——解决一切自动弹窗时序问题后的最后兜底 -->
|
||||
<view 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>
|
||||
</view>
|
||||
|
||||
<loading-cover ref="loadingCover"></loading-cover>
|
||||
<ns-login ref="login"></ns-login>
|
||||
@@ -30,9 +47,55 @@
|
||||
isRepeat: false,
|
||||
complete: false,
|
||||
scroll: true,
|
||||
urlParams: {}
|
||||
urlParams: {},
|
||||
// 登录后回跳地址(供 ns-login / H5 重定向使用)
|
||||
backUrl: '',
|
||||
// 行为设置(后端 __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
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 前端视觉默认:强制覆盖后端 form_config 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
|
||||
// 与 diy-form 路径(formdata.vue 的 styleDefaults)保持一致,使系统表单页也统一呈现新设计稿样式。
|
||||
// 后端仍保留行为类配置(behavior / submitText 等),本合并仅覆盖视觉字段。
|
||||
mergedFormConfig() {
|
||||
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'
|
||||
};
|
||||
return Object.assign({}, this.detail ? (this.detail.form_config || {}) : {}, styleDefaults);
|
||||
},
|
||||
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();
|
||||
@@ -55,27 +118,100 @@
|
||||
sceneParams = sceneParams.split('&');
|
||||
if (sceneParams.length) {
|
||||
sceneParams.forEach(item => {
|
||||
if (item.indexOf('id') != -1) this.id = item.split('-')[1];
|
||||
// scene 中也可能携带其他参数
|
||||
if (!item) return;
|
||||
// 兼容标准 key=value(id=13&uniacid=1)与旧式 id-13 两种 scene 格式
|
||||
let pair = item.split('=');
|
||||
if (pair.length === 2 && pair[0] !== 'id') {
|
||||
this.urlParams[pair[0]] = pair[1];
|
||||
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() {
|
||||
if (!this.storeToken) {
|
||||
this.redirectTologin('/pages_tool/form/form?id=' + this.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 打开是否需要登录由后端 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();
|
||||
if (nVal) {
|
||||
this.getData();
|
||||
// 填写需登录:弹窗登录成功后自动续提交
|
||||
this.resumeSubmitAfterLogin();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -85,40 +221,303 @@
|
||||
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 遮罩:
|
||||
// 之前这里直接 return 跳过了函数底部的 hide(),导致登录后进入表单一直转圈
|
||||
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 存在)但后端仍返回登录拦截:
|
||||
// 登录一次后就不再自动弹登录/跳登录页(防止 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 || 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 就尝试启动登录流程,至少让用户先完成登录
|
||||
// (登录成功后会再次 getData,那时网络一般已经正常)
|
||||
_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)
|
||||
// 彻底覆盖 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('[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.detail = res.data;
|
||||
this.captcha.id = res.data.id;
|
||||
this.captcha.img = res.data.img ? res.data.img.replace(/\r\n/g, '') : '';
|
||||
this.captcha.code = '';
|
||||
}
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
},
|
||||
fail: res => {
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
}
|
||||
});
|
||||
},
|
||||
// 登录拦截时保存待提交数据(组件内存 + 本地存储,H5 登录会整页回跳)
|
||||
savePendingSubmit(formData) {
|
||||
const data = formData || (this.$refs.form && this.$refs.form.formData) || 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 确保 ns-form 已挂载),避免 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();
|
||||
},
|
||||
// 将保存的 formData 回填到 ns-form
|
||||
restoreFormData(formData) {
|
||||
try {
|
||||
const form = this.$refs.form;
|
||||
if (form && Array.isArray(formData)) {
|
||||
form.formData = JSON.parse(JSON.stringify(formData));
|
||||
if (typeof form.processFormData === 'function') form.processFormData();
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
// 填完跳转:消费统一链接选择器的完整对象(wap_url 外链/页面路径、外部小程序、拨打电话)
|
||||
doRedirect(link) {
|
||||
const fallback = () => { this.complete = true; this.detail = null; };
|
||||
if (!link) return fallback();
|
||||
if (link.appid) {
|
||||
// 跳转其他微信小程序
|
||||
wx.navigateToMiniProgram({ appId: link.appid, path: link.page || '', fail: 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();
|
||||
}
|
||||
},
|
||||
create() {
|
||||
if (this.$refs.form.verify()) {
|
||||
if (this.isRepeat) return;
|
||||
this.isRepeat = true;
|
||||
const behavior = this.behavior || {};
|
||||
|
||||
// 填写需登录 / 限填(后端强制,前端提前拦截优化体验)
|
||||
if ((behavior.require_login_submit == 1 || behavior.submit_limit > 0) && !this.storeToken) {
|
||||
// 保存已填写数据,登录成功(MP 弹窗 / H5 回跳)后自动续提交,避免数据丢失
|
||||
this.savePendingSubmit();
|
||||
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('[FORM-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 this.$refs.form.formData === 'string' ? this.$refs.form.formData : JSON.stringify(this.$refs.form.formData),
|
||||
url_params: JSON.stringify(this.urlParams),
|
||||
member_id: this.memberInfo ? this.memberInfo.id : 0
|
||||
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') })
|
||||
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') });
|
||||
const link = behavior.redirect;
|
||||
setTimeout(() => {
|
||||
this.complete = true;
|
||||
this.detail = null;
|
||||
}, 1500)
|
||||
link ? this.doRedirect(link)
|
||||
: (this.complete = true, this.detail = null);
|
||||
}, link ? 800 : 1500);
|
||||
} else {
|
||||
this.isRepeat = false;
|
||||
this.$util.showToast({ title: res.message })
|
||||
if (res.message == 'FORM_LOGIN_REQUIRED') {
|
||||
// 提交时后端仍要求登录(如登录态过期):暂存数据,重登后自动续提交
|
||||
this.savePendingSubmit();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -153,6 +552,48 @@
|
||||
padding-top: 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;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 80rpx;
|
||||
line-height: 80rpx !important;
|
||||
|
||||
@@ -35,7 +35,21 @@
|
||||
<view class="page harmony-page">
|
||||
<scroll-view scroll-y="true" :show-scrollbar="false" class="form-scroll">
|
||||
<view class="system-form-wrap">
|
||||
<diy-form ref="form" :value="diyValue" dataMode="raw" :onSubmit="handleSubmit" />
|
||||
<diy-form ref="form" :value="diyValue" dataMode="raw" :showSubmit="!showCaptcha" :onSubmit="handleSubmit" />
|
||||
<!-- 行为设置:提交验证码开启时,隐藏 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>
|
||||
@@ -44,17 +58,34 @@
|
||||
<view class="page">
|
||||
<view class="system-form-wrap">
|
||||
<!-- 复用 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" :onSubmit="handleSubmit" />
|
||||
: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" />
|
||||
<!-- 行为设置:提交验证码开启时,隐藏 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="complete ? $lang('form.formdata.submitSuccess') : $lang('form.formdata.noFormInfo')"
|
||||
:isIndex="false"></ns-empty>
|
||||
<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>
|
||||
@@ -81,6 +112,20 @@ export default {
|
||||
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
|
||||
@@ -125,16 +170,64 @@ export default {
|
||||
// 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 只在 <block v-if="detail"> 内被消费,无需 null 守卫
|
||||
// 注:小程序端模板引用的 computed 会进入 setData 数据流、每次更新都重新求值,
|
||||
// 即使消费节点都在 v-if="detail" 内,detail 置空瞬间(提交成功/登录回跳等)也会求值,
|
||||
// 必须做空值守卫,否则真机抛 Cannot read properties of null (reading 'json_data') 导致渲染中断
|
||||
diyValue() {
|
||||
// 新视觉默认样式:强制覆盖后端 form_config 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
|
||||
// 使表单在前端统一呈现为新设计稿样式;后台仍保留行为类配置(如 captcha / behavior / submitText)。
|
||||
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: this.detail.form_config,
|
||||
formConfig: Object.assign({}, this.detail.form_config || {}, styleDefaults),
|
||||
show_banner: 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')
|
||||
}
|
||||
},
|
||||
// 行为设置:提交验证码开启时 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) {
|
||||
@@ -199,17 +292,67 @@ export default {
|
||||
const scene = decodeURIComponent(options.scene)
|
||||
const params = scene.split('&')
|
||||
params.length && params.forEach(item => {
|
||||
if (item.indexOf('id') != -1) {
|
||||
this.id = item.split('-')[1]
|
||||
}
|
||||
// scene 中也可能携带其他参数
|
||||
if (!item) return;
|
||||
// 兼容标准 key=value(id=13&uniacid=1)与旧式 id-13 两种 scene 格式
|
||||
let pair = item.split('=');
|
||||
if (pair.length === 2 && pair[0] !== 'id') {
|
||||
this.urlParams[pair[0]] = pair[1];
|
||||
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(() => {
|
||||
@@ -217,19 +360,64 @@ export default {
|
||||
if (navbar) navbar.style.display = 'none'
|
||||
})
|
||||
}
|
||||
// #endif
|
||||
// #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) {
|
||||
newVal && this.getData()
|
||||
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 控制按钮可见性
|
||||
@@ -252,18 +440,136 @@ export default {
|
||||
form_id: this.id,
|
||||
uniacid: this.uniacid
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
this.detail = res.data
|
||||
this.updateNavbarTitle();
|
||||
}
|
||||
this.$refs.loadingCover && this.$refs.loadingCover.hide()
|
||||
},
|
||||
fail: () => {
|
||||
this.$refs.loadingCover && this.$refs.loadingCover.hide()
|
||||
}
|
||||
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;
|
||||
@@ -282,6 +588,125 @@ export default {
|
||||
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,
|
||||
@@ -290,30 +715,69 @@ export default {
|
||||
// 防重复提交:console 在拦截之前,避免日志噪声;拦截后立即置位
|
||||
if (this.isRepeat) return;
|
||||
|
||||
this.isRepeat = true;
|
||||
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
|
||||
},
|
||||
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) {
|
||||
this.complete = true
|
||||
uni.showModal({
|
||||
title: this.$lang('form.formdata.tip'),
|
||||
content: this.$lang('form.formdata.submitSuccess'),
|
||||
showCancel: false,
|
||||
success: () => { }
|
||||
})
|
||||
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 {
|
||||
this.isRepeat = false
|
||||
this.$util.showToast({
|
||||
title: res.message
|
||||
})
|
||||
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: () => {
|
||||
@@ -490,6 +954,62 @@ page {
|
||||
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: 27rpx !important;
|
||||
|
||||
Reference in New Issue
Block a user