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{}{