chore: 入库生产基线(2026-10-26 工作树快照)

- 含首页壳/容器页/授权页/分享图/首页配置等此前未入库的在途改动
- 新增未跟踪的 pages/index/mall.vue(第 5 个 tabBar 页,pages.json 已引用)
- 排除本地备份文件(*.bak* / *.origin-bak)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-26 17:07:49 +08:00
parent 91f1ee849c
commit b45a190a59
14 changed files with 4116 additions and 2446 deletions

View File

@@ -1,43 +1,142 @@
<template>
<view class="auth-page">
<!-- 顶部说明 -->
<view class="auth-head">
<text class="auth-title">授权完善资料</text>
<text class="auth-sub">用于在应用里展示你的昵称 / 头像 / 手机号</text>
<!-- ============ 可滚动内容区 ============ -->
<view class="home">
<!-- 未授权:价值主张卡(先说清"有什么好处",再要授权) -->
<view v-if="!phoneBound" class="pitch">
<view class="pbadge">全部免费 · 无需付费</view>
<view class="pttl">{{ pitchTitle }}</view>
<view class="pttl pttl2 em">授权后全部免费用</view>
<view class="psub">胎动、体重、产检、血糖、待产清单……</view>
<view class="psub">从孕期到产后,一个地方全记下来</view>
<view class="social">
<view class="dots">
<image v-for="(d, i) in SOCIAL_AVATARS" :key="i" class="dot" :src="d" mode="aspectFit" />
</view>
<text class="social-txt">已有 {{ SOCIAL_COUNT }} 位妈妈在用</text>
</view>
<button class="cta" @tap="openPhoneSheet">授权手机号,立即免费体验</button>
<view class="ctahint">微信授权 · 10 秒完成 · 仅用于创建你的孕育档案</view>
<view class="trust">
<text class="trust-i"><text class="trust-tick">✓</text>全部免费</text>
<text class="trust-i"><text class="trust-tick">✓</text>无需下载 App</text>
<text class="trust-i"><text class="trust-tick">✓</text>换手机不丢</text>
</view>
</view>
<!-- 已授权:收起成一条状态,不再占地方 -->
<view v-else class="hero">
<view class="ava">
<image v-if="heroAvatar" class="ava-img" :src="heroAvatar" mode="aspectFill" />
<text v-else class="ava-emoji">📱</text>
</view>
<view class="who">
<view class="nm">{{ heroName }}</view>
<view class="st">{{ heroSub }}</view>
</view>
<view class="ok">已授权</view>
</view>
<template v-if="apps.length">
<view class="secthead">
<text class="secttl">孕期工具</text>
<text class="sectmore">共 {{ apps.length }} 个 ›</text>
</view>
<view v-if="!phoneBound" class="freebar">
<text class="freepill">全部免费</text>
<text class="freetxt">{{ apps.length }} 款工具当前未解锁,授权手机号后立即可用</text>
</view>
<!-- 工具宫格:数据来自 box_app 后台在架应用,**仅展示不可点击**;
未授权时右上角显示小锁(授权后解锁,仍不响应点击)。 -->
<view class="grid" :class="{ locked: !phoneBound }">
<view v-for="a in apps" :key="a.id" class="tile">
<view class="ic">
<image v-if="a.logo" class="tile-logo" :src="a.logo" mode="aspectFit" />
<text v-else class="tile-fb">{{ (a.name || '·').slice(0, 1) }}</text>
</view>
<text class="tn">{{ a.name }}</text>
<view class="lock">
<image class="lock-ic" :src="LOCK_ICON" mode="aspectFit" />
</view>
</view>
</view>
</template>
</view>
<!-- 授权表单卡片 -->
<view class="auth-card">
<!-- 头像:圆形选择区,点击选择;图片填满圆并裁剪,文字放圆外 -->
<view class="auth-avatar-wrap">
<button class="auth-avatar" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image v-if="authAvatar" class="auth-ava" :src="authAvatar" mode="aspectFill" />
<view v-else class="auth-ava-plus">+</view>
<!-- 常驻底部行动条:用户滚到哪一屏都能点 -->
<view v-if="!phoneBound" class="stickycta">
<button class="cta cta2" @tap="openPhoneSheet">{{ stickyText }}</button>
</view>
<!-- 遮罩(演示里点遮罩不关闭,必须走两个按钮之一) -->
<view class="mask" :class="{ show: !!sheet }" @tap="noop"></view>
<!-- ① 手机号半弹窗:「是」按钮本身必须是 open-type="getPhoneNumber",
手机号没有可编程调起的 API;带右上角「原生」角标的才调微信接口。 -->
<view class="sheet" :class="{ show: sheet === 'phone' }">
<view class="grab"></view>
<view class="sheet-h3">是否授权手机号进行体验?</view>
<view class="sheet-p">用于创建你的孕育档案,让胎动、体重、产检记录在换设备后也能找回。</view>
<view class="btnrow">
<button class="bsec" @tap="denyPhone">否</button>
<button class="bmain bwx" open-type="getPhoneNumber" @getphonenumber="onGetPhone">
是,授权手机号
</button>
<text class="auth-ava-label">{{ authAvatar ? '点击更换头像' : '点击选择头像' }}</text>
</view>
<view class="auth-field">
<input class="auth-input" type="nickname" v-model="authNickname" placeholder="输入昵称" maxlength="16" placeholder-class="auth-ph" />
</view>
<button class="auth-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhone">{{ authPhone ? '✅ 已授权手机号' : '获取手机号' }}</button>
<view class="auth-actions">
<button class="auth-cancel" @tap="cancel">取消</button>
<button class="auth-ok" @tap="submitAuth">保存并返回</button>
</view>
</view>
<text class="auth-tip">微信授权仅用于在应用内展示你的资料,不会泄露给第三方。</text>
<!-- ④-A 老用户(库里已有昵称/头像):是否复用。普通按钮 —— 复用走库里资料,不调微信接口 -->
<view class="sheet" :class="{ show: sheet === 'reuse' }">
<view class="grab"></view>
<view class="sheet-h3">是否复用你的微信昵称和头像?</view>
<view class="avarow">
<view class="avarow-big">
<image v-if="libAvatar" class="avarow-img" :src="libAvatar" mode="aspectFill" />
<text v-else class="avarow-emoji">🐣</text>
</view>
<view class="avarow-txt">
<view class="t1">{{ libNickname || '微信用户' }}</view>
</view>
</view>
<view class="btnrow">
<button class="bsec" @tap="reuseNo">否,稍后再说</button>
<button class="bmain" @tap="reuseYes">是,直接用</button>
</view>
</view>
<!-- ④-B 库里没有昵称/头像:不再追问、不再给表单,直接收尾返回(2026-09-22 定案) -->
<!-- 说明:本页只负责「手机号」这一件事 + 可选复用库里已有资料。
昵称/头像不由本页采集(getUserProfile 已废弃,且产品定案不在此追问),
它们由小程序其它流程/上游用户中心/商城同步维护,见 AGENTS §4.4。 -->
<!-- 页内 toast -->
<view class="toast" :class="{ show: toast.show }">
<text v-if="toast.tick" class="toast-tick">✓</text>
<text class="toast-msg">{{ toast.msg }}</text>
</view>
</view>
</template>
<script>
import store from '@/store';
import { HTTP_REQUEST_URL } from '@/config/app';
import { LOGIN_STATUS } from '@/config/cache';
/* 共享授权接口(user_auth_router:全 H5 应用共用,openid 单键,不区分应用) */
const AUTH_API = 'https://mp.yizhezhe.com/api/mp/public/auth/profile';
/* box_app 公开应用列表:宫格按后台登记的在架应用渲染(样式照 design/授权流程演示) */
const APPS_API = 'https://mp.yizhezhe.com/api/mp/apps';
/* ⚠️ 演示页占位数据:上线前必须核实真实数字,否则涉嫌虚假宣传。
三个头像是内联 SVG 示意插画(为保持单文件离线可用),不是真实用户头像。 */
const SOCIAL_COUNT = '36.5万+';
const SOCIAL_AVATARS = [
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCc+PGNpcmNsZSBjeD0nMTInIGN5PScxMicgcj0nMTInIGZpbGw9JyNGN0U0RUEnLz48cGF0aCBkPSdNMi42IDI0Yy43LTQuMyA0LjYtNi45IDkuNC02LjlzOC43IDIuNiA5LjQgNi45eicgZmlsbD0nI0M5N0I5MicvPjxjaXJjbGUgY3g9JzEyJyBjeT0nMTAuNicgcj0nNC4xNScgZmlsbD0nI0YyQzZBQycvPjxlbGxpcHNlIGN4PSc3LjknIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjM0IyQTI0Jy8+PGVsbGlwc2UgY3g9JzE2LjEnIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjM0IyQTI0Jy8+PHBhdGggZD0nTTcuOCAxMC42QzcuOCA2LjUgMTYuMiA2LjUgMTYuMiAxMC42Yy0uOS0yLjEtNy41LTIuMS04LjQgMHonIGZpbGw9JyMzQjJBMjQnLz48L3N2Zz4=',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCc+PGNpcmNsZSBjeD0nMTInIGN5PScxMicgcj0nMTInIGZpbGw9JyNFRkVBRkEnLz48cGF0aCBkPSdNMi42IDI0Yy43LTQuMyA0LjYtNi45IDkuNC02LjlzOC43IDIuNiA5LjQgNi45eicgZmlsbD0nIzlCODZDOScvPjxjaXJjbGUgY3g9JzEyJyBjeT0nMTAuNicgcj0nNC4xNScgZmlsbD0nI0Y2RDNCQycvPjxlbGxpcHNlIGN4PSc3LjknIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjNUEzQjJFJy8+PGVsbGlwc2UgY3g9JzE2LjEnIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjNUEzQjJFJy8+PHBhdGggZD0nTTcuOCAxMC42QzcuOCA2LjUgMTYuMiA2LjUgMTYuMiAxMC42Yy0uOS0yLjEtNy41LTIuMS04LjQgMHonIGZpbGw9JyM1QTNCMkUnLz48L3N2Zz4=',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCc+PGNpcmNsZSBjeD0nMTInIGN5PScxMicgcj0nMTInIGZpbGw9JyNFNEYyRUMnLz48cGF0aCBkPSdNMi42IDI0Yy43LTQuMyA0LjYtNi45IDkuNC02LjlzOC43IDIuNiA5LjQgNi45eicgZmlsbD0nIzVGQTk4QycvPjxjaXJjbGUgY3g9JzEyJyBjeT0nMTAuNicgcj0nNC4xNScgZmlsbD0nI0VGQzBBMCcvPjxlbGxpcHNlIGN4PSc3LjknIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjMjQxQzE5Jy8+PGVsbGlwc2UgY3g9JzE2LjEnIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjMjQxQzE5Jy8+PHBhdGggZD0nTTcuOCAxMC42QzcuOCA2LjUgMTYuMiA2LjUgMTYuMiAxMC42Yy0uOS0yLjEtNy41LTIuMS04LjQgMHonIGZpbGw9JyMyNDFDMTknLz48L3N2Zz4='
];
/* 宫格右上角小锁(未授权时显示;base64 保证 WXSS/小程序端可直接用) */
const LOCK_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCcgZmlsbD0nbm9uZScgc3Ryb2tlPScjOEM3QTZGJyBzdHJva2Utd2lkdGg9JzIuMic+PHJlY3QgeD0nNC41JyB5PScxMC41JyB3aWR0aD0nMTUnIGhlaWdodD0nMTAnIHJ4PScyLjUnLz48cGF0aCBkPSdNOCAxMC41VjcuNWE0IDQgMCAwIDEgOCAwdjMnLz48L3N2Zz4=';
function base64Decode(str) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
var output = '';
@@ -90,141 +189,365 @@
export default {
data() {
return {
authNickname: '',
authAvatar: '',
authPhone: false, // 手机号是否已授权(UI 反馈)
_authCode: '',
openid: ''
openid: '',
/* 当前半弹窗:'' | 'phone' | 'reuse' */
sheet: '',
phoneBound: false, // 手机号是否已落库(= 价值卡收起 / 宫格解锁)
phone: '',
libNickname: '', // 共享库里已有的昵称(判据的取值来源)
libAvatar: '', // 共享库里已有的头像
apps: [],
busy: false,
toast: { show: false, msg: '', tick: false },
SOCIAL_COUNT: SOCIAL_COUNT,
SOCIAL_AVATARS: SOCIAL_AVATARS,
LOCK_ICON: LOCK_ICON
}
},
computed: {
pitchTitle() {
return (this.apps.length ? this.apps.length + ' 款' : '') + '孕育必备工具';
},
heroName() {
return this.libNickname || this.phone || '已授权';
},
heroSub() {
return this.libNickname ? '资料已复用 · 手机号已绑定' : '手机号已绑定 · 未设置昵称';
},
heroAvatar() {
return this.libAvatar;
},
/* 应用列表拉取失败时不要显示「解锁 0 款工具」 */
stickyText() {
return this.apps.length ? '立即免费体验 · 解锁 ' + this.apps.length + ' 款工具' : '立即免费体验';
}
},
onLoad(options) {
this.openid = getOpenid();
/* H5 经 navigateTo query 带来的「发起授权的当前地址」(backUrl,已 encode):
成功时写进 __AUTH_RESULT__,宿主 onShow 优先按它回传 —— 从哪个 H5 触发的
结束后写进 __AUTH_RESULT__,宿主 onShow 优先按它回传 —— 从哪个 H5 触发的
授权就回到哪个 H5(宫格 location.href 换应用后,shareHref 可能还是旧应用的)。 */
let backUrl = (options && options.backUrl) || '';
if (backUrl) {
try { backUrl = decodeURIComponent(backUrl); } catch (e) { /* 已是原文 */ }
}
this._backUrl = backUrl;
if (!this.openid) uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
/* 宫格数据:失败不影响授权主流程(旁路能力绝不阻塞) */
this.fetchApps();
if (!this.openid) {
this.showToast('未获得 openid,请先登录', false, 1500);
setTimeout(() => { uni.navigateBack(); }, 1500);
return;
}
/* 进页即问手机号:用户是从 H5「授权完善资料」进来的,意图已经明确,少一次点击 */
this.sheet = 'phone';
},
onUnload() {
if (this._toastTimer) clearTimeout(this._toastTimer);
},
methods: {
onChooseAvatar(e) {
const avatarUrl = e.detail.avatarUrl;
if (!avatarUrl) return uni.showToast({ title: '未获取到头像', icon: 'none' });
/* 上传到 box_app 公开接口(无鉴权):定制授权页设计为匿名可用,
不依赖 CRMEB 登录态 —— 用 CRMEB 的 uploadImgs 在退出登录后会 401 弹「请登录」。 */
uni.showLoading({ title: '上传头像中…' });
uni.uploadFile({
url: 'https://mp.yizhezhe.com/api/upload',
filePath: avatarUrl,
name: 'file',
/* ---------- 工具宫格:box_app 在架应用(仅展示,不响应点击) ---------- */
fetchApps() {
uni.request({
url: APPS_API,
data: { isPublished: 1, pageSize: 200 },
success: (res) => {
try {
const d = JSON.parse(res.data);
if (d.code === 200 && d.result && d.result.url) {
this.authAvatar = d.result.url;
} else {
uni.showToast({ title: '头像上传失败', icon: 'none' });
}
} catch (err) {
uni.showToast({ title: '头像上传失败', icon: 'none' });
const d = res.data || {};
const items = (d.result && d.result.items) || d.data || [];
if (Array.isArray(items)) {
this.apps = items.filter((a) => a && a.name);
}
},
fail: () => uni.showToast({ title: '头像上传失败,请检查网络', icon: 'none' }),
complete: () => uni.hideLoading()
fail: () => {
console.warn('[auth] 应用列表拉取失败,宫格留空(不影响授权)');
}
});
},
/* ---------- ① 手机号 ---------- */
openPhoneSheet() {
if (!this.openid) return this.showToast('未获得 openid,请先登录');
this.sheet = 'phone';
},
denyPhone() {
/* 手机号都不授权 = 本次授权放弃:直接回 H5(不写 __AUTH_RESULT__,
宿主按现有逻辑重载当前 H5,不会有假成功回流)。 */
uni.navigateBack();
},
onGetPhone(e) {
if (e.detail.errMsg === 'getPhoneNumber:ok' && e.detail.code) {
this._authCode = e.detail.code;
this.authPhone = true;
const detail = (e && e.detail) || {};
if (detail.errMsg === 'getPhoneNumber:ok' && detail.code) {
this.submitPhone(detail.code);
} else {
uni.showToast({ title: '未授权手机号', icon: 'none' });
this.showToast('未授权手机号', false, 1800);
}
},
submitAuth() {
if (!this.authNickname && !this.authAvatar && !this._authCode) {
return uni.showToast({ title: '请至少授权一项', icon: 'none' });
async submitPhone(code) {
if (this.busy) return;
this.busy = true;
uni.showLoading({ title: '授权中…', mask: true });
const r = await this.postAuth({ code: code });
uni.hideLoading();
this.busy = false;
const phone = (r.res && r.res.phone) || '';
/* ⚠️ 成功判据必须核对「手机号真的拿到了」:后端解密失败(如 access_token 被
商城后端挤掉)会如实返回 code!=200;旧版后端也可能 200 但 result.phone 为空。
两种情况都不能往下走 —— 否则 H5 收到空 phone 的回传,闸门拦截,用户会陷入
「授权成功却又要求授权」的死循环(2026-09-18 孕检日历实测)。 */
if (!r.ok || !phone) {
return this.showToast(r.message || '手机号获取失败,请重试', false, 3000);
}
const openid = getOpenid() || this.openid;
if (!openid) return uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
/* unionid:用户中心身份键(预留)。带上后 box_app 授权成功自动推送用户中心
并回写 openid→unionid 映射;没拿到也不影响授权(后端可映射兜底)。 */
const unionid = getUnionid();
const api = 'https://mp.yizhezhe.com/api/mp/public/auth/profile?openid=' + encodeURIComponent(openid) +
(unionid ? '&unionid=' + encodeURIComponent(unionid) : '') +
(this._authCode ? '&code=' + encodeURIComponent(this._authCode) : '') +
(this.authNickname ? '&nickname=' + encodeURIComponent(this.authNickname) : '') +
(this.authAvatar ? '&avatar=' + encodeURIComponent(this.authAvatar) : '');
uni.request({
url: api, method: 'POST', timeout: 10000,
success: (r) => {
const d = r.data || {};
const res = d.result || {};
// ⚠️ 成功判据必须核对「手机号真的拿到了」:提交了 code 时,
// 后端解密失败(如 access_token 被商城后端挤掉)会如实返回
// code!=200;旧版后端也可能 200 但 result.phone 为空。
// 两种情况都不能写 __AUTH_RESULT__、不能提示成功 —— 否则 H5
// 收到空 phone 的回传,闸门拦截,用户会陷入「授权成功却又
// 要求授权」的死循环(2026-09-18 孕检日历实测)。
const phoneMissing = !!this._authCode && !res.phone;
if (d.code === 200 && !phoneMissing) {
uni.setStorageSync('__AUTH_RESULT__', {
openid: openid,
phone: res.phone || '',
nickname: res.userName || this.authNickname || '',
avatar: res.avatar || this.authAvatar || '',
backUrl: this._backUrl || ''
});
uni.showToast({ title: '授权成功', icon: 'success' });
setTimeout(() => {
uni.navigateBack();
}, 600);
} else {
uni.showToast({ title: (d.message || '授权保存失败,请稍后重试'), icon: 'none', duration: 3000 });
}
},
fail: () => uni.showToast({ title: '授权请求失败,请检查网络', icon: 'none' })
this.phone = phone;
this.libNickname = (r.res && r.res.userName) || '';
this.libAvatar = (r.res && r.res.avatar) || '';
this.phoneBound = true;
this.sheet = '';
this.showToast('授权成功', true, 1100);
/* ④ 查共享库(**必须走 GET,不能只看上面 POST 的返回**):
本页**不采集**昵称/头像(getUserProfile 已废弃,昵称/头像只能由用户手势触发
input type=nickname / chooseAvatar;且产品定案此页不再追问),展示的永远是
「库里已经存过的值」—— 可能来自上一次授权、上游用户中心、或 CRMEB 商城用户表同步。
而 GET /auth/profile 才带「先取后授权」兜底回填(用户中心 → CRMEB 同步),
POST(apply_auth) 不做回填 —— 只在商城有昵称/头像的用户,只有 GET 能带出来。
判据用 !!(userName || avatar),**不能**用「记录是否存在」(接口对任何 openid
都返回 200 并现场建一条空记录)。 */
const startedAt = Date.now();
const lib = await this.fetchLibraryProfile();
if (lib) {
this.libNickname = lib.userName || '';
this.libAvatar = lib.avatar || '';
}
/* 让「授权成功」toast 至少停 1.1s,查库太快也不闪 */
const wait = Math.max(0, 1100 - (Date.now() - startedAt));
setTimeout(() => {
if (this.libNickname || this.libAvatar) {
/* 库里有昵称/头像 → 提示是否复用 */
this.sheet = 'reuse';
} else {
/* 库里没有 → **不追问头像昵称**(本页不采集这两项),直接收尾返回 */
this.finish('', '', '授权成功');
}
}, wait);
},
/* ---------- ④-A 复用库里已有资料 ---------- */
async reuseYes() {
if (this.busy) return;
const extra = {};
if (this.libNickname) extra.nickname = this.libNickname;
if (this.libAvatar) extra.avatar = this.libAvatar;
/* 库里值可能来自 CRMEB 商城同步(authorized=false):这里再写一次,
把「复用」视为用户主动授权,置位 authorized 并回写用户中心。 */
if (extra.nickname || extra.avatar) {
this.busy = true;
uni.showLoading({ title: '处理中…', mask: true });
const r = await this.postAuth(extra);
uni.hideLoading();
this.busy = false;
if (!r.ok) return this.showToast(r.message, false, 3000);
this.libNickname = (r.res && r.res.userName) || this.libNickname;
this.libAvatar = (r.res && r.res.avatar) || this.libAvatar;
}
this.finish(this.libNickname, this.libAvatar, '已复用微信资料');
},
reuseNo() {
/* 不同步:库里资料原样保留,照样把真实值回传给 H5,然后直接返回 */
this.finish(this.libNickname, this.libAvatar, '已保留手机号,资料未改动');
},
/* ---------- 收尾:写 __AUTH_RESULT__ → 宿主 onShow 回传 H5 hash ---------- */
finish(nickname, avatar, msg) {
const openid = this.openid || getOpenid();
if (!openid) return this.showToast('未获得 openid,请先登录');
try {
uni.setStorageSync('__AUTH_RESULT__', {
openid: openid,
phone: this.phone || '',
nickname: nickname || '',
avatar: avatar || '',
backUrl: this._backUrl || ''
});
} catch (e) {
console.warn('[auth] 写 __AUTH_RESULT__ 失败:', e && e.message);
}
this.sheet = '';
this.showToast(msg || '授权成功', true, 1000);
setTimeout(() => { uni.navigateBack(); }, 700);
},
/* ---------- ④ 查共享库(GET)----------
唯一带「先取后授权」兜底回填的入口(本地 → 上游用户中心 → CRMEB 商城同步),
所以判「库里是否已有昵称/头像」必须走它,不能拿 POST 的返回凑。
超时/失败一律 resolve(null)(fail-soft):退回用 POST 带回的值判分支,
后续任意一次 GET 仍会把兜底数据补上,不会丢状态。 */
fetchLibraryProfile() {
return new Promise((resolve) => {
const openid = this.openid || getOpenid();
if (!openid) return resolve(null);
const unionid = getUnionid();
let url = AUTH_API + '?openid=' + encodeURIComponent(openid);
if (unionid) url += '&unionid=' + encodeURIComponent(unionid);
uni.request({
url: url,
method: 'GET',
timeout: 2500,
success: (r) => {
const d = r.data || {};
resolve(d.code === 200 ? (d.result || {}) : null);
},
fail: () => resolve(null)
});
});
},
cancel() { uni.navigateBack(); }
/* ---------- 共享授权接口 POST(code / nickname / avatar 三选若干) ---------- */
postAuth(extra) {
return new Promise((resolve) => {
const openid = this.openid || getOpenid();
if (!openid) return resolve({ ok: false, res: {}, message: '未获得 openid,请先登录' });
/* unionid:用户中心身份键(预留)。没拿到也不影响授权(后端可映射兜底)。 */
const unionid = getUnionid();
let url = AUTH_API + '?openid=' + encodeURIComponent(openid);
if (unionid) url += '&unionid=' + encodeURIComponent(unionid);
const ex = extra || {};
['code', 'nickname', 'avatar'].forEach((k) => {
if (ex[k]) url += '&' + k + '=' + encodeURIComponent(ex[k]);
});
uni.request({
url: url,
method: 'POST',
timeout: 10000,
success: (r) => {
const d = r.data || {};
if (d.code === 200) {
resolve({ ok: true, res: d.result || {}, message: d.message || '' });
} else {
resolve({ ok: false, res: d.result || {}, message: d.message || '授权保存失败,请稍后重试' });
}
},
fail: () => resolve({ ok: false, res: {}, message: '授权请求失败,请检查网络' })
});
});
},
showToast(msg, tick, ms) {
if (this._toastTimer) clearTimeout(this._toastTimer);
this.toast.msg = msg || '';
this.toast.tick = !!tick;
this.toast.show = true;
this._toastTimer = setTimeout(() => { this.toast.show = false; }, ms || 1500);
},
noop() {}
}
}
</script>
<style scoped>
/* 清 button 默认边框/padding(避免撑出卡片 + 消除默认灰底边框)。
不改 margin —— 统配 margin:0 会压掉各按钮自己的 margin-top,导致元素紧贴。 */
⚠️ 两条纪律,踩过:
① 这里不设 background —— 否则会盖掉 .cta/.bmain 自己的底色;
② `.auth-page button` 的特异性是 (0,1,1)(scoped 编译后约 (0,2,1)),**高于**单类选择器
`.bsec`/`.bmain`/`.cta`/(0,1,0)。所以下面所有按钮自己的 padding / border 规则
都必须带上 `.auth-page ` 前缀提权,否则会被这条 reset 清成 0 ——
表现是「否」没有边框只剩文字、按钮高度塌成一行字高(2026-09-22 真机截图)。 */
.auth-page button::after { border: none; }
.auth-page button { padding: 0; line-height: normal; box-sizing: border-box; border: none; -webkit-appearance: none; }
.auth-page input { box-sizing: border-box; margin: 0; }
.auth-page { min-height: 100vh; background: #F8F3EF; padding: 24px 20px 40px; display: flex; flex-direction: column; align-items: center; }
.auth-head { text-align: center; margin-top: 16px; }
.auth-title { font-size: 20px; font-weight: 800; color: #2B2320; display: block; }
.auth-sub { font-size: 13px; color: #A79C95; margin-top: 8px; display: block; }
.auth-card { width: 100%; max-width: 340px; background: #fff; border-radius: 18px; padding: 28px 20px 20px; margin-top: 28px; box-shadow: 0 6px 24px rgba(0,0,0,.05); display: flex; flex-direction: column; align-items: center; }
.auth-page { min-height: 100vh; background: #F5EFE7; color: #4A3B33; font-size: 15px; line-height: 1.6; }
/* 头部:圆形头像选择区(图片填满圆并裁剪),说明文字放圆外下方 */
.auth-avatar-wrap { display: flex; flex-direction: column; align-items: center; }
.auth-avatar { width: 92px; height: 92px; border-radius: 50%; background: #F3E9E3; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px dashed #D9C4B8; flex: 0 0 auto; }
.auth-ava { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.auth-ava-plus { font-size: 30px; color: #C9B7AC; line-height: 1; }
.auth-ava-label { font-size: 11px; color: #A79C95; margin-top: 8px; }
/* ---------- 内容区 ---------- */
.home { padding: 14px 14px 0; }
/* 昵称输入 */
.auth-field { width: 100%; margin-top: 22px; }
.auth-input { width: 100%; height: 48px; border-radius: 12px; background: #F8F3EF; font-size: 14px; color: #2B2320; padding: 0 14px; box-sizing: border-box; }
.auth-ph { color: #C9B7AC; }
/* ---------- 价值主张卡(未授权时的主转化区) ---------- */
.pitch { background-image: linear-gradient(160deg, #FFFFFF 0%, #FFFCF8 46%, #FDF6EF 100%); border: 1px solid #EDE2D7; border-radius: 16px; padding: 17px 15px 15px; box-shadow: 0 6px 18px -8px rgba(122,88,66,.18), 0 1px 3px rgba(122,88,66,.06); text-align: center; }
.pbadge { display: inline-block; background-image: linear-gradient(90deg, #F7E4EB, #FBF0E7); color: #A9506C; font-size: 10.5px; font-weight: 700; border-radius: 20px; padding: 4px 11px; }
.pttl { font-size: 18.5px; line-height: 1.45; margin-top: 11px; font-weight: 700; color: #4A3B33; }
.pttl2 { margin-top: 0; }
.pttl.em { color: #A9506C; }
.psub { font-size: 11.5px; color: #7D6A5E; line-height: 1.8; }
.social { display: flex; align-items: center; justify-content: center; margin-top: 12px; }
.dots { display: flex; align-items: center; }
.dot { width: 19px; height: 19px; border-radius: 50%; border: 1.5px solid #fff; box-shadow: 0 1px 2px rgba(60,42,36,.12); }
.dot + .dot { margin-left: -7px; }
.social-txt { font-size: 11px; color: #A9506C; font-weight: 600; margin-left: 7px; }
.auth-page .cta { display: block; width: 100%; margin-top: 14px; border-radius: 13px; padding: 14px 0; font-size: 15.5px; font-weight: 700; color: #fff; background-image: linear-gradient(100deg, #B2546D 0%, #CE7089 100%); animation: ctapulse 2.6s ease-in-out infinite; }
.auth-page .cta:active { opacity: .88; }
@keyframes ctapulse {
0%, 100% { box-shadow: 0 8px 18px -9px rgba(169,80,108,.55); }
50% { box-shadow: 0 10px 26px -7px rgba(169,80,108,.85); }
}
.ctahint { font-size: 10.5px; color: #97867C; margin-top: 9px; }
.trust { display: flex; justify-content: center; margin-top: 13px; padding-top: 12px; border-top: 1px dashed #EDE2D7; }
.trust-i { font-size: 10.5px; color: #7D6A5E; margin: 0 5px; }
.trust-tick { color: #12A67F; font-size: 11px; margin-right: 3px; }
/* 获取手机号按钮 */
.auth-btn { width: 100%; height: 48px; border-radius: 12px; background: #F8F3EF; color: #2B2320; font-size: 14px; margin-top: 14px; display: flex; align-items: center; justify-content: center; }
/* ---------- 已授权:收起成一条状态 ---------- */
.hero { background-image: linear-gradient(160deg, #FFFFFF 0%, #FFFCF8 46%, #FDF6EF 100%); border: 1px solid #EDE2D7; border-radius: 15px; padding: 14px; box-shadow: 0 6px 18px -8px rgba(122,88,66,.18), 0 1px 3px rgba(122,88,66,.06); display: flex; align-items: center; }
.hero .ava { width: 44px; height: 44px; border-radius: 50%; background: #F7EFE6; flex: 0 0 44px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.ava-img { width: 100%; height: 100%; border-radius: 50%; }
.ava-emoji { font-size: 19px; }
.hero .who { flex: 1; min-width: 0; margin-left: 12px; }
.hero .nm { font-size: 14.5px; font-weight: 600; }
.hero .st { font-size: 11.5px; color: #7D6A5E; margin-top: 2px; }
.hero .ok { font-size: 11.5px; color: #12A67F; font-weight: 700; background: #E4F5EF; border-radius: 20px; padding: 5px 11px; }
.auth-actions { display: flex; gap: 12px; margin-top: 24px; width: 100%; }
.auth-cancel, .auth-ok { flex: 1; height: 46px; border-radius: 12px; font-size: 14px; font-weight: 600; display: flex; align-items: center; justify-content: center; }
.auth-cancel { background: #F1E6E0; color: #6B615B; }
.auth-ok { background: #F4593C; color: #fff; }
.auth-tip { font-size: 11px; color: #A79C95; margin-top: 20px; text-align: center; line-height: 1.5; }
/* ---------- 工具区 ---------- */
.secthead { display: flex; align-items: baseline; justify-content: space-between; margin: 16px 2px 9px; }
.secttl { font-size: 12px; color: #7D6A5E; font-weight: 600; }
.sectmore { font-size: 11px; color: #97867C; }
.freebar { display: flex; align-items: center; margin: 0 2px 11px; padding: 9px 11px; background-image: linear-gradient(120deg, #FDF1F4 0%, #FCF6F1 100%); border: 1px solid #F1DDE3; border-radius: 11px; }
.freepill { flex: 0 0 auto; background: #A9506C; color: #fff; font-size: 10px; font-weight: 700; border-radius: 20px; padding: 3px 8px; }
.freetxt { font-size: 11px; color: #8E4960; line-height: 1.5; margin-left: 8px; }
/* 底部留白要够,否则最后一行被常驻行动条压住 */
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 9px; padding-bottom: 96px; }
.tile { background: #fff; border: 1px solid #EDE2D7; border-radius: 13px; padding: 11px 4px 9px; text-align: center; box-shadow: 0 6px 18px -8px rgba(122,88,66,.18), 0 1px 3px rgba(122,88,66,.06); position: relative; }
.tile .ic { height: 34px; display: flex; align-items: center; justify-content: center; }
.tile-logo { width: 34px; height: 34px; }
.tile-fb { font-size: 17px; color: #C4A18C; font-weight: 700; }
/* 工具名最长的要能折两行且不挤掉图标 */
.tile .tn { font-size: 9.5px; color: #5C4A41; margin-top: 6px; line-height: 1.3; min-height: 25px; }
/* 未解锁标记:制造"授权即可打开"的预期(仅展示,不响应点击) */
.lock { position: absolute; top: 6px; right: 6px; display: none; line-height: 0; }
.grid.locked .lock { display: block; }
.lock-ic { width: 12px; height: 12px; opacity: .5; }
/* ---------- 常驻底部行动条:上缘渐变淡出,避免硬生生切断内容 ---------- */
.stickycta { position: fixed; left: 0; right: 0; bottom: 0; padding: 14px 14px calc(14px + env(safe-area-inset-bottom)); z-index: 10; background-image: linear-gradient(180deg, rgba(245,239,231,0) 0%, rgba(245,239,231,.97) 38%); }
.auth-page .cta2 { margin-top: 0; font-size: 14.5px; padding: 13px 0; }
/* ---------- 遮罩 + 底部半弹窗 ---------- */
.mask { position: fixed; left: 0; right: 0; top: 0; bottom: 0; background: rgba(66,50,42,.42); opacity: 0; pointer-events: none; transition: opacity .26s; z-index: 20; }
.mask.show { opacity: 1; pointer-events: auto; }
.sheet { position: fixed; left: 0; right: 0; bottom: 0; background: #fff; border-radius: 20px 20px 0 0; padding: 20px 20px calc(24px + env(safe-area-inset-bottom)); z-index: 30; transform: translateY(102%); transition: transform .3s cubic-bezier(.22,.9,.28,1); box-shadow: 0 -8px 30px -10px rgba(96,70,54,.28); }
.sheet.show { transform: translateY(0); }
.sheet .grab { width: 34px; height: 4px; border-radius: 4px; background: #EBDCCF; margin: 0 auto 15px; }
.sheet-h3 { font-size: 16.5px; font-weight: 600; color: #4A3B33; margin-bottom: 7px; }
.sheet-p { font-size: 12.5px; color: #7D6A5E; line-height: 1.75; }
.btnrow { display: flex; gap: 10px; margin-top: 18px; }
.auth-page .bsec { flex: 1; border: 1px solid #EBDCCF; background: #fff; color: #A9506C; border-radius: 12px; padding: 12px 0; font-size: 14.5px; }
.auth-page .bsec:active { background: #FDF8F3; }
.auth-page .bmain { flex: 1.55; border-radius: 12px; padding: 12px 0; font-size: 14.5px; font-weight: 600; color: #fff; background: #A9506C; position: relative; }
.auth-page .bmain:active { opacity: .88; }
.auth-page .bmain.bwx { background: #07C160; }
/* ---------- ④-A 复用:库里的头像 + 昵称 ---------- */
.avarow { display: flex; align-items: center; margin-top: 18px; background: #FDF8F3; border: 1px solid #EBDCCF; border-radius: 12px; padding: 12px; }
.avarow-big { width: 52px; height: 52px; border-radius: 50%; flex: 0 0 52px; overflow: hidden; background: #F7EFE6; display: flex; align-items: center; justify-content: center; }
.avarow-img { width: 100%; height: 100%; border-radius: 50%; }
.avarow-emoji { font-size: 23px; }
.avarow-txt { min-width: 0; margin-left: 13px; }
.avarow-txt .t1 { font-size: 14px; font-weight: 600; color: #4A3B33; }
/* ---------- 页内 toast ---------- */
.toast { position: fixed; left: 50%; top: 44%; transform: translate(-50%,-50%) scale(.9); background: rgba(68,52,44,.92); color: #fff; border-radius: 12px; padding: 16px 22px; font-size: 14px; z-index: 60; opacity: 0; pointer-events: none; transition: .24s; text-align: center; min-width: 130px; }
.toast.show { opacity: 1; transform: translate(-50%,-50%) scale(1); }
.toast-tick { display: block; font-size: 26px; margin-bottom: 5px; color: #6EE7B7; }
.toast-msg { color: #fff; }
</style>

View File

@@ -161,6 +161,11 @@ export default {
}
},
onLoad(option) {
/* 诊断日志(过滤关键字 enter-app):容器页每次被打开都会打这一行 ——
它是「真的进入了某个 H5 应用」的最终证据(不管入口是恢复/分享/太阳码/商城宫格)。 */
try {
console.log('[enter-app] ===== 容器页 onLoad ===== option=' + JSON.stringify(option || {}));
} catch (e) {}
// 分享落地携带的推广人:与其它页面一致写进 globalData.spid(下单归因用)
if (option && option.spid) {
try { getApp().globalData.spid = option.spid; } catch (e) {}
@@ -251,6 +256,9 @@ export default {
this._applyShareImage(app.shareImageUrl, 'box', app.url);
console.log('[web_view] 分享图片: ' + app.shareImageUrl);
}
// 同一响应里就有分享标题(后台「分享标题」,留空 = 应用名称)→ 直接落上,
// 不必等反查表回来(太阳码是冷启动首发场景)
this._applyShareTitleFromApp(app);
let finalUrl = appUrl;
if (option._channel) {
const separator = finalUrl.includes('?') ? '&' : '?';
@@ -446,6 +454,16 @@ export default {
// #endif
this.url = targetUrl;
console.log('[enter-app] ✅ 容器页开始加载 H5 应用: ' + targetUrl.split('#')[0]);
/* 宿主恢复:把「这次真正进入的 H5 应用」记到 box_app(/api/mp/public/last-app)。
覆盖商城宫格 / 分享落地 / 朋友圈 / 太阳码 / JumpPath 这些**不经过 mama-box 宫格**
的入口;经过 mama-box 宫格的由 H5 自己 sendBeacon 上报
(见 mama-box js/app.js → reportLastApp)。冷启动恢复由 mama-box 首屏完成。
⚠️ 只上报净地址(stripAuthParams):恢复时身份必须按**当前**用户重新注入。
⚠️ 失败静默:这是旁路能力,绝不影响 H5 加载。 */
this._reportLastApp(targetUrl);
try {
const res = uni.getWindowInfo();
this.windowW = res.windowWidth;
@@ -458,6 +476,58 @@ export default {
this._fetchShareImage(targetUrl);
},
/* ---------- 宿主恢复:上报「本次进入的 H5 应用」(2026-09-22) ----------
box_app /api/mp/public/last-app 是 Redis 里的 per-openid 记录(7 天 TTL),
冷启动时由 mama-box 首屏读取并 location.replace 恢复(见 mama-box js/app.js resumeLastApp)。
本页负责**非 mama-box 宫格**的入口:商城宫格 / 分享落地 / 朋友圈 / 太阳码 / JumpPath。
⚠️ 传净地址:恢复时身份按当前用户重新注入,不能把记录时的 openid 带回去。
⚠️ 失败静默:旁路能力,绝不影响 H5 加载。 */
_reportLastApp(targetUrl) {
try {
const openid = getOpenid();
if (!openid || !targetUrl) {
console.log('[enter-app] ○ 容器页跳过 last-app 上报(openid=' + (openid ? '有' : '空') +
', url=' + (targetUrl ? '有' : '空') + ')');
return;
}
const clean = stripAuthParams(targetUrl);
if (!clean) return;
let api = 'https://mp.yizhezhe.com/api/mp/public/last-app?openid=' + encodeURIComponent(openid) +
'&url=' + encodeURIComponent(clean);
const id = this._lookupAppIdentifier(clean);
if (id) api += '&identifier=' + encodeURIComponent(id);
console.log('[enter-app] 容器页上报 last-app: url=' + clean + (id ? ' identifier=' + id : ''));
uni.request({
url: api,
method: 'POST',
timeout: 5000,
success: (res) => {
console.log('[enter-app] 容器页 last-app 上报结果: HTTP ' + ((res && res.statusCode) || '?') +
' body=' + JSON.stringify(res && res.data));
},
fail: (err) => {
console.log('[enter-app] ✗ 容器页 last-app 上报失败: ' + JSON.stringify(err));
}
});
} catch (e) {
console.warn('[resume] 上报 last_app 失败(忽略):', e);
}
},
/* 在内存应用表里按 URL 前缀反查 identifier(与 mixins/shareCard.js 的匹配同口径);
表还没回来时返回空 —— identifier 是可选参数,服务端会按 URL 兜底校验。 */
_lookupAppIdentifier(targetUrl) {
const base = this._shareUrlBase(targetUrl);
if (!base) return '';
const apps = this.shareApps || [];
for (let i = 0; i < apps.length; i++) {
const a = apps[i];
if (!a || !a.url || !a.identifier) continue;
const ab = this._shareUrlBase(a.url);
if (ab && (base === ab || base.indexOf(ab + '/') === 0)) return a.identifier;
}
return '';
},
/* 分享图反查 / 单应用缓存 / 朋友圈本地图转换,统一由 mixins/shareCard.js 提供:
_fetchShareImage(targetUrl) —— 本页 onLoad 兜底(按当前 H5 URL 反查 box_app
App.shareImageUrl;太阳码场景 _resolveAppScene 已直接设好)
@@ -471,9 +541,11 @@ export default {
// 含 H5 的 SPA 路由);this.shareHref(H5 postMessage)/ this.url(进入时的地址)
// 都可能过期或落后,仅作兜底。
const target = (options && options.webViewUrl) || this.shareHref || this.url;
// 应用切换检测 + 按当前地址重新反查分享图(H5 postMessage 覆盖优先)
// 应用切换检测 + 按当前地址重新反查分享图/标题(H5 postMessage 覆盖优先)
this.resolveShareCard(target);
return {
// 标题来源优先级:H5 postMessage 上报 → box_app 反查(后台「分享标题」,留空 = 应用名称,
// 由 resolveShareCard 写进 shareTitle)→ 兜底文案。
title: this.shareTitle || '分享',
// 分享出去必须剥离 openid/unionid/t 等身份参数:
// 1) 避免把分享者的微信匿名标识泄露给接收者;
@@ -483,12 +555,15 @@ export default {
}
},
onShareTimeline() {
// onShareTimeline 无回调参数,拿不到 webViewUrl,只能用 H5 传来的 href 兜底
// onShareTimeline 无回调参数,拿不到 webViewUrl,只能用 H5 传来的 href 兜底。
// 同一地址也解析一次:首次分享就走朋友圈时,标题/图不能还是空的。
const target = this.shareHref || this.url;
this.resolveShareCard(target);
return {
title: this.shareTitle || '分享',
query: 'url=' + encodeURIComponent(stripAuthParams(this.shareHref || this.url)),
query: 'url=' + encodeURIComponent(stripAuthParams(target)),
// 朋友圈分享图必须是本地路径/代码包路径,网络图会被微信静默忽略;
// 网络图已在 _prepareTimelineImage 中通过 getImageInfo 转成本地临时路径。
// 网络图已在 _prepareTimelineImage 中通过 getImageInfo 转成临时路径。
imageUrl: this.shareTimelineImage || ''
}
},