Files
crmeb-mp/pages/annex/auth/index.vue
Karlcarlo b45a190a59 chore: 入库生产基线(2026-10-26 工作树快照)
- 含首页壳/容器页/授权页/分享图/首页配置等此前未入库的在途改动
- 新增未跟踪的 pages/index/mall.vue(第 5 个 tabBar 页,pages.json 已引用)
- 排除本地备份文件(*.bak* / *.origin-bak)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 17:07:49 +08:00

554 lines
28 KiB
Vue
Raw Permalink 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>
<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>