feat(auth): 共享授权页 pages/annex/auth/index + web_view 宿主授权面板与回传(__AUTH_RESULT__ → H5 hash)

This commit is contained in:
2026-09-09 22:51:57 +08:00
parent 0f4a0f75a5
commit ad30f3eb01
3 changed files with 344 additions and 1 deletions

View File

@@ -1230,6 +1230,18 @@
// #endif
}
}
}, {
"path": "auth/index",
"style": {
"navigationBarTitleText": "授权完善资料",
"app-plus": {
// #ifdef APP-PLUS
"titleNView": {
"type": "default"
}
// #endif
}
}
}, {
"path": "vip_paid/index",
"style": {

202
pages/annex/auth/index.vue Normal file
View File

@@ -0,0 +1,202 @@
<template>
<view class="auth-page">
<!-- 顶部说明 -->
<view class="auth-head">
<text class="auth-title">授权完善资料</text>
<text class="auth-sub">用于在应用里展示你的昵称 / 头像 / 手机号</text>
</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>
</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>
</view>
</template>
<script>
import store from '@/store';
import { HTTP_REQUEST_URL } from '@/config/app';
import { LOGIN_STATUS } from '@/config/cache';
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 '';
}
export default {
data() {
return {
authNickname: '',
authAvatar: '',
authPhone: false, // 手机号是否已授权(UI 反馈)
_authCode: '',
openid: ''
}
},
onLoad() {
this.openid = getOpenid();
if (!this.openid) uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
},
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',
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' });
}
},
fail: () => uni.showToast({ title: '头像上传失败,请检查网络', icon: 'none' }),
complete: () => uni.hideLoading()
});
},
onGetPhone(e) {
if (e.detail.errMsg === 'getPhoneNumber:ok' && e.detail.code) {
this._authCode = e.detail.code;
this.authPhone = true;
} else {
uni.showToast({ title: '未授权手机号', icon: 'none' });
}
},
submitAuth() {
if (!this.authNickname && !this.authAvatar && !this._authCode) {
return uni.showToast({ title: '请至少授权一项', icon: 'none' });
}
const openid = getOpenid() || this.openid;
if (!openid) return uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
const api = 'https://mp.yizhezhe.com/api/mp/public/auth/profile?openid=' + encodeURIComponent(openid) +
(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 || {};
if (d.code === 200) {
const res = d.result || {};
// 把授权结果写入 storage,web_view onShow 读取后刷新 H5 URL
uni.setStorageSync('__AUTH_RESULT__', {
openid: openid,
phone: res.phone || '',
nickname: res.userName || this.authNickname || '',
avatar: res.avatar || this.authAvatar || ''
});
uni.showToast({ title: '授权成功', icon: 'success' });
setTimeout(() => {
uni.navigateBack();
}, 600);
} else {
uni.showToast({ title: d.message || '授权保存失败', icon: 'none' });
}
},
fail: () => uni.showToast({ title: '授权请求失败,请检查网络', icon: 'none' })
});
},
cancel() { uni.navigateBack(); }
}
}
</script>
<style scoped>
/* 清 button 默认边框/padding(避免撑出卡片 + 消除默认灰底边框)。
不改 margin —— 统配 margin:0 会压掉各按钮自己的 margin-top,导致元素紧贴。 */
.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-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; }
/* 昵称输入 */
.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; }
/* 获取手机号按钮 */
.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; }
.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; }
</style>

View File

@@ -2,6 +2,24 @@
<view>
<web-view v-if="url" class="web-view" :webview-styles="webviewStyles" :src="url"
:style="{width: windowW + 'px', height: windowH + 'px'}" @message="handleMessage"></web-view>
<!-- 共享原生授权面板(任意 H5 应用 postMessage({action:'requestProfile'}) 后显示) -->
<view v-if="showAuth" class="mb-auth-mask" @tap="closeAuth">
<view class="mb-auth-panel" @tap.stop="noop">
<view class="mb-auth-title">授权完善资料</view>
<view class="mb-auth-sub">用于在应用里展示你的昵称 / 头像 / 手机号</view>
<button class="mb-auth-btn avatar" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image v-if="authAvatar" class="mb-auth-ava" :src="authAvatar" mode="aspectFill" />
<text v-else>选择头像</text>
</button>
<input class="mb-auth-input" type="nickname" v-model="authNickname" placeholder="输入昵称" maxlength="16" />
<button class="mb-auth-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhone">获取手机号</button>
<view class="mb-auth-actions">
<button class="mb-auth-cancel" @tap="closeAuth">取消</button>
<button class="mb-auth-ok" @tap="submitAuth">保存并返回</button>
</view>
</view>
</view>
</view>
</template>
<script>
@@ -101,7 +119,11 @@ export default {
shareTimelineImage: '',
shareHref: '',
savedUrl: '',
_shareFetched: false
_shareFetched: false,
showAuth: false, // 授权面板开关(H5 postMessage 后显示)
authNickname: '',
authAvatar: '',
_authCode: '' // getPhoneNumber 的 code(提交时交 box_app 解密手机号)
}
},
onLoad(option) {
@@ -132,6 +154,26 @@ export default {
// 1) 首次登录返回(savedUrl 有值,url 未设置)→ 用保存的 URL 重新处理身份
// 2) H5 内 requireAuth 跳登录页返回 → url 已有值,但 token/身份可能刚更新 → 强制刷新注入新身份
// 3) 退出登录后再次进入 web_view(url 已存在)→ 用缓存的 openid 重新注入
// 0) 授权页返回:读取 __AUTH_RESULT__,把授权结果写入当前 H5 URL 的 hash,让 H5 重载读取
try {
const authRes = uni.getStorageSync('__AUTH_RESULT__');
if (authRes && authRes.openid && this.url) {
uni.removeStorageSync('__AUTH_RESULT__');
console.log('[auth-flow] 🎯 授权页返回,刷新身份 hash → openid=' + (authRes.openid || '').substring(0, 8) + '...');
const base = this.url.split('#')[0];
const joiner = base.indexOf('?') !== -1 ? '&' : '?';
this.url = base + joiner + 't=' + Date.now() +
'#openid=' + encodeURIComponent(authRes.openid) +
'&phone=' + encodeURIComponent(authRes.phone || '') +
'&nickname=' + encodeURIComponent(authRes.nickname || '') +
'&avatar=' + encodeURIComponent(authRes.avatar || '');
return;
}
} catch (e) {
console.warn('[auth-flow] 读取授权结果失败:', e);
}
const { openid, unionid } = getCurrentUser();
if (this.savedUrl && !this.url) {
console.log('[auth-flow] 🔄 onShow: savedUrl 重新处理 → openid=' + (openid || '(空)'));
@@ -386,6 +428,17 @@ export default {
if (qIdx !== -1) console.log('[web_view] params:', targetUrl.slice(qIdx + 1));
} catch (_) {}
// 强制缓存刷新:微信 webview 对相同 URL 会复用缓存。
// 每次进入都追加唯一 _v 参数(不受身份分支影响),保证加载最新 H5。
try {
const base = targetUrl.split('#')[0];
const joiner = base.indexOf('?') !== -1 ? '&' : '?';
targetUrl = base + joiner + '_v=' + Date.now() + (targetUrl.indexOf('#') !== -1 ? '#' + targetUrl.split('#')[1] : '');
console.log('[web_view] 已加缓存版本参数 _v →', targetUrl.substring(0, 120));
} catch (e) {
console.warn('[web_view] 加缓存版本参数失败:', e);
}
// #ifdef MP
uni.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] });
// #endif
@@ -545,6 +598,11 @@ export default {
})
break
case 'requestProfile':
// 任意 H5 应用请求授权 → 弹共享原生授权面板(手机号/昵称/头像)
this.showAuth = true;
break
case 'setTitle':
if (data.title) {
this.shareTitle = data.title;
@@ -554,5 +612,76 @@ export default {
}
},
/* ---------- 共享授权面板(任意 H5 应用 requestProfile → 弹面板 → box_app /auth/profile) ---------- */
onChooseAvatar(e) {
const avatarUrl = e.detail.avatarUrl;
if (!avatarUrl) return uni.showToast({ title: '未获取到头像', icon: 'none' });
// 上传到 box_app 公开接口:授权链路不依赖 CRMEB 登录态(退出登录后 uploadImgs 会 401 弹「请登录」)
uni.uploadFile({
url: 'https://mp.yizhezhe.com/api/upload',
filePath: avatarUrl,
name: 'file',
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 (e) { uni.showToast({ title: '头像上传失败', icon: 'none' }); }
},
fail: () => uni.showToast({ title: '头像上传失败,请检查网络', icon: 'none' })
});
},
onGetPhone(e) {
if (e.detail.errMsg === 'getPhoneNumber:ok' && e.detail.code) { this._authCode = e.detail.code; }
else { uni.showToast({ title: '未授权手机号', icon: 'none' }); }
},
submitAuth() {
if (!this.authNickname && !this.authAvatar && !this._authCode) {
return uni.showToast({ title: '请至少授权一项', icon: 'none' });
}
const openid = getOpenid();
if (!openid) return uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
const api = 'https://mp.yizhezhe.com/api/mp/public/auth/profile?openid=' + encodeURIComponent(openid) +
(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 || {};
if (d.code === 200) {
const res = d.result || {};
this.showAuth = false; this._authCode = '';
// 更新 web-view src(保留 query + 追加 hash 结果)→ H5 重载读取
const base = this.url.split('#')[0];
this.url = base + (base.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now() +
'#openid=' + encodeURIComponent(openid) +
'&phone=' + encodeURIComponent(res.phone || '') +
'&nickname=' + encodeURIComponent(res.userName || this.authNickname || '') +
'&avatar=' + encodeURIComponent(res.avatar || this.authAvatar || '');
} else {
uni.showToast({ title: d.message || '授权保存失败', icon: 'none' });
}
},
fail: () => uni.showToast({ title: '授权请求失败,请检查网络', icon: 'none' })
});
},
closeAuth() { this.showAuth = false; },
noop() {},
}
</script>
<style scoped>
.mb-auth-mask { position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; }
.mb-auth-panel { width: 82%; max-width: 340px; background: #fff; border-radius: 18px; padding: 24px 20px 18px; }
.mb-auth-title { font-size: 17px; font-weight: 700; color: #2B2320; text-align: center; }
.mb-auth-sub { font-size: 12px; color: #A79C95; text-align: center; margin: 8px 0 18px; line-height: 1.5; }
.mb-auth-btn { width: 100%; height: 46px; line-height: 46px; text-align: center; border-radius: 12px; background: #F8F3EF; color: #2B2320; font-size: 14px; margin-top: 12px; }
.mb-auth-btn.avatar { display: flex; align-items: center; justify-content: center; gap: 8px; }
.mb-auth-ava { width: 40px; height: 40px; border-radius: 50%; }
.mb-auth-input { width: 100%; height: 46px; border-radius: 12px; background: #F8F3EF; font-size: 14px; color: #2B2320; padding: 0 14px; margin-top: 12px; }
.mb-auth-actions { display: flex; gap: 12px; margin-top: 20px; }
.mb-auth-cancel, .mb-auth-ok { flex: 1; height: 44px; line-height: 44px; text-align: center; border-radius: 12px; font-size: 14px; font-weight: 600; }
.mb-auth-cancel { background: #F1E6E0; color: #6B615B; }
.mb-auth-ok { background: #F4593C; color: #fff; }
</style>