- 含首页壳/容器页/授权页/分享图/首页配置等此前未入库的在途改动 - 新增未跟踪的 pages/index/mall.vue(第 5 个 tabBar 页,pages.json 已引用) - 排除本地备份文件(*.bak* / *.origin-bak) Co-authored-by: Cursor <cursoragent@cursor.com>
554 lines
28 KiB
Vue
554 lines
28 KiB
Vue
<template>
|
||
<view class="auth-page">
|
||
<!-- ============ 可滚动内容区 ============ -->
|
||
<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 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>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- ④-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 { 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 = '';
|
||
var i = 0;
|
||
str = String(str).replace(/[^A-Za-z0-9+/=]/g, '');
|
||
while (i < str.length) {
|
||
var b1 = chars.indexOf(str.charAt(i++));
|
||
var b2 = chars.indexOf(str.charAt(i++));
|
||
var b3 = chars.indexOf(str.charAt(i++));
|
||
var b4 = chars.indexOf(str.charAt(i++));
|
||
output += String.fromCharCode((b1 << 2) | (b2 >> 4));
|
||
if (b3 !== 64) output += String.fromCharCode(((b2 & 15) << 4) | (b3 >> 2));
|
||
if (b4 !== 64) output += String.fromCharCode(((b3 & 3) << 6) | b4);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function getOpenid() {
|
||
let oid = store.state.app.openid;
|
||
if (oid) return oid;
|
||
try { oid = uni.getStorageSync('OPENID'); } catch (e) {}
|
||
if (oid) return oid;
|
||
try {
|
||
const token = store.state.app.token || uni.getStorageSync(LOGIN_STATUS);
|
||
if (token) {
|
||
let parts = token.split('.');
|
||
if (parts.length === 3) {
|
||
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||
while (payload.length % 4 !== 0) payload += '=';
|
||
const data = JSON.parse(base64Decode(payload));
|
||
oid = data.openid || '';
|
||
if (oid) { store.commit('SET_OPENID', oid); return oid; }
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[auth] JWT decode openid failed:', e.message);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/* unionid:用户中心身份键(预留功能)。小程序登录态静默缓存(store/Cache 'UNIONID');
|
||
带上后 box_app 授权成功自动推送用户中心并回写 openid→unionid 映射。 */
|
||
function getUnionid() {
|
||
let uid = store.state.app.unionid;
|
||
if (uid) return uid;
|
||
try { uid = uni.getStorageSync('UNIONID'); } catch (e) {}
|
||
return uid || '';
|
||
}
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
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 触发的
|
||
授权就回到哪个 H5(宫格 location.href 换应用后,shareHref 可能还是旧应用的)。 */
|
||
let backUrl = (options && options.backUrl) || '';
|
||
if (backUrl) {
|
||
try { backUrl = decodeURIComponent(backUrl); } catch (e) { /* 已是原文 */ }
|
||
}
|
||
this._backUrl = backUrl;
|
||
|
||
/* 宫格数据:失败不影响授权主流程(旁路能力绝不阻塞) */
|
||
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: {
|
||
/* ---------- 工具宫格:box_app 在架应用(仅展示,不响应点击) ---------- */
|
||
fetchApps() {
|
||
uni.request({
|
||
url: APPS_API,
|
||
data: { isPublished: 1, pageSize: 200 },
|
||
success: (res) => {
|
||
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: () => {
|
||
console.warn('[auth] 应用列表拉取失败,宫格留空(不影响授权)');
|
||
}
|
||
});
|
||
},
|
||
|
||
/* ---------- ① 手机号 ---------- */
|
||
openPhoneSheet() {
|
||
if (!this.openid) return this.showToast('未获得 openid,请先登录');
|
||
this.sheet = 'phone';
|
||
},
|
||
denyPhone() {
|
||
/* 手机号都不授权 = 本次授权放弃:直接回 H5(不写 __AUTH_RESULT__,
|
||
宿主按现有逻辑重载当前 H5,不会有假成功回流)。 */
|
||
uni.navigateBack();
|
||
},
|
||
onGetPhone(e) {
|
||
const detail = (e && e.detail) || {};
|
||
if (detail.errMsg === 'getPhoneNumber:ok' && detail.code) {
|
||
this.submitPhone(detail.code);
|
||
} else {
|
||
this.showToast('未授权手机号', false, 1800);
|
||
}
|
||
},
|
||
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);
|
||
}
|
||
|
||
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)
|
||
});
|
||
});
|
||
},
|
||
|
||
/* ---------- 共享授权接口 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(避免撑出卡片 + 消除默认灰底边框)。
|
||
⚠️ 两条纪律,踩过:
|
||
① 这里不设 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: #F5EFE7; color: #4A3B33; font-size: 15px; line-height: 1.6; }
|
||
|
||
/* ---------- 内容区 ---------- */
|
||
.home { padding: 14px 14px 0; }
|
||
|
||
/* ---------- 价值主张卡(未授权时的主转化区) ---------- */
|
||
.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; }
|
||
|
||
/* ---------- 已授权:收起成一条状态 ---------- */
|
||
.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; }
|
||
|
||
/* ---------- 工具区 ---------- */
|
||
.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>
|