feat: MBTI头像与用户规则链路升级,三端页面与接口同步

Made-with: Cursor
This commit is contained in:
卡若
2026-03-24 01:22:50 +08:00
parent fa3da12b16
commit 1d56d0336c
71 changed files with 3848 additions and 1621 deletions

View File

@@ -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。

View File

@@ -4,4 +4,5 @@
| 日期 | 摘要 | 文件 |
|------|------|------|
| 2026-03-21 | MBTI 头像小程序全链路兜底 + 匹配接口回填 | 2026-03-21-MBTI头像C端全链路兜底.md |
| 2026-03-16 | 用户交互习惯分析(基于 agent-transcripts | 2026-03-16-交互习惯分析.md |

View File

@@ -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 v2msg_type 必填) |
| 1.0 | 2026-03-20 | 初版:整合运营报表、视频切片、多平台分发、飞书视频文字下载 4 大技能,统一凭证管理 |

View File

@@ -7,14 +7,14 @@ description: >
triggers: 多平台分发、一键分发、全平台发布、批量分发、视频分发
owner: 木叶
group: 木
version: "4.3"
version: "4.4"
updated: "2026-03-23"
---
# 多平台分发 Skillv4.3
# 多平台分发 Skillv4.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

View File

@@ -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()) || ''
}
},
/**

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -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,

View File

@@ -72,7 +72,8 @@
<!-- 篇章标题 -->
<view class="part-header" bindtap="togglePart" data-id="{{item.id}}">
<view class="part-left">
<image wx:if="{{item.icon}}" class="part-icon-img" src="{{item.icon}}" mode="aspectFit"/>
<image wx:if="{{item.icon}}" class="part-icon-img" src="{{item.icon}}" mode="aspectFill"/>
<view wx:elif="{{item.iconEmoji}}" class="part-icon part-icon-emoji">{{item.iconEmoji}}</view>
<view wx:else class="part-icon">{{item.title[0] || '篇'}}</view>
<view class="part-info">
<text class="part-title">{{item.title}}</text>

View File

@@ -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;
}

View File

@@ -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() })
},

View File

@@ -16,7 +16,12 @@
<text class="logo-subtitle">{{mpUiLogoSubtitle}}</text>
</view>
</view>
<view class="header-right"></view>
<view class="header-right" wx:if="{{!auditMode}}">
<view class="contact-btn" catchtap="onLinkKaruo" hover-class="none">
<image class="contact-avatar" src="{{mpUiLinkKaruoDisplay}}" mode="aspectFill"/>
<text class="contact-name">{{mpUiLinkKaruoText}}</text>
</view>
</view>
</view>
<!-- 搜索栏(根据配置显示) -->
@@ -52,7 +57,6 @@
<view class="section" wx:if="{{!auditMode}}">
<view class="section-header">
<text class="section-title">{{mpUiSuperTitle}}</text>
<text class="section-subtitle" bindtap="goSuperSectionLink">{{mpUiSuperLinkText}}</text>
</view>
<!-- 加载中:骨架动画 -->
<view wx:if="{{superMembersLoading}}" class="super-loading">
@@ -160,4 +164,30 @@
<!-- 底部留白 -->
<view class="bottom-space"></view>
<!-- 隐私授权(首页在 needPrivacy 列表内,需有遮罩否则无法完成 agree -->
<view class="privacy-mask" wx:if="{{showPrivacyModal}}" catchtouchmove="preventMove">
<view class="privacy-modal">
<text class="privacy-title">温馨提示</text>
<text class="privacy-desc">使用手机号能力前,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onLeadPrivacyAuthorize">同意</button>
<view class="privacy-cancel" bindtap="onDisagreePrivacyForLead">拒绝</view>
</view>
</view>
<!-- 链接卡若 - 留资弹窗 -->
<view class="lead-mask" wx:if="{{showLeadModal}}" catchtap="closeLeadModal">
<view class="lead-box" catchtap="stopPropagation">
<text class="lead-title">留下联系方式</text>
<text class="lead-desc">方便卡若与您联系</text>
<button class="lead-get-phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumberForLead">一键获取手机号</button>
<text class="lead-divider">或手动输入</text>
<view class="lead-input-wrap">
<input class="lead-input" placeholder="请输入手机号" type="number" maxlength="11" value="{{leadPhone}}" bindinput="onLeadPhoneInput"/>
</view>
<view class="lead-actions">
<button class="lead-btn lead-btn-cancel" bindtap="closeLeadModal">取消</button>
<button class="lead-btn lead-btn-submit" bindtap="submitLead">提交</button>
</view>
</view>
</view>
</view>

View File

@@ -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;

View File

@@ -121,7 +121,8 @@
<!-- 用户卡片 -->
<view class="match-card">
<view class="card-header">
<image class="match-avatar" src="{{currentMatch.avatar}}" mode="aspectFill"></image>
<image wx:if="{{currentMatch.avatar}}" class="match-avatar" src="{{currentMatch.avatar}}" mode="aspectFill"></image>
<view wx:else class="match-avatar match-avatar-fallback"><text>{{currentMatch.nickname ? currentMatch.nickname[0] : '?'}}</text></view>
<view class="match-info">
<text class="match-name">{{currentMatch.nickname}}</text>
<view class="match-tags">

View File

@@ -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;

View File

@@ -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({

View File

@@ -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 })
},
// 退出登录

View File

@@ -22,7 +22,7 @@
<view class="profile-top-row">
<view class="avatar-wrap" bindtap="tapAvatar">
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
<image wx:if="{{userInfo.avatar && userInfo.avatar.length > 5}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
<image wx:if="{{profileAvatarDisplay}}" class="avatar-img" src="{{profileAvatarDisplay}}" mode="aspectFill"/>
<text wx:else class="avatar-text">{{userInfo.nickname ? userInfo.nickname[0] : '?'}}</text>
</view>
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
@@ -32,7 +32,7 @@
<view class="profile-name-row">
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
</view>
<view class="profile-actions-row" wx:if="{{!auditMode}}">
<view class="profile-actions-row profile-actions-under-name" wx:if="{{!auditMode}}">
<view class="profile-action-btn" catchtap="goToMySuperCard">{{mpUiCardLabel}}</view>
<view class="profile-action-btn" catchtap="goToVip">{{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}}</view>
</view>
@@ -43,7 +43,7 @@
<text class="profile-stat-val">{{readCountText || '0'}}</text>
<text class="profile-stat-label">{{mpUiReadStatLabel}}</text>
</view>
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral" data-focus="bindings">
<text class="profile-stat-val">{{referralCount}}</text>
<text class="profile-stat-label">推荐好友</text>
</view>
@@ -51,7 +51,7 @@
<text class="profile-stat-val">{{matchHistoryText}}</text>
<text class="profile-stat-label">匹配伙伴</text>
</view>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToReferral">
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToReferral" data-focus="earnings">
<text class="profile-stat-val">{{pendingEarnings || '0.00'}}</text>
<text class="profile-stat-label">我的收益</text>
</view>

View File

@@ -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;

View File

@@ -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',

View File

@@ -29,7 +29,7 @@
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
</view>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
@@ -179,7 +179,7 @@
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
</view>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>

View File

@@ -4,6 +4,7 @@
*/
const app = getApp()
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
Page({
data: {
@@ -31,6 +32,9 @@ Page({
}
this.setData({ loading: true })
try {
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userInfo.id}`, silent: true })
if (res?.success && res.data) {
const d = res.data
@@ -38,10 +42,17 @@ Page({
const phone = d.phone || ''
const wechat = d.wechatId || wx.getStorageSync('user_wechat') || ''
const av = d.avatar
const safeAv = isSafeImageSrc(av) ? String(av).trim() : ''
const displayAv = resolveAvatarWithMbti(
safeAv,
d.mbti,
app.globalData.mbtiAvatarsMap || {},
app.globalData.baseUrl || ''
)
this.setData({
profile: {
...d,
avatar: isSafeImageSrc(av) ? String(av).trim() : '',
avatar: displayAv,
industry: e(d.industry),
position: e(d.position),
businessScale: e(d.businessScale || d.business_scale),

View File

@@ -46,6 +46,8 @@ Page({
// 绑定用户列表
showBindingList: true,
showShareSection: false,
showEarningsDetails: false,
activeTab: 'active',
activeBindings: [],
convertedBindings: [],
@@ -63,10 +65,13 @@ Page({
posterReferralLink: '',
posterNickname: '',
posterNicknameInitial: '',
/** 推广海报推荐人头像:优先微信头像,否则按 MBTI 用后台映射 */
posterDisplayAvatar: '',
posterCaseCount: 62
},
onLoad() {
onLoad(options) {
this._focusTarget = (options && options.focus) || ""
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
this.initData()
// 启用分享到朋友圈(需同时有 onShareAppMessage 和 onShareTimelinemenus 在 Android 支持iOS 为 Beta
@@ -207,12 +212,37 @@ Page({
// 隐藏加载提示
wx.hideLoading()
this._applyFocusTarget()
} else {
// 未登录时也隐藏loading
this.setData({ isLoading: false })
}
},
toggleShareSection() {
this.setData({ showShareSection: !this.data.showShareSection })
},
toggleEarningsDetails() {
this.setData({ showEarningsDetails: !this.data.showEarningsDetails })
},
_applyFocusTarget() {
const t = this._focusTarget || ''
if (!t) return
if (t === 'bindings') {
this.setData({ showBindingList: true, activeTab: 'active', currentBindings: this.data.activeBindings })
setTimeout(() => wx.pageScrollTo({ selector: '#binding-card', duration: 220 }), 80)
this._focusTarget = ''
return
}
if (t === 'earnings') {
setTimeout(() => wx.pageScrollTo({ selector: '#earnings-card', duration: 220 }), 80)
this._focusTarget = ''
return
}
},
// 切换Tab
switchTab(e) {
const tab = e.currentTarget.dataset.tab
@@ -342,11 +372,20 @@ Page({
console.log('[Poster] 小程序码已保存到本地:', filePath)
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
const u = this.data.userInfo || app.globalData.userInfo || {}
const posterDisplayAvatar = app.resolveAvatarWithMbti
? app.resolveAvatarWithMbti(u.avatar, u.mbti)
: ''
this.setData({
posterQrSrc: filePath,
posterReferralLink: '', // 小程序版本不再使用 H5 链接
posterNickname: nickname,
posterNicknameInitial: (nickname || '用').charAt(0),
posterDisplayAvatar,
isGeneratingPoster: false
})
wx.hideLoading()
@@ -354,7 +393,7 @@ Page({
console.error('[Poster] 生成二维码失败:', e)
wx.hideLoading()
wx.showToast({ title: '生成失败', icon: 'none' })
this.setData({ showPosterModal: false, isGeneratingPoster: false, posterQrSrc: '', posterReferralLink: '' })
this.setData({ showPosterModal: false, isGeneratingPoster: false, posterQrSrc: '', posterReferralLink: '', posterDisplayAvatar: '' })
}
},

View File

@@ -25,8 +25,7 @@
</view>
<!-- 收益卡片 - 对齐 Next.js -->
<view class="earnings-card">
<view class="earnings-bg"></view>
<view id="earnings-card" class="earnings-card">
<view class="earnings-main">
<view class="earnings-header">
<view class="earnings-left">
@@ -87,7 +86,7 @@
</view>
<!-- 绑定用户列表 -->
<view class="binding-card">
<view id="binding-card" class="binding-card">
<view class="binding-header" bindtap="toggleBindingList">
<view class="binding-title">
<image class="binding-icon-img" src="/assets/icons/users.svg" mode="aspectFit"></image>
@@ -161,9 +160,14 @@
</block>
</view>
<!-- 分享按钮 - 1:1 对齐 Next.js -->
<view class="share-section">
<view class="share-item" bindtap="generatePoster">
<!-- 分享按钮(可折叠,默认收起) -->
<view class="section-collapse-card">
<view class="section-collapse-header" bindtap="toggleShareSection">
<text class="section-collapse-title">分享推广</text>
<icon name="{{showShareSection ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)"></icon>
</view>
<view class="share-section" wx:if="{{showShareSection}}">
<view class="share-item" bindtap="generatePoster">
<view class="share-icon poster">
<image class="icon-share-btn" src="/assets/icons/image.svg" mode="aspectFit"></image>
</view>
@@ -196,13 +200,15 @@
<image class="share-arrow-icon" src="/assets/icons/arrow-right.svg" mode="aspectFit"></image>
</view>
</view>
</view>
<!-- 收益明细 - 增强版 -->
<view class="earnings-detail-card" wx:if="{{earningsDetails.length > 0}}">
<view class="detail-header">
<text class="detail-title">收益明细</text>
<!-- 收益明细(可折叠,默认收起) -->
<view class="section-collapse-card earnings-detail-card" wx:if="{{earningsDetails.length > 0}}">
<view class="section-collapse-header" bindtap="toggleEarningsDetails">
<text class="section-collapse-title">收益明细</text>
<icon name="{{showEarningsDetails ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)"></icon>
</view>
<view class="detail-list">
<view class="detail-list" wx:if="{{showEarningsDetails}}">
<view class="detail-item" wx:for="{{earningsDetails}}" wx:key="id">
<!-- 买家头像 -->
<view class="detail-avatar-wrap">
@@ -297,7 +303,8 @@
<!-- 推荐人 -->
<view class="poster-recommender">
<view class="poster-avatar">
<text class="poster-avatar-text">{{posterNicknameInitial}}</text>
<image wx:if="{{posterDisplayAvatar}}" class="poster-avatar-img" src="{{posterDisplayAvatar}}" mode="aspectFill" />
<text wx:else class="poster-avatar-text">{{posterNicknameInitial}}</text>
</view>
<text class="poster-recommender-text">{{posterNickname}} 推荐你来读</text>
</view>

View File

@@ -1,28 +1,39 @@
/* ???????? - 1:1??Web?? */
@import './earnings-detail-styles.wxss';
/* 分销中心 - 主结构样式 */
.page { min-height: 100vh; background: #000; padding-bottom: 64rpx; }
/* ??? */
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(0,0,0,0.9); backdrop-filter: blur(40rpx); display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx; height: 88rpx; }
.nav-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
background: rgba(0,0,0,0.92);
display: flex; align-items: center; justify-content: space-between;
padding: 0 32rpx; height: 88rpx;
border-bottom: 1rpx solid rgba(255,255,255,0.06);
}
.nav-left { display: flex; gap: 16rpx; align-items: center; }
.nav-back { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.nav-icon { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.nav-icon { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(70%); }
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; flex: 1; text-align: center; }
.nav-right-placeholder { width: 144rpx; }
.nav-btn { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.content { padding: 24rpx; width: 100%; box-sizing: border-box; }
/* ?????? */
.expiring-banner { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; background: rgba(255,165,0,0.1); border: 2rpx solid rgba(255,165,0,0.3); border-radius: 24rpx; margin-bottom: 24rpx; }
.expiring-banner {
display: flex; align-items: center; gap: 24rpx; padding: 24rpx;
background: rgba(255,165,0,0.1); border: 2rpx solid rgba(255,165,0,0.3);
border-radius: 24rpx; margin-bottom: 24rpx;
}
.banner-icon { width: 80rpx; height: 80rpx; background: rgba(255,165,0,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.icon-bell-warning { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(64%) sepia(89%) saturate(1363%) hue-rotate(4deg) brightness(101%) contrast(102%); }
.banner-content { flex: 1; }
.banner-title { font-size: 28rpx; font-weight: 500; color: #fff; display: block; }
.banner-desc { font-size: 24rpx; color: rgba(255,165,0,0.8); margin-top: 4rpx; display: block; }
/* ???? - ?? Next.js */
.earnings-card { position: relative; background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 48rpx; margin-bottom: 24rpx; overflow: hidden; width: 100%; box-sizing: border-box; }
.earnings-bg { position: absolute; top: 0; right: 0; width: 256rpx; height: 256rpx; background: rgba(0,206,209,0.15); border-radius: 50%; filter: blur(100rpx); }
.earnings-card {
position: relative; background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1);
border-radius: 32rpx; padding: 48rpx; margin-bottom: 24rpx; overflow: hidden;
width: 100%; box-sizing: border-box;
}
.earnings-main { position: relative; }
.earnings-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 32rpx; }
.earnings-left { display: flex; align-items: center; gap: 16rpx; }
@@ -34,26 +45,17 @@
.earnings-right { text-align: right; }
.earnings-value { font-size: 60rpx; font-weight: 700; color: #fff; display: block; line-height: 1; }
.pending-text { font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; display: block; }
.withdraw-btn { padding: 28rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #fff; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 24rpx; box-shadow: 0 8rpx 24rpx rgba(0,206,209,0.3); }
.withdraw-btn.btn-disabled { background: rgba(0,206,209,0.2); color: rgba(255,255,255,0.3); box-shadow: none; }
.withdraw-btn { padding: 28rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #fff; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 24rpx; }
.withdraw-btn.btn-disabled { background: rgba(0,206,209,0.2); color: rgba(255,255,255,0.3); }
.wechat-tip { display: block; font-size: 24rpx; color: rgba(255,165,0,0.9); margin-top: 16rpx; text-align: center; }
.withdraw-records-link { display: block; margin-top: 16rpx; text-align: center; font-size: 26rpx; color: #00CED1; }
/* ???? - ?? Next.js 4??? */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.stat-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 24rpx 12rpx; text-align: center; }
.stat-card { background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 24rpx 12rpx; text-align: center; }
.stat-value { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
.stat-value.orange { color: #FFA500; }
.stat-label { font-size: 20rpx; color: rgba(255,255,255,0.6); margin-top: 8rpx; display: block; }
/* ????? */
.visit-stat { display: flex; align-items: center; justify-content: center; gap: 12rpx; padding: 20rpx 32rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; margin-bottom: 24rpx; }
.visit-label { font-size: 24rpx; color: rgba(255,255,255,0.5); }
.visit-value { font-size: 32rpx; font-weight: 700; color: #00CED1; }
.visit-tip { font-size: 24rpx; color: rgba(255,255,255,0.5); }
/* ???? - ?? Next.js */
.rules-card { background: rgba(0,206,209,0.05); border: 2rpx solid rgba(0,206,209,0.2); border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.rules-header { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
.rules-icon { width: 64rpx; height: 64rpx; background: rgba(0,206,209,0.2); border-radius: 16rpx; display: flex; align-items: center; justify-content: center; }
@@ -63,240 +65,89 @@
.rule-item { font-size: 24rpx; color: rgba(255,255,255,0.6); line-height: 2; display: block; margin-bottom: 4rpx; }
.rule-item .gold { color: #FFD700; font-weight: 500; }
.rule-item .brand { color: #00CED1; font-weight: 500; }
.rule-item .orange { color: #FFA500; font-weight: 500; }
/* ?????? - ?? Next.js */
.binding-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.binding-card { background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.binding-header { display: flex; align-items: center; justify-content: space-between; padding: 28rpx 32rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.binding-title { display: flex; align-items: center; gap: 12rpx; }
.binding-icon-img { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
.title-text { font-size: 30rpx; font-weight: 600; color: #fff; }
.binding-count { font-size: 26rpx; color: rgba(255,255,255,0.5); }
.toggle-icon { font-size: 24rpx; color: rgba(255,255,255,0.5); }
/* Tab?? */
.binding-tabs { display: flex; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.tab-item { flex: 1; padding: 24rpx 0; text-align: center; font-size: 26rpx; color: rgba(255,255,255,0.5); position: relative; }
.tab-item.tab-active { color: #00CED1; }
.tab-item.tab-active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 80rpx; height: 4rpx; background: #00CED1; border-radius: 4rpx; }
/* ???? */
.binding-list { max-height: 640rpx; overflow-y: auto; }
.empty-state { padding: 80rpx 0; text-align: center; }
.empty-icon { font-size: 64rpx; display: block; margin-bottom: 16rpx; }
.empty-text { font-size: 26rpx; color: rgba(255,255,255,0.5); }
/* ?????? - ??? */
.binding-item { display: flex; align-items: center; padding: 24rpx 32rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); gap: 24rpx; }
.binding-item:last-child { border-bottom: none; }
/* ?? */
.user-avatar { width: 88rpx; height: 88rpx; border-radius: 50%; background: rgba(0,206,209,0.2); display: flex; align-items: center; justify-content: center; font-size: 32rpx; font-weight: 600; color: #00CED1; flex-shrink: 0; }
.user-avatar.avatar-converted { background: rgba(76,175,80,0.2); color: #4CAF50; }
.user-avatar.avatar-expired { background: rgba(158,158,158,0.2); color: #9E9E9E; }
/* ???? */
.user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4rpx; }
.user-name { font-size: 28rpx; color: #fff; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-time { font-size: 22rpx; color: rgba(255,255,255,0.5); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ???? */
.user-status { flex-shrink: 0; text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; min-width: 100rpx; }
.status-amount { font-size: 28rpx; color: #4CAF50; font-weight: 600; white-space: nowrap; }
.status-order { font-size: 20rpx; color: rgba(255,255,255,0.5); white-space: nowrap; }
.status-time { font-size: 20rpx; color: rgba(255,255,255,0.5); white-space: nowrap; }
/* ???? */
.status-order, .status-time { font-size: 20rpx; color: rgba(255,255,255,0.5); white-space: nowrap; }
.status-tag { font-size: 24rpx; font-weight: 600; padding: 6rpx 16rpx; border-radius: 16rpx; white-space: nowrap; }
.status-tag.tag-green { background: rgba(76,175,80,0.2); color: #4CAF50; }
.status-tag.tag-orange { background: rgba(255,165,0,0.2); color: #FFA500; }
.status-tag.tag-red { background: rgba(244,67,54,0.2); color: #F44336; }
.status-tag.tag-gray { background: rgba(158,158,158,0.2); color: #9E9E9E; }
/* ????? - ?? Next.js */
.invite-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 40rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.invite-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
.invite-title { font-size: 30rpx; font-weight: 600; color: #fff; }
.invite-code-box { background: rgba(0,206,209,0.2); padding: 12rpx 24rpx; border-radius: 16rpx; }
.invite-code { font-size: 28rpx; font-weight: 600; color: #00CED1; font-family: 'Courier New', monospace; letter-spacing: 2rpx; }
.invite-tip { font-size: 24rpx; color: rgba(255,255,255,0.6); line-height: 1.5; display: block; }
.invite-tip .gold { color: #FFD700; }
.invite-tip .brand { color: #00CED1; }
/* ?????? - ??? */
.earnings-detail-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.detail-header { padding: 40rpx 40rpx 24rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.detail-title { font-size: 30rpx; font-weight: 600; color: #fff; }
.detail-list { max-height: 480rpx; overflow-y: auto; padding: 16rpx 0; }
/* ??????? */
.earnings-detail-card .detail-item { display: flex; align-items: center; gap: 24rpx; padding: 24rpx 40rpx; background: transparent; border-bottom: 2rpx solid rgba(255,255,255,0.03); }
.earnings-detail-card .detail-item:last-child { border-bottom: none; }
.earnings-detail-card .detail-item:active { background: rgba(255, 255, 255, 0.05); }
/* ???? */
.earnings-detail-card .detail-avatar-wrap { width: 88rpx; height: 88rpx; flex-shrink: 0; }
.earnings-detail-card .detail-avatar { width: 100%; height: 100%; border-radius: 50%; border: 2rpx solid rgba(56, 189, 172, 0.2); }
.earnings-detail-card .detail-avatar-text { width: 100%; height: 100%; border-radius: 50%; background: linear-gradient(135deg, #38bdac 0%, #2da396 100%); display: flex; align-items: center; justify-content: center; font-size: 36rpx; font-weight: 700; color: #ffffff; }
/* ???? */
.earnings-detail-card .detail-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.earnings-detail-card .detail-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.earnings-detail-card .detail-buyer { font-size: 28rpx; font-weight: 500; color: #ffffff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.earnings-detail-card .detail-amount { font-size: 32rpx; font-weight: 700; color: #38bdac; flex-shrink: 0; white-space: nowrap; }
/* ???? */
.earnings-detail-card .detail-product { display: flex; align-items: baseline; gap: 4rpx; font-size: 24rpx; color: rgba(255, 255, 255, 0.6); min-width: 0; overflow: hidden; }
.earnings-detail-card .detail-book { color: rgba(255, 255, 255, 0.7); font-weight: 500; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.earnings-detail-card .detail-chapter { color: rgba(255, 255, 255, 0.5); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.earnings-detail-card .detail-time { font-size: 22rpx; color: rgba(255, 255, 255, 0.4); }
/* ???? - ?? Next.js */
.share-section { display: flex; flex-direction: column; gap: 12rpx; width: 100%; margin-bottom: 24rpx; }
.share-item { display: flex; align-items: center; background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 32rpx; border: none; margin: 0; text-align: left; width: 100%; box-sizing: border-box; }
.share-item { display: flex; align-items: center; background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 32rpx; width: 100%; box-sizing: border-box; }
.share-item::after { border: none; }
.share-icon { width: 96rpx; height: 96rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
.share-icon.poster { background: rgba(103,58,183,0.2); }
.share-icon.wechat { background: rgba(7,193,96,0.2); }
.share-icon.link { background: rgba(158,158,158,0.2); }
.icon-share-btn { width: 48rpx; height: 48rpx; display: block; }
.share-icon.poster .icon-share-btn { filter: brightness(0) saturate(100%) invert(37%) sepia(73%) saturate(2296%) hue-rotate(252deg) brightness(96%) contrast(92%); }
.share-icon.wechat .icon-share-btn { filter: brightness(0) saturate(100%) invert(58%) sepia(91%) saturate(1255%) hue-rotate(105deg) brightness(96%) contrast(97%); }
.share-icon.link .icon-share-btn { filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.share-info { flex: 1; text-align: left; }
.share-title { font-size: 28rpx; color: #fff; font-weight: 500; display: block; text-align: left; }
.share-desc { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; text-align: left; }
.share-arrow-icon { width: 40rpx; height: 40rpx; display: block; flex-shrink: 0; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.share-btn-wechat { line-height: normal; font-size: inherit; padding: 24rpx 32rpx !important; margin: 0 !important; width: 100% !important; }
.share-title { font-size: 28rpx; color: #fff; font-weight: 500; display: block; }
.share-desc { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; }
.share-arrow-icon { width: 40rpx; height: 40rpx; display: block; flex-shrink: 0; filter: brightness(0) saturate(100%) invert(60%); }
.empty-earnings { background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 64rpx 40rpx; text-align: center; margin-bottom: 24rpx; }
.empty-icon-wrapper { width: 128rpx; height: 128rpx; border-radius: 50%; background: rgba(28, 28, 30, 0.8); display: flex; align-items: center; justify-content: center; margin: 0 auto 32rpx; }
.empty-gift-icon { width: 64rpx; height: 64rpx; display: block; filter: brightness(0) saturate(100%) invert(60%); }
.empty-title { font-size: 30rpx; font-weight: 500; color: #fff; display: block; margin-bottom: 16rpx; }
.empty-desc { font-size: 26rpx; color: rgba(255,255,255,0.6); display: block; line-height: 1.5; }
/* ?????????????? + ???? + ???????? */
/* ???????? backdrop-filter??????????????? */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 32rpx; box-sizing: border-box; }
.poster-dialog { width: 686rpx; border-radius: 24rpx; overflow: hidden; position: relative; background: transparent; }
.poster-close { position: absolute; top: 20rpx; right: 20rpx; width: 56rpx; height: 56rpx; border-radius: 28rpx; background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); display: flex; align-items: center; justify-content: center; z-index: 5; font-size: 28rpx; }
/* ???? */
.poster-close { position: absolute; top: 20rpx; right: 20rpx; width: 56rpx; height: 56rpx; border-radius: 28rpx; background: rgba(0,0,0,0.25); display: flex; align-items: center; justify-content: center; z-index: 5; }
.poster-card { position: relative; background: linear-gradient(135deg, #0a1628 0%, #0f2137 50%, #1a3a5c 100%); color: #fff; padding: 44rpx 40rpx 36rpx; }
.poster-inner { position: relative; z-index: 2; display: flex; flex-direction: column; align-items: center; text-align: center; }
/* ???? */
/* ???????? filter: blur ??????????????? + ???? */
.poster-glow { position: absolute; width: 320rpx; height: 320rpx; border-radius: 50%; opacity: 0.6; z-index: 1; }
.poster-glow-left { top: -120rpx; left: -160rpx; background: rgba(0,206,209,0.12); box-shadow: 0 0 140rpx 40rpx rgba(0,206,209,0.18); }
.poster-glow-right { bottom: -140rpx; right: -160rpx; background: rgba(255,215,0,0.10); box-shadow: 0 0 160rpx 50rpx rgba(255,215,0,0.14); }
.poster-ring { position: absolute; width: 520rpx; height: 520rpx; border-radius: 50%; border: 2rpx solid rgba(0,206,209,0.06); left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 1; }
/* ???? */
.poster-badges { display: flex; gap: 16rpx; margin-bottom: 24rpx; }
.poster-badge { padding: 10rpx 22rpx; font-size: 20rpx; font-weight: 700; border-radius: 999rpx; border: 2rpx solid transparent; }
.poster-badge-gold { background: rgba(255,215,0,0.18); color: #FFD700; border-color: rgba(255,215,0,0.28); }
.poster-badge-brand { background: rgba(0,206,209,0.18); color: #00CED1; border-color: rgba(0,206,209,0.28); }
/* ?? */
.poster-title { margin-bottom: 8rpx; }
.poster-title-line1 { display: block; font-size: 44rpx; font-weight: 900; line-height: 1.1; color: #00CED1; }
.poster-title-line2 { display: block; font-size: 44rpx; font-weight: 900; line-height: 1.1; color: #fff; margin-top: 6rpx; }
.poster-subtitle { display: block; font-size: 22rpx; color: rgba(255,255,255,0.6); margin-bottom: 28rpx; }
/* ???? */
.poster-stats { width: 100%; display: flex; gap: 16rpx; justify-content: center; margin-bottom: 24rpx; }
.poster-stat { flex: 1; max-width: 190rpx; background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.10); border-radius: 16rpx; padding: 18rpx 10rpx; }
.poster-stat-value { display: block; font-size: 44rpx; font-weight: 900; line-height: 1; }
.poster-stat-label { display: block; font-size: 20rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; }
.poster-stat-gold { color: #FFD700; }
.poster-stat-brand { color: #00CED1; }
.poster-stat-pink { color: #E91E63; }
/* ?? */
.poster-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 10rpx; margin: 0 24rpx 26rpx; }
.poster-tag { font-size: 20rpx; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.10); border-radius: 12rpx; padding: 6rpx 14rpx; }
/* ??? */
.poster-recommender { display: flex; align-items: center; gap: 12rpx; background: rgba(0,206,209,0.10); border: 2rpx solid rgba(0,206,209,0.20); border-radius: 999rpx; padding: 12rpx 22rpx; margin-bottom: 22rpx; }
.poster-avatar { width: 44rpx; height: 44rpx; border-radius: 22rpx; background: rgba(0,206,209,0.30); display: flex; align-items: center; justify-content: center; }
.poster-avatar-text { font-size: 20rpx; font-weight: 800; color: #00CED1; }
.poster-recommender-text { font-size: 22rpx; color: #00CED1; }
/* ??? */
.poster-discount { width: 100%; background: linear-gradient(90deg, rgba(255,215,0,0.10) 0%, rgba(233,30,99,0.10) 100%); border: 2rpx solid rgba(255,215,0,0.20); border-radius: 18rpx; padding: 18rpx 18rpx; margin-bottom: 26rpx; }
.poster-discount-text { font-size: 22rpx; color: rgba(255,255,255,0.80); }
.poster-discount-highlight { color: #00CED1; font-weight: 800; }
/* ??? */
.poster-qr-wrap { background: #fff; padding: 14rpx; border-radius: 16rpx; margin-bottom: 12rpx; box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.35); }
.poster-qr-img { width: 240rpx; height: 240rpx; display: block; }
.poster-qr-tip { font-size: 20rpx; color: rgba(255,255,255,0.40); margin-bottom: 8rpx; }
.poster-code { font-size: 22rpx; font-family: monospace; letter-spacing: 2rpx; color: rgba(0,206,209,0.80); }
/* ??????? */
.poster-footer { background: #fff; padding: 28rpx 28rpx 32rpx; display: flex; flex-direction: column; gap: 18rpx; }
.poster-footer-tip { font-size: 22rpx; color: rgba(0,0,0,0.55); text-align: center; }
.poster-footer-btn { height: 72rpx; border-radius: 18rpx; border: 2rpx solid rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: rgba(0,0,0,0.75); background: #fff; }
/* ????- ?? Next.js */
.empty-earnings { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 64rpx 40rpx; text-align: center; margin-bottom: 24rpx; }
.empty-icon-wrapper { width: 128rpx; height: 128rpx; border-radius: 50%; background: rgba(28, 28, 30, 0.8); display: flex; align-items: center; justify-content: center; margin: 0 auto 32rpx; }
.empty-gift-icon { width: 64rpx; height: 64rpx; display: block; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.empty-title { font-size: 30rpx; font-weight: 500; color: #fff; display: block; margin-bottom: 16rpx; }
.empty-desc { font-size: 26rpx; color: rgba(255,255,255,0.6); display: block; line-height: 1.5; }
.poster-avatar { position: relative; width: 72rpx; height: 72rpx; flex-shrink: 0; }
.poster-avatar-img {
width: 72rpx; height: 72rpx; border-radius: 50%;
border: 3rpx solid rgba(255, 215, 0, 0.88);
box-sizing: border-box; display: block; background: #111;
}
/* ===== Loading 遮罩(备用) ===== */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(10rpx);
z-index: 999;
/* 分组折叠(用于优化页面长度) */
.section-collapse-card {
background: rgba(28, 28, 30, 0.8);
border: 2rpx solid rgba(255,255,255,0.1);
border-radius: 24rpx;
overflow: hidden;
margin-bottom: 24rpx;
}
.section-collapse-header {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
padding: 24rpx 28rpx;
border-bottom: 1rpx solid rgba(255,255,255,0.06);
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.loading-spinner {
width: 80rpx;
height: 80rpx;
border: 6rpx solid rgba(56,189,172,0.2);
border-top-color: #38bdac;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
.section-collapse-title {
font-size: 28rpx;
color: rgba(255,255,255,0.8);
font-weight: 500;
color: #fff;
font-weight: 600;
}
.content-loading {
opacity: 0.3;
pointer-events: none;
}
/* ===== 收益明细独立块 ===== */
.detail-item { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; background: rgba(255,255,255,0.02); border-radius: 16rpx; margin-bottom: 16rpx; transition: all 0.3s; }
.detail-item:active { background: rgba(255,255,255,0.05); }
.detail-avatar-wrap { flex-shrink: 0; }
.detail-avatar { width: 88rpx; height: 88rpx; border-radius: 50%; border: 2rpx solid rgba(56,189,172,0.2); }
.detail-avatar-text { width: 88rpx; height: 88rpx; border-radius: 50%; background: linear-gradient(135deg, #38bdac 0%, #2da396 100%); display: flex; align-items: center; justify-content: center; font-size: 36rpx; font-weight: 700; color: #ffffff; }
.detail-content { flex: 1; display: flex; flex-direction: column; gap: 8rpx; min-width: 0; }
.detail-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.detail-buyer { font-size: 28rpx; font-weight: 500; color: #ffffff; flex-shrink: 0; }
.detail-amount { font-size: 32rpx; font-weight: 700; color: #38bdac; flex-shrink: 0; }
.detail-product { display: flex; align-items: center; font-size: 24rpx; color: rgba(255,255,255,0.6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-book { color: rgba(255,255,255,0.7); font-weight: 500; }
.detail-chapter { color: rgba(255,255,255,0.5); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-time { font-size: 22rpx; color: rgba(255,255,255,0.4); }

View File

@@ -34,7 +34,7 @@ Page({
const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim()
const displayVersion =
wxPkgVersion ||
(app.globalData.appDisplayVersion || '1.7.1')
(app.globalData.appDisplayVersion || '1.7.2')
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
isLoggedIn: app.globalData.isLoggedIn,
@@ -50,7 +50,7 @@ Page({
const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim()
const displayVersion =
wxPkgVersion ||
(app.globalData.appDisplayVersion || '1.7.1')
(app.globalData.appDisplayVersion || '1.7.2')
this.setData({ version: displayVersion })
this.loadBindingInfo()
},

View File

@@ -0,0 +1,11 @@
{
"size": {
"total": 1579970,
"packages": [
{
"name": "TOTAL",
"size": 1579970
}
]
}
}

View File

@@ -0,0 +1,39 @@
/**
* MBTI 默认头像:与后台 system_config.mbti_avatars + GET /api/miniprogram/config/mbti-avatars 一致
*/
const MBTI_RE = /^[EI][NS][FT][JP]$/
function normalizeMbti(m) {
const s = (m && String(m).trim().toUpperCase()) || ''
return MBTI_RE.test(s) ? s : ''
}
/**
* 展示用头像:优先用户已设头像(补全相对路径),否则合法 MBTI + 映射表中有 URL 则用映射
* @param {string} avatar
* @param {string} mbti
* @param {Record<string,string>} map
* @param {string} baseUrl
*/
function resolveAvatarWithMbti(avatar, mbti, map, baseUrl) {
let a = (avatar && String(avatar).trim()) || ''
if (a) {
if (!/^https?:\/\//i.test(a) && baseUrl) {
if (a.startsWith('/')) a = baseUrl + a
}
return a
}
const key = normalizeMbti(mbti)
if (!key || !map || typeof map !== 'object') return ''
let u = (map[key] && String(map[key]).trim()) || ''
if (!u) return ''
if (!/^https?:\/\//i.test(u) && baseUrl && u.startsWith('/')) u = baseUrl + u
return u
}
module.exports = {
MBTI_RE,
normalizeMbti,
resolveAvatarWithMbti,
}

View File

@@ -0,0 +1,13 @@
/**
* 与管理端 content/ChapterTree.tsx 的 PART_ICONS、正文篇序规则一致
* 后台篇头用 emoji 轮询;小程序目录页与之对齐(无自定义图时)
*/
const PART_ICONS = ['📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📚', '📖']
/** 正文篇在列表中的从 0 开始的序号 → emoji与 ChapterTree bodyPartOrdinal 一致) */
function partEmojiForBodyIndex(bodyIndex) {
const i = Math.max(0, Number(bodyIndex) || 0)
return PART_ICONS[i % PART_ICONS.length]
}
module.exports = { PART_ICONS, partEmojiForBodyIndex }

View File

@@ -13,8 +13,8 @@ if [[ ! -x "$CLI" ]]; then
exit 1
fi
# 未传参时默认 1.7.1(避免手滑打成 1.17 等与展示不一致)
DEFAULT_VER="${MINIPROGRAM_DEFAULT_VERSION:-1.7.1}"
# 未传参时默认 1.7.2(避免手滑打成 1.17 等与展示不一致)
DEFAULT_VER="${MINIPROGRAM_DEFAULT_VERSION:-1.7.2}"
VERSION="${1:-$DEFAULT_VER}"
DESC="${2:-版本 v$VERSION}"

View File

@@ -1,12 +1,14 @@
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import requests
ROUTER_GO = r"e:\\Gongsi\\Mycontent\\soul-api\\internal\\router\\router.go"
PROJECT_ROOT = Path(__file__).resolve().parents[3]
ROUTER_GO = PROJECT_ROOT / "soul-api" / "internal" / "router" / "router.go"
@dataclass
@@ -17,7 +19,7 @@ class Route:
full_path: str # full path appended to API_BASE_URL
def _read_text(path: str) -> str:
def _read_text(path: Path) -> str:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()

View File

@@ -1,10 +1,12 @@
import re
from dataclasses import dataclass
from pathlib import Path
import requests
ROUTER_GO = r"e:\\Gongsi\\Mycontent\\soul-api\\internal\\router\\router.go"
PROJECT_ROOT = Path(__file__).resolve().parents[3]
ROUTER_GO = PROJECT_ROOT / "soul-api" / "internal" / "router" / "router.go"
@dataclass
@@ -15,7 +17,7 @@ class Check:
preview: str
def _read_text(path: str) -> str:
def _read_text(path: Path) -> str:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()

View File

@@ -247,8 +247,8 @@ def main() -> None:
p_up.add_argument(
"--version",
"-v",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.1"),
help="版本号,默认 1.7.1 或环境变量 MINIPROGRAM_DEFAULT_VERSION",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
help="版本号,默认 1.7.2 或环境变量 MINIPROGRAM_DEFAULT_VERSION",
)
p_up.add_argument(
"--desc",
@@ -289,7 +289,7 @@ def main() -> None:
p_uo.add_argument(
"--version",
"-v",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.1"),
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
)
p_uo.add_argument("--desc", "-d", default="", help="默认:版本 v<版本号>")
@@ -300,7 +300,7 @@ def main() -> None:
p_rel.add_argument(
"--version",
"-v",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.1"),
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
)
p_rel.add_argument("--desc", "-d", default="", help="上传说明,默认:版本 v<版本号>")
p_rel.add_argument("--version-desc", default="", help="提交审核说明,默认同上传说明")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1006
soul-admin/dist/assets/index-Dv-LWSbq.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-CW7Mmh6Q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CDCvtX8z.css">
<script type="module" crossorigin src="/assets/index-Dv-LWSbq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DXojA1Za.css">
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,173 @@
import { useState, useEffect, useCallback } from 'react'
import { get, post } from '@/api/client'
import toast from '@/utils/toast'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Smile, Save, RefreshCw, WandSparkles } from 'lucide-react'
import {
MBTI_TYPES_ORDERED,
MBTI_AVATAR_PROFILES,
buildMbtiSvgAvatarDataUrl,
type MbtiType,
} from '@/lib/mbtiAvatarPrompts'
export function MbtiAvatarsManager() {
const [avatars, setAvatars] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [generating, setGenerating] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await get<{ success?: boolean; avatars?: Record<string, string> }>('/api/admin/mbti-avatars')
if (res?.avatars) setAvatars(res.avatars)
else setAvatars({})
} catch {
toast.error('加载 MBTI 头像配置失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const save = async () => {
setSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/admin/mbti-avatars', { avatars })
if (!res || res.success === false) {
toast.error(res?.error || '保存失败')
return
}
toast.success('已保存,后台与小程序默认头像同步生效')
load()
} catch {
toast.error('保存失败')
} finally {
setSaving(false)
}
}
const generateOne = (type: MbtiType) => {
const dataUrl = buildMbtiSvgAvatarDataUrl(type)
setAvatars((prev) => ({ ...prev, [type]: dataUrl }))
toast.success(`${type} 已生成`)
}
const generateAll = () => {
setGenerating(true)
try {
const next = { ...avatars }
MBTI_TYPES_ORDERED.forEach((t) => {
next[t] = buildMbtiSvgAvatarDataUrl(t)
})
setAvatars(next)
toast.success('16 型头像已生成(仅人物)')
} finally {
setGenerating(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-gray-400">
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-[#38bdac]" />
</div>
)
}
return (
<div className="space-y-4">
<Card className="bg-[#0f2137] border-[#38bdac]/25 shadow-xl">
<CardHeader className="pb-2">
<CardTitle className="text-white flex items-center gap-2 text-lg">
<Smile className="w-5 h-5 text-[#38bdac]" />
MBTI
</CardTitle>
<CardDescription className="text-gray-400 text-sm leading-relaxed">
MBTI
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button type="button" size="sm" className="bg-[#38bdac] hover:bg-[#2da396]" onClick={generateAll} disabled={generating}>
<WandSparkles className="w-3.5 h-3.5 mr-1" />
{generating ? '生成中…' : '一键生成16头像'}
</Button>
<Button type="button" size="sm" variant="outline" className="border-gray-600 text-gray-300" onClick={load}>
<RefreshCw className="w-3.5 h-3.5 mr-1" />
</Button>
<Button type="button" size="sm" className="bg-emerald-600 hover:bg-emerald-500" onClick={save} disabled={saving}>
<Save className="w-3.5 h-3.5 mr-1" />
{saving ? '保存中…' : '保存映射'}
</Button>
</CardContent>
</Card>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{MBTI_TYPES_ORDERED.map((t) => {
const url = avatars[t] ?? ''
const meta = MBTI_AVATAR_PROFILES[t]
return (
<div
key={t}
className="rounded-xl border border-gray-700/60 bg-[#0a1628] p-3 flex flex-col gap-2 hover:border-[#38bdac]/35 transition-colors"
>
<div className="flex items-center gap-2">
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0 font-mono text-xs">{t}</Badge>
<span className="text-xs text-gray-400 truncate" title={meta.title}>
{meta.title}
</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-16 rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-[#081322] ring-2 ring-[#38bdac]/40 ring-offset-2 ring-offset-[#0a1628]">
{url ? (
<img src={url} alt={t} className="w-full h-full object-cover scale-110" />
) : (
<span className="text-gray-600 text-[10px]"></span>
)}
</div>
<div className="flex-1 min-w-0">
<Input
className="bg-[#162840] border-gray-700 text-white h-8 text-xs"
placeholder="https://... 或 data:image/..."
value={url}
onChange={(e) => setAvatars((prev) => ({ ...prev, [t]: e.target.value }))}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px] border-[#38bdac]/40 text-[#38bdac]"
onClick={() => generateOne(t)}
>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-[11px] text-gray-400"
onClick={() => setAvatars((prev) => ({ ...prev, [t]: '' }))}
>
</Button>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -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<UserTrack[]>([])
const [trackStats, setTrackStats] = useState<Record<string, number>>({})
const [referrals, setReferrals] = useState<unknown[]>([])
const [inboundSource, setInboundSource] = useState<InboundSourceData | null>(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<Record<string, unknown> | null>(null)
const [avatarBroken, setAvatarBroken] = useState(false)
const [mbtiAvatarsMap, setMbtiAvatarsMap] = useState<Record<string, string>>({})
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<string, string> }>('/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<string, unknown>; 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 <Icon className="w-4 h-4" />
}
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({
<div className="flex flex-col sm:flex-row gap-2.5 p-2.5 bg-[#0a1628] rounded-lg mb-2 shrink-0">
<div className="flex gap-2.5 min-w-0 flex-1">
<div className="w-11 h-11 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-lg text-[#38bdac] shrink-0">
{user.avatar ? (
<img src={normalizeImageUrl(user.avatar)} className="w-full h-full rounded-full object-cover" alt="" />
{resolveAvatarByMbti(user.avatar, user.mbti) && !avatarBroken ? (
<img
src={resolveAvatarByMbti(user.avatar, user.mbti)}
className="w-full h-full rounded-full object-cover"
alt=""
onError={() => setAvatarBroken(true)}
/>
) : (
user.nickname?.charAt(0) || '?'
)}
@@ -526,31 +635,29 @@ export function UserDetailModal({
{user.hasFullBook && <Badge className="bg-green-500/20 text-green-400 border-0 text-[10px] py-0"></Badge>}
{user.vipRole && <Badge className="bg-amber-500/20 text-amber-400 border-0 text-[10px] py-0">{user.vipRole}</Badge>}
</div>
<p className="text-[10px] text-gray-500 font-mono truncate mt-0.5">
{user.id}
{user.referralCode && (
<>
{' · '}
<code className="text-[#38bdac]">{user.referralCode}</code>
</>
)}
</p>
<p className="text-[10px] text-gray-500 leading-snug mt-1">
<span className="text-gray-400">OpenID</span>
<span className="text-[#38bdac]/90"> </span> / wxid OpenID
</p>
<div className="grid grid-cols-1 xs:grid-cols-3 gap-x-2 gap-y-1 mt-1.5 text-[10px]">
<div className="min-w-0">
<span className="text-gray-500">OpenID</span>
<p className="text-gray-300 font-mono break-all line-clamp-2">{user.openId || '—'}</p>
{user.referralCode && (
<p className="text-[10px] text-gray-500 mt-0.5">
<code className="text-[#38bdac]">{user.referralCode}</code>
</p>
)}
<div className="mt-1 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-1.5 text-[11px]">
<div className="px-2 py-1 rounded bg-[#162840] border border-gray-700/50">
<span className="text-gray-500"></span>
<p className="text-white truncate">{editNickname || user.nickname || '—'}</p>
</div>
<div className="min-w-0">
<span className="text-gray-500"></span>
<p className="text-gray-300">{user.phone || '—'}</p>
<div className="px-2 py-1 rounded bg-[#162840] border border-gray-700/50">
<span className="text-gray-500"></span>
<p className="text-white truncate">{editPhone || '—'}</p>
</div>
<div className="min-w-0">
<span className="text-gray-500"></span>
<p className="text-gray-300 font-mono break-all">{user.wechatId || '—'}</p>
<div className="px-2 py-1 rounded bg-[#162840] border border-gray-700/50">
<span className="text-gray-500"></span>
<p className="text-white truncate">{editWechatId || '—'}</p>
</div>
<div className="px-2 py-1 rounded bg-[#162840] border border-gray-700/50">
<span className="text-gray-500"></span>
<p className="text-[#38bdac] truncate">
{[user.region, user.industry, user.position, user.mbti ? `MBTI ${user.mbti}` : ''].filter(Boolean).join(' · ') || '未完善'}
</p>
</div>
</div>
</div>
@@ -611,8 +718,25 @@ export function UserDetailModal({
</TabsTrigger>
</TabsList>
{/* ===== 用户信息(紧凑单屏) ===== */}
{/* ===== 用户信息(紧凑单屏):基础字段 + 超级个体 + 外部同步合并为一屏滚动 ===== */}
<TabsContent value="info" className="flex-1 min-h-0 overflow-y-auto space-y-2 pr-0.5">
<details className="rounded-lg bg-[#0a1628] border border-gray-700/40 p-2 text-[11px] group">
<summary className="cursor-pointer text-gray-400 select-none list-none flex items-center gap-1">
<span className="group-open:text-[#38bdac]"></span>
<span className="text-gray-600">ID / OpenID</span>
</summary>
<div className="mt-2 space-y-1.5 text-gray-300 font-mono text-[10px] break-all border-t border-gray-700/30 pt-2">
<p>
<span className="text-gray-500 not-italic font-sans">ID</span> {user.id}
</p>
<p>
<span className="text-gray-500 not-italic font-sans">OpenID</span> {user.openId || '—'}
</p>
<p className="text-gray-500 not-italic font-sans leading-snug">
OpenID /wxid OpenID
</p>
</div>
</details>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-gray-400 text-[11px]"></Label>
@@ -691,7 +815,7 @@ export function UserDetailModal({
<div className="p-2 rounded-lg bg-[#0a1628] border border-[#38bdac]/20">
<div className="flex items-center gap-1.5 mb-1.5">
<Zap className="w-3.5 h-3.5 text-[#38bdac]" />
<span className="text-white text-xs font-medium"> · / </span>
<span className="text-white text-xs font-medium"> · / </span>
</div>
<div className="grid grid-cols-3 gap-1.5 mb-1.5">
<Input className="bg-[#162840] border-gray-700 text-white h-7 text-xs" placeholder="查:手机" value={sssQueryPhone} onChange={(e) => setSssQueryPhone(e.target.value)} />
@@ -744,6 +868,27 @@ export function UserDetailModal({
{/* ===== 用户旅程 + 行为轨迹 ===== */}
<TabsContent value="journey" className="flex-1 min-h-0 overflow-y-auto space-y-2 pr-0.5">
{purchaseList.length > 0 && (
<div className="p-2 bg-[#0a1628] rounded-lg border border-amber-500/20">
<div className="flex items-center gap-1.5 mb-1.5">
<ShoppingBag className="w-3.5 h-3.5 text-amber-400" />
<span className="text-white text-xs font-medium">{purchaseList.length} </span>
</div>
<div className="space-y-1 max-h-[120px] overflow-y-auto">
{purchaseList.map((o, i) => (
<div key={o.orderSn || i} className="flex items-center justify-between p-1.5 bg-[#162840] rounded text-[11px]">
<div className="min-w-0">
<span className="text-amber-300">
{o.productType === 'fullbook' || o.productType === 'vip' ? '全书/VIP' : `章节 ${o.productId || ''}`}
</span>
<span className="text-gray-500 ml-2">¥{Number(o.amount || 0).toFixed(2)}</span>
</div>
<span className="text-gray-500 text-[10px] shrink-0">{o.createdAt ? new Date(o.createdAt).toLocaleString('zh-CN') : ''}</span>
</div>
))}
</div>
</div>
)}
<div className="p-2 bg-[#0a1628] rounded-lg flex flex-col gap-1.5 text-[11px]">
<div className="flex items-center gap-1.5 text-gray-400">
<Navigation className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
@@ -772,11 +917,14 @@ export function UserDetailModal({
<div className="flex-1 pb-0.5 min-w-0 text-xs">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-white font-medium">{track.actionLabel || track.action}</span>
{track.moduleLabel && <span className="text-[#38bdac]/90">· {track.moduleLabel}</span>}
{track.chapterTitle && <span className="text-gray-500">· {track.chapterTitle}</span>}
</div>
{track.target && track.target !== track.chapterTitle && (
<p className="text-gray-600 text-[10px] font-mono mt-0.5 break-all">target: {track.target}</p>
)}
{track.target &&
track.target !== track.chapterTitle &&
!trackTargetIsOpaqueId(track.target) && (
<p className="text-gray-600 text-[10px] mt-0.5 break-all">: {track.target}</p>
)}
<p className="text-gray-500 text-[10px] mt-0.5">
<Clock className="w-3 h-3 inline mr-0.5" />
{track.timeAgo ? `${track.timeAgo} · ` : ''}
@@ -796,6 +944,62 @@ export function UserDetailModal({
{/* ===== 关系链路 ===== */}
<TabsContent value="relations" className="flex-1 min-h-0 overflow-y-auto space-y-2 pr-0.5">
<div className="p-2 bg-[#0a1628] rounded-lg border border-[#38bdac]/25">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Link2 className="w-3.5 h-3.5 text-[#38bdac]" />
<span className="text-white text-sm font-medium"></span>
</div>
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0 text-[10px]">
{inboundSource?.totalVisits || 0}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-2">
<div className="p-1.5 bg-[#162840] rounded text-xs">
<p className="text-gray-500"></p>
<p className="text-white mt-0.5">
{inboundSource?.firstVisit?.referrerNickname || '—'}
{inboundSource?.firstVisit?.referrerId ? `${inboundSource.firstVisit.referrerId}` : ''}
</p>
</div>
<div className="p-1.5 bg-[#162840] rounded text-xs">
<p className="text-gray-500"></p>
<p className="text-white mt-0.5">
{inboundSource?.latestVisit?.referrerNickname || '—'}
{inboundSource?.latestVisit?.referrerId ? `${inboundSource.latestVisit.referrerId}` : ''}
</p>
</div>
</div>
{inboundSource?.activeBinding?.referrerId ? (
<div className="p-1.5 bg-amber-500/10 border border-amber-500/30 rounded text-xs mb-2">
<p className="text-amber-300">
{inboundSource.activeBinding.referrerNickname || '微信用户'}
{`${inboundSource.activeBinding.referrerId}`}
</p>
</div>
) : null}
<div className="space-y-1 max-h-[160px] overflow-y-auto">
{(inboundSource?.visits || []).length > 0 ? (
(inboundSource?.visits || []).map((v, i) => (
<div key={`${v.referrerId || 'unknown'}_${i}`} className="flex items-center justify-between p-1.5 bg-[#162840] rounded text-xs">
<div className="min-w-0">
<p className="text-white truncate">
{v.seq || i + 1} · {v.referrerNickname || '微信用户'}
{v.referrerId ? `${v.referrerId}` : ''}
</p>
{v.page ? <p className="text-gray-500 text-[10px] truncate">{v.page}</p> : null}
</div>
<span className="text-gray-500 text-[10px] shrink-0">
{v.visitedAt ? new Date(v.visitedAt).toLocaleString() : ''}
</span>
</div>
))
) : (
<p className="text-gray-500 text-sm text-center py-2"></p>
)}
</div>
</div>
<div className="p-2 bg-[#0a1628] rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">

View File

@@ -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<MbtiType, MbtiAvatarProfile> = {
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 = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="g" cx="50%" cy="35%" r="70%">
<stop offset="0%" stop-color="#1a2a42"/>
<stop offset="100%" stop-color="${palette.bg}"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="url(#g)"/>
<circle cx="256" cy="256" r="228" fill="none" stroke="#e6bd4f" stroke-width="8"/>
<circle cx="256" cy="256" r="214" fill="#f6f8fb"/>
<g transform="rotate(${face.tilt} 256 256)">
<path d="${shoulder}" fill="${palette.body}" stroke="${palette.line}" stroke-width="5" stroke-linejoin="round"/>
<polygon points="214,190 256,164 298,190 290,250 222,250" fill="#f8e9d8" stroke="${palette.line}" stroke-width="5"/>
<path d="M206 196 L226 150 L286 150 L306 196 L286 206 L226 206 Z" fill="${palette.hair}" stroke="${palette.line}" stroke-width="5" stroke-linejoin="round"/>
<circle cx="220" cy="186" r="9" fill="${palette.hair}"/>
<circle cx="292" cy="186" r="9" fill="${palette.hair}"/>
<path d="${face.brow}" fill="none" stroke="${palette.line}" stroke-width="5" stroke-linecap="round"/>
<path d="${face.eye}" fill="none" stroke="${palette.line}" stroke-width="5" stroke-linecap="round"/>
<path d="M256 228 L250 240 L262 240" fill="none" stroke="${palette.line}" stroke-width="4" stroke-linecap="round"/>
<path d="${face.mouth}" fill="none" stroke="${palette.line}" stroke-width="5" stroke-linecap="round"/>
<path d="M212 332 L256 356 L300 332" fill="none" stroke="${palette.accent}" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>`
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
}

View File

@@ -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<AdminUser[]>([])
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<ListRes>(`/api/admin/admin-users?${params}`)
const data = await get<ListRes>(`/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) {

View File

@@ -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<string>('week')
const [trackPeriod, setTrackPeriod] = useState<string>('today')
const [trackStats, setTrackStats] = useState<{
total: number
byModule: Record<string, { action: string; target: string; module: string; page: string; count: number }[]>
} | 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<VipMemberLite[]>([])
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<MatchStatsRes>('/api/db/match-records?stats=true', init),
get<DistributionOverviewRes>('/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<string, string> = {
home: '首页',
chapters: '目录',
read: '阅读页',
my: '我的',
vip: '超级个体',
wallet: '钱包',
match: '找伙伴',
referral: '推广中心',
search: '搜索',
settings: '设置',
about: '关于',
member_detail: '成员详情',
other: '其他',
}
const actionLabels: Record<string, string> = {
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<string, string> = {
'开始匹配': '开始匹配',
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() {
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="flex flex-nowrap gap-6 mb-8 overflow-x-auto pb-1">
{stats.map((stat, index) => (
<Card
key={index}
className="bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
className="min-w-[220px] flex-1 bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
onClick={() => stat.link && navigate(stat.link)}
>
<CardHeader className="flex flex-row items-center justify-between pb-2">
@@ -433,6 +677,63 @@ export function DashboardPage() {
</div>
{bottomTab === 'overview' && (
<div className="space-y-8">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white"> × 广</CardTitle>
<button
type="button"
onClick={() => loadAll()}
disabled={partnerPromoLoading}
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1 disabled:opacity-50"
title="刷新共统计"
>
<RefreshCw className={`w-3.5 h-3.5 ${partnerPromoLoading ? 'animate-spin' : ''}`} />
</button>
</CardHeader>
<CardContent>
{partnerPromoLoading && !matchStats && !distributionOverview ? (
<div className="flex items-center justify-center py-10 text-gray-500">
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-white mt-1">{matchStats?.totalMatches ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-white mt-1">{matchStats?.todayMatches ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-white mt-1">{matchStats?.uniqueUsers ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400">广</p>
<p className="text-2xl font-bold text-white mt-1">{distributionOverview?.totalClicks ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400">广</p>
<p className="text-2xl font-bold text-white mt-1">{distributionOverview?.totalBindings ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400">广</p>
<p className="text-2xl font-bold text-white mt-1">{distributionOverview?.totalConversions ?? 0}</p>
</div>
</div>
)}
{distributionOverview?.conversionRate && (
<p className="text-xs text-gray-500 mt-3">
广{distributionOverview.conversionRate}
</p>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="flex flex-row items-center justify-between">
@@ -488,7 +789,7 @@ export function DashboardPage() {
<img
src={p.userAvatar}
alt={buyer}
className="w-9 h-9 rounded-full object-cover flex-shrink-0 mt-0.5"
className="w-9 h-9 rounded-full object-cover shrink-0 mt-0.5"
onError={(e) => {
e.currentTarget.style.display = 'none'
const next = e.currentTarget.nextElementSibling as HTMLElement
@@ -497,7 +798,7 @@ export function DashboardPage() {
/>
) : null}
<div
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
>
{buyer.charAt(0)}
</div>
@@ -537,7 +838,7 @@ export function DashboardPage() {
</div>
</div>
<div className="text-right ml-4 flex-shrink-0">
<div className="text-right ml-4 shrink-0">
<p className="text-sm font-bold text-[#38bdac]">
+¥{Number(p.amount).toFixed(2)}
</p>
@@ -620,6 +921,7 @@ export function DashboardPage() {
</CardContent>
</Card>
</div>
</div>
)}
{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<string, string> = {
home: '首页', chapters: '目录', read: '阅读', my: '我的',
vip: 'VIP', wallet: '钱包', match: '找伙伴', referral: '推广',
search: '搜索', settings: '设置', about: '关于', other: '其他',
}
return (
<div key={mod} className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
<div className="flex items-center justify-between mb-3">
@@ -681,28 +978,10 @@ export function DashboardPage() {
.sort((a, b) => b.count - a.count)
.slice(0, 8)
.map((item, i) => {
const targetLabels: Record<string, string> = {
'开始匹配': '开始匹配', '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<string, string> = {
'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 (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-gray-300 truncate mr-2" title={`${item.action}: ${item.target}`}>
<span className="text-gray-300 truncate mr-2" title={label}>
{label}
</span>
<div className="flex items-center gap-2 shrink-0">

View File

@@ -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 || '微信支付'}
</TableCell>
<TableCell>
{purchase.status === 'refunded' ? (
<Badge className="bg-gray-500/20 text-gray-400 hover:bg-gray-500/20 border-0">
退
</Badge>
) : purchase.status === 'paid' || purchase.status === 'completed' ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
</Badge>
) : purchase.status === 'pending' || purchase.status === 'created' ? (
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
</Badge>
)}
<div className="flex items-center gap-2 flex-wrap">
{purchase.status === 'refunded' ? (
<Badge className="bg-gray-500/20 text-gray-400 hover:bg-gray-500/20 border-0">
退
</Badge>
) : purchase.status === 'paid' || purchase.status === 'completed' ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
</Badge>
) : purchase.status === 'pending' || purchase.status === 'created' ? (
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
</Badge>
)}
{(purchase.status === 'paid' || purchase.status === 'completed') &&
(purchase.webhookPushStatus === 'sent' ? (
<Badge className="bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/20 border-0">
</Badge>
) : (
<Badge className="bg-orange-500/20 text-orange-300 hover:bg-orange-500/20 border-0">
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-gray-400 text-sm max-w-[120px] truncate" title={purchase.refundReason}>
{purchase.status === 'refunded' && purchase.refundReason ? purchase.refundReason : '-'}

View File

@@ -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<string, Record<string, string>> = {
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<string, Record<string, string>> = {
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<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(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<Record<string, string>>({})
const [mbtiLoading, setMbtiLoading] = useState(false)
const [mbtiSaving, setMbtiSaving] = useState(false)
const loadMbtiAvatars = async () => {
setMbtiLoading(true)
try {
const res = await get<{ success?: boolean; avatars?: Record<string, string> }>('/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 <div className="p-8 text-gray-500">...</div>
@@ -440,7 +432,45 @@ export function SettingsPage() {
</TabsList>
<TabsContent value="system" className="mt-0">
<div className="space-y-6">
<p className="text-xs text-gray-500 mb-3">
MBTI {' '}
<Link to="/users" className="text-[#38bdac] underline">
</Link>
</p>
<Tabs value={systemSection} onValueChange={handleSystemSectionChange} className="w-full">
<TabsList className="mb-4 bg-[#0a1628] border border-gray-700/50 p-1 flex-wrap h-auto gap-1">
<TabsTrigger
value="basic"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<LayoutGrid className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
<TabsTrigger
value="mp"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<Smartphone className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
<TabsTrigger
value="oss"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<Cloud className="w-3.5 h-3.5 mr-1" />
OSS
</TabsTrigger>
<TabsTrigger
value="features"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<Settings className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -641,7 +671,9 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="mp" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -730,6 +762,43 @@ export function SettingsPage() {
</CardContent>
</Card>
<Card className={`bg-[#0f2137] shadow-xl ${mpConfig.auditMode ? 'border-amber-500/50 border-2' : 'border-gray-700/50'}`}>
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-amber-400" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent>
<div className={`flex items-center justify-between p-4 rounded-lg border ${mpConfig.auditMode ? 'bg-amber-500/10 border-amber-500/30' : 'bg-[#0a1628] border-gray-700/50'}`}>
<div className="space-y-1">
<div className="flex items-center gap-2">
<ShieldCheck className={`w-4 h-4 ${mpConfig.auditMode ? 'text-amber-400' : 'text-gray-400'}`} />
<Label htmlFor="audit-mode" className="text-white font-medium cursor-pointer">
{mpConfig.auditMode ? '审核模式(已开启)' : '审核模式(已关闭)'}
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
{mpConfig.auditMode
? '当前已隐藏所有支付、VIP、充值、收益等入口审核员看不到任何付费内容'
: '关闭状态小程序正常显示所有功能含支付、VIP 等)'}
</p>
</div>
<Switch
id="audit-mode"
checked={mpConfig.auditMode ?? false}
disabled={auditModeSaving}
onCheckedChange={handleAuditModeSwitch}
/>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="oss" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -801,42 +870,9 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</TabsContent>
<Card className={`bg-[#0f2137] shadow-xl ${mpConfig.auditMode ? 'border-amber-500/50 border-2' : 'border-gray-700/50'}`}>
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-amber-400" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent>
<div className={`flex items-center justify-between p-4 rounded-lg border ${mpConfig.auditMode ? 'bg-amber-500/10 border-amber-500/30' : 'bg-[#0a1628] border-gray-700/50'}`}>
<div className="space-y-1">
<div className="flex items-center gap-2">
<ShieldCheck className={`w-4 h-4 ${mpConfig.auditMode ? 'text-amber-400' : 'text-gray-400'}`} />
<Label htmlFor="audit-mode" className="text-white font-medium cursor-pointer">
{mpConfig.auditMode ? '审核模式(已开启)' : '审核模式(已关闭)'}
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
{mpConfig.auditMode
? '当前已隐藏所有支付、VIP、充值、收益等入口审核员看不到任何付费内容'
: '关闭状态小程序正常显示所有功能含支付、VIP 等)'}
</p>
</div>
<Switch
id="audit-mode"
checked={mpConfig.auditMode ?? false}
disabled={auditModeSaving}
onCheckedChange={handleAuditModeSwitch}
/>
</div>
</CardContent>
</Card>
<TabsContent value="features" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -933,53 +969,6 @@ export function SettingsPage() {
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Smile className="w-5 h-5 text-[#38bdac]" />
MBTI
</CardTitle>
<CardDescription className="text-gray-400">
16 MBTI URL使
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{mbtiLoading ? (
<p className="text-gray-500 text-sm">...</p>
) : (
<>
<div className="grid grid-cols-2 gap-3">
{MBTI_TYPES.map((t) => (
<div key={t} className="flex items-center gap-2">
<span className="text-xs text-[#38bdac] font-mono w-10 flex-shrink-0">{t}</span>
{mbtiAvatars[t] && (
<img src={mbtiAvatars[t]} alt={t} className="w-8 h-8 rounded-full flex-shrink-0 object-cover border border-gray-600" />
)}
<Input
className="bg-[#0a1628] border-gray-700 text-white text-xs h-8 flex-1"
placeholder="头像 URL"
value={mbtiAvatars[t] ?? ''}
onChange={(e) =>
setMbtiAvatars((prev) => ({ ...prev, [t]: e.target.value }))
}
/>
</div>
))}
</div>
<Button
onClick={saveMbtiAvatars}
disabled={mbtiSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
size="sm"
>
<Save className="w-3 h-3 mr-1" />
{mbtiSaving ? '保存中...' : '保存头像映射'}
</Button>
</>
)}
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -998,7 +987,7 @@ export function SettingsPage() {
{ mod: '搜索', ctrl: '搜索功能开关', icon: <BookOpen className="w-3 h-3" /> },
{ mod: '关于页面', ctrl: '关于页面开关', icon: <UserCircle className="w-3 h-3" /> },
{ mod: '支付 / VIP / 充值 / 收益', ctrl: '审核模式', icon: <ShieldCheck className="w-3 h-3" /> },
{ mod: '超级个体名片', ctrl: '审核模式', icon: <Smile className="w-3 h-3" /> },
{ mod: '超级个体名片', ctrl: '审核模式', icon: <Sparkles className="w-3 h-3" /> },
{ mod: '首页获客入口', ctrl: '已移除', icon: <EyeOff className="w-3 h-3" /> },
].map((r) => (
<div key={r.mod} className="flex items-center gap-2 p-2 rounded bg-[#0a1628] border border-gray-700/30">
@@ -1012,7 +1001,8 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="author" className="mt-0">

View File

@@ -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<string, unknown>
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<User[]>([])
@@ -159,6 +198,7 @@ export function UsersPage() {
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState<string | null>(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<UserRule | null>(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<VipMember[]>([])
@@ -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<Record<string, string>>({})
// ===== 获客列表(存客宝) =====
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<string, string> }>('/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<number | null>(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<VipMember | null>(null)
const [vipRoleInput, setVipRoleInput] = useState('')
const [vipRoleSaving, setVipRoleSaving] = useState(false)
const [showVipWebhookModal, setShowVipWebhookModal] = useState(false)
const [vipWebhookModalMember, setVipWebhookModalMember] = useState<VipMember | null>(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<VipMember | null>(null)
const [vipSortInput, setVipSortInput] = useState('')
@@ -879,34 +1010,48 @@ export function UsersPage() {
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] overflow-hidden">
{user.avatar ? (
<img
src={user.avatar}
className="w-full h-full rounded-full object-cover"
alt=""
onError={(e) => {
(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) || '?'}
</div>
<div>
{(() => {
const avatarUrl = resolveUserAvatarByMbti(user.avatar, user.mbti)
const initial = user.nickname?.charAt(0) || '?'
return (
<button
type="button"
title="点击管理 MBTI 默认头像库"
onClick={() => setShowMbtiAvatarDialog(true)}
className="w-10 h-10 shrink-0 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] overflow-hidden ring-1 ring-transparent hover:ring-[#38bdac]/60 transition"
>
{avatarUrl ? (
<img
src={avatarUrl}
className="w-full h-full rounded-full object-cover"
alt=""
onError={(e) => {
const img = e.target as HTMLImageElement
img.style.display = 'none'
if (img.nextElementSibling) return
const span = document.createElement('span')
span.textContent = initial
img.parentElement?.appendChild(span)
}}
/>
) : initial}
</button>
)
})()}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }}
className="font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
className="font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left truncate max-w-[120px]"
>
{user.nickname}
</button>
{user.isAdmin && <Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs"></Badge>}
{user.openId && !user.id?.startsWith('user_') && <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0 text-xs"></Badge>}
</div>
<p className="text-xs text-gray-500 font-mono">
{user.openId ? user.openId.slice(0, 12) + '...' : user.id?.slice(0, 12)}
<p className="text-xs text-gray-500 font-mono truncate max-w-[140px]" title={user.id}>
{user.id?.slice(0, 16)}{(user.id?.length ?? 0) > 16 ? '…' : ''}
</p>
</div>
</div>
@@ -915,16 +1060,38 @@ export function UsersPage() {
<div className="space-y-1">
{user.phone && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">📱</span><span className="text-gray-300">{user.phone}</span></div>}
{user.wechatId && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">💬</span><span className="text-gray-300">{user.wechatId}</span></div>}
{user.openId && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">🔗</span><span className="text-gray-500 truncate max-w-[100px]" title={user.openId}>{user.openId.slice(0, 12)}...</span></div>}
{!user.phone && !user.wechatId && !user.openId && <span className="text-gray-600 text-xs"></span>}
{!user.phone && !user.wechatId && <span className="text-gray-600 text-xs"></span>}
</div>
</TableCell>
<TableCell>
{user.hasFullBook ? (
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0">VIP</Badge>
) : (
<Badge variant="outline" className="text-gray-500 border-gray-600"></Badge>
)}
{(() => {
const purchase = getPurchaseState(user)
if (purchase.tone === 'vip') {
return (
<div className="space-y-1">
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0">
{purchase.main}
</Badge>
{purchase.sub && <p className="text-[11px] text-amber-300/80">{purchase.sub}</p>}
</div>
)
}
if (purchase.tone === 'paid') {
return (
<div className="space-y-1">
<Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0">
{purchase.main}
</Badge>
{purchase.sub && <p className="text-[11px] text-blue-300/80">{purchase.sub}</p>}
</div>
)
}
return (
<Badge variant="outline" className="text-gray-500 border-gray-600">
{purchase.main}
</Badge>
)
})()}
</TableCell>
<TableCell>
<div className="space-y-1">
@@ -939,15 +1106,13 @@ export function UsersPage() {
</TableCell>
{/* RFM 分值列 */}
<TableCell>
{user.rfmScore !== undefined ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<span className="text-white font-bold text-base">{user.rfmScore}</span>
<Badge className={`border-0 text-xs ${getRFMLevelColor(user.rfmLevel)}`}>{user.rfmLevel}</Badge>
</div>
{user.rfmScore != null && user.rfmScore !== undefined ? (
<div className="flex items-center gap-1.5">
<span className="text-white font-bold text-base">{user.rfmScore}</span>
<Badge className={`border-0 text-xs ${getRFMLevelColor(user.rfmLevel)}`}>{user.rfmLevel}</Badge>
</div>
) : (
<span className="text-gray-600 text-sm"> <span className="text-xs text-gray-700"></span></span>
<span className="text-gray-600 text-xs"></span>
)}
</TableCell>
<TableCell>
@@ -1329,7 +1494,7 @@ export function UsersPage() {
<Button variant="outline" onClick={loadRules} disabled={rulesLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${rulesLoading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={() => { setEditingRule(null); setRuleForm({ title: '', description: '', trigger: '', sort: 0, enabled: true }); setShowRuleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Button onClick={() => { setEditingRule(null); setRuleForm({ title: '', description: '', trigger: '', triggerConditions: [], actionType: 'popup', sort: 0, enabled: true }); setShowRuleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
@@ -1353,17 +1518,34 @@ export function UsersPage() {
<PenLine className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
<span className="text-white font-medium text-sm truncate">{rule.title}</span>
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-[10px] shrink-0">{rule.trigger}</Badge>}
{(rule.triggerConditions || []).length > 0 && (
<div className="flex flex-wrap gap-0.5 ml-1">
{(rule.triggerConditions || []).slice(0, 3).map((tc) => {
const opt = TRIGGER_OPTIONS.find((o) => o.value === tc)
return <Badge key={tc} className="bg-purple-500/10 text-purple-400 border border-purple-500/30 text-[9px]">{opt?.label || tc}</Badge>
})}
{(rule.triggerConditions || []).length > 3 && <span className="text-gray-500 text-[9px]">+{(rule.triggerConditions || []).length - 3}</span>}
</div>
)}
{rule.actionType && rule.actionType !== 'popup' && (
<Badge className="bg-amber-500/10 text-amber-400 border border-amber-500/30 text-[9px] shrink-0">
{ACTION_TYPE_OPTIONS.find((o) => o.value === rule.actionType)?.label || rule.actionType}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 ml-3 shrink-0">
<Switch checked={rule.enabled} onCheckedChange={() => handleToggleRule(rule)} />
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10 h-7 w-7 p-0"><Edit3 className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, triggerConditions: rule.triggerConditions || [], actionType: rule.actionType || 'popup', sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10 h-7 w-7 p-0"><Edit3 className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 w-7 p-0"><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</div>
{rule.description && (
<details className="ml-[52px] mt-1">
<summary className="text-gray-500 text-xs cursor-pointer hover:text-gray-400 select-none"></summary>
<p className="text-gray-400 text-sm mt-1 pl-1 border-l-2 border-gray-700">{rule.description}</p>
<summary className="text-gray-500 text-xs cursor-pointer hover:text-gray-400 select-none">
<span className="text-gray-600 ml-1">{rule.description.length} </span>
</summary>
<p className="text-gray-400 text-sm mt-1 pl-1 border-l-2 border-gray-700 whitespace-pre-wrap">{rule.description}</p>
</details>
)}
</div>
@@ -1417,9 +1599,10 @@ export function UsersPage() {
<TableHead className="text-gray-400 w-12"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 min-w-40"></TableHead>
<TableHead className="text-gray-400 w-16 text-center"></TableHead>
<TableHead className="text-gray-400 w-16 text-center"></TableHead>
<TableHead className="text-gray-400 w-16 text-center"></TableHead>
<TableHead className="text-gray-400 w-20"></TableHead>
<TableHead className="text-gray-400 w-36"></TableHead>
<TableHead className="text-gray-400 w-36 text-right"></TableHead>
</TableRow>
</TableHeader>
@@ -1442,9 +1625,9 @@ export function UsersPage() {
<TableCell className="text-gray-300">{index + 1}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
{m.avatar ? (
{resolveUserAvatarByMbti(m.avatar, m.mbti) ? (
<img
src={m.avatar}
src={resolveUserAvatarByMbti(m.avatar, m.mbti)}
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
alt=""
onError={(e) => {
@@ -1481,6 +1664,15 @@ export function UsersPage() {
<TableCell className="text-gray-300">
{m.vipSort ?? index + 1}
</TableCell>
<TableCell className="text-xs">
{m.webhookUrl ? (
<span className="text-[#38bdac] truncate block max-w-[180px]" title={m.webhookUrl}>
</span>
) : (
<span className="text-gray-500"></span>
)}
</TableCell>
<TableCell className="text-right text-xs text-gray-300">
<div className="inline-flex items-center gap-1.5">
<Button
@@ -1496,11 +1688,8 @@ export function UsersPage() {
variant="ghost"
size="sm"
className="h-7 w-7 px-0 text-[#38bdac] hover:text-[#5fe0cd]"
onClick={() => {
setSelectedUserIdForDetail(m.id)
setShowDetailModal(true)
}}
title="编辑资料"
onClick={() => openVipWebhookModal(m)}
title="编辑飞书群Webhook"
>
<Edit3 className="w-3.5 h-3.5" />
</Button>
@@ -1523,8 +1712,19 @@ export function UsersPage() {
</Card>
)}
</TabsContent>
</Tabs>
<Dialog open={showMbtiAvatarDialog} onOpenChange={setShowMbtiAvatarDialog}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-6xl">
<DialogHeader>
<DialogTitle className="text-white">MBTI </DialogTitle>
</DialogHeader>
<MbtiAvatarsManager />
</DialogContent>
</Dialog>
{/* ===== 弹框组件 ===== */}
{/* 添加/编辑用户 */}
@@ -1603,6 +1803,38 @@ export function UsersPage() {
</DialogContent>
</Dialog>
{/* 设置超级个体飞书群 Webhook */}
<Dialog open={showVipWebhookModal} onOpenChange={(open) => { setShowVipWebhookModal(open); if (!open) setVipWebhookModalMember(null) }}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-xl">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />
Webhook {vipWebhookModalMember?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Label className="text-gray-300 text-sm">VOX Webhook </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
value={vipWebhookInput}
onChange={(e) => setVipWebhookInput(e.target.value)}
/>
<p className="text-xs text-gray-500">
线
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowVipWebhookModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSetVipWebhook} disabled={vipWebhookSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-4 h-4 mr-2" />{vipWebhookSaving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2">{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}{editingUser ? '编辑用户' : '添加用户'}</DialogTitle></DialogHeader>
@@ -1622,12 +1854,71 @@ export function UsersPage() {
{/* 添加/编辑规则 */}
<Dialog open={showRuleModal} onOpenChange={setShowRuleModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><PenLine className="w-5 h-5 text-[#38bdac]" />{editingRule ? '编辑规则' : '添加规则'}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2"><Label className="text-gray-300"> *</Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="例匹配后填写头像、付款1980需填写信息" value={ruleForm.title} onChange={(e) => setRuleForm({ ...ruleForm, title: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Textarea className="bg-[#0a1628] border-gray-700 text-white min-h-[80px] resize-none" placeholder="详细说明规则内容..." value={ruleForm.description} onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="例:完成匹配、付款后、注册时" value={ruleForm.trigger} onChange={(e) => setRuleForm({ ...ruleForm, trigger: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Textarea className="bg-[#0a1628] border-gray-700 text-white min-h-[60px] resize-none" placeholder="弹窗内容/推送文案..." value={ruleForm.description} onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })} /></div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="space-y-2">
{['用户状态', '阅读行为', '付费行为', '社交行为', '分销行为'].map((group) => {
const items = TRIGGER_OPTIONS.filter((o) => o.group === group)
if (items.length === 0) return null
return (
<div key={group}>
<p className="text-[10px] text-gray-500 mb-1">{group}</p>
<div className="flex flex-wrap gap-1.5">
{items.map((opt) => {
const selected = (ruleForm.triggerConditions || []).includes(opt.value)
return (
<button
key={opt.value}
type="button"
className={`px-2.5 py-1 rounded-md text-xs border transition-colors ${selected ? 'bg-[#38bdac]/20 border-[#38bdac]/50 text-[#38bdac]' : 'bg-[#0a1628] border-gray-700 text-gray-400 hover:border-gray-500'}`}
onClick={() => {
const current = ruleForm.triggerConditions || []
const next = selected ? current.filter((v) => v !== opt.value) : [...current, opt.value]
setRuleForm({ ...ruleForm, triggerConditions: next })
}}
>
{opt.label}
</button>
)
})}
</div>
</div>
)
})}
</div>
{(ruleForm.triggerConditions || []).length > 0 && (
<p className="text-[10px] text-[#38bdac]"> {(ruleForm.triggerConditions || []).length} </p>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white text-xs h-8" placeholder="例after_login、after_pay兼容现有小程序 ruleEngine" value={ruleForm.trigger} onChange={(e) => setRuleForm({ ...ruleForm, trigger: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="grid grid-cols-2 gap-2">
{ACTION_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`p-2 rounded-lg border text-left transition-colors ${ruleForm.actionType === opt.value ? 'bg-[#38bdac]/15 border-[#38bdac]/50' : 'bg-[#0a1628] border-gray-700 hover:border-gray-500'}`}
onClick={() => setRuleForm({ ...ruleForm, actionType: opt.value })}
>
<span className={`text-xs font-medium ${ruleForm.actionType === opt.value ? 'text-[#38bdac]' : 'text-gray-300'}`}>{opt.label}</span>
<p className="text-[10px] text-gray-500 mt-0.5">{opt.desc}</p>
</button>
))}
</div>
</div>
<div className="flex items-center justify-between"><div><Label className="text-gray-300"></Label></div><Switch checked={ruleForm.enabled} onCheckedChange={(c) => setRuleForm({ ...ruleForm, enabled: c })} /></div>
</div>
<DialogFooter>

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}

View File

@@ -92,6 +92,8 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.Person{}); err != nil {
log.Printf("database: persons migrate warning: %v", err)
}
// persons 历史库可能因旧索引冲突导致 AutoMigrate 中断,补一层列级自愈,避免 /api/db/persons 报 Unknown column。
ensurePersonSchema(db)
if err := db.AutoMigrate(&model.LinkTag{}); err != nil {
log.Printf("database: link_tags migrate warning: %v", err)
}
@@ -125,3 +127,22 @@ func Init(dsn string) error {
func DB() *gorm.DB {
return db
}
func ensurePersonSchema(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.Person{}, "is_pinned") {
if err := db.Exec("ALTER TABLE persons ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0 COMMENT '置顶到小程序首页'").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=add is_pinned", err)
}
}
if !m.HasColumn(&model.Person{}, "person_source") {
if err := db.Exec("ALTER TABLE persons ADD COLUMN person_source VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源:空=后台手工vip_sync=超级个体同步'").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=add person_source", err)
}
}
if !m.HasIndex(&model.Person{}, "idx_persons_is_pinned") {
if err := db.Exec("CREATE INDEX idx_persons_is_pinned ON persons(is_pinned)").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=create idx_persons_is_pinned", err)
}
}
}

View File

@@ -307,7 +307,7 @@ func AdminDashboardMerchantBalance(c *gin.Context) {
}
// AdminSuperIndividualStats GET /api/admin/super-individual/stats
// 超级个体点击/获客统计:从 user_tracks 中筛选 target LIKE '超级个体_%' 的记录
// 超级个体点击/获客统计:从 user_tracks 中筛选「点击头像」记录(target LIKE '链接头像_%'
// 按被点击的超级个体 ID 分组,统计点击次数、独立点击用户数
func AdminSuperIndividualStats(c *gin.Context) {
db := database.DB()
@@ -324,7 +324,8 @@ func AdminSuperIndividualStats(c *gin.Context) {
COUNT(*) AS clicks,
COUNT(DISTINCT user_id) AS unique_clicks
FROM user_tracks
WHERE action = 'card_click' AND target LIKE '超级个体\_%'
WHERE action IN ('avatar_click', 'btn_click')
AND target LIKE '链接头像\_%'
GROUP BY target_id
ORDER BY clicks DESC
`).Scan(&rows).Error; err != nil {

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
@@ -92,11 +93,14 @@ func DBUserRulesAction(c *gin.Context) {
switch c.Request.Method {
case http.MethodPost:
var body struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Trigger string `json:"trigger"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Trigger string `json:"trigger"`
TriggerConditions interface{} `json:"triggerConditions"`
ActionType string `json:"actionType"`
ActionConfig interface{} `json:"actionConfig"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
@@ -110,9 +114,20 @@ func DBUserRulesAction(c *gin.Context) {
Title: trimSpace(body.Title),
Description: body.Description,
Trigger: trimSpace(body.Trigger),
ActionType: trimSpace(body.ActionType),
Sort: body.Sort,
Enabled: enabled,
}
if body.TriggerConditions != nil {
if b, err := json.Marshal(body.TriggerConditions); err == nil {
rule.TriggerConditions = b
}
}
if body.ActionConfig != nil {
if b, err := json.Marshal(body.ActionConfig); err == nil {
rule.ActionConfig = b
}
}
if err := db.Create(&rule).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
@@ -121,12 +136,15 @@ func DBUserRulesAction(c *gin.Context) {
case http.MethodPut:
var body struct {
ID uint `json:"id" binding:"required"`
Title string `json:"title"`
Description string `json:"description"`
Trigger string `json:"trigger"`
Sort *int `json:"sort"`
Enabled *bool `json:"enabled"`
ID uint `json:"id" binding:"required"`
Title string `json:"title"`
Description string `json:"description"`
Trigger string `json:"trigger"`
TriggerConditions interface{} `json:"triggerConditions"`
ActionType string `json:"actionType"`
ActionConfig interface{} `json:"actionConfig"`
Sort *int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
@@ -147,6 +165,17 @@ func DBUserRulesAction(c *gin.Context) {
}
updates["description"] = body.Description
updates["trigger"] = trimSpace(body.Trigger)
updates["action_type"] = trimSpace(body.ActionType)
if body.TriggerConditions != nil {
if b, err := json.Marshal(body.TriggerConditions); err == nil {
updates["trigger_conditions"] = string(b)
}
}
if body.ActionConfig != nil {
if b, err := json.Marshal(body.ActionConfig); err == nil {
updates["action_config"] = string(b)
}
}
if body.Sort != nil {
updates["sort"] = *body.Sort
}

View File

@@ -85,6 +85,8 @@ type cachedPartRow struct {
Subtitle string `json:"subtitle"`
ChapterCount int `json:"chapterCount"`
MinSortOrder int `json:"minSortOrder"`
// Icon 可选system_config.book_part_icons JSON 中按 part_id 配置的封面图 URL
Icon string `json:"icon,omitempty"`
}
type cachedFixedItem struct {
ID string `json:"id"`
@@ -109,6 +111,48 @@ var bookPartsCache struct {
const bookPartsCacheTTL = 30 * time.Second
// loadBookPartIconURLs 读取 system_config.book_part_icons{"part-1":"https://..."}key 与 chapters.part_id 一致
func loadBookPartIconURLs() map[string]string {
out := map[string]string{}
var row model.SystemConfig
if err := database.DB().Where("config_key = ?", "book_part_icons").First(&row).Error; err != nil {
return out
}
var raw map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &raw); err != nil {
return out
}
for k, v := range raw {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if s, ok := v.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
out[k] = s
}
}
}
return out
}
// mergeBookPartIcons 将配置中的篇封面 URL 写入 parts每次接口响应前调用避免 Redis 旧缓存缺 icon
func mergeBookPartIcons(parts []cachedPartRow) {
if len(parts) == 0 {
return
}
m := loadBookPartIconURLs()
if len(m) == 0 {
return
}
for i := range parts {
if u := strings.TrimSpace(m[parts[i].PartID]); u != "" {
parts[i].Icon = u
}
}
}
// chaptersByPartCache 篇章内章节列表内存缓存30 秒 TTL
type chaptersByPartEntry struct {
data []model.Chapter
@@ -320,6 +364,7 @@ func BookParts(c *gin.Context) {
// 1. 优先 Redis后台无更新时长期有效
var redisPayload bookPartsRedisPayload
if cache.Get(context.Background(), cache.KeyBookParts, &redisPayload) && len(redisPayload.Parts) > 0 {
mergeBookPartIcons(redisPayload.Parts)
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": redisPayload.Parts,
@@ -336,6 +381,7 @@ func BookParts(c *gin.Context) {
total := bookPartsCache.total
fixed := bookPartsCache.fixed
bookPartsCache.mu.RUnlock()
mergeBookPartIcons(parts)
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": parts,
@@ -348,6 +394,7 @@ func BookParts(c *gin.Context) {
// 3. DB 查询并更新 Redis + 内存
parts, total, fixed := fetchAndCacheBookParts()
mergeBookPartIcons(parts)
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)

View File

@@ -429,6 +429,7 @@ func CKBIndexLead(c *gin.Context) {
Phone: phone,
Wechat: wechatId,
PersonName: personName,
TargetMemberID: "",
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
@@ -497,6 +498,7 @@ func CKBLead(c *gin.Context) {
// 首页链接卡若targetUserId 为空 → 用全局 getCkbLeadApiKey()
leadKey := getCkbLeadApiKey()
targetName := strings.TrimSpace(body.TargetNickname)
targetMemberID := strings.TrimSpace(body.TargetMemberID)
personTips := "" // Person 配置的获客成功提示,优先于默认文案
if body.TargetUserID != "" {
var p model.Person
@@ -513,6 +515,11 @@ func CKBLead(c *gin.Context) {
if targetName == "" {
targetName = p.Name
}
if targetMemberID == "" {
if p.UserID != nil {
targetMemberID = strings.TrimSpace(*p.UserID)
}
}
}
// 去重:同一用户对同一目标人物只记录一次(不再限制时间间隔,允许对不同人物立即提交)
@@ -611,6 +618,7 @@ func CKBLead(c *gin.Context) {
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
@@ -684,6 +692,7 @@ func CKBLead(c *gin.Context) {
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
@@ -720,6 +729,7 @@ type leadWebhookPayload struct {
Wechat string
PersonName string // 对接人Person 表 name / targetNickname
MemberName string // 超级个体名称targetMemberName
TargetMemberID string // 超级个体 userId用于按人路由 webhook
Source string // 技术来源标识
Repeated bool
LeadUserID string // 留资用户ID用于查询行为轨迹
@@ -750,31 +760,45 @@ var _webhookDedupCache = struct {
m map[string]string
}{m: make(map[string]string)}
func webhookShouldSkip(userId string) bool {
if userId == "" {
func webhookShouldSkip(userId string, targetMemberID string) bool {
if userId == "" && targetMemberID == "" {
return false
}
today := time.Now().Format("2006-01-02")
key := strings.TrimSpace(userId) + "|" + strings.TrimSpace(targetMemberID)
if key == "|" {
return false
}
_webhookDedupCache.Lock()
defer _webhookDedupCache.Unlock()
if _webhookDedupCache.m[userId] == today {
if _webhookDedupCache.m[key] == today {
return true
}
_webhookDedupCache.m[userId] = today
_webhookDedupCache.m[key] = today
if len(_webhookDedupCache.m) > 10000 {
_webhookDedupCache.m = map[string]string{userId: today}
_webhookDedupCache.m = map[string]string{key: today}
}
return false
}
func sendLeadWebhook(db *gorm.DB, p leadWebhookPayload) {
if p.LeadUserID != "" && webhookShouldSkip(p.LeadUserID) {
log.Printf("webhook: skip duplicate for user %s today", p.LeadUserID)
return
func loadLeadWebhookURL(db *gorm.DB, targetMemberID string) string {
// 优先按超级个体 userId 映射(单人单群)
targetMemberID = strings.TrimSpace(targetMemberID)
if targetMemberID != "" {
var mapCfg model.SystemConfig
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&mapCfg).Error; err == nil && len(mapCfg.ConfigValue) > 0 {
var m map[string]string
if json.Unmarshal(mapCfg.ConfigValue, &m) == nil {
if u := strings.TrimSpace(m[targetMemberID]); u != "" && strings.HasPrefix(u, "http") {
return u
}
}
}
}
// 回退全局获客 webhook
var cfg model.SystemConfig
if db.Where("config_key = ?", "ckb_lead_webhook_url").First(&cfg).Error != nil {
return
return ""
}
var webhookURL string
if len(cfg.ConfigValue) > 0 {
@@ -782,6 +806,18 @@ func sendLeadWebhook(db *gorm.DB, p leadWebhookPayload) {
}
webhookURL = strings.TrimSpace(webhookURL)
if webhookURL == "" || !strings.HasPrefix(webhookURL, "http") {
return ""
}
return webhookURL
}
func sendLeadWebhook(db *gorm.DB, p leadWebhookPayload) {
if p.LeadUserID != "" && webhookShouldSkip(p.LeadUserID, p.TargetMemberID) {
log.Printf("webhook: skip duplicate for user %s today", p.LeadUserID)
return
}
webhookURL := loadLeadWebhookURL(db, p.TargetMemberID)
if webhookURL == "" {
return
}

View File

@@ -172,6 +172,18 @@ func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error)
).Delete(&model.Order{})
processReferralCommission(db, o.UserID, totalAmount, o.OrderSN, &o)
if pushErr := pushPaidOrderWebhook(db, &o); pushErr != nil {
syncOrdersLogf("订单 %s webhook 推送失败: %v", o.OrderSN, pushErr)
markOrderWebhookResult(db, o.OrderSN, false, pushErr)
} else {
markOrderWebhookResult(db, o.OrderSN, true, nil)
}
}
// 兜底补偿:服务器卡顿/回调异常导致的未推送订单,统一补推
if retried, sentCount, rerr := RetryPendingPaidOrderWebhooks(ctx, 500); rerr != nil {
syncOrdersLogf("补推未发送订单失败: %v", rerr)
} else if retried > 0 {
syncOrdersLogf("补推未发送订单: 扫描 %d 笔,成功 %d 笔", retried, sentCount)
}
return synced, total, nil
}
@@ -199,6 +211,28 @@ func CronSyncOrders(c *gin.Context) {
})
}
// CronRetryOrderWebhooks GET/POST /api/cron/retry-order-webhooks
// 手动补偿重推:仅推送未成功推送过的已支付订单。
func CronRetryOrderWebhooks(c *gin.Context) {
limit := 500
if s := strings.TrimSpace(c.Query("limit")); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
limit = n
}
}
retried, sent, err := RetryPendingPaidOrderWebhooks(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"retried": retried,
"sent": sent,
"limit": limit,
})
}
// CronUnbindExpired GET/POST /api/cron/unbind-expired
func CronUnbindExpired(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})

View File

@@ -17,6 +17,36 @@ import (
"github.com/gin-gonic/gin"
)
// parseConfigBool 将 JSON/map 中可能出现的 bool、字符串、数字归一为开关态auditMode 等)
func parseConfigBool(v interface{}) bool {
if v == nil {
return false
}
switch t := v.(type) {
case bool:
return t
case string:
s := strings.ToLower(strings.TrimSpace(t))
return s == "1" || s == "true" || s == "yes" || s == "on"
case float64:
return t != 0
case int:
return t != 0
case int64:
return t != 0
case json.Number:
if i, err := t.Int64(); err == nil {
return i != 0
}
if f, err := t.Float64(); err == nil {
return f != 0
}
return false
default:
return false
}
}
// defaultMpUi 小程序文案与导航默认值,存于 mp_config.mpUi管理端系统设置可部分覆盖深合并
func defaultMpUi() gin.H {
return gin.H{
@@ -29,7 +59,8 @@ func defaultMpUi() gin.H {
},
"homePage": gin.H{
"logoTitle": "卡若创业派对", "logoSubtitle": "来自派对房的真实故事",
"linkKaruoText": "点击链接卡若", "searchPlaceholder": "搜索章节标题或内容...",
"linkKaruoText": "点击链接卡若", "linkKaruoAvatar": "",
"searchPlaceholder": "搜索章节标题或内容...",
"bannerTag": "推荐", "bannerReadMoreText": "点击阅读",
"superSectionTitle": "超级个体", "superSectionLinkText": "获客入口",
"superSectionLinkPath": "/pages/match/match",
@@ -224,13 +255,9 @@ func buildMiniprogramConfig() gin.H {
// 未找到配置或查询失败,使用空数组作为默认值
out["linkedMiniprograms"] = []gin.H{}
}
// 明确归一化 auditMode:仅当 DB 显式为 true 时返回 true否则一律 false避免历史脏数据/类型异常导致误判
// 归一化 auditMode(兼容历史 bool / 字符串 / 数字
if mp, ok := out["mpConfig"].(gin.H); ok {
if v, ok := mp["auditMode"].(bool); ok && v {
mp["auditMode"] = true
} else {
mp["auditMode"] = false
}
mp["auditMode"] = parseConfigBool(mp["auditMode"])
}
return out
}
@@ -275,10 +302,7 @@ func getAuditModeFromDB() bool {
if err := json.Unmarshal(row.ConfigValue, &mp); err != nil {
return false
}
if v, ok := mp["auditMode"].(bool); ok && v {
return true
}
return false
return parseConfigBool(mp["auditMode"])
}
// GetCoreConfig GET /api/miniprogram/config/core 核心配置prices、features、userDiscount、mpConfig首屏/Tab 用
@@ -371,9 +395,7 @@ func WarmConfigCache() {
// 拆分接口预热
auditMode := false
if mp, ok := out["mpConfig"].(gin.H); ok {
if v, ok := mp["auditMode"].(bool); ok && v {
auditMode = true
}
auditMode = parseConfigBool(mp["auditMode"])
}
cache.Set(context.Background(), cache.KeyConfigAuditMode, gin.H{"auditMode": auditMode}, cache.AuditModeTTL)
core := gin.H{
@@ -880,7 +902,9 @@ func DBUsersList(c *gin.Context) {
pattern := "%" + search + "%"
query = query.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
}
if vipFilter == "true" || vipFilter == "1" {
if poolFilter == "complete" {
query = query.Where("(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')")
} else if vipFilter == "true" || vipFilter == "1" {
query = query.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND (status = ? OR status = ?)) OR (is_vip = 1 AND vip_expire_date > ?)",
[]string{"fullbook", "vip"}, "paid", "completed", time.Now())
}
@@ -915,7 +939,7 @@ func DBUsersList(c *gin.Context) {
var fullbookRows []struct {
UserID string
}
db.Model(&model.Order{}).Select("user_id").Where("product_type IN ? AND status = ?", []string{"fullbook", "vip"}, "paid").Find(&fullbookRows)
db.Model(&model.Order{}).Select("user_id").Where("product_type IN ? AND status IN ?", []string{"fullbook", "vip"}, []string{"paid", "completed", "success"}).Find(&fullbookRows)
for _, r := range fullbookRows {
hasFullBookMap[r.UserID] = true
}
@@ -924,7 +948,7 @@ func DBUsersList(c *gin.Context) {
Count int64
}
db.Model(&model.Order{}).Select("user_id, COUNT(*) as count").
Where("product_type = ? AND status = ?", "section", "paid").
Where("product_type = ? AND status IN ?", "section", []string{"paid", "completed", "success"}).
Group("user_id").Find(&sectionRows)
for _, r := range sectionRows {
sectionCountMap[r.UserID] = int(r.Count)
@@ -987,6 +1011,35 @@ func DBUsersList(c *gin.Context) {
}
}
// 4. RFM 实时打分:对当前页用户批量计算(只查当前页 userIDs 的聚合)
type rfmAgg struct {
UserID string
OrderCount int
TotalAmount float64
LastOrderAt time.Time
}
var rfmAggs []rfmAgg
db.Raw(`SELECT user_id, COUNT(*) as order_count, SUM(amount) as total_amount, MAX(created_at) as last_order_at
FROM orders WHERE user_id IN ? AND status IN ('paid','success','completed')
GROUP BY user_id`, userIDs).Scan(&rfmAggs)
rfmAggMap := make(map[string]rfmAgg, len(rfmAggs))
var rfmMaxRecency, rfmMaxFreq int
var rfmMaxMonetary float64
now := time.Now()
for _, a := range rfmAggs {
rfmAggMap[a.UserID] = a
days := int(now.Sub(a.LastOrderAt).Hours() / 24)
if days > rfmMaxRecency {
rfmMaxRecency = days
}
if a.OrderCount > rfmMaxFreq {
rfmMaxFreq = a.OrderCount
}
if a.TotalAmount > rfmMaxMonetary {
rfmMaxMonetary = a.TotalAmount
}
}
// 填充每个用户的实时计算字段
for i := range users {
uid := users[i].ID
@@ -1019,6 +1072,16 @@ func DBUsersList(c *gin.Context) {
bindCount = dbCount
}
users[i].ReferralCount = ptrInt(bindCount)
// RFM 打分(有订单的用户才有分数)
if agg, ok := rfmAggMap[uid]; ok {
recencyDays := int(now.Sub(agg.LastOrderAt).Hours() / 24)
score := calcRFMScoreForUser(recencyDays, agg.OrderCount, agg.TotalAmount,
rfmMaxRecency, rfmMaxFreq, rfmMaxMonetary)
level := calcRFMLevel(score)
users[i].RFMScore = ptrFloat64(score)
users[i].RFMLevel = &level
}
}
c.JSON(http.StatusOK, gin.H{
@@ -1238,6 +1301,106 @@ func DBUsersReferrals(c *gin.Context) {
}
db := database.DB()
// 入站来源链路:即使未完成绑定,也保留“通过谁的分享链接点击进入”的历史
var currentUser model.User
_ = db.Select("id,open_id").Where("id = ?", userId).First(&currentUser).Error
var inboundVisits []model.ReferralVisit
visitQ := db.Model(&model.ReferralVisit{}).Where("visitor_id = ?", userId)
if currentUser.OpenID != nil && strings.TrimSpace(*currentUser.OpenID) != "" {
visitQ = visitQ.Or("visitor_openid = ?", strings.TrimSpace(*currentUser.OpenID))
}
_ = visitQ.Order("created_at ASC").Limit(300).Find(&inboundVisits).Error
referrerVisitIDs := make(map[string]bool)
for _, v := range inboundVisits {
if strings.TrimSpace(v.ReferrerID) != "" {
referrerVisitIDs[strings.TrimSpace(v.ReferrerID)] = true
}
}
referrerVisitList := make([]string, 0, len(referrerVisitIDs))
for id := range referrerVisitIDs {
referrerVisitList = append(referrerVisitList, id)
}
referrerVisitUserMap := make(map[string]*model.User)
if len(referrerVisitList) > 0 {
var rs []model.User
_ = db.Where("id IN ?", referrerVisitList).Find(&rs).Error
for i := range rs {
referrerVisitUserMap[rs[i].ID] = &rs[i]
}
}
inboundVisitItems := make([]gin.H, 0, len(inboundVisits))
firstInbound := gin.H{}
latestInbound := gin.H{}
for i, v := range inboundVisits {
nickname := "微信用户"
avatar := ""
if u := referrerVisitUserMap[v.ReferrerID]; u != nil {
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
nickname = strings.TrimSpace(*u.Nickname)
}
if u.Avatar != nil {
avatar = resolveAvatarURL(strings.TrimSpace(*u.Avatar))
}
}
source := ""
page := ""
if v.Source != nil {
source = strings.TrimSpace(*v.Source)
}
if v.Page != nil {
page = strings.TrimSpace(*v.Page)
}
item := gin.H{
"seq": i + 1,
"visitedAt": v.CreatedAt,
"referrerId": v.ReferrerID,
"referrerNickname": nickname,
"referrerAvatar": avatar,
"source": source,
"page": page,
}
if i == 0 {
firstInbound = item
}
latestInbound = item
inboundVisitItems = append(inboundVisitItems, item)
}
activeBinding := gin.H{}
var activeRef model.ReferralBinding
if err := db.Where("referee_id = ? AND status = ?", userId, "active").Order("binding_date DESC").First(&activeRef).Error; err == nil {
bindNick := "微信用户"
bindAvatar := ""
if u := referrerVisitUserMap[activeRef.ReferrerID]; u != nil {
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
bindNick = strings.TrimSpace(*u.Nickname)
}
if u.Avatar != nil {
bindAvatar = resolveAvatarURL(strings.TrimSpace(*u.Avatar))
}
} else {
var ru model.User
if err := db.Select("id,nickname,avatar").Where("id = ?", activeRef.ReferrerID).First(&ru).Error; err == nil {
if ru.Nickname != nil && strings.TrimSpace(*ru.Nickname) != "" {
bindNick = strings.TrimSpace(*ru.Nickname)
}
if ru.Avatar != nil {
bindAvatar = resolveAvatarURL(strings.TrimSpace(*ru.Avatar))
}
}
}
activeBinding = gin.H{
"referrerId": activeRef.ReferrerID,
"referrerNickname": bindNick,
"referrerAvatar": bindAvatar,
"referralCode": activeRef.ReferralCode,
"bindingDate": activeRef.BindingDate,
"expiryDate": activeRef.ExpiryDate,
}
}
var bindings []model.ReferralBinding
if err := db.Where("referrer_id = ?", userId).Order("binding_date DESC").Find(&bindings).Error; err != nil {
bindings = []model.ReferralBinding{}
@@ -1380,6 +1543,13 @@ func DBUsersReferrals(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true, "referrals": referrals,
"inboundSource": gin.H{
"totalVisits": len(inboundVisitItems),
"firstVisit": firstInbound,
"latestVisit": latestInbound,
"activeBinding": activeBinding,
"visits": inboundVisitItems,
},
"stats": gin.H{
"total": totalReferrals, "purchased": purchased, "free": totalReferrals - purchased,
"earnings": roundFloat(earningsE, 2), "pendingEarnings": roundFloat(availableE, 2), "withdrawnEarnings": roundFloat(withdrawnE, 2),

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"soul-api/internal/database"
@@ -295,6 +296,16 @@ func MatchUsers(c *gin.Context) {
if r.Avatar != nil {
avatar = resolveAvatarURL(*r.Avatar)
}
if avatar == "" && r.Mbti != nil {
mbti := strings.ToUpper(strings.TrimSpace(*r.Mbti))
if mbti != "" {
avatar = resolveAvatarURL(getMbtiAvatar(db, mbti))
}
}
mbtiOut := ""
if r.Mbti != nil {
mbtiOut = strings.TrimSpace(*r.Mbti)
}
wechat := ""
if r.WechatID != nil {
wechat = *r.WechatID
@@ -331,7 +342,7 @@ func MatchUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": r.ID, "nickname": nickname, "avatar": avatar, "wechat": wechat, "phone": phone,
"id": r.ID, "nickname": nickname, "avatar": avatar, "mbti": mbtiOut, "wechat": wechat, "phone": phone,
"introduction": intro, "tags": []string{"创业者", tag},
"matchScore": 80 + (r.CreatedAt.Unix() % 20),
"commonInterests": []gin.H{

View File

@@ -798,6 +798,13 @@ func MiniprogramPayNotify(c *gin.Context) {
).Delete(&model.Order{})
processReferralCommission(db, beneficiaryUserID, totalAmount, orderSn, &order)
}
// 支付成功后实时推送到 webhook失败记录交给定时补偿任务统一重推
if pushErr := pushPaidOrderWebhook(db, &order); pushErr != nil {
fmt.Printf("[PayNotify] webhook 推送失败: orderSn=%s, err=%v\n", orderSn, pushErr)
markOrderWebhookResult(db, orderSn, false, pushErr)
} else {
markOrderWebhookResult(db, orderSn, true, nil)
}
return nil
})
if err != nil {

View File

@@ -0,0 +1,37 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// MiniprogramMbtiAvatarsGet GET /api/miniprogram/config/mbti-avatars
// 公开只读:返回 16 型 MBTI → 头像 URL供小程序在无用户头像时按性格展示推广海报等
func MiniprogramMbtiAvatarsGet(c *gin.Context) {
db := database.DB()
var row model.SystemConfig
err := db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": map[string]string{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": "读取失败"})
return
}
out := make(map[string]string)
if len(row.ConfigValue) > 0 {
if uerr := json.Unmarshal(row.ConfigValue, &out); uerr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置无效"})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": out})
}

View File

@@ -0,0 +1,163 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"gorm.io/gorm"
)
func loadOrderWebhookURL(db *gorm.DB) string {
keys := []string{"order_paid_webhook_url", "ckb_lead_webhook_url"}
for _, key := range keys {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", key).First(&cfg).Error; err != nil {
continue
}
var webhookURL string
if len(cfg.ConfigValue) > 0 {
_ = json.Unmarshal(cfg.ConfigValue, &webhookURL)
}
webhookURL = strings.TrimSpace(webhookURL)
if webhookURL != "" && strings.HasPrefix(webhookURL, "http") {
return webhookURL
}
}
return ""
}
func pushPaidOrderWebhook(db *gorm.DB, order *model.Order) error {
if order == nil || order.OrderSN == "" {
return fmt.Errorf("empty order")
}
if order.WebhookPushStatus == "sent" {
return nil
}
webhookURL := loadOrderWebhookURL(db)
if webhookURL == "" {
return nil
}
var user model.User
_ = db.Select("id,nickname,phone,open_id").Where("id = ?", order.UserID).First(&user).Error
productName := order.ProductType
if order.Description != nil && strings.TrimSpace(*order.Description) != "" {
productName = strings.TrimSpace(*order.Description)
}
status := ""
if order.Status != nil {
status = *order.Status
}
if status == "" {
status = "paid"
}
text := "💰 用户购买成功(实时推送)"
text += fmt.Sprintf("\n订单号: %s", order.OrderSN)
if user.Nickname != nil && strings.TrimSpace(*user.Nickname) != "" {
text += fmt.Sprintf("\n用户: %s", strings.TrimSpace(*user.Nickname))
}
if user.Phone != nil && strings.TrimSpace(*user.Phone) != "" {
text += fmt.Sprintf("\n手机: %s", strings.TrimSpace(*user.Phone))
}
text += fmt.Sprintf("\n商品: %s", productName)
text += fmt.Sprintf("\n金额: %.2f", order.Amount)
text += fmt.Sprintf("\n状态: %s", status)
if order.PayTime != nil {
text += fmt.Sprintf("\n支付时间: %s", order.PayTime.Format("2006-01-02 15:04:05"))
} else {
text += fmt.Sprintf("\n支付时间: %s", time.Now().Format("2006-01-02 15:04:05"))
}
var payload []byte
if strings.Contains(webhookURL, "qyapi.weixin.qq.com") {
payload, _ = json.Marshal(map[string]interface{}{
"msgtype": "text",
"text": map[string]string{"content": text},
})
} else {
payload, _ = json.Marshal(map[string]interface{}{
"msg_type": "text",
"content": map[string]string{"text": text},
})
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(payload))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("webhook status=%d", resp.StatusCode)
}
return nil
}
func markOrderWebhookResult(db *gorm.DB, orderSn string, sent bool, pushErr error) {
if orderSn == "" {
return
}
updates := map[string]interface{}{
"webhook_push_attempts": gorm.Expr("COALESCE(webhook_push_attempts, 0) + 1"),
"updated_at": time.Now(),
}
if sent {
now := time.Now()
updates["webhook_push_status"] = "sent"
updates["webhook_pushed_at"] = now
updates["webhook_push_error"] = ""
} else {
errText := ""
if pushErr != nil {
errText = strings.TrimSpace(pushErr.Error())
}
if len(errText) > 500 {
errText = errText[:500]
}
updates["webhook_push_status"] = "failed"
updates["webhook_push_error"] = errText
}
_ = db.Model(&model.Order{}).Where("order_sn = ?", orderSn).Updates(updates).Error
}
// RetryPendingPaidOrderWebhooks 扫描未推送成功的已支付订单并补推。
func RetryPendingPaidOrderWebhooks(ctx context.Context, limit int) (retried, sent int, err error) {
if limit <= 0 {
limit = 200
}
if limit > 2000 {
limit = 2000
}
db := database.DB()
var rows []model.Order
if err := db.Where(
"status IN ? AND COALESCE(webhook_push_status,'') <> ?",
[]string{"paid", "completed"}, "sent",
).Order("pay_time ASC, created_at ASC").Limit(limit).Find(&rows).Error; err != nil {
return 0, 0, err
}
for i := range rows {
select {
case <-ctx.Done():
return retried, sent, ctx.Err()
default:
}
retried++
pushErr := pushPaidOrderWebhook(db, &rows[i])
if pushErr == nil {
sent++
markOrderWebhookResult(db, rows[i].OrderSN, true, nil)
} else {
markOrderWebhookResult(db, rows[i].OrderSN, false, pushErr)
}
}
return retried, sent, nil
}

View File

@@ -685,8 +685,34 @@ func userTrackActionLabelCN(action string) string {
return "绑定微信"
case "fill_profile":
return "完善资料"
case "fill_avatar":
return "设置头像"
case "visit_page":
return "访问页面"
case "first_pay":
return "首次付款"
case "vip_activate":
return "开通会员"
case "click_super":
return "点击超级个体"
case "lead_submit":
return "提交留资"
case "withdraw":
return "申请提现"
case "referral_bind":
return "绑定推荐人"
case "card_click":
return "点击名片"
case "btn_click":
return "按钮点击"
case "tab_click":
return "切换标签"
case "nav_click":
return "导航点击"
case "page_view":
return "页面浏览"
case "search":
return "搜索"
default:
if action == "" {
return "行为"
@@ -695,6 +721,41 @@ func userTrackActionLabelCN(action string) string {
}
}
// userTrackModuleLabelCN 埋点 module 英文字段 → 中文位置(与用户旅程、群播报一致)
func userTrackModuleLabelCN(module string) string {
m := strings.TrimSpace(strings.ToLower(module))
switch m {
case "":
return ""
case "home", "index":
return "首页"
case "chapters":
return "目录"
case "match":
return "找伙伴"
case "my":
return "我的"
case "read", "reading":
return "阅读"
case "vip":
return "会员中心"
case "referral":
return "推广中心"
case "member_detail", "member-detail", "memberdetail":
return "超级个体详情"
case "profile", "profile_show", "profile-show":
return "个人资料"
case "search":
return "搜索"
case "wallet":
return "钱包"
case "settings":
return "设置"
default:
return module
}
}
func humanTimeAgoCN(t time.Time) string {
if t.IsZero() {
return ""
@@ -803,6 +864,16 @@ func UserTrackGet(c *gin.Context) {
chapterTitle = v
}
}
var extra map[string]interface{}
if len(t.ExtraData) > 0 {
_ = json.Unmarshal(t.ExtraData, &extra)
}
module := ""
if extra != nil {
if m, ok := extra["module"].(string); ok {
module = m
}
}
var createdAt time.Time
if t.CreatedAt != nil {
createdAt = *t.CreatedAt
@@ -813,6 +884,8 @@ func UserTrackGet(c *gin.Context) {
"actionLabel": userTrackActionLabelCN(t.Action),
"target": target,
"chapterTitle": chapterTitle,
"module": module,
"moduleLabel": userTrackModuleLabelCN(module),
"createdAt": t.CreatedAt,
"timeAgo": humanTimeAgoCN(createdAt),
})
@@ -865,6 +938,7 @@ func DBUserTracksList(c *gin.Context) {
out = append(out, gin.H{
"id": t.ID, "action": t.Action, "actionLabel": userTrackActionLabelCN(t.Action),
"target": target, "chapterTitle": chTitle, "module": module,
"moduleLabel": userTrackModuleLabelCN(module),
"createdAt": t.CreatedAt, "timeAgo": humanTimeAgoCN(createdAt),
})
}
@@ -905,10 +979,11 @@ func GetUserRecentTracks(db *gorm.DB, userId string, limit int) []string {
}
}
var line string
modCN := userTrackModuleLabelCN(module)
if target != "" {
line = fmt.Sprintf("%s: %s", label, sanitizeDisplayOneLine(target))
} else if module != "" {
line = fmt.Sprintf("%s (%s)", label, module)
} else if modCN != "" {
line = fmt.Sprintf("%s · %s", label, modCN)
} else {
line = label
}

View File

@@ -296,12 +296,8 @@ func isWechatDefaultNickname(s string) bool {
return s != "" && strings.HasPrefix(s, "微信用户")
}
// vipMemberShowcaseOK 首页「超级个体」横滑:必须有可展示头像 URL展示名非微信默认占位
// vipMemberShowcaseOK 首页「超级个体」横滑:展示名非微信默认占位即可;无头像时小程序用首字/MBTI 映射图(后台可配 mbti_avatars
func vipMemberShowcaseOK(item gin.H) bool {
av, _ := item["avatar"].(string)
if strings.TrimSpace(av) == "" {
return false
}
name, _ := item["name"].(string)
name = strings.TrimSpace(name)
if name == "" || isWechatDefaultNickname(name) {

View File

@@ -1,7 +1,9 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"soul-api/internal/database"
@@ -11,7 +13,34 @@ import (
"gorm.io/gorm"
)
// batchSuperIndividualClicks 与 AdminSuperIndividualStats 一致user_tracks 中 action=card_click 且 target 前缀「超级个体_」
const superIndividualWebhookConfigKey = "super_individual_webhook_map"
func loadSuperIndividualWebhookMap(db *gorm.DB) map[string]string {
out := map[string]string{}
var cfg model.SystemConfig
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&cfg).Error; err != nil {
return out
}
if len(cfg.ConfigValue) == 0 {
return out
}
raw := map[string]string{}
if err := json.Unmarshal(cfg.ConfigValue, &raw); err != nil {
return out
}
for k, v := range raw {
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
if k == "" || v == "" {
continue
}
out[k] = v
}
return out
}
// batchSuperIndividualClicks 统计「点击头像」行为:
// user_tracks 中 action=avatar_click兼容历史 btn_click且 target 前缀「链接头像_」。
func batchSuperIndividualClicks(db *gorm.DB, userIDs []string) map[string]int64 {
out := make(map[string]int64)
if len(userIDs) == 0 {
@@ -23,9 +52,13 @@ func batchSuperIndividualClicks(db *gorm.DB, userIDs []string) map[string]int64
}
var rows []row
_ = db.Raw(`
SELECT SUBSTRING(target, 6) AS user_id, COUNT(*) AS clicks
SELECT
SUBSTRING(target, 6) AS user_id,
COUNT(*) AS clicks
FROM user_tracks
WHERE action = 'card_click' AND target LIKE '超级个体\_%' AND SUBSTRING(target, 6) IN ?
WHERE action IN ('avatar_click', 'btn_click')
AND target LIKE '链接头像\_%'
AND SUBSTRING(target, 6) IN ?
GROUP BY user_id
`, userIDs).Scan(&rows)
for _, r := range rows {
@@ -103,6 +136,7 @@ func DBVipMembersList(c *gin.Context) {
}
clickByUser := batchSuperIndividualClicks(db, ids)
leadByUser := batchSuperIndividualLeads(db, ids)
webhookMap := loadSuperIndividualWebhookMap(db)
list := make([]gin.H, 0, len(users))
for i := range users {
@@ -110,8 +144,74 @@ func DBVipMembersList(c *gin.Context) {
uid := users[i].ID
item["clickCount"] = clickByUser[uid]
item["leadCount"] = leadByUser[uid]
item["webhookUrl"] = strings.TrimSpace(webhookMap[uid])
list = append(list, item)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
}
// DBVipMemberWebhookSet PUT /api/db/vip-members/webhook
// 按超级个体用户维度配置飞书群 webhookVOX 地址)。
func DBVipMemberWebhookSet(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
WebhookURL string `json:"webhookUrl"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
return
}
userID := strings.TrimSpace(body.UserID)
webhookURL := strings.TrimSpace(body.WebhookURL)
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "userId 不能为空"})
return
}
if webhookURL != "" && !strings.HasPrefix(webhookURL, "http") {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "Webhook 地址必须是 http/https"})
return
}
db := database.DB()
var count int64
db.Model(&model.User{}).Where("id = ?", userID).Count(&count)
if count == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
return
}
webhookMap := loadSuperIndividualWebhookMap(db)
if webhookURL == "" {
delete(webhookMap, userID)
} else {
webhookMap[userID] = webhookURL
}
val, _ := json.Marshal(webhookMap)
desc := "超级个体飞书群Webhook映射按userId"
var row model.SystemConfig
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
row = model.SystemConfig{
ConfigKey: superIndividualWebhookConfigKey,
ConfigValue: val,
Description: &desc,
}
if e := db.Create(&row).Error; e != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": e.Error()})
return
}
} else {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
} else {
row.ConfigValue = val
row.Description = &desc
if e := db.Save(&row).Error; e != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": e.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -4,26 +4,31 @@ import "time"
// Order 对应表 ordersJSON 输出与现网接口 1:1小写驼峰
type Order struct {
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
OrderSN string `gorm:"column:order_sn;uniqueIndex;size:50" json:"orderSn"`
UserID string `gorm:"column:user_id;size:50" json:"userId"`
OpenID string `gorm:"column:open_id;size:100" json:"openId"`
ProductType string `gorm:"column:product_type;size:50" json:"productType"`
ProductID *string `gorm:"column:product_id;size:50" json:"productId,omitempty"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
Description *string `gorm:"column:description;size:200" json:"description,omitempty"`
Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
TransactionID *string `gorm:"column:transaction_id;size:100" json:"transactionId,omitempty"`
PayTime *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"`
ReferralCode *string `gorm:"column:referral_code;size:255" json:"referralCode,omitempty"`
ReferrerID *string `gorm:"column:referrer_id;size:255" json:"referrerId,omitempty"`
RefundReason *string `gorm:"column:refund_reason;size:500" json:"refundReason,omitempty"`
PaymentMethod *string `gorm:"column:payment_method;size:20" json:"paymentMethod,omitempty"`
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
OrderSN string `gorm:"column:order_sn;uniqueIndex;size:50" json:"orderSn"`
UserID string `gorm:"column:user_id;size:50" json:"userId"`
OpenID string `gorm:"column:open_id;size:100" json:"openId"`
ProductType string `gorm:"column:product_type;size:50" json:"productType"`
ProductID *string `gorm:"column:product_id;size:50" json:"productId,omitempty"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
Description *string `gorm:"column:description;size:200" json:"description,omitempty"`
Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
TransactionID *string `gorm:"column:transaction_id;size:100" json:"transactionId,omitempty"`
PayTime *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"`
ReferralCode *string `gorm:"column:referral_code;size:255" json:"referralCode,omitempty"`
ReferrerID *string `gorm:"column:referrer_id;size:255" json:"referrerId,omitempty"`
RefundReason *string `gorm:"column:refund_reason;size:500" json:"refundReason,omitempty"`
PaymentMethod *string `gorm:"column:payment_method;size:20" json:"paymentMethod,omitempty"`
// 代付:关联代付请求、实际付款人
GiftPayRequestID *string `gorm:"column:gift_pay_request_id;size:50" json:"giftPayRequestId,omitempty"`
PayerUserID *string `gorm:"column:payer_user_id;size:50" json:"payerUserId,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
// 飞书 webhook 推送状态paid 后实时推送;失败可补偿重推)
WebhookPushStatus string `gorm:"column:webhook_push_status;size:20;default:''" json:"webhookPushStatus,omitempty"`
WebhookPushedAt *time.Time `gorm:"column:webhook_pushed_at" json:"webhookPushedAt,omitempty"`
WebhookPushAttempts int `gorm:"column:webhook_push_attempts;default:0" json:"webhookPushAttempts,omitempty"`
WebhookPushError *string `gorm:"column:webhook_push_error;size:500" json:"webhookPushError,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Order) TableName() string { return "orders" }

View File

@@ -62,6 +62,8 @@ type User struct {
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
WalletBalance *float64 `gorm:"-" json:"walletBalance,omitempty"`
RFMScore *float64 `gorm:"-" json:"rfmScore,omitempty"`
RFMLevel *string `gorm:"-" json:"rfmLevel,omitempty"`
}
func (User) TableName() string { return "users" }

View File

@@ -1,17 +1,40 @@
package model
import "time"
import (
"database/sql/driver"
"time"
)
// UserRule 用户旅程触达规则(各节点弹窗标题/说明,由管理端配置
// RuleJSON 存储 JSON 数组/对象的列user_rules 的 trigger_conditions 等
type RuleJSON []byte
func (r RuleJSON) Value() (driver.Value, error) { return []byte(r), nil }
func (r *RuleJSON) Scan(value interface{}) error {
if value == nil {
*r = nil
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
*r = append((*r)[0:0], b...)
return nil
}
// UserRule 用户旅程触达规则(结构化触发条件 + 推送动作,由管理端配置)
type UserRule struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Title string `gorm:"column:title;size:200;not null" json:"title"`
Description string `gorm:"column:description;type:text" json:"description"`
Trigger string `gorm:"column:trigger;size:100" json:"trigger"`
Sort int `gorm:"column:sort;default:0" json:"sort"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Title string `gorm:"column:title;size:200;not null" json:"title"`
Description string `gorm:"column:description;type:text" json:"description"`
Trigger string `gorm:"column:trigger;size:100" json:"trigger"`
TriggerConditions RuleJSON `gorm:"column:trigger_conditions;type:json" json:"triggerConditions,omitempty"`
ActionType string `gorm:"column:action_type;size:50;default:'popup'" json:"actionType,omitempty"`
ActionConfig RuleJSON `gorm:"column:action_config;type:json" json:"actionConfig,omitempty"`
Sort int `gorm:"column:sort;default:0" json:"sort"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (UserRule) TableName() string { return "user_rules" }

View File

@@ -163,6 +163,8 @@ func Setup(cfg *config.Config) *gin.Engine {
{
cron.GET("/sync-orders", handler.CronSyncOrders)
cron.POST("/sync-orders", handler.CronSyncOrders)
cron.GET("/retry-order-webhooks", handler.CronRetryOrderWebhooks)
cron.POST("/retry-order-webhooks", handler.CronRetryOrderWebhooks)
cron.GET("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.POST("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.GET("/unbind-expired", handler.CronUnbindExpired)
@@ -201,6 +203,7 @@ func Setup(cfg *config.Config) *gin.Engine {
db.PUT("/vip-roles", handler.DBVipRolesAction)
db.DELETE("/vip-roles", handler.DBVipRolesAction)
db.GET("/vip-members", handler.DBVipMembersList)
db.PUT("/vip-members/webhook", handler.DBVipMemberWebhookSet)
db.GET("/match-records", handler.DBMatchRecordsList)
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
db.GET("/mentors", handler.DBMentorsList)
@@ -305,11 +308,12 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/config/audit-mode", handler.GetAuditMode)
miniprogram.GET("/config/core", handler.GetCoreConfig)
miniprogram.GET("/config/read-extras", handler.GetReadExtras)
miniprogram.GET("/config/mbti-avatars", handler.MiniprogramMbtiAvatarsGet)
// Deprecated: 保留以兼容线上,计划迁移至上述拆分接口
miniprogram.GET("/config", handler.GetPublicDBConfig)
miniprogram.POST("/login", handler.MiniprogramLogin)
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
miniprogram.POST("/dev/login-as", handler.MiniprogramDevLoginAs) // 开发专用:按 userId 切换账号
miniprogram.POST("/dev/login-as", handler.MiniprogramDevLoginAs) // 开发专用:按 userId 切换账号
miniprogram.POST("/dev/login-by-phone", handler.MiniprogramDevLoginByPhone) // 开发专用:按手机号登录(密码可空)
miniprogram.POST("/phone", handler.MiniprogramPhone)
miniprogram.GET("/pay", handler.MiniprogramPay)

View File

@@ -0,0 +1,38 @@
-- persons 表:补齐首页置顶与来源字段(兼容低版本 MySQL 的幂等写法)
SET @db = DATABASE();
SET @sql = (
SELECT IF(
EXISTS(
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'is_pinned'
),
'SELECT 1',
"ALTER TABLE persons ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0 COMMENT '置顶到小程序首页'"
)
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
EXISTS(
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'person_source'
),
'SELECT 1',
"ALTER TABLE persons ADD COLUMN person_source VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源:空=后台手工vip_sync=超级个体同步'"
)
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
EXISTS(
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'persons' AND INDEX_NAME = 'idx_persons_is_pinned'
),
'SELECT 1',
'CREATE INDEX idx_persons_is_pinned ON persons(is_pinned)'
)
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,42 @@
-- 用户规则表 v2新增结构化触发条件和推送动作列GORM AutoMigrate 会自动加列,此脚本用于填充现有规则的结构化数据)
ALTER TABLE user_rules ADD COLUMN IF NOT EXISTS trigger_conditions JSON DEFAULT NULL;
ALTER TABLE user_rules ADD COLUMN IF NOT EXISTS action_type VARCHAR(50) DEFAULT 'popup';
ALTER TABLE user_rules ADD COLUMN IF NOT EXISTS action_config JSON DEFAULT NULL;
-- 根据现有 trigger 字段回填结构化条件(最佳实践预设)
-- #10: 注册完成 → 填写头像
UPDATE user_rules SET trigger_conditions = '["after_login","fill_profile"]', action_type = 'popup'
WHERE sort = 10 AND trigger_conditions IS NULL;
-- #20: 完成匹配 → 补充个人资料
UPDATE user_rules SET trigger_conditions = '["after_match","fill_profile"]', action_type = 'popup'
WHERE sort = 20 AND trigger_conditions IS NULL;
-- #30: 首次浏览章节 → 绑定手机号
UPDATE user_rules SET trigger_conditions = '["view_chapter","bind_phone"]', action_type = 'popup'
WHERE sort = 30 AND trigger_conditions IS NULL;
-- #40: 付款 ¥1980 → 填写完整信息
UPDATE user_rules SET trigger_conditions = '["purchase_fullbook","fill_profile","add_wechat"]', action_type = 'popup'
WHERE sort = 40 AND trigger_conditions IS NULL;
-- #50: 加入派对房 → 填写项目介绍
UPDATE user_rules SET trigger_conditions = '["after_match"]', action_type = 'navigate'
WHERE sort = 50 AND trigger_conditions IS NULL;
-- #60: 浏览 5 个章节 → 分享推广
UPDATE user_rules SET trigger_conditions = '["browse_5_chapters","share_action"]', action_type = 'popup'
WHERE sort = 60 AND trigger_conditions IS NULL;
-- #70: 绑定微信 → 开启分销
UPDATE user_rules SET trigger_conditions = '["add_wechat","referral_bind"]', action_type = 'navigate'
WHERE sort = 70 AND trigger_conditions IS NULL;
-- #80: 收益达到
UPDATE user_rules SET trigger_conditions = '["withdraw_request"]', action_type = 'popup'
WHERE sort = 80 AND trigger_conditions IS NULL;
-- #90: 完善信息 → 进入流量池
UPDATE user_rules SET trigger_conditions = '["fill_profile","add_wechat","bind_phone"]', action_type = 'popup'
WHERE sort = 90 AND trigger_conditions IS NULL;