From 7cd8ffdd267286c1e39a208fcae2427adff2985c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=98=E9=A3=8E?= Date: Mon, 20 Apr 2026 11:10:50 +0800 Subject: [PATCH] feat: add MBTI tag functionality to user profile - Introduced a new MBTI tag in the user profile section, displaying the user's MBTI type or a default label if not available. - Implemented navigation to a mini program for MBTI testing, ensuring user phone number is included if logged in. - Enhanced styling for the MBTI tag to improve visibility and user interaction. Made-with: Cursor --- miniprogram/pages/my/my.js | 35 ++++ miniprogram/pages/my/my.wxml | 1 + miniprogram/pages/my/my.wxss | 20 ++ .../utils/linkedMiniprogramNavigate.js | 191 ++++++++++++++++++ soul-api/internal/handler/db.go | 2 + 5 files changed, 249 insertions(+) create mode 100644 miniprogram/utils/linkedMiniprogramNavigate.js diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index 5bc732fd..58a6ceec 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -10,6 +10,11 @@ const { trackClick } = require('../../utils/trackClick') const { cleanSingleLineField } = require('../../utils/contentParser.js') const { navigateMpPath } = require('../../utils/mpNavigate.js') const { isSafeImageSrc } = require('../../utils/imageUrl.js') +const { appendQueryToPath, ensureLoggedInUserPhone } = require('../../utils/linkedMiniprogramNavigate.js') + +/** MBTI 测试小程序(写死;须与 app.json navigateToMiniProgramAppIdList 一致) */ +const MBTI_MP_APP_ID = 'wx3d15ed02e98b04e3' +const MBTI_MP_PATH = 'pages/index/index' Page({ data: { @@ -95,6 +100,8 @@ Page({ mpUiVipLabelGuest: '成为会员', mpUiReadStatLabel: '已读章节', mpUiRecentTitle: '最近阅读', + /** 资料区 MBTI 标签文案:已测为四字母大写,未测为「MBTI」 */ + mbtiTagText: '', }, onLoad() { @@ -147,6 +154,12 @@ Page({ }) }, + _formatMbtiTagText(user) { + const raw = String(user && user.mbti != null ? user.mbti : '').trim() + if (!raw) return 'MBTI' + return raw.toUpperCase() + }, + async _refreshMyAvatarDisplay(safeUser) { if (!safeUser || !app.globalData.isLoggedIn) return try { @@ -198,6 +211,7 @@ Page({ isLoggedIn: true, userInfo: safeUser, profileAvatarDisplay: '', + mbtiTagText: this._formatMbtiTagText(safeUser), userIdShort, userWechat, readCount: 0, @@ -227,6 +241,7 @@ Page({ isLoggedIn: false, userInfo: null, profileAvatarDisplay: '', + mbtiTagText: '', userIdShort: '', readCount: guestReadCount, readCountText: formatStatNum(guestReadCount), @@ -1065,6 +1080,26 @@ Page({ wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${encodeURIComponent(uid)}` }) }, + /** MBTI 测试小程序:写死 appId + path,path 上带 phone(有则传,与阅读页一致) */ + async onMbtiTagTap() { + trackClick('my', 'btn_click', 'MBTI标签') + if (!this.data.isLoggedIn) { + this.showLogin() + return + } + const phone = await ensureLoggedInUserPhone() + let path = MBTI_MP_PATH.trim().replace(/^\/+/, '') + if (phone) path = appendQueryToPath(path, 'phone', phone) + wx.navigateToMiniProgram({ + appId: MBTI_MP_APP_ID, + path, + envVersion: 'release', + fail: (err) => { + wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' }) + }, + }) + }, + goToProfileEdit() { trackClick('my', 'nav_click', '资料编辑') if (!this.data.isLoggedIn) { this.showLogin(); return } diff --git a/miniprogram/pages/my/my.wxml b/miniprogram/pages/my/my.wxml index 017c17e0..67a47649 100644 --- a/miniprogram/pages/my/my.wxml +++ b/miniprogram/pages/my/my.wxml @@ -36,6 +36,7 @@ {{mpUiCardLabel}} {{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}} + {{mbtiTagText}} diff --git a/miniprogram/pages/my/my.wxss b/miniprogram/pages/my/my.wxss index 7355438f..242c3737 100644 --- a/miniprogram/pages/my/my.wxss +++ b/miniprogram/pages/my/my.wxss @@ -105,6 +105,26 @@ font-size: 24rpx; font-weight: 500; border-radius: 40rpx; white-space: nowrap; flex-shrink: 0; } .profile-action-btn:active { opacity: 0.75; } +/* MBTI:高亮胶囊(实心青渐变 + 白字 + 外发光),与描边按钮区分 */ +.profile-mbti-tag { + padding: 12rpx 26rpx; + border-radius: 40rpx; + font-size: 24rpx; + font-weight: 700; + letter-spacing: 0.06em; + color: #fff; + background: linear-gradient(135deg, #5eead4 0%, #4FD1C5 45%, #2dd4bf 100%); + border: 1rpx solid rgba(255, 255, 255, 0.35); + box-shadow: + 0 4rpx 16rpx rgba(79, 209, 197, 0.45), + 0 0 0 1rpx rgba(79, 209, 197, 0.25) inset; + white-space: nowrap; + flex-shrink: 0; +} +.profile-mbti-tag:active { + opacity: 0.88; + transform: scale(0.98); +} .user-wechat { font-size: 26rpx; color: #6B7280; } .super-card-entry { position: relative; diff --git a/miniprogram/utils/linkedMiniprogramNavigate.js b/miniprogram/utils/linkedMiniprogramNavigate.js new file mode 100644 index 00000000..20922bff --- /dev/null +++ b/miniprogram/utils/linkedMiniprogramNavigate.js @@ -0,0 +1,191 @@ +/** + * Soul 创业派对 - 关联小程序跳转(与阅读页 onLinkTagTap miniprogram 分支一致) + */ +const app = getApp() + +function normalizeLinkTagLabel(raw) { + return String(raw || '') + .replace(/^[##\s\u00a0\u200b\u3000]+/u, '') + .replace(/[\s\u00a0\u200b\u3000]+$/u, '') + .trim() + .toLowerCase() +} + +function resolveLinkTagByLabel(label) { + const normalized = normalizeLinkTagLabel(label) + if (!normalized) return null + const tags = Array.isArray(app.globalData.linkTagsConfig) ? app.globalData.linkTagsConfig : [] + for (const t of tags) { + if (!t) continue + const candidates = [t.label] + if (typeof t.aliases === 'string' && t.aliases.trim()) { + candidates.push(...t.aliases.split(',')) + } + for (const c of candidates) { + if (normalizeLinkTagLabel(c) === normalized) return t + } + } + return null +} + +function pickLinkTagField(tag, keys, defaultValue = '') { + if (!tag || typeof tag !== 'object') return defaultValue + for (const key of keys) { + const v = tag[key] + if (v == null) continue + const s = String(v).trim() + if (s) return s + } + return defaultValue +} + +function parseDatasetBool(v) { + if (typeof v === 'boolean') return v + const s = String(v || '').trim().toLowerCase() + return s === '1' || s === 'true' || s === 'yes' || s === 'on' +} + +function normalizeQueryKey(raw, defaultKey = 'phone') { + const s = String(raw || '').trim() + if (!s) return defaultKey + if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(s)) return defaultKey + return s +} + +/** 跳转其他小程序时在 path 上追加/覆盖 query(保留已有 ?…&…,避免重复同名 key) */ +function appendQueryToPath(path, key, value) { + if (value == null || value === '') return (path || '').trim() + const base = (path || '').trim() + const hashIdx = base.indexOf('#') + const hashPart = hashIdx >= 0 ? base.slice(hashIdx) : '' + const noHash = hashIdx >= 0 ? base.slice(0, hashIdx) : base + const qIdx = noHash.indexOf('?') + const pathname = qIdx >= 0 ? noHash.slice(0, qIdx) : noHash + const query = qIdx >= 0 ? noHash.slice(qIdx + 1) : '' + const params = [] + if (query) { + query.split('&').forEach((pair) => { + if (!pair) return + const [k = '', v = ''] = pair.split('=') + const dk = decodeURIComponent(k || '') + if (dk && dk !== key) params.push(`${encodeURIComponent(dk)}=${v}`) + }) + } + params.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + return `${pathname}${params.length ? '?' + params.join('&') : ''}${hashPart}` +} + +function getLoggedInUserPhone() { + const u = app.globalData.userInfo || {} + return (u.phone || u.phoneNumber || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '') +} + +async function ensureLoggedInUserPhone() { + let phone = getLoggedInUserPhone() + if (phone) return phone + try { + const profileRes = await app.request({ url: '/api/miniprogram/user/profile', silent: true, timeout: 3000 }) + const p = (profileRes && profileRes.data) ? profileRes.data : {} + phone = String(p.phone || p.phoneNumber || '').trim().replace(/\s/g, '') + if (phone) { + const merged = { ...(app.globalData.userInfo || {}), ...p, phone } + app.globalData.userInfo = merged + try { + wx.setStorageSync('userInfo', merged) + wx.setStorageSync('user_phone', phone) + } catch (_) {} + } + } catch (e) {} + return phone +} + +function resolveMiniprogramLinkFromTag(tag) { + if (!tag || typeof tag !== 'object') return null + const tagType = pickLinkTagField(tag, ['type', 'tagType'], '').toLowerCase() + if (tagType !== 'miniprogram') return null + const mpKey = pickLinkTagField(tag, ['mpKey', 'mp_key', 'appId', 'app_id'], '') + const pagePath = pickLinkTagField(tag, ['pagePath', 'page_path'], '') + let passPhone = true + const hasCachedPassPhone = !(tag.passPhone == null || tag.passPhone === '') + if (hasCachedPassPhone) passPhone = parseDatasetBool(tag.passPhone) + const phoneParamName = normalizeQueryKey(tag.phoneParamName, 'phone') + return { mpKey, pagePath, passPhone, phoneParamName } +} + +/** + * @param {{ mpKey: string, pagePath?: string, passPhone?: boolean, phoneParamName?: string }} opts + */ +async function navigateLinkedMiniprogram(opts) { + const mpKey = String(opts.mpKey || '').trim() + if (!mpKey) { + wx.showToast({ title: '未配置小程序', icon: 'none' }) + return + } + await app.getReadExtras() + const linked = (app.globalData.linkedMiniprograms || []).find((m) => m.key === mpKey) + if (!linked || !linked.appId) { + wx.showToast({ title: '未找到关联小程序配置', icon: 'none' }) + return + } + let targetPath = (opts.pagePath != null && String(opts.pagePath).trim()) + ? String(opts.pagePath).trim() + : (linked.path || '') + const phone = await ensureLoggedInUserPhone() + const passPhone = opts.passPhone !== false + const phoneParamName = normalizeQueryKey(opts.phoneParamName, 'phone') + if (passPhone && phone) { + targetPath = appendQueryToPath(targetPath, phoneParamName, phone) + } + wx.navigateToMiniProgram({ + appId: linked.appId, + path: targetPath || '', + envVersion: 'release', + success: () => {}, + fail: (err) => { + wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' }) + }, + }) +} + +async function navigateMiniprogramFromLinkTagConfig(tag) { + const r = resolveMiniprogramLinkFromTag(tag) + if (!r || !r.mpKey) return false + await navigateLinkedMiniprogram(r) + return true +} + +/** + * @param {Record} myPageUi mp_config.mpUi.myPage + */ +function findMbtiLinkTagInConfig(myPageUi) { + const tags = Array.isArray(app.globalData.linkTagsConfig) ? app.globalData.linkTagsConfig : [] + const preferred = myPageUi && String(myPageUi.mbtiLinkLabel || '').trim() + if (preferred) { + const t = resolveLinkTagByLabel(preferred) + if (t) { + const r = resolveMiniprogramLinkFromTag(t) + if (r && r.mpKey) return t + } + } + for (const t of tags) { + const r = resolveMiniprogramLinkFromTag(t) + if (!r || !r.mpKey) continue + const lbl = pickLinkTagField(t, ['label'], '').toLowerCase() + const key = String(r.mpKey || '').toLowerCase() + const path = String(r.pagePath || '').toLowerCase() + if (lbl.includes('mbti') || key.includes('mbti') || path.includes('mbti')) return t + } + return null +} + +module.exports = { + appendQueryToPath, + getLoggedInUserPhone, + ensureLoggedInUserPhone, + navigateLinkedMiniprogram, + navigateMiniprogramFromLinkTagConfig, + findMbtiLinkTagInConfig, + resolveLinkTagByLabel, + resolveMiniprogramLinkFromTag, + normalizeQueryKey, +} diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go index ccc319c9..36e4878a 100644 --- a/soul-api/internal/handler/db.go +++ b/soul-api/internal/handler/db.go @@ -180,6 +180,8 @@ func defaultMpUi() gin.H { "readStatLabel": "已读章节", "recentReadTitle": "最近阅读", "readStatPath": "/pages/reading-records/reading-records?focus=all", "recentReadPath": "/pages/reading-records/reading-records?focus=recent", + // 我的页 MBTI 标签跳转:与 read-extras linkTags 某条 label 一致时优先匹配;空则自动匹配 label/mpKey/pagePath 含 mbti 的「小程序」类标签 + "mbtiLinkLabel": "", }, // 弹窗文案:管理端按 pagePath + key 维护,见 mpUi.pagePopupItems(memberDetailPage/readPage 已废弃,由迁移合并) "pagePopupItems": []interface{}{