Files
lucky_shop/components-diy/components-recommend-diy.vue

1180 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!--
推荐码(我的邀请码)DIY 组件
componentName 兼容:MyInviteCode / RecommendCode / Recommend / InviteCode / RecommendCodeDiy
核心能力:
1) 后端 4 接口驱动:getMyCode(含 consent 同意机制)/ regenerateCode(换新码,旧码失效)/ parseCode / getMyQrcode(占位)
2) 3 套风格预设:gradient 渐变 / minimal 极简 / card 金色卡片
3) 推荐码 + share_link 全部后端生成,前端不做自编码逻辑,防止伪造
4) 接口返回的 code + share_link 会持久化到 Vuex + storage.memberInfo,供 util.js 现场 banner 跳转自动拼参数
-->
<view data-component-name="diy-recommend-code" class="diy-recommend-code" :style="wrapStyle">
<!-- ============ 未登录:占位引导卡片(保持原有风格与逻辑一致) ============ -->
<view class="invite-login-tip" v-if="!storeToken" @tap="goLogin"
:style="loginWrapStyle">
<text class="tip-icon">{{ value.loginIcon || '🔑' }}</text>
<text class="tip-text" :style="{ color: value.loginTipColor || '#666666' }">
{{ value.loginTipText || '登录后查看我的推荐码' }}
</text>
<text class="tip-btn" :style="loginBtnStyle">
{{ value.loginBtnText || '去登录' }}
</text>
</view>
<!-- ============ 已登录:推荐码卡片(后端驱动,3 套预设 + consent 遮罩) ============ -->
<view v-else>
<!-- ============ Consent 同意遮罩(requireConsent 开启 + 后端返回 consent_required=true 才显示) ============ -->
<view v-if="showConsent" class="consent-overlay" :style="cardBaseStyle">
<view class="consent-header">
<text class="consent-title" :style="{ color: value.titleColor || value.textColor || '#333333' }">
{{ value.title || '我的推荐码' }}
</text>
</view>
<view class="consent-content">
<text class="consent-text" :style="{ color: value.textColor || '#666666' }">
{{ value.consentText || '您的推荐码将用于企业入驻来源追踪,我们会记录每一次通过您分享链接完成的入驻申请,用于您的推广收益结算。开通后可随时在页面停止使用。' }}
</text>
</view>
<view class="consent-action">
<view class="consent-btn" :style="consentBtnStyle" @tap="confirmConsent" :class="{ 'btn-loading': loadingCode }">
<text>{{ loadingCode ? '提交中...' : (value.consentButtonText || '同意并获取推荐码') }}</text>
</view>
</view>
</view>
<!-- ============ 加载中占位(首次加载 / consent 提交中 / regenerate 中) ============ -->
<view v-else-if="loadingCode && !code" class="loading-wrap" :style="cardBaseStyle">
<text class="loading-text" :style="{ color: value.titleColor || '#999999' }">推荐码加载中...</text>
</view>
<!-- ============ 预设 1:Gradient 渐变风格(默认) ============ -->
<view v-else-if="stylePreset === 'gradient'" class="style-gradient" :style="gradientStyle">
<view class="rc-header">
<text class="rc-title" :style="{ color: value.titleColor || '#ffffff' }">{{ value.title || '我的推荐码' }}</text>
</view>
<view class="rc-code-box" @tap="copyRecommendCode">
<text class="rc-prefix" :style="{ color: 'rgba(255,255,255,0.7)' }">推荐码</text>
<text class="rc-code" :style="codeTextStyle">{{ code || '暂无' }}</text>
<text class="rc-copy" :style="mainBtnBgStyle(true)">复制</text>
</view>
<view class="rc-action-bar">
<view v-if="isFeatureOn(value.showButton)" class="rc-btn rc-btn-share"
:style="mainBtnBgStyle()" @tap="onShareClick" open-type="share">
<text>{{ value.buttonText || '立即分享' }}</text>
</view>
<view class="rc-btn rc-btn-copy" :style="secondaryBtnStyle()" @tap="copyLink">
<text>{{ value.placeholderCopyText || '复制链接' }}</text>
</view>
<view v-if="isFeatureOn(value.allowRegenerate)" class="rc-btn rc-btn-regenerate"
:style="secondaryBtnStyle()" @tap="regenerate">
<text>{{ value.placeholderRegenerateText || '换一个新码' }}</text>
</view>
</view>
<!-- showLink:是否显示分享链接文字 -->
<view v-if="isFeatureOn(value.showLink)" class="rc-link-row">
<text class="rc-link-text" @tap="copyLink">{{ shareLink }}</text>
</view>
<!-- showQR:是否显示二维码 -->
<view v-if="isFeatureOn(value.showQR)" class="rc-qr-row">
<image :src="qrcodeRenderUrl" mode="aspectFit" class="rc-qr-img" />
<text class="rc-qr-tip" :style="{ color: 'rgba(255,255,255,0.8)' }">
{{ value.posterQrTip || '扫码进入并填写入驻信息' }}
</text>
</view>
</view>
<!-- ============ 预设 2:Minimal 极简风格(纯色 + 边框) ============ -->
<view v-else-if="stylePreset === 'minimal'" class="style-minimal" :style="minimalStyle">
<view class="rc-header">
<text class="rc-title" :style="{ color: value.titleColor || '#333333' }">{{ value.title || '我的推荐码' }}</text>
</view>
<view class="rc-code-box rc-code-minimal" @tap="copyRecommendCode" :style="minimalCodeBoxStyle">
<text class="rc-code rc-code-minimal-text" :style="codeTextStyle">{{ code || '暂无' }}</text>
<text class="rc-copy rc-copy-minimal" :style="minimalCopyBtnStyle">{{ value.placeholderCopyText || '复制链接' }}</text>
</view>
<view class="rc-action-bar rc-action-minimal">
<button v-if="isFeatureOn(value.showButton)" class="rc-btn rc-btn-share rc-btn-share-minimal"
:style="mainBtnBgStyle()" @tap="onShareClick" open-type="share">
<text>{{ value.buttonText || '立即分享' }}</text>
</button>
<view v-if="isFeatureOn(value.allowRegenerate)" class="rc-btn rc-btn-regenerate"
:style="secondaryBtnStyle()" @tap="regenerate">
<text>{{ value.placeholderRegenerateText || '换一个新码' }}</text>
</view>
</view>
<view v-if="isFeatureOn(value.showLink)" class="rc-link-row">
<text class="rc-link-text rc-link-minimal" @tap="copyLink">{{ shareLink }}</text>
</view>
<view v-if="isFeatureOn(value.showQR)" class="rc-qr-row rc-qr-minimal">
<image :src="qrcodeRenderUrl" mode="aspectFit" class="rc-qr-img rc-qr-minimal-img" />
<text class="rc-qr-tip rc-qr-tip-minimal" :style="{ color: '#999999' }">
{{ value.posterQrTip || '扫码进入并填写入驻信息' }}
</text>
</view>
</view>
<!-- ============ 预设 3:Card 金色边框卡片风格 ============ -->
<view v-else class="style-card" :style="cardStyleBox">
<view class="rc-accent-bar" :style="{ background: value.accentColor || '#FFD700' }"></view>
<view class="rc-header rc-header-card">
<text class="rc-title" :style="{ color: value.titleColor || '#333333' }">{{ value.title || '我的推荐码' }}</text>
</view>
<view class="rc-code-box rc-code-card" @tap="copyRecommendCode">
<text class="rc-code" :style="codeTextStyle">{{ code || '暂无' }}</text>
<view class="rc-copy rc-copy-card" :style="{ background: value.accentColor || '#FFD700', color: '#4b3600' }">
<text>复制</text>
</view>
</view>
<view class="rc-action-bar rc-action-card">
<button v-if="isFeatureOn(value.showButton)" class="rc-btn rc-btn-share"
:style="mainBtnBgStyle()" @tap="onShareClick" open-type="share">
<text>{{ value.buttonText || '立即分享' }}</text>
</button>
<view class="rc-btn rc-btn-copy" :style="secondaryBtnStyle()" @tap="copyLink">
<text>{{ value.placeholderCopyText || '复制链接' }}</text>
</view>
<view v-if="isFeatureOn(value.allowRegenerate)" class="rc-btn rc-btn-regenerate"
:style="secondaryBtnStyle()" @tap="regenerate">
<text>{{ value.placeholderRegenerateText || '换一个新码' }}</text>
</view>
</view>
<view v-if="isFeatureOn(value.showLink)" class="rc-link-row rc-link-card">
<text class="rc-link-text" @tap="copyLink">{{ shareLink }}</text>
</view>
<view v-if="isFeatureOn(value.showQR)" class="rc-qr-row rc-qr-card">
<view class="rc-qr-wrap-card" :style="{ borderColor: value.accentColor || '#FFD700' }">
<image :src="qrcodeRenderUrl" mode="aspectFit" class="rc-qr-img rc-qr-card-img" />
</view>
<text class="rc-qr-tip rc-qr-tip-card" :style="{ color: '#666666' }">
{{ value.posterQrTip || '扫码进入并填写入驻信息' }}
</text>
</view>
</view>
</view>
<!-- 登录组件占位(ns-login 全局 mixin 已注册) -->
<ns-login ref="nsLoginBox" :popup="true" @getUserInfo="onLoginSuccess"></ns-login>
</view>
</template>
<script>
/* =========================== 剪贴板双端工具(和之前保持一致,改名为 rc 前缀) =========================== */
function rcCopyToClipboard(vm, text, cb) {
if (!text) { cb && cb(false, '内容为空'); return; }
// #ifdef H5
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
() => cb && cb(true, null),
() => rcFallbackCopy(text, cb)
);
return;
}
} catch (e) {}
rcFallbackCopy(text, cb);
return;
// #endif
// #ifndef H5
uni.setClipboardData({
data: text,
showToast: false,
success: () => cb && cb(true, null),
fail: () => cb && cb(false, '复制失败')
});
// #endif
}
function rcFallbackCopy(text, cb) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.readOnly = true;
ta.style.position = 'absolute';
ta.style.left = '-9999px';
ta.style.top = '-9999px';
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, text.length);
const ok = document.execCommand && document.execCommand('copy');
document.body.removeChild(ta);
cb && cb(ok ? true : false, ok ? null : '复制失败');
} catch (e) {
cb && cb(false, '复制失败');
}
}
/* =========================== 组件本体 =========================== */
import DiyMinx from './minx.js';
import golbalConfig from '@/common/js/golbalConfig.js';
export default {
name: 'DiyRecommendCode',
mixins: [DiyMinx, golbalConfig],
props: {
value: {
type: Object,
default: () => ({
/* ============== 内容配置,diy_view.php 默认值对齐) ============== */
title: '我的推荐码',
buttonText: '立即分享',
showButton: true,
allowRegenerate: true,
placeholderRegenerateText: '换一个新码',
placeholderCopyText: '复制链接',
placeholderShareText: '分享',
requireConsent: true,
consentText: '您的推荐码将用于企业入驻来源追踪,我们会记录每一次通过您分享链接完成的入驻申请,用于您的推广收益结算。',
consentButtonText: '同意并获取推荐码',
/* ============== 风格与样式 ============== */
stylePreset: 'gradient', // gradient / minimal / card
bgColorStart: '#3B82F6',
bgColorEnd: '#1E40AF',
bgColor: '#FFFFFF',
borderColor: '#E5E7EB',
accentColor: '#FFD700',
textColor: '#FFFFFF',
titleColor: '#333333',
codeColor: '#E4393C',
buttonBgColor: '#E4393C',
buttonTextColor: '#FFFFFF',
shareButtonBgColor: '#67C23A',
titleFontSize: 14, // px,前端转 rpx(×2)
codeFontSize: 28, // px,前端转 rpx(×2)
borderRadius: 8, // px → rpx(×2)
padding: 16, // px → rpx(×2)
showQR: false,
showLink: false,
/* ============== 兼容旧字段(不破坏之前商家已经配置过的卡片) ============== */
// 外间距
marginTop: 20, marginBottom: 20,
// 未登录引导
loginIcon: '🔑', loginTipText: '登录后查看我的推荐码', loginTipColor: '#666666',
loginBtnText: '去登录', loginBtnBg: '#E4393C', loginBtnTextColor: '#FFFFFF',
loginBg: '#FFF5F5', loginRadius: 20, loginPaddingY: 40, loginPaddingX: 30,
posterQrTip: '扫码进入并填写入驻信息'
})
}
},
data() {
return {
/* 接口返回的核心数据(完全后端驱动,不自己生成) */
code: '',
shareLink: '',
qrcodeUrl: '',
/* 状态机 */
showConsent: false, // 是否弹同意遮罩
loadingCode: false, // 接口 loading 中
codeLoaded: false, // 是否至少成功调过一次 getMyCode(防止登录后重复接口)
/* 内部缓存(防止 watch / 登录成功重复刷接口时丢失) */
__lastConsentRequired: false,
/* 渲染调试用:组件实例唯一ID,多个组件同屏时能区分日志 */
__cid: 'rc_' + Date.now() + '_' + Math.floor(Math.random() * 10000)
};
},
computed: {
/* 开关解析:兼容 true / false / 1 / 0 / 'true' / 'false'(后端配置可能传字符串) */
isFeatureOn(v) {
if (v === false || v === 0 || v === '0' || v === 'false' || v === null || v === undefined) return false;
return !!v;
},
/* ✅ 关键修复:props.value 为空对象 {} 时,Vue 的 props default 不会生效(只有 value === undefined 才用 default)
这里做一次深 merge:把 props.value 里非空字段覆盖到默认 defaultValue 上,保证商家后台没配置的字段也能拿到默认值,否则所有 style 都是 undefined 导致模板样式全空,组件透明不可见 */
nc() {
const raw = (this.value && typeof this.value === 'object') ? this.value : {};
const def = this.$options.props.value.default.call(this);
// 深合并:遍历 default 的所有 key,raw 里有就用 raw 的,否则用 default 的
var merged = {};
var allKeys = Object.keys(def || {});
// 同时把 raw 里新增的字段(后端可能新增配置)也加进去
Object.keys(raw || {}).forEach(function(k){ if (allKeys.indexOf(k) === -1) allKeys.push(k); });
allKeys.forEach(function(k){
if (raw && raw[k] !== undefined && raw[k] !== null && raw[k] !== '') {
merged[k] = raw[k];
} else {
merged[k] = def && def[k] !== undefined ? def[k] : raw[k];
}
});
return merged;
},
stylePreset() {
const s = String(this.nc.stylePreset || 'gradient').toLowerCase();
if (['minimal', 'card'].includes(s)) return s;
return 'gradient';
},
requireConsentComputed() {
return this.isFeatureOn(this.nc.requireConsent);
},
/* ---------- 公共 ---------- */
px2rpx(v) { return (Number(v) || 0) * 2; },
wrapStyle() {
const v = this.nc;
return [
'width:100%;box-sizing:border-box;',
v.marginTop != null ? `margin-top:${this.px2rpx(v.marginTop)}rpx;` : '',
v.marginBottom != null ? `margin-bottom:${this.px2rpx(v.marginBottom)}rpx;` : ''
].join('');
},
cardBaseStyle() {
const v = this.nc;
return [
`padding:${this.px2rpx(v.padding)}rpx;`,
`border-radius:${this.px2rpx(v.borderRadius)}rpx;`,
'box-sizing:border-box;'
].join('');
},
loginWrapStyle() {
const v = this.nc;
return [
v.loginBg ? `background:${v.loginBg};` : '',
v.loginRadius != null ? `border-radius:${this.px2rpx(v.loginRadius)}rpx;` : '',
v.loginPaddingY != null ? `padding-top:${this.px2rpx(v.loginPaddingY)}rpx;padding-bottom:${this.px2rpx(v.loginPaddingY)}rpx;` : '',
v.loginPaddingX != null ? `padding-left:${this.px2rpx(v.loginPaddingX)}rpx;padding-right:${this.px2rpx(v.loginPaddingX)}rpx;` : '',
'width:100%;display:flex;align-items:center;justify-content:center;flex-wrap:nowrap;box-sizing:border-box;'
].join('');
},
loginBtnStyle() {
const v = this.nc;
return [
v.loginBtnBg ? `background:${v.loginBtnBg};` : '',
v.loginBtnTextColor ? `color:${v.loginBtnTextColor};` : '',
'padding:12rpx 28rpx;border-radius:40rpx;font-size:26rpx;margin-left:20rpx;'
].join('');
},
/* ---------- 三套容器样式 ---------- */
gradientStyle() {
const v = this.nc;
const start = v.bgColorStart || '#3B82F6';
const end = v.bgColorEnd || '#1E40AF';
return this.cardBaseStyle + [
`background:linear-gradient(135deg, ${start} 0%, ${end} 100%);`,
'box-shadow:0 12rpx 36rpx rgba(0,0,0,0.12);'
].join('');
},
minimalStyle() {
const v = this.nc;
return this.cardBaseStyle + [
`background:${v.bgColor || '#FFFFFF'};`,
`border:2rpx solid ${v.borderColor || '#E5E7EB'};`
].join('');
},
minimalCodeBoxStyle() {
const v = this.nc;
return [
`border:2rpx dashed ${v.codeColor || '#E4393C'};`,
`border-radius:${this.px2rpx(Math.max(4, (Number(v.borderRadius) || 8) - 4))}rpx;`,
'margin-top:28rpx;'
].join('');
},
minimalCopyBtnStyle() {
const v = this.nc;
return [
`background:${v.buttonBgColor || '#E4393C'};`,
`color:${v.buttonTextColor || '#FFFFFF'};`,
`border-radius:${this.px2rpx(v.borderRadius)}rpx;`,
`font-size:24rpx;padding:10rpx 24rpx;`
].join('');
},
cardStyleBox() {
const v = this.nc;
return this.cardBaseStyle + [
`background:${v.bgColor || '#FFFFFF'};`,
`border:2rpx solid ${v.accentColor || '#FFD700'};`,
'box-shadow:0 10rpx 30rpx rgba(0,0,0,0.08);',
'position:relative;overflow:hidden;'
].join('');
},
/* ---------- 推荐码文字样式 ---------- */
codeTextStyle() {
const v = this.nc;
return [
`color:${v.codeColor || '#E4393C'};`,
`font-size:${this.px2rpx(v.codeFontSize)}rpx;`,
`letter-spacing:${this.px2rpx(1)}rpx;`,
'font-weight:800;',
"font-family:'Courier New', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;"
].join('');
},
/* ---------- 按钮样式 ---------- */
mainBtnBgStyle(isCopyMini) {
const v = this.nc;
const bg = isCopyMini ? 'rgba(255,255,255,0.92)' : (v.buttonBgColor || '#E4393C');
const color = isCopyMini ? (v.buttonBgColor || '#E4393C') : (v.buttonTextColor || '#FFFFFF');
return [
`background:${bg};`,
`color:${color};`,
`border-radius:${this.px2rpx(v.borderRadius)}rpx;`,
`padding:${this.px2rpx(Math.round((Number(v.padding) || 16) * 0.6))}rpx ${this.px2rpx(Math.round((Number(v.padding) || 16) * 1.2))}rpx;`,
'font-weight:700;font-size:26rpx;'
].join('');
},
secondaryBtnStyle() {
const v = this.nc;
return [
'background:rgba(0,0,0,0.06);',
`color:${this.stylePreset === 'gradient' ? '#ffffff' : (v.titleColor || '#333333')};`,
`border-radius:${this.px2rpx(v.borderRadius)}rpx;`,
`padding:${this.px2rpx(Math.round((Number(v.padding) || 16) * 0.55))}rpx ${this.px2rpx(Math.round((Number(v.padding) || 16) * 0.9))}rpx;`,
'font-size:24rpx;font-weight:600;'
].join('');
},
consentBtnStyle() {
const v = this.nc;
return [
`background:linear-gradient(135deg, ${v.bgColorStart || '#3B82F6'} 0%, ${v.bgColorEnd || '#1E40AF'} 100%);`,
`color:${v.buttonTextColor || '#FFFFFF'};`,
`border-radius:${this.px2rpx(Math.max(12, Number(v.borderRadius) || 8))}rpx;`,
`padding:${this.px2rpx(Math.round((Number(v.padding) || 16) * 0.9))}rpx ${this.px2rpx(Math.round((Number(v.padding) || 16) * 2))}rpx;`,
'font-size:28rpx;font-weight:800;'
].join('');
},
/* ---------- 二维码渲染 URL ---------- */
qrcodeRenderUrl() {
// 优先用后端占位接口返回的真实 qrcode_url;没返回就 fallback 用公共 api 合成 share_link 的二维码
if (this.qrcodeUrl && String(this.qrcodeUrl).trim()) return this.qrcodeUrl;
const url = encodeURIComponent(this.shareLink || '');
if (!url) return '';
return 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&margin=0&data=' + url;
}
},
watch: {
/* 登录态切换:guest → login → 自动调 getMyCode;logout → 清空状态
全部 try-catch 包裹:任何 watcher 错误不会冒泡到父级,避免整个 diy-group 崩掉 */
'storeToken'(nv) {
try {
if (nv) {
this._resetAll();
var vm = this;
this.$nextTick(function(){ try { vm.loadCode(false); } catch(e){} });
} else {
this._resetAll();
}
} catch (e) {
try { console.error('[RecommendDiy][watch.storeToken] 错误(已拦截):', e && e.message ? e.message : e); } catch(_) {}
}
},
'memberInfo.member_id'(nv) {
try {
if (nv) {
this._resetAll();
var vm2 = this;
this.$nextTick(function(){ try { vm2.loadCode(false); } catch(e){} });
}
} catch (e) {
try { console.error('[RecommendDiy][watch.memberInfo.member_id] 错误(已拦截):', e && e.message ? e.message : e); } catch(_) {}
}
}
},
/* 页面/组件 created 生命周期:首次登录好的直接拉码 */
/**
* 关键修复:捕获子组件/本组件任何渲染错误,避免因为 ns-login / $refs / Vuex commit 等小问题
* 导致整个 diy-group 渲染中断、页面底部导航点了也不切换(BUG1 的连锁问题)
* Vue 的 errorCaptured 返回 false 时阻止错误继续向上冒泡
*/
errorCaptured(err, vm, info) {
try { console.error('[RecommendDiy][' + this.__cid + '][errorCaptured] 组件内部错误(已拦截):', err && err.message ? err.message : err, 'info=', info); } catch (e) {}
return false;
},
created() {
try {
if (this.storeToken) {
this._resetAll();
var vm = this;
this.$nextTick(function(){ try { vm.loadCode(false); } catch(e){} });
}
} catch (e) {
try { console.error('[RecommendDiy][' + this.__cid + '][created] 初始化失败(已拦截):', e && e.message ? e.message : e); } catch(_) {}
}
},
mounted() {
// ✅ 关键调试日志:页面打开后直接去 F12 搜 "[RecommendDiy][mounted 就能看到组件是否真的渲染了、value 配置是什么、走 v-if 哪个分支会命中
try {
var cid = this.__cid;
var rawVal = this.value || {};
var mergedVal = this.nc || {};
var info = {
cid: cid,
storeToken: !!this.storeToken,
memberId: (this.memberInfo && this.memberInfo.member_id) ? this.memberInfo.member_id : null,
rawValueKeys: Object.keys(rawVal),
mergedTitle: mergedVal.title,
stylePreset: mergedVal.stylePreset,
componentName: rawVal.componentName,
componentId: rawVal.id,
requireConsentComputed: this.requireConsentComputed
};
console.log('[RecommendDiy][' + cid + '][mounted] 组件已渲染 ✓ 配置=', JSON.stringify(info));
if (!this.storeToken) {
console.log('[RecommendDiy][' + cid + '][mounted] 当前未登录,渲染「登录引导卡片(未登录占位)');
} else {
console.log('[RecommendDiy][' + cid + '][mounted] 已登录,等待 loadCode 接口返回后渲染推荐码卡片');
}
} catch (e) {
try { console.error('[RecommendDiy][mounted] 日志输出失败:', e && e.message ? e.message : e); } catch(_) {}
}
},
/* 小程序分享钩子(uni-app 支持组件内定义,作为页面级钩子的补充) */
onShareAppMessage() {
const v = this.nc;
const title = v.shareTitle || v.title || '邀您入驻低空经济产业平台';
return {
title,
path: this.extractMiniProgramPath(),
imageUrl: v.shareImageUrl || ''
};
},
onShareTimeline() {
const v = this.nc;
return {
title: v.shareTitle || v.title || '邀您入驻低空经济产业平台',
query: this.code ? ('recommend_code=' + encodeURIComponent(this.code)) : '',
imageUrl: v.shareImageUrl || ''
};
},
methods: {
/* ======================== 工具:万能响应解包(兼容后端各种嵌套结构) ======================== */
/**
* 万能解包:后端返回结构可能是:
* 标准 {code:0, msg:'ok', data:{code:'...', share_link:'...'}}
* 嵌套 {data:{code:0, data:{...}}}
* 双data {code:0, data:{data:{...}}}
* TP风格 {status:200, info:'ok', data:{...}}
* 裸返回 {code:'...', share_link:'...'} (没包 code/msg 外层)
* 统一返回 { ok: bool, businessCode: number, msg: string, data: object, raw: any }
*/
_unwrapRecommendResponse(res) {
const cid = this.__cid;
var out = { ok: false, businessCode: -1, msg: '', data: {}, raw: res };
try {
if (!res) return out;
var r = res;
// 支持一层 data 包裹(如外层 {code:0, data:{...}} → 再检查 data 里是不是还有 code/data 结构)
var attempts = [
function(){ return r; }, // 最外层:直接用 r
function(){ return (r && r.data && typeof r.data === 'object') ? r.data : null; }, // 下钻一层:r.data
function(){ return (r && r.data && r.data.data && typeof r.data.data === 'object') ? r.data.data : null; }, // 下钻两层
function(){ return (r && r.result && typeof r.result === 'object') ? r.result : null; } // 兼容 .result
];
for (var i = 0; i < attempts.length; i++) {
var obj = attempts[i]();
if (!obj || typeof obj !== 'object') continue;
var codeKey = null;
if (typeof obj.code === 'number' || typeof obj.code === 'string') codeKey = 'code';
else if (typeof obj.status === 'number' || typeof obj.status === 'string') codeKey = 'status';
var messageKey = null;
if (typeof obj.msg === 'string') messageKey = 'msg';
else if (typeof obj.message === 'string') messageKey = 'message';
else if (typeof obj.info === 'string') messageKey = 'info';
// 规则:如果该层有 code/status 字段(业务码),就算是业务层
if (codeKey) {
var codeVal = Number(obj[codeKey]) || 0;
out.businessCode = codeVal;
if (messageKey) out.msg = String(obj[messageKey]);
// 业务码为 0 / 200 / '0' / '200' → 成功
if (codeVal === 0 || codeVal === 200) {
out.ok = true;
// data 优先级:obj.data > obj.result > 整个 obj(防止把 {code,msg,data} 里的 data 当外层当 data 用)
var d = null;
if (obj.data && typeof obj.data === 'object') d = obj.data;
else if (obj.result && typeof obj.result === 'object') d = obj.result;
else d = obj; // 兜底:裸对象,code/share_link 在最外层
out.data = d && typeof d === 'object' ? d : {};
// 特殊处理:如果 data 里又有 data(后端双 data 包裹),再解一层
if (out.data.data && typeof out.data.data === 'object' && !out.data.code && !out.data.share_link) {
out.data = out.data.data;
}
try { console.log('[RecommendDiy][' + cid + '][解包] 命中第 ' + (i+1) + ' 层结构,businessCode=' + codeVal + ', data keys=', Object.keys(out.data)); } catch(e){}
return out;
}
} else if (typeof obj.code === 'string' || typeof obj.share_link === 'string') {
// 裸对象:外层没有 code/status 业务码,但内层直接有 code/share_link → 当成成功处理
out.ok = true;
out.businessCode = 0;
out.msg = 'ok';
out.data = obj;
try { console.log('[RecommendDiy][' + cid + '][解包] 裸对象结构,直接返回 data keys=', Object.keys(obj)); } catch(e){}
return out;
}
}
// 没解出来:打印完整 res 给用户看
try { console.warn('[RecommendDiy][' + cid + '][解包] ⚠️ 未能识别后端响应结构,raw=', JSON.stringify(res).slice(0, 500)); } catch(e){}
} catch (e) {
try { console.error('[RecommendDiy][' + cid + '][解包] 异常:', e && e.message ? e.message : e); } catch(_){}
}
return out;
},
/* ======================== 接口调用(核心 3 个) ======================== */
/**
* 调接口 1:/recommend/api/recommend/getMyCode
* HTTP Method 兼容:先按文档调 GET;GET 没拿到业务 code===0 的数据,自动 fallback 用 POST
* consent:false=首次加载,true=用户点击了同意按钮
* 后端返回(经万能解包):data.consent_required=true 弹同意遮罩;否则 data.code + data.share_link + data.qrcode_url
*/
loadCode(consent) {
const vm = this;
const cid = this.__cid;
if (!this.storeToken) {
try { console.log('[RecommendDiy][' + cid + '][loadCode] 跳过:未登录'); } catch(e){}
return;
}
if (this.loadingCode) return;
this.loadingCode = true;
var reqData = consent ? { consent: 1 } : {};
console.log('[RecommendDiy][' + cid + '][loadCode] 开始请求,consent=' + !!consent + ',params=', JSON.stringify(reqData));
var attempts = [
{ method: 'GET', label: 'GET(文档标准)' },
{ method: 'POST', label: 'POST(兜底兼容)' }
];
var attemptIdx = 0;
var lastMsg = '推荐码获取失败,请稍后重试';
function doOneAttempt() {
if (attemptIdx >= attempts.length) {
// 所有尝试都失败了
vm.loadingCode = false;
uni.showToast && uni.showToast({ title: lastMsg, icon: 'none', duration: 2800 });
return;
}
var meta = attempts[attemptIdx];
attemptIdx += 1;
try { console.log('[RecommendDiy][' + cid + '][loadCode] 尝试 ' + attemptIdx + '/' + attempts.length + ':' + meta.label); } catch(e){}
vm.$api.sendRequest({
url: '/recommend/api/recommend/getMyCode',
method: meta.method,
data: reqData,
async: vm.$api && vm.$api.async === false ? false : true,
success: function(res){
try { console.log('[RecommendDiy][' + cid + '][loadCode][' + meta.label + '] 原始响应=', JSON.stringify(res).slice(0, 1200)); } catch(e){}
var unpacked = vm._unwrapRecommendResponse(res);
if (!unpacked.ok) {
// 本次尝试业务失败(可能是 method 不对,或者 token 不对,或者 code!=0)
lastMsg = unpacked.msg ? String(unpacked.msg) : ('推荐码获取失败(业务码=' + unpacked.businessCode + ')');
try { console.warn('[RecommendDiy][' + cid + '][loadCode][' + meta.label + '] 业务失败:businessCode=' + unpacked.businessCode + ',msg=' + unpacked.msg + ',准备尝试下一个 method'); } catch(e){}
// 第一个 method 失败 → 尝试下一个(GET→POST);最后一个失败就停
if (attemptIdx < attempts.length) {
doOneAttempt();
} else {
vm.loadingCode = false;
uni.showToast && uni.showToast({ title: lastMsg, icon: 'none', duration: 2800 });
}
return;
}
// ✅ 业务成功
var d = unpacked.data || {};
// Case 1:需要同意 + 组件配置了开启同意机制 + 不是同意按钮触发的(还没同意)
if (d.consent_required && !consent && vm.requireConsentComputed) {
try { console.log('[RecommendDiy][' + cid + '][loadCode] 命中:需要同意授权 → 弹 consent 遮罩'); } catch(e){}
vm.__lastConsentRequired = true;
vm.showConsent = true;
vm.code = '';
vm.shareLink = '';
vm.qrcodeUrl = '';
vm.codeLoaded = true;
vm.loadingCode = false;
return;
}
// Case 2:正常拿到推荐码
try { console.log('[RecommendDiy][' + cid + '][loadCode] 成功 ✓ code=', d.code, 'share_link=', d.share_link, 'qrcode_url=', d.qrcode_url ? '有' : '无'); } catch(e){}
vm.__lastConsentRequired = false;
vm.showConsent = false;
vm.code = d.code ? String(d.code) : '';
vm.shareLink = d.share_link ? String(d.share_link) : '';
vm.qrcodeUrl = d.qrcode_url ? String(d.qrcode_url) : '';
// 兼容旧字段名:后端可能返回 recommend_code / recommendShareLink 等
if (!vm.code && d.recommend_code) vm.code = String(d.recommend_code);
if (!vm.code && d.invite_code) vm.code = String(d.invite_code);
if (!vm.shareLink && d.share_url) vm.shareLink = String(d.share_url);
if (!vm.shareLink && d.link) vm.shareLink = String(d.link);
// 持久化:Vuex + storage,供 util.js 现场 banner 跳转自动拼 recommend_code 参数用
vm._persistRecommendInfo();
vm.codeLoaded = true;
vm.loadingCode = false;
if (consent) {
uni.showToast && uni.showToast({ title: '获取成功', icon: 'success' });
}
// 拿到码后如果 showQR=true,顺便调 getMyQrcode(占位接口)
try { vm.loadQrcodeIfNeeded(); } catch(e){}
},
fail: function(err){
var failMsg = (err && err.errMsg) ? String(err.errMsg) : '';
try { console.error('[RecommendDiy][' + cid + '][loadCode][' + meta.label + '] 网络失败:', failMsg, 'raw err=', err); } catch(e){}
lastMsg = failMsg ? ('网络异常:' + failMsg.slice(0, 30)) : '网络异常,请稍后重试';
// GET 网络失败也尝试 POST(有些服务器接口路由不支持 GET,只支持 POST)
if (attemptIdx < attempts.length) {
doOneAttempt();
} else {
vm.loadingCode = false;
uni.showToast && uni.showToast({ title: lastMsg, icon: 'none', duration: 2800 });
}
}
});
}
doOneAttempt();
},
/** 用户点击同意按钮 → consent=1 再调一次 getMyCode 拿真码 */
confirmConsent() {
this.loadCode(true);
},
/**
* 接口 2:POST /recommend/api/recommend/regenerateCode
* 换新码:旧码立即 status=0 失效,后端返回新 code + share_link + old_code + regenerated_at
*/
regenerate() {
const vm = this;
const cid = this.__cid;
if (!this.code) return;
uni.showModal({
title: '换新码',
content: '换新码后旧码会立即失效,后续所有使用旧分享链接的入驻申请都不会归属到您,确认继续?',
confirmText: '确认换新',
cancelText: '取消',
success: (r) => {
if (!r.confirm) return;
if (vm.loadingCode) return;
vm.loadingCode = true;
vm.$api.sendRequest({
url: '/recommend/api/recommend/regenerateCode',
method: 'POST',
data: {},
success: (res) => {
try { console.log('[RecommendDiy][' + cid + '][regenerate] 原始响应=', JSON.stringify(res).slice(0, 1200)); } catch(e){}
var unpacked = vm._unwrapRecommendResponse(res);
if (!unpacked.ok) {
vm.loadingCode = false;
uni.showToast && uni.showToast({
title: unpacked.msg ? String(unpacked.msg) : '换新失败,请稍后重试',
icon: 'none',
duration: 2200
});
return;
}
const d = unpacked.data || {};
vm.code = d.code ? String(d.code) : (d.recommend_code ? String(d.recommend_code) : (d.invite_code ? String(d.invite_code) : ''));
vm.shareLink = d.share_link ? String(d.share_link) : (d.share_url ? String(d.share_url) : (d.link ? String(d.link) : ''));
vm._persistRecommendInfo();
const old = d.old_code ? String(d.old_code) : '';
try { console.log('[RecommendDiy][' + cid + '][regenerate] 成功 ✓ 新码=', vm.code, '旧码=', old); } catch(e){}
uni.showToast && uni.showToast({
title: old ? ('已换新码,旧码 ' + old + ' 已停用') : '换新成功',
icon: 'none',
duration: 2200
});
vm.loadingCode = false;
},
fail: (err) => {
var failMsg = (err && err.errMsg) ? String(err.errMsg) : '';
try { console.error('[RecommendDiy][' + cid + '][regenerate] 网络失败:', failMsg); } catch(e){}
vm.loadingCode = false;
uni.showToast && uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
}
});
},
/** 【接口 5:占位】调 getMyQrcode(可选兜底,失败不影响 showQR,本组件会 fallback 到 qrserver) */
loadQrcodeIfNeeded() {
const vm = this;
if (!this.isFeatureOn(this.nc.showQR)) return;
if (this.qrcodeUrl) return;
if (!this.code) return;
try {
this.$api.sendRequest({
url: '/recommend/api/recommend/getMyQrcode',
method: 'GET',
data: {},
success: (res) => {
if (res && res.code === 0 && res.data && res.data.qrcode_url) {
vm.qrcodeUrl = String(res.data.qrcode_url);
}
}
});
} catch (e) {}
},
/* ======================== 分享 / 复制 ======================== */
/** 点击主按钮「立即分享」:小程序(原生 open-type 已处理);H5/APP 走 navigator.share */
onShareClick() {
const vm = this;
const v = this.nc;
// #ifdef H5
try {
if (navigator && navigator.share && typeof navigator.share === 'function' && this.shareLink) {
navigator.share({
title: v.shareTitle || v.title || '邀您入驻低空经济产业平台',
text: v.shareDesc || '',
url: this.shareLink
}).catch(() => vm.copyLink());
return;
}
} catch (e) {}
this.copyLink();
return;
// #endif
// #ifdef APP-PLUS
try {
if (plus && plus.share && this.shareLink) {
plus.share.sendWithSystem({
title: v.shareTitle || v.title || '邀您入驻低空经济产业平台',
content: v.shareDesc || '',
href: this.shareLink
}, function () {}, function () { vm.copyLink(); });
return;
}
} catch (e) {}
this.copyLink();
// #endif
// #ifdef MP-WEIXIN
// 小程序:主按钮 open-type=share 原生处理,这里兜底复制链接
if (this.shareLink) this.copyLink();
// #endif
},
/** 复制「推荐码」本身(点击推荐码盒子时) */
copyRecommendCode() {
if (!this.code) {
uni.showToast && uni.showToast({ title: '推荐码还未生成,请稍候', icon: 'none' });
return;
}
rcCopyToClipboard(this, this.code, (ok, err) => {
uni.showToast && uni.showToast({
title: ok ? '推荐码已复制' : (err || '复制失败'),
icon: ok ? 'success' : 'none'
});
});
},
/** 复制「分享链接」(后端给的 share_link 原样复制) */
copyLink() {
if (!this.shareLink) {
uni.showToast && uni.showToast({ title: '分享链接还未生成,请稍候', icon: 'none' });
return;
}
rcCopyToClipboard(this, this.shareLink, (ok, err) => {
uni.showToast && uni.showToast({
title: ok ? '推广链接已复制' : (err || '复制失败'),
icon: ok ? 'success' : 'none'
});
});
},
/**
* 从后端返回的 share_link 中提取小程序相对 path(给 onShareAppMessage 用)
* - 相对路径:直接返回
* - 绝对 URL / H5 URL:提取 recommend_code 参数,拼成小程序标准落地页(/pages_tool/form/formdata?recommend_code=xxx)
*/
extractMiniProgramPath() {
try {
const u = this.shareLink || '';
if (!u) return '/pages_tool/form/formdata';
if (u.startsWith('/')) return u;
// 绝对 URL:解析 recommend_code query
const m = u.match(/[?&](recommend_code|invite)=([^&]+)/i);
if (m && m[2]) return '/pages_tool/form/formdata?recommend_code=' + decodeURIComponent(m[2]);
return '/pages_tool/form/formdata';
} catch (e) {
return '/pages_tool/form/formdata';
}
},
/* ======================== 工具 / 持久化 ======================== */
/** 把 code + share_link 持久化到 Vuex + storage,供 util.js 现场 banner 跳转自动拼 recommend_code */
_persistRecommendInfo() {
try {
const sInfo = (this.$store && this.$store.state && this.$store.state.memberInfo) || {};
let stored = {};
try { stored = uni.getStorageSync('memberInfo') || {}; } catch (e) { stored = {}; }
const merged = Object.assign({}, typeof sInfo === 'object' ? sInfo : {}, typeof stored === 'object' ? stored : {}, {
recommend_code: this.code || '',
recommend_share_link: this.shareLink || ''
});
if (this.$store && this.$store.commit) {
try { this.$store.commit('setMemberInfo', merged); } catch (e) {}
}
try { uni.setStorageSync('memberInfo', merged); } catch (e) {}
} catch (e) {}
},
/** 登出 / 换账号时清空所有缓存状态 */
_resetAll() {
this.code = '';
this.shareLink = '';
this.qrcodeUrl = '';
this.showConsent = false;
this.loadingCode = false;
this.codeLoaded = false;
this.__lastConsentRequired = false;
},
/** ns-login 登录成功回调 → 拉一次 getMyCode */
onLoginSuccess() {
this._resetAll();
this.$nextTick(() => this.loadCode(false));
},
/** 点击未登录卡片 → 弹出登录 */
goLogin() {
const isPage = getCurrentPages().length > 0;
if (this.$refs && this.$refs.nsLoginBox) {
try {
if (typeof this.$refs.nsLoginBox.showLoginBox === 'function') {
this.$refs.nsLoginBox.showLoginBox();
return;
}
} catch (e) {}
}
uni.navigateTo && uni.navigateTo({
url: '/pages/login/login',
fail: () => { uni.redirectTo && uni.redirectTo({ url: '/pages/login/login' }); }
});
}
}
};
</script>
<style lang="scss" scoped>
.diy-recommend-code {
width: 100%;
box-sizing: border-box;
/* ===== 未登录占位卡片 ===== */
.invite-login-tip {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
box-sizing: border-box;
user-select: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
.tip-icon { font-size: 40rpx; margin-right: 16rpx; }
.tip-text { font-size: 28rpx; flex: 1; }
.tip-btn { font-weight: 500; }
}
/* ===== 通用:同意遮罩 / loading ===== */
.consent-overlay {
width: 100%;
background: #FFFFFF;
box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.08);
.consent-header {
.consent-title { font-size: 34rpx; font-weight: 700; display: block; margin-bottom: 8rpx; }
}
.consent-content {
margin-top: 28rpx;
background: rgba(0,0,0,0.04);
border-radius: 20rpx;
padding: 28rpx 28rpx;
.consent-text { font-size: 26rpx; line-height: 1.75; }
}
.consent-action {
margin-top: 40rpx;
display: flex;
justify-content: center;
.consent-btn {
display: inline-flex; justify-content: center; align-items: center;
user-select: none; cursor: pointer; transition: opacity 0.2s ease, transform 0.15s ease;
-webkit-tap-highlight-color: transparent;
&:active { opacity: 0.92; transform: scale(0.98); }
&.btn-loading { opacity: 0.7; pointer-events: none; }
}
}
}
.loading-wrap {
width: 100%;
background: #F9FAFB;
min-height: 240rpx;
display: flex; align-items: center; justify-content: center;
.loading-text { font-size: 28rpx; }
}
/* ===== 通用:推荐码卡片各组件 ===== */
.rc-header {
width: 100%;
.rc-title { font-size: 34rpx; font-weight: 800; display: block; }
}
.rc-code-box {
margin-top: 30rpx;
background: rgba(255,255,255,0.16);
border-radius: 20rpx;
padding: 26rpx 30rpx;
display: flex; align-items: center; justify-content: space-between; flex-wrap: nowrap;
cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent;
transition: opacity 0.15s ease;
&:active { opacity: 0.88; }
.rc-prefix { font-size: 24rpx; margin-right: 20rpx; }
.rc-code { flex: 1; word-break: break-all; }
.rc-copy {
font-size: 24rpx;
font-weight: 600;
padding: 12rpx 26rpx;
border-radius: 40rpx;
display: inline-flex; justify-content: center; align-items: center;
flex-shrink: 0;
}
}
.rc-action-bar {
margin-top: 36rpx;
display: flex;
flex-direction: row;
gap: 16rpx;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
}
.rc-btn {
box-sizing: border-box;
display: inline-flex;
justify-content: center;
align-items: center;
text-align: center;
border: none;
outline: none;
line-height: 1.35;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: opacity 0.2s ease, transform 0.15s ease;
&:active { opacity: 0.9; transform: scale(0.98); }
&.rc-btn-share {
min-width: 220rpx;
flex: 0 0 auto;
}
}
button.rc-btn {
margin: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
background: transparent;
overflow: hidden;
&::after { border: none; }
}
/* ===== 通用:分享链接 / 二维码 ===== */
.rc-link-row {
margin-top: 28rpx;
width: 100%;
.rc-link-text {
display: block;
width: 100%;
font-size: 22rpx;
line-height: 1.5;
word-break: break-all;
cursor: pointer;
opacity: 0.82;
padding: 18rpx 22rpx;
border-radius: 16rpx;
background: rgba(255,255,255,0.08);
}
&.rc-link-minimal .rc-link-text,
&.rc-link-card .rc-link-text {
color: #666666;
background: #F3F4F6;
opacity: 1;
}
}
.rc-qr-row {
margin-top: 36rpx;
display: flex; flex-direction: column; align-items: center; gap: 14rpx;
.rc-qr-img {
width: 300rpx; height: 300rpx;
display: block;
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.1);
}
.rc-qr-tip {
font-size: 24rpx;
font-weight: 500;
line-height: 1.4;
text-align: center;
max-width: 420rpx;
}
&.rc-qr-minimal-img, &.rc-qr-card-img { border: 2rpx solid #eee; }
&.rc-qr-minimal {
align-items: flex-start;
gap: 12rpx;
.rc-qr-minimal-img { border: 2rpx solid #F3F4F6; }
.rc-qr-tip-minimal { text-align: left; max-width: 500rpx; }
}
&.rc-qr-card {
gap: 20rpx;
.rc-qr-wrap-card {
border-radius: 20rpx;
border: 4rpx solid #FFD700;
padding: 16rpx;
background: #ffffff;
.rc-qr-card-img { width: 260rpx; height: 260rpx; border-radius: 12rpx; }
}
.rc-qr-tip-card { max-width: 480rpx; }
}
}
/* ===== 预设 1:gradient ===== */
.style-gradient {
box-sizing: border-box;
width: 100%;
.rc-header .rc-title { color: #ffffff; }
.rc-code-box {
background: rgba(0,0,0,0.16);
.rc-prefix { color: rgba(255,255,255,0.7); }
.rc-code { color: #ffffff; }
}
}
/* ===== 预设 2:minimal ===== */
.style-minimal {
box-sizing: border-box;
width: 100%;
.rc-code-minimal {
background: transparent;
border-style: dashed;
padding: 22rpx 26rpx;
gap: 16rpx;
.rc-code-minimal-text { text-align: center; letter-spacing: 4rpx; }
.rc-copy-minimal { font-size: 22rpx; }
}
.rc-action-minimal { justify-content: space-between; }
}
/* ===== 预设 3:card 金色边框 ===== */
.style-card {
box-sizing: border-box;
width: 100%;
.rc-accent-bar {
position: absolute;
left: 0; top: 0; right: 0;
height: 8rpx;
}
.rc-header-card { margin-top: 8rpx; }
.rc-code-card {
background: #FAFAFA;
padding: 28rpx 32rpx;
gap: 16rpx;
border-radius: 20rpx;
.rc-copy-card {
padding: 14rpx 30rpx;
font-weight: 700;
font-size: 24rpx;
}
}
.rc-action-card { justify-content: flex-start; }
}
}
</style>