diff --git a/.cursor/agent/开发助理/evolution/2026-03-21-MBTI头像C端全链路兜底.md b/.cursor/agent/开发助理/evolution/2026-03-21-MBTI头像C端全链路兜底.md new file mode 100644 index 00000000..173f3477 --- /dev/null +++ b/.cursor/agent/开发助理/evolution/2026-03-21-MBTI头像C端全链路兜底.md @@ -0,0 +1,13 @@ +# 2026-03-21 MBTI 头像 C 端全链路兜底 + +## 问题 +系统设置瘦身与 MBTI 映射迁到用户管理后,需在小程序多页面与匹配接口统一「无微信头像 → MBTI 映射」行为,避免仅海报单点生效。 + +## 做法 +- 新增 `miniprogram/utils/mbtiAvatar.js`(`resolveAvatarWithMbti`);`app.resolveAvatarWithMbti` 封装全局 map。 +- 我的页 `profileAvatarDisplay`;资料编辑 `avatarPreviewUrl`;profile-show、member-detail、referral 海报复用同一逻辑。 +- 后端 `match.go`:`avatar` 为空时用 `getMbtiAvatar`;响应增加 `mbti` 字段;找伙伴卡片 wxml 增加无图占位。 +- 管理端 `MbtiAvatarsManager` 补充 downloadFile 域名说明。 + +## 可复用规则 +配置驱动展示:公开 `GET /api/miniprogram/config/mbti-avatars` + 本地短时缓存;业务侧只调 `resolveAvatarWithMbti`,避免重复拼接 baseUrl。 diff --git a/.cursor/agent/开发助理/evolution/索引.md b/.cursor/agent/开发助理/evolution/索引.md index 39773d58..7dfccffb 100644 --- a/.cursor/agent/开发助理/evolution/索引.md +++ b/.cursor/agent/开发助理/evolution/索引.md @@ -4,4 +4,5 @@ | 日期 | 摘要 | 文件 | |------|------|------| +| 2026-03-21 | MBTI 头像小程序全链路兜底 + 匹配接口回填 | 2026-03-21-MBTI头像C端全链路兜底.md | | 2026-03-16 | 用户交互习惯分析(基于 agent-transcripts) | 2026-03-16-交互习惯分析.md | diff --git a/.cursor/skills/karuo-party/SKILL.md b/.cursor/skills/karuo-party/SKILL.md index 83681e0b..98f2fd8d 100644 --- a/.cursor/skills/karuo-party/SKILL.md +++ b/.cursor/skills/karuo-party/SKILL.md @@ -7,8 +7,8 @@ description: > triggers: 运营报表、视频切片、多平台分发、飞书视频下载、派对运营、卡若创业派对、派对填表、视频剪辑、一键分发、妙记下载 owner: 水岸 group: 运营 -version: "1.1" -updated: "2026-03-21" +version: "1.2" +updated: "2026-03-23" --- # 卡若创业派对运营 Skill 包 @@ -123,6 +123,14 @@ python3 "$DIST_SCRIPT/distribute_all.py" --now **详细流程**:见 `skills/多平台分发_SKILL.md` +#### 视频号发布前置(强制) + +在执行视频号发布前,固定做以下 3 步: + +1. **账号信息校验**:调用 `auth_data` 校验 `nickname` 与 `headImgUrl`,不一致先改到目标值再发。 +2. **线上失败/重复清理**:先查 `post_list`,删除失败条目;同标题仅保留最新一条(去重后再补发)。 +3. **仅定时发布**:禁止立即发布;若页面定时控件失效,使用 `post_create` 注入定时参数并拦截立即发布。 + --- ## 四、完整流程(派对结束后) @@ -279,5 +287,6 @@ curl -sS -X POST -H "Content-Type: application/json" -d "$TEXT" "$FEISHU_PARTY_C | 版本 | 日期 | 说明 | |:---|:---|:---| +| 1.2 | 2026-03-23 | 新增视频号发布前置三步:头像昵称校验、失败/重复清理、强制定时发布(含请求注入兜底) | | 1.1 | 2026-03-21 | 新增 §九 闭环复盘发群:卡若五块复盘 + 飞书 Webhook v2(msg_type 必填) | | 1.0 | 2026-03-20 | 初版:整合运营报表、视频切片、多平台分发、飞书视频文字下载 4 大技能,统一凭证管理 | diff --git a/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md b/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md index 7717b056..0f61a7b2 100644 --- a/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md +++ b/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md @@ -7,14 +7,14 @@ description: > triggers: 多平台分发、一键分发、全平台发布、批量分发、视频分发 owner: 木叶 group: 木 -version: "4.3" +version: "4.4" updated: "2026-03-23" --- -# 多平台分发 Skill(v4.3) +# 多平台分发 Skill(v4.4) > **核心原则**:API 发布为主,Playwright 为辅。确保确定性地分发到各平台。 -> **v4.3**:默认**静默**(不自动 `channels_login`);需弹窗时 `--auto-channels-login` 或 `CHANNELS_AUTO_LOGIN=1`(独立脚本)。**v4.2**:智能排期与去重下标对齐。 +> **v4.4**:视频号新增发前强制检查(头像昵称校验、失败清理、同标题去重)与“仅定时发布(请求注入兜底)”。**v4.3**:默认静默登录。 ## 〇、执行原则(第一性原理) @@ -89,7 +89,7 @@ python3 distribute_all.py --platforms 视频号 --auto-channels-login --video-di | 平台 | 定时方式 | 参数 | |------|----------|------| | B站 | API `meta.dtime` | Unix 时间戳(秒) | -| 视频号 | API `postTimingInfo.postTime`(秒级 Unix);首条若时间过近则立即发 | `channels_api_publish._scheduled_ts_for_channels` | +| 视频号 | API `postTimingInfo.postTime`(秒级 Unix);过近时间自动顺延,不允许立即发 | `channels_api_publish._scheduled_ts_for_channels` | | 抖音 | API `timing_ts` | Unix 时间戳 | | 快手 | Playwright UI | `schedule_helper.py` | | 小红书 | Playwright UI | `schedule_helper.py` | @@ -148,6 +148,17 @@ meta.hashtags("视频号") # … + #小程序卡若创业派对 #公众号卡 --- +## 六点五、视频号发布前置检查(强制) + +每次发布视频号前,必须先跑: + +1. `auth/auth_data`:校验 `nickname` 与 `headImgUrl`(不一致先改号资料,再执行发布)。 +2. `post/post_list`:筛查失败条目并删除。 +3. 同标题去重:若存在多条,仅保留最新 `objectId`,其余调用 `post/post_delete` 删除。 +4. 发布阶段若页面定时控件失败,改为 `post_create` 请求注入 `postTimingInfo`,继续定时发布;注入也失败则中止该条(防止误发立即)。 + +--- + ## 七、去重机制 - 日志:`publish_log.json`(JSON Lines) diff --git a/miniprogram/app.js b/miniprogram/app.js index 6bc0d36a..2c1f4a66 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -12,7 +12,7 @@ const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE' const PRODUCTION_BASE_URL = 'https://soulapi.quwanzhi.com' const CONFIG_CACHE_KEY = 'mpConfigCacheV1' // 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version(正式版),否则用本字段 -const APP_DISPLAY_VERSION = '1.7.1' +const APP_DISPLAY_VERSION = '1.7.2' App({ globalData: { @@ -98,6 +98,9 @@ App({ lastVipContactCheck: 0, // 头像昵称检测:上次检测时间戳(与 VIP 检测同周期刷新) lastAvatarNicknameCheck: 0, + /** MBTI → 默认头像 URL(/api/miniprogram/config/mbti-avatars),供推广海报等 */ + mbtiAvatarsMap: {}, + mbtiAvatarsExpires: 0, }, @@ -824,27 +827,58 @@ App({ async loadMpConfig() { try { const res = await this.getConfig() - if (!res) return - const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config) - if (mp && typeof mp === 'object') { - if (mp.appId) this.globalData.appId = mp.appId - if (mp.mchId) this.globalData.mchId = mp.mchId - if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId - this.globalData.auditMode = !!mp.auditMode - this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || '' - // 通知当前已加载的页面刷新 auditMode(从后台切回时配置更新后立即生效) - try { - const pages = getCurrentPages() - pages.forEach(p => { - if (p && p.data && 'auditMode' in p.data) { - p.setData({ auditMode: this.globalData.auditMode || false }) - } - }) - } catch (_) {} + if (res) { + const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config) + if (mp && typeof mp === 'object') { + if (mp.appId) this.globalData.appId = mp.appId + if (mp.mchId) this.globalData.mchId = mp.mchId + if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId + this.globalData.auditMode = !!mp.auditMode + this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || '' + } } } catch (e) { console.warn('[App] loadMpConfig 失败,使用默认值:', e?.message || e) } + // 审核模式不走 5min 本地 config 缓存:始终以独立接口为准,避免后台已开审核端仍显示支付入口 + try { + await this.getAuditMode() + } catch (_) {} + this.loadMbtiAvatarsMap() + }, + + /** 拉取后台配置的 16 型 MBTI 默认头像(公开接口,约 5 分钟本地缓存) */ + async loadMbtiAvatarsMap() { + try { + const now = Date.now() + if (this.globalData.mbtiAvatarsExpires && this.globalData.mbtiAvatarsExpires > now) return + const res = await this.request({ + url: '/api/miniprogram/config/mbti-avatars', + silent: true, + timeout: 8000, + }) + if (res && res.success && res.avatars && typeof res.avatars === 'object') { + this.globalData.mbtiAvatarsMap = res.avatars + this.globalData.mbtiAvatarsExpires = now + 5 * 60 * 1000 + } + } catch (e) { + console.warn('[App] loadMbtiAvatarsMap:', e?.message || e) + } + }, + + /** 展示用头像:优先用户头像,否则 MBTI 映射(需已 loadMbtiAvatarsMap) */ + resolveAvatarWithMbti(avatar, mbti) { + try { + const { resolveAvatarWithMbti } = require('./utils/mbtiAvatar.js') + return resolveAvatarWithMbti( + avatar, + mbti, + this.globalData.mbtiAvatarsMap || {}, + this.globalData.baseUrl || '' + ) + } catch (_) { + return (avatar && String(avatar).trim()) || '' + } }, /** diff --git a/miniprogram/assets/images/karuo-link-avatar.png b/miniprogram/assets/images/karuo-link-avatar.png new file mode 100644 index 00000000..e059c5b6 Binary files /dev/null and b/miniprogram/assets/images/karuo-link-avatar.png differ diff --git a/miniprogram/assets/images/part-books/0.png b/miniprogram/assets/images/part-books/0.png new file mode 100644 index 00000000..ed3dc4a0 Binary files /dev/null and b/miniprogram/assets/images/part-books/0.png differ diff --git a/miniprogram/assets/images/part-books/1.png b/miniprogram/assets/images/part-books/1.png new file mode 100644 index 00000000..29cad917 Binary files /dev/null and b/miniprogram/assets/images/part-books/1.png differ diff --git a/miniprogram/assets/images/part-books/2.png b/miniprogram/assets/images/part-books/2.png new file mode 100644 index 00000000..0bc8f47e Binary files /dev/null and b/miniprogram/assets/images/part-books/2.png differ diff --git a/miniprogram/assets/images/part-books/3.png b/miniprogram/assets/images/part-books/3.png new file mode 100644 index 00000000..ed3dc4a0 Binary files /dev/null and b/miniprogram/assets/images/part-books/3.png differ diff --git a/miniprogram/assets/images/part-books/4.png b/miniprogram/assets/images/part-books/4.png new file mode 100644 index 00000000..29cad917 Binary files /dev/null and b/miniprogram/assets/images/part-books/4.png differ diff --git a/miniprogram/pages/chapters/chapters.js b/miniprogram/pages/chapters/chapters.js index a48bd084..0a4e0fdd 100644 --- a/miniprogram/pages/chapters/chapters.js +++ b/miniprogram/pages/chapters/chapters.js @@ -7,6 +7,8 @@ const app = getApp() const { trackClick } = require('../../utils/trackClick') +const { partEmojiForBodyIndex } = require('../../utils/partIcons.js') +const { isSafeImageSrc } = require('../../utils/imageUrl.js') Page({ data: { @@ -116,15 +118,21 @@ Page({ { id: 'appendix-2', title: '附录2|创业者自检清单', mid: fixedMap['appendix-2'] }, { id: 'appendix-3', title: '附录3|本书提到的工具和资源', mid: fixedMap['appendix-3'] } ] - const bookData = parts.map((p) => ({ - id: p.id, - icon: p.icon || '', - title: p.title, - subtitle: p.subtitle || '', - chapterCount: p.chapterCount || 0, - chapters: [], - alwaysShow: (p.title || '').indexOf('每日派对干货') > -1 - })) + const bookData = parts.map((p, idx) => { + let icon = String(p.icon || '').trim() + if (icon && !isSafeImageSrc(icon)) icon = '' + const iconEmoji = icon ? '' : partEmojiForBodyIndex(idx) + return { + id: p.id, + icon, + iconEmoji, + title: p.title, + subtitle: p.subtitle || '', + chapterCount: p.chapterCount || 0, + chapters: [], + alwaysShow: (p.title || '').indexOf('每日派对干货') > -1 + } + }) app.globalData.totalSections = totalSections this.setData({ bookData, diff --git a/miniprogram/pages/chapters/chapters.wxml b/miniprogram/pages/chapters/chapters.wxml index 1cc3ff76..b3a972fe 100644 --- a/miniprogram/pages/chapters/chapters.wxml +++ b/miniprogram/pages/chapters/chapters.wxml @@ -72,7 +72,8 @@ - + + {{item.iconEmoji}} {{item.title[0] || '篇'}} {{item.title}} diff --git a/miniprogram/pages/chapters/chapters.wxss b/miniprogram/pages/chapters/chapters.wxss index c2226657..82d3596f 100644 --- a/miniprogram/pages/chapters/chapters.wxss +++ b/miniprogram/pages/chapters/chapters.wxss @@ -356,6 +356,15 @@ color: #ffffff; flex-shrink: 0; } +/* 与管理端 ChapterTree 篇头 emoji 一致 */ +.part-icon-emoji { + font-size: 34rpx; + font-weight: 400; + line-height: 1; + color: #ffffff; + background: linear-gradient(135deg, #1e3a4a 0%, #0f172a 100%); + border: 2rpx solid rgba(0, 206, 209, 0.35); +} .part-icon-img { width: 64rpx; height: 64rpx; border-radius: 16rpx; flex-shrink: 0; } diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 29078092..46c28211 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -8,6 +8,10 @@ const app = getApp() const { trackClick } = require('../../utils/trackClick') const { cleanSingleLineField } = require('../../utils/contentParser') const { navigateMpPath } = require('../../utils/mpNavigate.js') +const { isSafeImageSrc } = require('../../utils/imageUrl.js') + +const DEFAULT_KARUO_LINK_AVATAR = '/assets/images/karuo-link-avatar.png' +const KARUO_USER_ID = 'ogpTW5Wbbo9DfSyB3-xCWN6EGc-g' /** 与首页固定「卡若」获客位重复时从横滑列表剔除(含历史误写「卡路」) */ function isKaruoHostDuplicateName(displayName) { @@ -92,11 +96,12 @@ Page({ mpUiLogoTitle: '卡若创业派对', mpUiLogoSubtitle: '来自派对房的真实故事', mpUiLinkKaruoText: '点击链接卡若', + /** 最终展示:后台 linkKaruoAvatar 或本包默认卡若照片 */ + mpUiLinkKaruoDisplay: DEFAULT_KARUO_LINK_AVATAR, mpUiSearchPlaceholder: '搜索章节标题或内容...', mpUiBannerTag: '推荐', mpUiBannerReadMore: '点击阅读', mpUiSuperTitle: '超级个体', - mpUiSuperLinkText: '获客入口', mpUiPickTitle: '精选推荐', mpUiLatestTitle: '最新新增' }, @@ -321,51 +326,62 @@ Page({ _applyHomeMpUi() { const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {} + let linkKaruoAvatar = String(h.linkKaruoAvatar || h.linkKaruoImage || '').trim() + if (linkKaruoAvatar && !isSafeImageSrc(linkKaruoAvatar)) linkKaruoAvatar = '' this.setData({ mpUiLogoTitle: String(h.logoTitle || '卡若创业派对').trim() || '卡若创业派对', mpUiLogoSubtitle: String(h.logoSubtitle || '来自派对房的真实故事').trim() || '来自派对房的真实故事', mpUiLinkKaruoText: String(h.linkKaruoText || '点击链接卡若').trim() || '点击链接卡若', + mpUiLinkKaruoDisplay: linkKaruoAvatar || DEFAULT_KARUO_LINK_AVATAR, mpUiSearchPlaceholder: String(h.searchPlaceholder || '搜索章节标题或内容...').trim() || '搜索章节标题或内容...', mpUiBannerTag: String(h.bannerTag || '推荐').trim() || '推荐', mpUiBannerReadMore: String(h.bannerReadMoreText || '点击阅读').trim() || '点击阅读', mpUiSuperTitle: String(h.superSectionTitle || '超级个体').trim() || '超级个体', - mpUiSuperLinkText: String(h.superSectionLinkText || '获客入口').trim() || '获客入口', mpUiPickTitle: String(h.pickSectionTitle || '精选推荐').trim() || '精选推荐', mpUiLatestTitle: String(h.latestSectionTitle || '最新新增').trim() || '最新新增' }) + if (!linkKaruoAvatar) this._loadKaruoAvatarLazy() }, - /** 超级个体右侧文案:默认跳转找伙伴 Tab(路径可由 homePage.superSectionLinkPath 配置) */ - goSuperSectionLink() { - const p = String( - app.globalData.configCache?.mpConfig?.mpUi?.homePage?.superSectionLinkPath || '/pages/match/match' - ).trim() - if (p) navigateMpPath(p) + _loadKaruoAvatarLazy() { + app.request({ url: `/api/miniprogram/user/profile?userId=${KARUO_USER_ID}`, silent: true, timeout: 3000 }) + .then(res => { + if (res?.success && res.data?.avatar && isSafeImageSrc(res.data.avatar)) { + this.setData({ mpUiLinkKaruoDisplay: res.data.avatar }) + } + }) + .catch(() => {}) }, async loadFeatureConfig() { try { const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean' - if (hasCachedFeatures) { - this.setData({ - searchEnabled: app.globalData.features.searchEnabled, - auditMode: app.globalData.auditMode || false - }) - this._applyHomeMpUi() - return + if (!hasCachedFeatures) { + const res = await app.getConfig() + const features = (res && res.features) || (res && res.data && res.data.features) || {} + const searchEnabled = features.searchEnabled !== false + if (!app.globalData.features) app.globalData.features = {} + app.globalData.features.searchEnabled = searchEnabled + if (typeof features.matchEnabled === 'boolean') app.globalData.features.matchEnabled = features.matchEnabled + if (typeof features.referralEnabled === 'boolean') app.globalData.features.referralEnabled = features.referralEnabled + const mp = (res && res.mpConfig) || {} + app.globalData.auditMode = !!mp.auditMode } - const res = await app.getConfig() - const features = (res && res.features) || {} - const mp = (res && res.mpConfig) || {} - const searchEnabled = features.searchEnabled !== false - const auditMode = !!mp.auditMode - if (!app.globalData.features) app.globalData.features = {} - app.globalData.features.searchEnabled = searchEnabled - app.globalData.auditMode = auditMode - this.setData({ searchEnabled, auditMode }) + await app.getAuditMode() + const searchEnabled = app.globalData.features?.searchEnabled !== false + this.setData({ + searchEnabled, + auditMode: app.globalData.auditMode || false + }) this._applyHomeMpUi() } catch (e) { - this.setData({ searchEnabled: true, auditMode: app.globalData.auditMode || false }) + try { + await app.getAuditMode() + } catch (_) {} + this.setData({ + searchEnabled: app.globalData.features?.searchEnabled !== false, + auditMode: app.globalData.auditMode || false + }) this._applyHomeMpUi() } }, @@ -459,6 +475,22 @@ Page({ // 阻止弹窗内部点击事件冒泡到遮罩层 stopPropagation() {}, + preventMove() {}, + + onLeadPrivacyAuthorize() { + this.onAgreePrivacyForLead() + }, + + onDisagreePrivacyForLead() { + if (app._privacyResolve) { + try { + app._privacyResolve({ event: 'disagree' }) + } catch (_) {} + app._privacyResolve = null + } + this.setData({ showPrivacyModal: false }) + }, + onLeadPhoneInput(e) { this.setData({ leadPhone: (e.detail.value || '').trim() }) }, diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index 85868358..05222397 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -16,7 +16,12 @@ {{mpUiLogoSubtitle}} - + + + + {{mpUiLinkKaruoText}} + + @@ -52,7 +57,6 @@ {{mpUiSuperTitle}} - {{mpUiSuperLinkText}} @@ -160,4 +164,30 @@ + + + + 温馨提示 + 使用手机号能力前,请先同意《用户隐私保护指引》 + + 拒绝 + + + + + + + 留下联系方式 + 方便卡若与您联系 + + 或手动输入 + + + + + + + + + diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index f8c22e4c..7fc9b66f 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -85,6 +85,10 @@ font-size: 20rpx; color: rgba(255, 255, 255, 0.7); white-space: nowrap; + max-width: 140rpx; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; } .logo-title { @@ -963,6 +967,61 @@ height: 40rpx; } +/* ===== 隐私授权(与 avatar-nickname 对齐) ===== */ +.privacy-mask { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 48rpx; + box-sizing: border-box; +} +.privacy-modal { + width: 100%; + max-width: 560rpx; + background: #17212F; + border-radius: 24rpx; + padding: 48rpx; + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; +} +.privacy-title { + font-size: 36rpx; + font-weight: 700; + color: #fff; + margin-bottom: 24rpx; +} +.privacy-desc { + font-size: 28rpx; + color: #94A3B8; + text-align: center; + line-height: 1.5; + margin-bottom: 40rpx; +} +.privacy-btn { + width: 100%; + height: 88rpx; + line-height: 88rpx; + text-align: center; + background: #5EEAD4; + color: #000; + font-size: 30rpx; + font-weight: 600; + border-radius: 16rpx; + border: none; +} +.privacy-btn::after { border: none; } +.privacy-cancel { + margin-top: 24rpx; + font-size: 28rpx; + color: #64748B; +} + /* ===== 链接卡若 - 留资弹窗 ===== */ .lead-mask { position: fixed; @@ -971,7 +1030,7 @@ top: 0; bottom: 0; background: rgba(0, 0, 0, 0.6); - z-index: 1000; + z-index: 2100; display: flex; align-items: center; justify-content: center; diff --git a/miniprogram/pages/match/match.wxml b/miniprogram/pages/match/match.wxml index eb9d8fb5..24d3146b 100644 --- a/miniprogram/pages/match/match.wxml +++ b/miniprogram/pages/match/match.wxml @@ -121,7 +121,8 @@ - + + {{currentMatch.nickname ? currentMatch.nickname[0] : '?'}} {{currentMatch.nickname}} diff --git a/miniprogram/pages/match/match.wxss b/miniprogram/pages/match/match.wxss index 7852ded2..106966af 100644 --- a/miniprogram/pages/match/match.wxss +++ b/miniprogram/pages/match/match.wxss @@ -445,6 +445,20 @@ flex-shrink: 0; } +.match-avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 206, 209, 0.15); + box-sizing: border-box; +} + +.match-avatar-fallback text { + font-size: 48rpx; + font-weight: 600; + color: #00CED1; +} + .match-info { flex: 1; min-width: 0; diff --git a/miniprogram/pages/member-detail/member-detail.js b/miniprogram/pages/member-detail/member-detail.js index 6051a6ff..48a7468a 100644 --- a/miniprogram/pages/member-detail/member-detail.js +++ b/miniprogram/pages/member-detail/member-detail.js @@ -10,6 +10,7 @@ const app = getApp() const { trackClick } = require('../../utils/trackClick') const { isSafeImageSrc } = require('../../utils/imageUrl.js') +const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js') Page({ data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true, isOwnProfile: false }, @@ -29,6 +30,9 @@ Page({ }, async loadMember(id) { + try { + if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap() + } catch (_) {} const myId = app.globalData.userInfo?.id const isOwn = !!(myId && id != null && String(id) === String(myId)) if (isOwn && app.globalData.isLoggedIn && myId) { @@ -114,10 +118,19 @@ Page({ enrichAndFormat(raw) { const e = (v) => this._emptyIfPlaceholder(v) const rawAv = raw.avatar || raw.vipAvatar || raw.vip_avatar || '' + let dispAv = isSafeImageSrc(rawAv) ? String(rawAv).trim() : '' + if (!dispAv) { + dispAv = resolveAvatarWithMbti( + '', + raw.mbti, + app.globalData.mbtiAvatarsMap || {}, + app.globalData.baseUrl || '' + ) + } const merged = { id: raw.id, name: raw.nickname || raw.name || raw.vipName || raw.vip_name || '创业者', - avatar: isSafeImageSrc(rawAv) ? String(rawAv).trim() : '', + avatar: dispAv, isVip: !!(raw.isVip || raw.is_vip), mbti: e(raw.mbti), region: e(raw.region), @@ -275,7 +288,7 @@ Page({ const member = this.data.member if (!member) return const nickname = (member.name || 'TA').trim() || 'TA' - trackClick('member_detail', 'btn_click', '链接头像_' + (member.id || '')) + trackClick('member_detail', 'avatar_click', '链接头像_' + (member.id || '')) if (!app.globalData.isLoggedIn || !app.globalData.userInfo) { wx.showModal({ diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index ac109d75..c4175f1b 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -20,6 +20,8 @@ Page({ // 用户状态 isLoggedIn: false, userInfo: null, + /** 我的页头像展示:微信头像或 MBTI 映射图 */ + profileAvatarDisplay: '', // 统计数据 totalSections: 62, @@ -142,6 +144,16 @@ Page({ }) }, + async _refreshMyAvatarDisplay(safeUser) { + if (!safeUser || !app.globalData.isLoggedIn) return + try { + if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap() + } catch (_) {} + const url = app.resolveAvatarWithMbti ? app.resolveAvatarWithMbti(safeUser.avatar, safeUser.mbti) : '' + if (!this.data.isLoggedIn) return + this.setData({ profileAvatarDisplay: url || '' }) + }, + async loadFeatureConfig() { try { const res = await app.getConfig() @@ -150,8 +162,9 @@ Page({ const referralEnabled = features.referralEnabled !== false const searchEnabled = features.searchEnabled !== false const mp = (res && res.mpConfig) || {} - const auditMode = !!mp.auditMode - app.globalData.auditMode = auditMode + app.globalData.auditMode = !!mp.auditMode + await app.getAuditMode() + const auditMode = app.globalData.auditMode || false app.globalData.features = { matchEnabled, referralEnabled, searchEnabled } this.setData({ matchEnabled, referralEnabled, searchEnabled, auditMode }) this._applyMyMpUiLabels() @@ -181,10 +194,11 @@ Page({ this.setData({ isLoggedIn: true, userInfo: safeUser, + profileAvatarDisplay: '', userIdShort, userWechat, readCount: 0, - referralCount: userInfo.referralCount || 0, + referralCount: 0, earnings: '-', pendingEarnings: '-', earningsLoading: true, @@ -200,12 +214,14 @@ Page({ this.loadPendingConfirm() this.loadVipStatus() this.loadWalletBalance() + this._refreshMyAvatarDisplay(safeUser) } else { const guestReadCount = app.getReadCount() const guestRecent = this._mergeRecentChaptersFromLocal([]) this.setData({ isLoggedIn: false, userInfo: null, + profileAvatarDisplay: '', userIdShort: '', readCount: guestReadCount, readCountText: formatStatNum(guestReadCount), @@ -644,6 +660,7 @@ Page({ const userInfo = this.data.userInfo userInfo.avatar = avatarUrl this.setData({ userInfo }) + this._refreshMyAvatarDisplay(userInfo) app.globalData.userInfo = userInfo wx.setStorageSync('userInfo', userInfo) @@ -689,9 +706,9 @@ Page({ } }, - // 点击昵称:跳转资料编辑页(type="nickname" 在弹窗内无法触发微信昵称选择器,需在主页面) + // 点击昵称:先进个人资料名片页,再在右上角进入编辑(与需求「编辑收进名片流」一致) editNickname() { - wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) + wx.navigateTo({ url: '/pages/profile-show/profile-show' }) }, // 关闭昵称弹窗 @@ -916,14 +933,17 @@ Page({ }, // 跳转到推广中心(需登录) - goToReferral() { - trackClick('my', 'nav_click', '推广中心') + goToReferral(e) { + const focus = e && e.currentTarget && e.currentTarget.dataset ? (e.currentTarget.dataset.focus || '') : '' + const action = focus === 'bindings' ? '推荐好友' : focus === 'earnings' ? '我的收益' : '推广中心' + trackClick('my', 'nav_click', action) if (!this.data.isLoggedIn) { this.showLogin() return } if (!this.data.referralEnabled) return - wx.navigateTo({ url: '/pages/referral/referral' }) + const url = focus ? `/pages/referral/referral?focus=${focus}` : '/pages/referral/referral' + wx.navigateTo({ url }) }, // 退出登录 diff --git a/miniprogram/pages/my/my.wxml b/miniprogram/pages/my/my.wxml index eeefac3c..0004fba4 100644 --- a/miniprogram/pages/my/my.wxml +++ b/miniprogram/pages/my/my.wxml @@ -22,7 +22,7 @@ - + {{userInfo.nickname ? userInfo.nickname[0] : '?'}} VIP @@ -32,7 +32,7 @@ {{userInfo.nickname || '点击设置昵称'}} - + {{mpUiCardLabel}} {{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}} @@ -43,7 +43,7 @@ {{readCountText || '0'}} {{mpUiReadStatLabel}} - + {{referralCount}} 推荐好友 @@ -51,7 +51,7 @@ {{matchHistoryText}} 匹配伙伴 - + {{pendingEarnings || '0.00'}} 我的收益 diff --git a/miniprogram/pages/my/my.wxss b/miniprogram/pages/my/my.wxss index 79ff7e1a..b8dbdede 100644 --- a/miniprogram/pages/my/my.wxss +++ b/miniprogram/pages/my/my.wxss @@ -79,6 +79,8 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } .profile-actions-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12rpx; } +/* 名片/会员中心:紧挨昵称下方(在 profile-meta 内) */ +.profile-actions-under-name { margin-top: 4rpx; } /* 名片 / 会员中心:统一品牌青,与 tabBar 选中色一致 */ .profile-action-btn { padding: 12rpx 28rpx; border: 2rpx solid #4FD1C5; color: #4FD1C5; diff --git a/miniprogram/pages/profile-edit/profile-edit.js b/miniprogram/pages/profile-edit/profile-edit.js index 0fdf293e..f9a0cccc 100644 --- a/miniprogram/pages/profile-edit/profile-edit.js +++ b/miniprogram/pages/profile-edit/profile-edit.js @@ -48,6 +48,8 @@ Page({ wizardMode: false, wizardStep: 1, totalWizardSteps: 3, + /** 头像区展示:含 MBTI 默认图 */ + avatarPreviewUrl: '', }, onLoad(options) { @@ -83,6 +85,9 @@ Page({ app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }), app.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true }), ]) + try { + if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap() + } catch (_) {} this.setData({ isVip: vipRes?.data?.isVip || false }) const res = profileRes if (res?.success && res.data) { @@ -110,6 +115,7 @@ Page({ loading: false, }) this._applyWizardModeFromProfile(d) + this._syncAvatarPreview() setTimeout(() => this.generateShareCard(), 200) } else { this.setData({ loading: false }) @@ -367,7 +373,22 @@ Page({ onMbtiPickerChange(e) { const i = parseInt(e.detail.value, 10) - this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] }) + this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] }, () => this._syncAvatarPreview()) + }, + + _syncAvatarPreview() { + try { + const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js') + const url = resolveAvatarWithMbti( + this.data.avatar, + this.data.mbti, + app.globalData.mbtiAvatarsMap || {}, + app.globalData.baseUrl || '' + ) + this.setData({ avatarPreviewUrl: url || '' }) + } catch (_) { + this.setData({ avatarPreviewUrl: (this.data.avatar || '').trim() }) + } }, // 微信原生 chooseAvatar 回调(点击头像直接弹出原生选择器:用微信头像/从相册选择/拍照) @@ -400,7 +421,7 @@ Page({ if (avatarUrl && !avatarUrl.startsWith('http')) { avatarUrl = app.globalData.baseUrl + avatarUrl } - this.setData({ avatar: avatarUrl }) + this.setData({ avatar: avatarUrl }, () => this._syncAvatarPreview()) const avatarToSave = toAvatarPath(avatarUrl) await app.request({ url: '/api/miniprogram/user/profile', diff --git a/miniprogram/pages/profile-edit/profile-edit.wxml b/miniprogram/pages/profile-edit/profile-edit.wxml index 52fe55d9..851cef9e 100644 --- a/miniprogram/pages/profile-edit/profile-edit.wxml +++ b/miniprogram/pages/profile-edit/profile-edit.wxml @@ -29,7 +29,7 @@ + + + + + +
+ {MBTI_TYPES_ORDERED.map((t) => { + const url = avatars[t] ?? '' + const meta = MBTI_AVATAR_PROFILES[t] + return ( +
+
+ {t} + + {meta.title} + +
+ +
+
+ {url ? ( + {t} + ) : ( + 未配 + )} +
+
+ setAvatars((prev) => ({ ...prev, [t]: e.target.value }))} + /> +
+
+ +
+ + +
+
+ ) + })} +
+ + ) +} diff --git a/soul-admin/src/components/modules/user/UserDetailModal.tsx b/soul-admin/src/components/modules/user/UserDetailModal.tsx index 06c04139..4f7cc163 100644 --- a/soul-admin/src/components/modules/user/UserDetailModal.tsx +++ b/soul-admin/src/components/modules/user/UserDetailModal.tsx @@ -83,10 +83,37 @@ interface UserTrack { actionLabel?: string target?: string chapterTitle?: string + module?: string + moduleLabel?: string createdAt: string timeAgo?: string } +interface InboundVisitItem { + seq: number + visitedAt?: string + referrerId?: string + referrerNickname?: string + referrerAvatar?: string + source?: string + page?: string +} + +interface InboundSourceData { + totalVisits?: number + firstVisit?: InboundVisitItem + latestVisit?: InboundVisitItem + activeBinding?: { + referrerId?: string + referrerNickname?: string + referrerAvatar?: string + referralCode?: string + bindingDate?: string + expiryDate?: string + } + visits?: InboundVisitItem[] +} + interface ShensheShouData { rfm_score?: number user_level?: string @@ -152,6 +179,7 @@ export function UserDetailModal({ const [tracks, setTracks] = useState([]) const [trackStats, setTrackStats] = useState>({}) const [referrals, setReferrals] = useState([]) + const [inboundSource, setInboundSource] = useState(null) const [balanceData, setBalanceData] = useState<{ balance: number; transactions: Array<{ id: string; type: string; amount: number; orderId?: string; createdAt: string }> } | null>(null) const [loading, setLoading] = useState(false) const [syncing, setSyncing] = useState(false) @@ -187,9 +215,13 @@ export function UserDetailModal({ const [sssQueryOpenId, setSssQueryOpenId] = useState('') const [batchIngestLoading, setBatchIngestLoading] = useState(false) const [batchIngestResult, setBatchIngestResult] = useState | null>(null) + const [avatarBroken, setAvatarBroken] = useState(false) + const [mbtiAvatarsMap, setMbtiAvatarsMap] = useState>({}) + const [purchaseList, setPurchaseList] = useState<{ orderSn: string; productType: string; productId?: string; amount: number; createdAt: string }[]>([]) useEffect(() => { if (open && userId) { + setAvatarBroken(false) setActiveTab('info') setSssData(null) setSssError(null) @@ -203,6 +235,24 @@ export function UserDetailModal({ } }, [open, userId]) + useEffect(() => { + if (!open) return + get<{ success?: boolean; avatars?: Record }>('/api/admin/mbti-avatars') + .then((r) => { + if (r?.avatars && typeof r.avatars === 'object') setMbtiAvatarsMap(r.avatars) + else setMbtiAvatarsMap({}) + }) + .catch(() => setMbtiAvatarsMap({})) + }, [open]) + + const resolveAvatarByMbti = (avatar?: string | null, mbti?: string | null): string => { + const av = (avatar || '').trim() + if (av) return normalizeImageUrl(av) + const key = (mbti || '').trim().toUpperCase() + if (!/^[EI][NS][FT][JP]$/.test(key)) return '' + return (mbtiAvatarsMap[key] || '').trim() + } + async function loadUserDetail() { if (!userId) return setLoading(true) @@ -259,11 +309,20 @@ export function UserDetailModal({ } // 关系链路 try { - const refData = await get<{ success?: boolean; referrals?: unknown[] }>( + const refData = await get<{ success?: boolean; referrals?: unknown[]; inboundSource?: InboundSourceData }>( `/api/db/users/referrals?userId=${encodeURIComponent(userId)}`, ) - if (refData?.success && refData.referrals) setReferrals(refData.referrals) - } catch { setReferrals([]) } + if (refData?.success) { + setReferrals(refData.referrals || []) + setInboundSource(refData.inboundSource || null) + } else { + setReferrals([]) + setInboundSource(null) + } + } catch { + setReferrals([]) + setInboundSource(null) + } try { const balData = await get<{ success?: boolean; data?: { balance: number; transactions: Array<{ id: string; type: string; amount: number; orderId?: string; createdAt: string }> } }>( `/api/admin/users/${encodeURIComponent(userId)}/balance`, @@ -271,6 +330,13 @@ export function UserDetailModal({ if (balData?.success && balData.data) setBalanceData(balData.data) else setBalanceData(null) } catch { setBalanceData(null) } + try { + const ordersData = await get<{ success?: boolean; orders?: { orderSn: string; productType: string; productId?: string; amount: number; createdAt: string }[] }>( + `/api/orders?userId=${encodeURIComponent(userId)}&status=paid&pageSize=50`, + ) + if (ordersData?.success && ordersData.orders) setPurchaseList(ordersData.orders) + else setPurchaseList([]) + } catch { setPurchaseList([]) } } catch (e) { console.error('Load user detail error:', e) } finally { @@ -434,12 +500,32 @@ export function UserDetailModal({ setBatchIngestLoading(true) setBatchIngestResult(null) try { + // 购买意向:看浏览/购买轨迹,把章节名备注给神射手(需求:便于 wepop/外部侧识别“想买哪章”) + const purchaseIntentChapterTitles = Array.from( + new Set( + tracks + .filter((t) => t.action === 'view_chapter' || t.action === 'purchase' || t.action === 'first_pay') + .map((t) => (t.chapterTitle || t.target || '').trim()) + .filter(Boolean), + ), + ).slice(0, 12) + const purchaseIntentActions = { + viewChapter: trackStats.view_chapter || 0, + purchase: trackStats.purchase || 0, + firstPay: trackStats.first_pay || 0, + } + const purchaseIntentRemark = purchaseIntentChapterTitles.length > 0 + ? `意向章节:${purchaseIntentChapterTitles.join('、')}` + : '' const payload = { users: [{ phone: user.phone || '', name: user.nickname || '', openId: user.openId || '', tags: editTags, + purchaseIntent: purchaseIntentActions, + purchaseIntentChapters: purchaseIntentChapterTitles, + remark: purchaseIntentRemark, }] } const data = await post<{ success?: boolean; data?: Record; error?: string }>( @@ -467,12 +553,30 @@ export function UserDetailModal({ bind_phone: Phone, bind_wechat: MessageCircle, fill_profile: Tag, + fill_avatar: User, visit_page: Navigation, + first_pay: ShoppingBag, + vip_activate: Crown, + click_super: Users, + lead_submit: Phone, + withdraw: Key, + referral_bind: Link2, + card_click: User, + btn_click: Zap, + tab_click: Navigation, + nav_click: Navigation, + page_view: Navigation, + search: Navigation, } const Icon = icons[action] || Clock return } + function trackTargetIsOpaqueId(s: string) { + const t = String(s || '').trim() + return t.length > 22 && /^[a-zA-Z0-9_-]+$/.test(t) + } + const journeyInferredTags = useMemo( () => inferTagsFromJourney(trackStats, user), [trackStats, user], @@ -513,8 +617,13 @@ export function UserDetailModal({
- {user.avatar ? ( - + {resolveAvatarByMbti(user.avatar, user.mbti) && !avatarBroken ? ( + setAvatarBroken(true)} + /> ) : ( user.nickname?.charAt(0) || '?' )} @@ -526,31 +635,29 @@ export function UserDetailModal({ {user.hasFullBook && 全书已购} {user.vipRole && {user.vipRole}}
-

- {user.id} - {user.referralCode && ( - <> - {' · '} - {user.referralCode} - - )} -

-

- OpenID 为微信开放平台用户唯一标识; - 微信标识 填微信号 / wxid(存客宝归属用),与 OpenID 不是同一字段。 -

-
-
- OpenID -

{user.openId || '—'}

+ {user.referralCode && ( +

+ 推荐码 {user.referralCode} +

+ )} +
+
+ 昵称 +

{editNickname || user.nickname || '—'}

-
- 库内手机 -

{user.phone || '—'}

+
+ 手机号 +

{editPhone || '—'}

-
- 库内微信标识 -

{user.wechatId || '—'}

+
+ 微信标识 +

{editWechatId || '—'}

+
+
+ 画像 +

+ {[user.region, user.industry, user.position, user.mbti ? `MBTI ${user.mbti}` : ''].filter(Boolean).join(' · ') || '未完善'} +

@@ -611,8 +718,25 @@ export function UserDetailModal({ - {/* ===== 用户信息(紧凑单屏) ===== */} + {/* ===== 用户信息(紧凑单屏):基础字段 + 超级个体 + 外部同步合并为一屏滚动 ===== */} +
+ + 技术标识 + (用户ID / OpenID,默认折叠) + +
+

+ 用户ID {user.id} +

+

+ OpenID {user.openId || '—'} +

+

+ OpenID 为微信用户标识;下方「微信标识」为微信号/wxid,供存客宝归属,与 OpenID 不同。 +

+
+
@@ -691,7 +815,7 @@ export function UserDetailModal({
- 资料完善 · 神射手 / 存客宝 + 外部资料 · 神射手 / 存客宝(与上方基础信息联动)
setSssQueryPhone(e.target.value)} /> @@ -744,6 +868,27 @@ export function UserDetailModal({ {/* ===== 用户旅程 + 行为轨迹 ===== */} + {purchaseList.length > 0 && ( +
+
+ + 购买清单({purchaseList.length} 笔) +
+
+ {purchaseList.map((o, i) => ( +
+
+ + {o.productType === 'fullbook' || o.productType === 'vip' ? '全书/VIP' : `章节 ${o.productId || ''}`} + + ¥{Number(o.amount || 0).toFixed(2)} +
+ {o.createdAt ? new Date(o.createdAt).toLocaleString('zh-CN') : ''} +
+ ))} +
+
+ )}
@@ -772,11 +917,14 @@ export function UserDetailModal({
{track.actionLabel || track.action} + {track.moduleLabel && · {track.moduleLabel}} {track.chapterTitle && · {track.chapterTitle}}
- {track.target && track.target !== track.chapterTitle && ( -

target: {track.target}

- )} + {track.target && + track.target !== track.chapterTitle && + !trackTargetIsOpaqueId(track.target) && ( +

详情: {track.target}

+ )}

{track.timeAgo ? `${track.timeAgo} · ` : ''} @@ -796,6 +944,62 @@ export function UserDetailModal({ {/* ===== 关系链路 ===== */} +

+
+
+ + 入站关系链路 +
+ + 点击 {inboundSource?.totalVisits || 0} 次 + +
+
+
+

首次来自

+

+ {inboundSource?.firstVisit?.referrerNickname || '—'} + {inboundSource?.firstVisit?.referrerId ? `(${inboundSource.firstVisit.referrerId})` : ''} +

+
+
+

最近来自

+

+ {inboundSource?.latestVisit?.referrerNickname || '—'} + {inboundSource?.latestVisit?.referrerId ? `(${inboundSource.latestVisit.referrerId})` : ''} +

+
+
+ {inboundSource?.activeBinding?.referrerId ? ( +
+

+ 当前绑定: + {inboundSource.activeBinding.referrerNickname || '微信用户'} + {`(${inboundSource.activeBinding.referrerId})`} +

+
+ ) : null} +
+ {(inboundSource?.visits || []).length > 0 ? ( + (inboundSource?.visits || []).map((v, i) => ( +
+
+

+ 第 {v.seq || i + 1} 次 · {v.referrerNickname || '微信用户'} + {v.referrerId ? `(${v.referrerId})` : ''} +

+ {v.page ?

{v.page}

: null} +
+ + {v.visitedAt ? new Date(v.visitedAt).toLocaleString() : ''} + +
+ )) + ) : ( +

暂无来源点击记录

+ )} +
+
diff --git a/soul-admin/src/lib/mbtiAvatarPrompts.ts b/soul-admin/src/lib/mbtiAvatarPrompts.ts new file mode 100644 index 00000000..cbc5d3ca --- /dev/null +++ b/soul-admin/src/lib/mbtiAvatarPrompts.ts @@ -0,0 +1,130 @@ +export const MBTI_TYPES_ORDERED = [ + 'INTJ', + 'INTP', + 'ENTJ', + 'ENTP', + 'INFJ', + 'INFP', + 'ENFJ', + 'ENFP', + 'ISTJ', + 'ISFJ', + 'ESTJ', + 'ESFJ', + 'ISTP', + 'ISFP', + 'ESTP', + 'ESFP', +] as const + +export type MbtiType = (typeof MBTI_TYPES_ORDERED)[number] + +type MbtiGroup = 'NT' | 'NF' | 'SJ' | 'SP' +type MbtiAvatarMood = 'calm' | 'sharp' | 'warm' | 'playful' + +export interface MbtiAvatarProfile { + title: string + group: MbtiGroup + mood: MbtiAvatarMood +} + +/** + * 以用户给的参考图为基准:多边形人物、无中英文字,仅保留人物头像。 + * 颜色与网站深色主题融合(青绿/琥珀/紫青等低饱和高对比)。 + */ +export const MBTI_AVATAR_PROFILES: Record = { + INTJ: { title: '战略家', group: 'NT', mood: 'sharp' }, + INTP: { title: '逻辑学家', group: 'NT', mood: 'calm' }, + ENTJ: { title: '指挥官', group: 'NT', mood: 'sharp' }, + ENTP: { title: '辩论家', group: 'NT', mood: 'playful' }, + INFJ: { title: '提倡者', group: 'NF', mood: 'warm' }, + INFP: { title: '调停者', group: 'NF', mood: 'warm' }, + ENFJ: { title: '主人公', group: 'NF', mood: 'warm' }, + ENFP: { title: '竞选者', group: 'NF', mood: 'playful' }, + ISTJ: { title: '物流师', group: 'SJ', mood: 'calm' }, + ISFJ: { title: '守卫者', group: 'SJ', mood: 'warm' }, + ESTJ: { title: '总经理', group: 'SJ', mood: 'sharp' }, + ESFJ: { title: '执政官', group: 'SJ', mood: 'warm' }, + ISTP: { title: '鉴赏家', group: 'SP', mood: 'sharp' }, + ISFP: { title: '探险家', group: 'SP', mood: 'playful' }, + ESTP: { title: '企业家', group: 'SP', mood: 'playful' }, + ESFP: { title: '表演者', group: 'SP', mood: 'playful' }, +} + +function paletteByGroup(group: MbtiGroup) { + switch (group) { + case 'NT': + return { bg: '#0d1424', body: '#c89a2c', accent: '#ffd66b', hair: '#6d540f', line: '#111827' } + case 'NF': + return { bg: '#0a1721', body: '#2e9f7c', accent: '#84e9c9', hair: '#2d6a4f', line: '#11212a' } + case 'SJ': + return { bg: '#101828', body: '#4f8cb8', accent: '#9bd4ff', hair: '#2e4a66', line: '#111f2d' } + case 'SP': + return { bg: '#161225', body: '#8b6bc0', accent: '#ccb3ff', hair: '#574183', line: '#211832' } + default: + return { bg: '#0e1422', body: '#38bdac', accent: '#7ee7db', hair: '#1f6f66', line: '#10202d' } + } +} + +function faceByMood(mood: MbtiAvatarMood): { eye: string; brow: string; mouth: string; tilt: number } { + switch (mood) { + case 'sharp': + return { eye: 'M222 222 L242 220 M270 220 L290 222', brow: 'M218 210 L244 202 M268 202 L294 210', mouth: 'M234 256 Q256 246 278 256', tilt: -5 } + case 'warm': + return { eye: 'M222 224 Q232 230 242 224 M270 224 Q280 230 290 224', brow: 'M220 210 Q232 206 244 210 M268 210 Q280 206 292 210', mouth: 'M232 254 Q256 272 280 254', tilt: 2 } + case 'playful': + return { eye: 'M222 224 Q232 236 242 224 M270 224 Q280 236 290 224', brow: 'M220 210 Q234 200 246 208 M266 208 Q278 200 292 210', mouth: 'M232 256 Q256 266 280 250', tilt: 8 } + default: + return { eye: 'M222 224 Q232 220 242 224 M270 224 Q280 220 290 224', brow: 'M220 210 Q232 208 244 210 M268 210 Q280 208 292 210', mouth: 'M236 256 Q256 260 276 256', tilt: 0 } + } +} + +function shouldersByMood(mood: MbtiAvatarMood): string { + switch (mood) { + case 'sharp': + return 'M168 370 L206 300 L256 332 L306 300 L344 370 L306 392 L256 374 L206 392 Z' + case 'warm': + return 'M166 368 Q188 318 226 314 L256 340 L286 314 Q324 318 346 368 L314 392 Q286 404 256 396 Q226 404 198 392 Z' + case 'playful': + return 'M164 370 L198 304 L252 332 L318 300 L350 374 L316 394 L258 378 L196 396 Z' + default: + return 'M166 370 L202 306 L256 336 L310 306 L346 370 L310 392 L256 380 L202 392 Z' + } +} + +export function buildMbtiSvgAvatarDataUrl(type: MbtiType): string { + const p = MBTI_AVATAR_PROFILES[type] + const palette = paletteByGroup(p.group) + const face = faceByMood(p.mood) + const shoulder = shouldersByMood(p.mood) + + const svg = ` + + + + + + + + + + + + + + + + + + + + + + + + + +` + + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}` +} diff --git a/soul-admin/src/pages/admin-users/AdminUsersPage.tsx b/soul-admin/src/pages/admin-users/AdminUsersPage.tsx index 7d09b4cf..8d4cc9e1 100644 --- a/soul-admin/src/pages/admin-users/AdminUsersPage.tsx +++ b/soul-admin/src/pages/admin-users/AdminUsersPage.tsx @@ -44,6 +44,12 @@ interface ListRes { error?: string } +function confirmDangerousDelete(entity: string): boolean { + if (!confirm(`确定删除该${entity}?此操作不可恢复。`)) return false + const verifyText = window.prompt(`请输入「删除」以确认删除${entity}`) + return verifyText === '删除' +} + export function AdminUsersPage() { const [records, setRecords] = useState([]) const [total, setTotal] = useState(0) @@ -72,7 +78,7 @@ export function AdminUsersPage() { pageSize: String(pageSize), }) if (debouncedSearch.trim()) params.set('search', debouncedSearch.trim()) - const data = await get(`/api/admin/admin-users?${params}`) + const data = await get(`/api/admin/users?${params}`) if (data?.success) { setRecords((data as ListRes).records || []) setTotal((data as ListRes).total ?? 0) @@ -130,7 +136,7 @@ export function AdminUsersPage() { setSaving(true) try { if (editingUser) { - const data = await put<{ success?: boolean; error?: string }>('/api/admin/admin-users', { + const data = await put<{ success?: boolean; error?: string }>('/api/admin/users', { id: editingUser.id, password: formPassword || undefined, name: formName.trim(), @@ -144,7 +150,7 @@ export function AdminUsersPage() { setError(data?.error || '保存失败') } } else { - const data = await post<{ success?: boolean; error?: string }>('/api/admin/admin-users', { + const data = await post<{ success?: boolean; error?: string }>('/api/admin/users', { username: formUsername.trim(), password: formPassword, name: formName.trim(), @@ -166,9 +172,12 @@ export function AdminUsersPage() { } const handleDelete = async (id: number) => { - if (!confirm('确定删除该管理员?')) return + if (!confirmDangerousDelete('管理员')) { + setError('已取消删除') + return + } try { - const data = await del<{ success?: boolean; error?: string }>(`/api/admin/admin-users?id=${id}`) + const data = await del<{ success?: boolean; error?: string }>(`/api/admin/users?id=${id}`) if (data?.success) loadList() else setError(data?.error || '删除失败') } catch (e: unknown) { diff --git a/soul-admin/src/pages/dashboard/DashboardPage.tsx b/soul-admin/src/pages/dashboard/DashboardPage.tsx index 7adad202..94810b03 100644 --- a/soul-admin/src/pages/dashboard/DashboardPage.tsx +++ b/soul-admin/src/pages/dashboard/DashboardPage.tsx @@ -50,6 +50,32 @@ interface DashboardOverviewRes { newUsers?: UserRow[] } +interface MatchStatsRes { + success?: boolean + data?: { + totalMatches?: number + todayMatches?: number + uniqueUsers?: number + paidMatchCount?: number + } +} + +interface DistributionOverviewRes { + success?: boolean + overview?: { + todayClicks?: number + todayBindings?: number + todayConversions?: number + monthClicks?: number + monthBindings?: number + monthConversions?: number + totalClicks?: number + totalBindings?: number + totalConversions?: number + conversionRate?: string + } +} + interface UsersRes { success?: boolean users?: UserRow[] @@ -62,6 +88,13 @@ interface OrdersRes { total?: number } +interface VipMemberLite { + id: string + name?: string + nickname?: string + token?: string +} + export function DashboardPage() { const navigate = useNavigate() const [statsLoading, setStatsLoading] = useState(true) @@ -84,12 +117,32 @@ export function DashboardPage() { Array<{ userId: string; nickname?: string; avatar?: string; phone?: string; clicks: number; uniqueClicks: number; leadCount?: number }> >([]) const [superLoading, setSuperLoading] = useState(false) - const [trackPeriod, setTrackPeriod] = useState('week') + const [trackPeriod, setTrackPeriod] = useState('today') const [trackStats, setTrackStats] = useState<{ total: number byModule: Record } | null>(null) const [trackLoading, setTrackLoading] = useState(false) + const [partnerPromoLoading, setPartnerPromoLoading] = useState(true) + const [matchStats, setMatchStats] = useState<{ + totalMatches: number + todayMatches: number + uniqueUsers: number + paidMatchCount: number + } | null>(null) + const [distributionOverview, setDistributionOverview] = useState<{ + todayClicks: number + todayBindings: number + todayConversions: number + monthClicks: number + monthBindings: number + monthConversions: number + totalClicks: number + totalBindings: number + totalConversions: number + conversionRate?: string + } | null>(null) + const [vipMembers, setVipMembers] = useState([]) const showError = (err: unknown) => { const e = err as Error & { status?: number; name?: string } @@ -153,6 +206,60 @@ export function DashboardPage() { setCkbStats(null) } + // 加载「找伙伴 × 推广中心」共统计 + setPartnerPromoLoading(true) + try { + const [matchRes, distRes] = await Promise.allSettled([ + get('/api/db/match-records?stats=true', init), + get('/api/admin/distribution/overview', init), + ]) + + if (matchRes.status === 'fulfilled' && matchRes.value?.success && matchRes.value.data) { + setMatchStats({ + totalMatches: matchRes.value.data.totalMatches ?? 0, + todayMatches: matchRes.value.data.todayMatches ?? 0, + uniqueUsers: matchRes.value.data.uniqueUsers ?? 0, + paidMatchCount: matchRes.value.data.paidMatchCount ?? 0, + }) + } else { + setMatchStats(null) + } + + if (distRes.status === 'fulfilled' && distRes.value?.success && distRes.value.overview) { + setDistributionOverview({ + todayClicks: distRes.value.overview.todayClicks ?? 0, + todayBindings: distRes.value.overview.todayBindings ?? 0, + todayConversions: distRes.value.overview.todayConversions ?? 0, + monthClicks: distRes.value.overview.monthClicks ?? 0, + monthBindings: distRes.value.overview.monthBindings ?? 0, + monthConversions: distRes.value.overview.monthConversions ?? 0, + totalClicks: distRes.value.overview.totalClicks ?? 0, + totalBindings: distRes.value.overview.totalBindings ?? 0, + totalConversions: distRes.value.overview.totalConversions ?? 0, + conversionRate: distRes.value.overview.conversionRate, + }) + } else { + setDistributionOverview(null) + } + } catch { + setMatchStats(null) + setDistributionOverview(null) + } finally { + setPartnerPromoLoading(false) + } + + // 加载超级个体名单(用于点击统计里把 ID 显示为名字) + try { + const vipRes = await get<{ success?: boolean; data?: VipMemberLite[] }>('/api/db/vip-members?limit=500', init) + if (vipRes?.success && Array.isArray(vipRes.data)) { + setVipMembers(vipRes.data) + } else { + setVipMembers([]) + } + } catch { + setVipMembers([]) + } + // 2. 并行加载订单和用户 setOrdersLoading(true) setUsersLoading(true) @@ -220,6 +327,130 @@ export function DashboardPage() { } } + const moduleLabels: Record = { + home: '首页', + chapters: '目录', + read: '阅读页', + my: '我的', + vip: '超级个体', + wallet: '钱包', + match: '找伙伴', + referral: '推广中心', + search: '搜索', + settings: '设置', + about: '关于', + member_detail: '成员详情', + other: '其他', + } + + const actionLabels: Record = { + btn_click: '按钮点击', + nav_click: '导航点击', + card_click: '卡片点击', + tab_click: '标签切换', + page_view: '页面浏览', + share: '分享', + purchase: '购买', + register: '注册', + rule_trigger: '规则触发', + view_chapter: '浏览章节', + link_click: '链接点击', + } + + const normalizeTrackToken = (value?: string) => { + if (!value) return '' + return value + .replace(/^part-/, '') + .replace(/^soulvip_/, '') + .replace(/^super_?/, '') + .replace(/^user_/, '') + .replace(/[_-]+/g, ' ') + .trim() + } + + const resolveVipNameByToken = (value?: string) => { + if (!value) return '' + const key = value.trim().toLowerCase() + if (!key) return '' + const byId = vipMembers.find((m) => { + const id = String(m.id || '').toLowerCase() + return id === key || id.includes(key) || key.includes(id) + }) + if (byId) return byId.name || byId.nickname || '' + const byToken = vipMembers.find((m) => { + const t = String(m.token || '').toLowerCase() + return t && (t === key || t.includes(key) || key.includes(t)) + }) + if (byToken) return byToken.name || byToken.nickname || '' + return '' + } + + const prettyTrackTarget = (target?: string) => { + if (!target) return '未命名点击' + const t = target.trim() + const lower = t.toLowerCase() + + if (/^链接头像[_-]/.test(t)) { + const rawName = normalizeTrackToken(t.replace(/^链接头像[_-]/, '')) + return rawName ? `头像:${rawName}` : '头像点击' + } + if (/^member[_-]?detail$/i.test(lower) || lower.includes('member detail')) return '成员详情' + if (/^giftpay$/i.test(lower) || lower.includes('gift pay')) return '代付入口' + if (/^part[-_]/i.test(lower)) return `章节:${normalizeTrackToken(t)}` + if (lower.includes('soulvip') || lower.includes('super')) { + const raw = t + .replace(/^超级个体[::]?/i, '') + .replace(/^super[_-]?/i, '') + .replace(/^soulvip[_-]?/i, '') + .replace(/^user[_-]?/i, '') + .trim() + const vipName = resolveVipNameByToken(raw) || resolveVipNameByToken(normalizeTrackToken(raw)) + if (vipName) return `超级个体:${vipName}` + return `超级个体:${normalizeTrackToken(raw)}` + } + if (lower.includes('qgdtw') || lower.includes('token') || lower.includes('0000')) return `对象:${normalizeTrackToken(t)}` + + const targetLabels: Record = { + '开始匹配': '开始匹配', + mentor: '导师顾问', + team: '团队招募', + investor: '资源对接', + '充值': '充值', + '退款': '退款', + wallet: '钱包', + '设置': '设置', + 'VIP': 'VIP会员', + '推广': '推广中心', + '目录': '目录', + '搜索': '搜索', + '匹配': '找伙伴', + settings: '设置', + expired: '已过期', + active: '活跃', + converted: '已转化', + fill_profile: '完善资料', + register: '注册', + purchase: '购买', + '链接卡若': '链接卡若', + '更多分享': '更多分享', + '分享朋友圈文案': '分享朋友圈', + '选择金额10': '选择金额10元', + member_detail: '成员详情', + giftPay: '代付入口', + } + if (targetLabels[t]) return targetLabels[t] + + if (/^[a-z0-9_-]+$/i.test(t)) return normalizeTrackToken(t) || t + return t + } + + const buildTrackLocationLabel = (item: { module: string; page: string; action: string; target: string }) => { + const moduleName = moduleLabels[item.module] || moduleLabels[item.page] || item.module || item.page || '其他' + const actionName = actionLabels[item.action] || item.action || '点击' + const targetName = prettyTrackTarget(item.target) + return `${moduleName} · ${actionName} · ${targetName}` + } + async function loadSuperStats() { setSuperLoading(true) try { @@ -342,6 +573,19 @@ export function DashboardPage() { bg: 'bg-cyan-500/20', link: '/users?tab=leads', }, + { + title: '伙伴&推广协同', + value: partnerPromoLoading + ? null + : (matchStats?.totalMatches ?? 0) + (distributionOverview?.totalClicks ?? 0), + sub: partnerPromoLoading + ? null + : `找伙伴 ${(matchStats?.totalMatches ?? 0)} / 推广 ${(distributionOverview?.totalClicks ?? 0)}`, + icon: BarChart3, + color: 'text-emerald-400', + bg: 'bg-emerald-500/20', + link: '/find-partner', + }, ] return ( @@ -359,11 +603,11 @@ export function DashboardPage() {
)} -
+
{stats.map((stat, index) => ( stat.link && navigate(stat.link)} > @@ -433,6 +677,63 @@ export function DashboardPage() {
{bottomTab === 'overview' && ( +
+ + + 找伙伴 × 推广中心(共统计) + + + + {partnerPromoLoading && !matchStats && !distributionOverview ? ( +
+ + 加载中... +
+ ) : ( +
+
+

找伙伴总匹配

+

{matchStats?.totalMatches ?? 0}

+
+
+

找伙伴今日

+

{matchStats?.todayMatches ?? 0}

+
+
+

找伙伴用户数

+

{matchStats?.uniqueUsers ?? 0}

+
+
+

推广总点击

+

{distributionOverview?.totalClicks ?? 0}

+
+
+

推广总绑定

+

{distributionOverview?.totalBindings ?? 0}

+
+
+

推广总转化

+

{distributionOverview?.totalConversions ?? 0}

+
+
+ )} + {distributionOverview?.conversionRate && ( +

+ 推广转化率:{distributionOverview.conversionRate} +

+ )} +
+
+
@@ -488,7 +789,7 @@ export function DashboardPage() { {buyer} { e.currentTarget.style.display = 'none' const next = e.currentTarget.nextElementSibling as HTMLElement @@ -497,7 +798,7 @@ export function DashboardPage() { /> ) : null}
{buyer.charAt(0)}
@@ -537,7 +838,7 @@ export function DashboardPage() {
-
+

+¥{Number(p.amount).toFixed(2)}

@@ -620,6 +921,7 @@ export function DashboardPage() {
+
)} {bottomTab === 'tags' && ( @@ -663,11 +965,6 @@ export function DashboardPage() { .slice(0, 5) .map(([mod, items]) => { const moduleTotal = items.reduce((s, i) => s + i.count, 0) - const moduleLabels: Record = { - home: '首页', chapters: '目录', read: '阅读', my: '我的', - vip: 'VIP', wallet: '钱包', match: '找伙伴', referral: '推广', - search: '搜索', settings: '设置', about: '关于', other: '其他', - } return (
@@ -681,28 +978,10 @@ export function DashboardPage() { .sort((a, b) => b.count - a.count) .slice(0, 8) .map((item, i) => { - const targetLabels: Record = { - '开始匹配': '开始匹配', 'mentor': '导师顾问', 'team': '团队招募', - 'investor': '资源对接', '充值': '充值', '退款': '退款', - 'wallet': '钱包', '设置': '设置', 'VIP': 'VIP会员', - '推广': '推广中心', '目录': '目录', '搜索': '搜索', - '匹配': '找伙伴', 'settings': '设置', 'expired': '已过期', - 'active': '活跃', 'converted': '已转化', 'fill_profile': '完善资料', - 'register': '注册', 'purchase': '购买', 'btn_click': '按钮点击', - 'nav_click': '导航点击', 'card_click': '卡片点击', 'tab_click': '标签切换', - 'rule_trigger': '规则触发', 'view_chapter': '浏览章节', - '链接卡若': '链接卡若', '更多分享': '更多分享', '分享朋友圈文案': '分享朋友圈', - '选择金额10': '选择金额10元', - } - const actionLabels: Record = { - 'btn_click': '按钮点击', 'nav_click': '导航点击', 'card_click': '卡片点击', - 'tab_click': '标签切换', 'purchase': '购买', 'register': '注册', - 'rule_trigger': '规则触发', 'view_chapter': '浏览章节', - } - const label = targetLabels[item.target] || item.target || actionLabels[item.action] || item.action + const label = buildTrackLocationLabel(item) return (
- + {label}
diff --git a/soul-admin/src/pages/orders/OrdersPage.tsx b/soul-admin/src/pages/orders/OrdersPage.tsx index da83bedb..e0634cdf 100644 --- a/soul-admin/src/pages/orders/OrdersPage.tsx +++ b/soul-admin/src/pages/orders/OrdersPage.tsx @@ -44,6 +44,10 @@ interface Purchase { giftPayRequestId?: string payerUserId?: string payerNickname?: string + webhookPushStatus?: 'sent' | 'failed' | '' + webhookPushedAt?: string + webhookPushAttempts?: number + webhookPushError?: string } interface UsersItem { @@ -362,23 +366,35 @@ export function OrdersPage() { : purchase.paymentMethod || '微信支付'} - {purchase.status === 'refunded' ? ( - - 已退款 - - ) : purchase.status === 'paid' || purchase.status === 'completed' ? ( - - 已完成 - - ) : purchase.status === 'pending' || purchase.status === 'created' ? ( - - 待支付 - - ) : ( - - 已失败 - - )} +
+ {purchase.status === 'refunded' ? ( + + 已退款 + + ) : purchase.status === 'paid' || purchase.status === 'completed' ? ( + + 已完成 + + ) : purchase.status === 'pending' || purchase.status === 'created' ? ( + + 待支付 + + ) : ( + + 已失败 + + )} + {(purchase.status === 'paid' || purchase.status === 'completed') && + (purchase.webhookPushStatus === 'sent' ? ( + + 已推送 + + ) : ( + + 待补推 + + ))} +
{purchase.status === 'refunded' && purchase.refundReason ? purchase.refundReason : '-'} diff --git a/soul-admin/src/pages/settings/SettingsPage.tsx b/soul-admin/src/pages/settings/SettingsPage.tsx index 8fc5bf75..b205e160 100644 --- a/soul-admin/src/pages/settings/SettingsPage.tsx +++ b/soul-admin/src/pages/settings/SettingsPage.tsx @@ -36,9 +36,10 @@ import { Link2, FileText, Cloud, - Smile, Eye, EyeOff, + LayoutGrid, + Sparkles, } from 'lucide-react' import { get, post } from '@/api/client' import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage' @@ -125,10 +126,12 @@ const MP_UI_TEMPLATE_OBJECT: Record> = { bookTitle: '一场SOUL的创业实验场', bookSubtitle: '来自Soul派对房的真实商业故事', }, + // homePage.linkKaruoAvatar:首页「链接卡若」头像 HTTPS,空则小程序用「卡」字占位 homePage: { logoTitle: '卡若创业派对', logoSubtitle: '来自派对房的真实故事', linkKaruoText: '点击链接卡若', + linkKaruoAvatar: '', searchPlaceholder: '搜索章节标题或内容...', bannerTag: '推荐', bannerReadMoreText: '点击阅读', @@ -154,10 +157,17 @@ const MP_UI_TEMPLATE_OBJECT: Record> = { const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const type TabKey = (typeof TAB_KEYS)[number] +const SYSTEM_SECTION_KEYS = ['basic', 'mp', 'oss', 'features'] as const +type SystemSectionKey = (typeof SYSTEM_SECTION_KEYS)[number] + export function SettingsPage() { const [searchParams, setSearchParams] = useSearchParams() const tabParam = searchParams.get('tab') ?? 'system' const activeTab = TAB_KEYS.includes(tabParam as TabKey) ? (tabParam as TabKey) : 'system' + const systemSectionParam = searchParams.get('section') ?? 'basic' + const systemSection: SystemSectionKey = SYSTEM_SECTION_KEYS.includes(systemSectionParam as SystemSectionKey) + ? (systemSectionParam as SystemSectionKey) + : 'basic' const [localSettings, setLocalSettings] = useState(defaultSettings) const [featureConfig, setFeatureConfig] = useState(defaultFeatures) @@ -172,39 +182,6 @@ export function SettingsPage() { const [dialogIsError, setDialogIsError] = useState(false) const [featureSwitchSaving, setFeatureSwitchSaving] = useState(false) - const MBTI_TYPES = [ - 'INTJ','INTP','ENTJ','ENTP', - 'INFJ','INFP','ENFJ','ENFP', - 'ISTJ','ISFJ','ESTJ','ESFJ', - 'ISTP','ISFP','ESTP','ESFP', - ] - const [mbtiAvatars, setMbtiAvatars] = useState>({}) - const [mbtiLoading, setMbtiLoading] = useState(false) - const [mbtiSaving, setMbtiSaving] = useState(false) - - const loadMbtiAvatars = async () => { - setMbtiLoading(true) - try { - const res = await get<{ success?: boolean; avatars?: Record }>('/api/admin/mbti-avatars') - if (res?.avatars) setMbtiAvatars(res.avatars) - } catch { /* ignore */ } - finally { setMbtiLoading(false) } - } - - const saveMbtiAvatars = async () => { - setMbtiSaving(true) - try { - const res = await post<{ success?: boolean; error?: string }>('/api/admin/mbti-avatars', { avatars: mbtiAvatars }) - if (!res || res.success === false) { - showResult('保存失败', res?.error ?? '未知错误', true) - return - } - showResult('已保存', 'MBTI 头像映射已保存,无头像的超级个体将自动使用对应性格头像。') - } catch (error) { - showResult('保存失败', error instanceof Error ? error.message : String(error), true) - } finally { setMbtiSaving(false) } - } - const showResult = (title: string, message: string, isError = false) => { setDialogTitle(title) setDialogMessage(message) @@ -257,7 +234,6 @@ export function SettingsPage() { } } load() - loadMbtiAvatars() }, []) const saveFeatureConfigOnly = async ( @@ -379,7 +355,23 @@ export function SettingsPage() { } const handleTabChange = (v: string) => { - setSearchParams(v === 'system' ? {} : { tab: v }) + if (v === 'system') { + const sp = new URLSearchParams(searchParams) + sp.delete('tab') + if (!SYSTEM_SECTION_KEYS.includes((sp.get('section') || 'basic') as SystemSectionKey)) { + sp.set('section', 'basic') + } + setSearchParams(sp) + return + } + setSearchParams({ tab: v }) + } + + const handleSystemSectionChange = (v: string) => { + const sp = new URLSearchParams(searchParams) + sp.delete('tab') + sp.set('section', v) + setSearchParams(sp) } if (loading) return
加载中...
@@ -440,7 +432,45 @@ export function SettingsPage() { -
+

+ MBTI 默认头像已迁至{' '} + + 用户管理(用户列表点头像打开) + +

+ + + + + 基础与价格 + + + + 小程序与审核 + + + + OSS + + + + 功能开关 + + + + @@ -641,7 +671,9 @@ export function SettingsPage() {
+
+ @@ -730,6 +762,43 @@ export function SettingsPage() { + + + + + 小程序审核模式 + + + 提交微信审核前开启,审核通过后关闭即可恢复支付功能 + + + +
+
+
+ + +
+

+ {mpConfig.auditMode + ? '当前已隐藏所有支付、VIP、充值、收益等入口,审核员看不到任何付费内容' + : '关闭状态,小程序正常显示所有功能(含支付、VIP 等)'} +

+
+ +
+
+
+
+ + @@ -801,42 +870,9 @@ export function SettingsPage() {
+ - - - - - 小程序审核模式 - - - 提交微信审核前开启,审核通过后关闭即可恢复支付功能 - - - -
-
-
- - -
-

- {mpConfig.auditMode - ? '当前已隐藏所有支付、VIP、充值、收益等入口,审核员看不到任何付费内容' - : '关闭状态,小程序正常显示所有功能(含支付、VIP 等)'} -

-
- -
-
-
- + @@ -933,53 +969,6 @@ export function SettingsPage() { - - - - - MBTI 头像组 - - - 为 16 种 MBTI 性格类型配置默认头像 URL。无头像的超级个体将自动使用对应性格的头像。 - - - - {mbtiLoading ? ( -

加载中...

- ) : ( - <> -
- {MBTI_TYPES.map((t) => ( -
- {t} - {mbtiAvatars[t] && ( - {t} - )} - - setMbtiAvatars((prev) => ({ ...prev, [t]: e.target.value })) - } - /> -
- ))} -
- - - )} -
-
- @@ -998,7 +987,7 @@ export function SettingsPage() { { mod: '搜索', ctrl: '搜索功能开关', icon: }, { mod: '关于页面', ctrl: '关于页面开关', icon: }, { mod: '支付 / VIP / 充值 / 收益', ctrl: '审核模式', icon: }, - { mod: '超级个体名片', ctrl: '审核模式', icon: }, + { mod: '超级个体名片', ctrl: '审核模式', icon: }, { mod: '首页获客入口', ctrl: '已移除', icon: }, ].map((r) => (
@@ -1012,7 +1001,8 @@ export function SettingsPage() {
-
+ + diff --git a/soul-admin/src/pages/users/UsersPage.tsx b/soul-admin/src/pages/users/UsersPage.tsx index 327238e8..f12a3739 100644 --- a/soul-admin/src/pages/users/UsersPage.tsx +++ b/soul-admin/src/pages/users/UsersPage.tsx @@ -48,6 +48,7 @@ import { UserPlus as LeadIcon, } from 'lucide-react' import { UserDetailModal } from '@/components/modules/user/UserDetailModal' +import { MbtiAvatarsManager } from '@/components/modules/mbti/MbtiAvatarsManager' import { Pagination } from '@/components/ui/Pagination' import { useDebounce } from '@/hooks/useDebounce' import { useSearchParams } from 'react-router-dom' @@ -62,6 +63,7 @@ interface User { avatar?: string | null isAdmin?: boolean | number hasFullBook?: boolean | number + purchasedSectionCount?: number referralCode?: string earnings: number | string pendingEarnings?: number | string @@ -83,17 +85,47 @@ interface UserRule { title: string description: string trigger: string + triggerConditions?: string[] + actionType?: string + actionConfig?: Record sort: number enabled: boolean createdAt?: string } +const TRIGGER_OPTIONS: { value: string; label: string; group: string }[] = [ + { value: 'after_login', label: '注册/登录成功', group: '用户状态' }, + { value: 'bind_phone', label: '绑定手机号', group: '用户状态' }, + { value: 'fill_profile', label: '完善资料(头像/MBTI/行业)', group: '用户状态' }, + { value: 'view_chapter', label: '浏览章节', group: '阅读行为' }, + { value: 'browse_5_chapters', label: '累计浏览5个章节', group: '阅读行为' }, + { value: 'purchase_section', label: '购买单章', group: '付费行为' }, + { value: 'purchase_fullbook', label: '购买全书/VIP', group: '付费行为' }, + { value: 'after_pay', label: '任意付款成功', group: '付费行为' }, + { value: 'after_match', label: '完成派对匹配', group: '社交行为' }, + { value: 'click_super_individual', label: '点击超级个体头像', group: '社交行为' }, + { value: 'lead_submit', label: '提交留资/链接', group: '社交行为' }, + { value: 'referral_bind', label: '被推荐人绑定', group: '分销行为' }, + { value: 'share_action', label: '分享给好友/朋友圈', group: '分销行为' }, + { value: 'withdraw_request', label: '申请提现', group: '分销行为' }, + { value: 'add_wechat', label: '添加微信联系方式', group: '用户状态' }, +] + +const ACTION_TYPE_OPTIONS: { value: string; label: string; desc: string }[] = [ + { value: 'popup', label: '弹窗提示', desc: '在小程序内弹窗引导用户完成下一步' }, + { value: 'navigate', label: '跳转页面', desc: '引导用户跳转到指定页面' }, + { value: 'webhook', label: '推送飞书群', desc: '触发后推送消息到飞书群Webhook' }, + { value: 'tag', label: '自动打标签', desc: '触发后自动给用户打上指定标签' }, +] + interface VipMember { id: string name: string avatar?: string | null + mbti?: string | null vipRole?: string | null vipSort?: number | null + webhookUrl?: string | null /** 首页超级个体卡片点击次数(/api/db/vip-members 聚合 user_tracks) */ clickCount?: number | null /** 绑定人物后的去重获客人数 */ @@ -121,10 +153,17 @@ const JOURNEY_STAGES = [ { id: 'distribution', label: '开启分销', icon: '🔗', color: 'bg-[#38bdac]/20 border-[#38bdac]/40 text-[#38bdac]', desc: '生成推广码并推荐好友' }, ] +function confirmDangerousDelete(entity: string): boolean { + if (!confirm(`确定删除该${entity}?此操作不可恢复。`)) return false + const verifyText = window.prompt(`请输入「删除」以确认删除${entity}`) + return verifyText === '删除' +} + export function UsersPage() { const [searchParams, setSearchParams] = useSearchParams() const poolParam = searchParams.get('pool') // 'vip' | 'complete' | 'all' | null - const tabParam = searchParams.get('tab') || 'users' // users | journey | rules | vip-roles | leads + const rawTabParam = searchParams.get('tab') || 'users' + const tabParam = ['users', 'journey', 'rules', 'vip-roles', 'leads'].includes(rawTabParam) ? rawTabParam : 'users' // ===== 用户列表 state ===== const [users, setUsers] = useState([]) @@ -159,6 +198,7 @@ export function UsersPage() { const [selectedUserForReferrals, setSelectedUserForReferrals] = useState(null) const [showDetailModal, setShowDetailModal] = useState(false) const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState(null) + const [showMbtiAvatarDialog, setShowMbtiAvatarDialog] = useState(false) const [formData, setFormData] = useState({ phone: '', nickname: '', password: '', isAdmin: false, hasFullBook: false }) // ===== 规则管理 ===== @@ -166,7 +206,7 @@ export function UsersPage() { const [rulesLoading, setRulesLoading] = useState(false) const [showRuleModal, setShowRuleModal] = useState(false) const [editingRule, setEditingRule] = useState(null) - const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', sort: 0, enabled: true }) + const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', triggerConditions: [] as string[], actionType: 'popup', sort: 0, enabled: true }) // ===== 超级个体(VIP 用户列表) ===== const [vipMembers, setVipMembers] = useState([]) @@ -184,6 +224,7 @@ export function UsersPage() { const [trackUserNick, setTrackUserNick] = useState('') const [userTracks, setUserTracks] = useState<{ id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[]>([]) const [userTracksLoading, setUserTracksLoading] = useState(false) + const [mbtiAvatarsMap, setMbtiAvatarsMap] = useState>({}) // ===== 获客列表(存客宝) ===== const [leadsRecords, setLeadsRecords] = useState<{ @@ -246,10 +287,56 @@ export function UsersPage() { setLeadsLoading(false) } }, [leadsPage, leadsPageSize, debouncedLeadsSearch, leadsSourceFilter]) + + const loadMbtiAvatarsMap = useCallback(async () => { + try { + const data = await get<{ success?: boolean; avatars?: Record }>('/api/admin/mbti-avatars') + const map = (data?.avatars && typeof data.avatars === 'object') ? data.avatars : {} + setMbtiAvatarsMap(map) + } catch { + setMbtiAvatarsMap({}) + } + }, []) useEffect(() => { if (searchParams.get('tab') === 'leads') loadLeads() }, [searchParams.get('tab'), leadsPage, loadLeads]) + useEffect(() => { + loadMbtiAvatarsMap() + }, [loadMbtiAvatarsMap]) + + const resolveUserAvatarByMbti = useCallback((avatar: string | null | undefined, mbti: string | null | undefined): string => { + const av = (avatar || '').trim() + if (av) return av + const key = (mbti || '').trim().toUpperCase() + if (!/^[EI][NS][FT][JP]$/.test(key)) return '' + return (mbtiAvatarsMap[key] || '').trim() + }, [mbtiAvatarsMap]) + + const getPurchaseState = useCallback((user: User) => { + const hasFull = !!user.hasFullBook + const sectionCount = Number(user.purchasedSectionCount || 0) + if (hasFull) { + return { + tone: 'vip' as const, + main: '已购全书', + sub: sectionCount > 0 ? `另购单章 ${sectionCount} 章` : '购买项:VIP / 全书', + } + } + if (sectionCount > 0) { + return { + tone: 'paid' as const, + main: `已购 ${sectionCount} 章`, + sub: '购买项:章节', + } + } + return { + tone: 'free' as const, + main: '未购买', + sub: '', + } + }, []) + // ===== 在线人数(WSS 占位) ===== const [onlineCount, setOnlineCount] = useState(null) const loadOnlineStats = useCallback(async () => { @@ -344,7 +431,10 @@ export function UsersPage() { } async function handleDelete(userId: string) { - if (!confirm('确定要删除这个用户吗?')) return + if (!confirmDangerousDelete('用户')) { + toast.info('已取消删除') + return + } try { const data = await del<{ success?: boolean; error?: string }>(`/api/db/users?id=${encodeURIComponent(userId)}`) if (data?.success) { @@ -422,7 +512,10 @@ export function UsersPage() { } async function handleDeleteRule(id: number) { - if (!confirm('确定删除?')) return + if (!confirmDangerousDelete('规则')) { + toast.info('已取消删除') + return + } try { const data = await del<{ success?: boolean }>(`/api/db/user-rules?id=${id}`) if (data?.success) loadRules() @@ -461,6 +554,10 @@ export function UsersPage() { const [vipRoleModalMember, setVipRoleModalMember] = useState(null) const [vipRoleInput, setVipRoleInput] = useState('') const [vipRoleSaving, setVipRoleSaving] = useState(false) + const [showVipWebhookModal, setShowVipWebhookModal] = useState(false) + const [vipWebhookModalMember, setVipWebhookModalMember] = useState(null) + const [vipWebhookInput, setVipWebhookInput] = useState('') + const [vipWebhookSaving, setVipWebhookSaving] = useState(false) const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手'] @@ -470,6 +567,12 @@ export function UsersPage() { setShowVipRoleModal(true) } + const openVipWebhookModal = (member: VipMember) => { + setVipWebhookModalMember(member) + setVipWebhookInput((member.webhookUrl || '').trim()) + setShowVipWebhookModal(true) + } + const handleSetVipRole = async (value: string) => { const trimmed = value.trim() if (!vipRoleModalMember) return @@ -498,6 +601,34 @@ export function UsersPage() { } } + const handleSetVipWebhook = async () => { + if (!vipWebhookModalMember) return + const val = vipWebhookInput.trim() + if (val && !/^https?:\/\//i.test(val)) { + toast.error('Webhook 地址需以 http/https 开头') + return + } + setVipWebhookSaving(true) + try { + const res = await put<{ success?: boolean; error?: string }>('/api/db/vip-members/webhook', { + userId: vipWebhookModalMember.id, + webhookUrl: val, + }) + if (!res?.success) { + toast.error(res?.error || '保存飞书群 Webhook 失败') + return + } + toast.success(val ? '已保存该超级个体的飞书群 Webhook' : '已清空该超级个体的飞书群 Webhook') + setShowVipWebhookModal(false) + setVipWebhookModalMember(null) + await loadVipMembers() + } catch { + toast.error('保存飞书群 Webhook 失败') + } finally { + setVipWebhookSaving(false) + } + } + const [showVipSortModal, setShowVipSortModal] = useState(false) const [vipSortModalMember, setVipSortModalMember] = useState(null) const [vipSortInput, setVipSortInput] = useState('') @@ -879,34 +1010,48 @@ export function UsersPage() {
-
- {user.avatar ? ( - { - (e.target as HTMLImageElement).style.display = 'none' - const parent = (e.target as HTMLImageElement).parentElement - if (parent) parent.textContent = user.nickname?.charAt(0) || '?' - }} - /> - ) : user.nickname?.charAt(0) || '?'} -
-
+ {(() => { + const avatarUrl = resolveUserAvatarByMbti(user.avatar, user.mbti) + const initial = user.nickname?.charAt(0) || '?' + return ( + + ) + })()} +
{user.isAdmin && 管理员} {user.openId && !user.id?.startsWith('user_') && 微信}
-

- {user.openId ? user.openId.slice(0, 12) + '...' : user.id?.slice(0, 12)} +

+ {user.id?.slice(0, 16)}{(user.id?.length ?? 0) > 16 ? '…' : ''}

@@ -915,16 +1060,38 @@ export function UsersPage() {
{user.phone &&
📱{user.phone}
} {user.wechatId &&
💬{user.wechatId}
} - {user.openId &&
🔗{user.openId.slice(0, 12)}...
} - {!user.phone && !user.wechatId && !user.openId && 未绑定} + {!user.phone && !user.wechatId && 未绑定}
- {user.hasFullBook ? ( - VIP - ) : ( - 未购买 - )} + {(() => { + const purchase = getPurchaseState(user) + if (purchase.tone === 'vip') { + return ( +
+ + {purchase.main} + + {purchase.sub &&

{purchase.sub}

} +
+ ) + } + if (purchase.tone === 'paid') { + return ( +
+ + {purchase.main} + + {purchase.sub &&

{purchase.sub}

} +
+ ) + } + return ( + + {purchase.main} + + ) + })()}
@@ -939,15 +1106,13 @@ export function UsersPage() { {/* RFM 分值列 */} - {user.rfmScore !== undefined ? ( -
-
- {user.rfmScore} - {user.rfmLevel} -
+ {user.rfmScore != null && user.rfmScore !== undefined ? ( +
+ {user.rfmScore} + {user.rfmLevel}
) : ( - 点列头排序 + 无订单 )} @@ -1329,7 +1494,7 @@ export function UsersPage() { -
@@ -1353,17 +1518,34 @@ export function UsersPage() { {rule.title} {rule.trigger && {rule.trigger}} + {(rule.triggerConditions || []).length > 0 && ( +
+ {(rule.triggerConditions || []).slice(0, 3).map((tc) => { + const opt = TRIGGER_OPTIONS.find((o) => o.value === tc) + return {opt?.label || tc} + })} + {(rule.triggerConditions || []).length > 3 && +{(rule.triggerConditions || []).length - 3}} +
+ )} + {rule.actionType && rule.actionType !== 'popup' && ( + + {ACTION_TYPE_OPTIONS.find((o) => o.value === rule.actionType)?.label || rule.actionType} + + )}
handleToggleRule(rule)} /> - +
{rule.description && (
- 查看描述 -

{rule.description}

+ + 查看完整描述 + ({rule.description.length} 字,默认折叠) + +

{rule.description}

)}
@@ -1417,9 +1599,10 @@ export function UsersPage() { 序号 成员 超级个体标签 - 点击数 + 头像点击 获客数 排序值 + 飞书群 操作 @@ -1442,9 +1625,9 @@ export function UsersPage() { {index + 1}
- {m.avatar ? ( + {resolveUserAvatarByMbti(m.avatar, m.mbti) ? ( { @@ -1481,6 +1664,15 @@ export function UsersPage() { {m.vipSort ?? index + 1} + + {m.webhookUrl ? ( + + 已配置 + + ) : ( + 未配置 + )} +
@@ -1523,8 +1712,19 @@ export function UsersPage() { )} + + + + + + MBTI 默认头像库 + + + + + {/* ===== 弹框组件 ===== */} {/* 添加/编辑用户 */} @@ -1603,6 +1803,38 @@ export function UsersPage() { + {/* 设置超级个体飞书群 Webhook */} + { setShowVipWebhookModal(open); if (!open) setVipWebhookModalMember(null) }}> + + + + + 设置飞书群 Webhook — {vipWebhookModalMember?.name} + + +
+ + setVipWebhookInput(e.target.value)} + /> +

+ 当用户点击该超级个体头像并提交链接时,线索将优先推送到这里配置的飞书群。 +

+
+ + + + +
+
+ {editingUser ? : }{editingUser ? '编辑用户' : '添加用户'} @@ -1622,12 +1854,71 @@ export function UsersPage() { {/* 添加/编辑规则 */} - + {editingRule ? '编辑规则' : '添加规则'}
setRuleForm({ ...ruleForm, title: e.target.value })} />
-