feat: 数据概览简化 + 用户管理增加余额/提现列

- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额
- 数据概览:移除余额统计区块(余额改在用户管理中展示)
- 数据概览:恢复转化率卡片(唯一付费用户/总用户)
- 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额
- 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段
- 后端:User model 添加 WalletBalance 非数据库字段
- 包含之前的小程序埋点和管理后台点击统计面板

Made-with: Cursor
This commit is contained in:
卡若
2026-03-15 15:57:09 +08:00
parent 991e17698c
commit 708547d0dd
52 changed files with 3161 additions and 1103 deletions

View File

@@ -8,9 +8,9 @@ const { parseScene } = require('./utils/scene.js')
App({
globalData: {
// API基础地址 - 连接真实后端
// baseUrl: 'https://soulapi.quwanzhi.com',
baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'https://souldev.quwanzhi.com',
baseUrl: 'http://localhost:8080',
// baseUrl: 'http://localhost:8080',
// 小程序配置 - 真实AppID

View File

@@ -21,7 +21,8 @@
"pages/mentors/mentors",
"pages/mentor-detail/mentor-detail",
"pages/profile-show/profile-show",
"pages/profile-edit/profile-edit"
"pages/profile-edit/profile-edit",
"pages/wallet/wallet"
],
"window": {
"backgroundTextStyle": "light",

View File

@@ -6,6 +6,7 @@
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
@@ -206,6 +207,7 @@ Page({
// 切换展开状态
togglePart(e) {
trackClick('chapters', 'tab_click', e.currentTarget.dataset.id || '篇章')
const partId = e.currentTarget.dataset.id
this.setData({
expandedPart: this.data.expandedPart === partId ? null : partId
@@ -216,6 +218,7 @@ Page({
goToRead(e) {
const id = e.currentTarget.dataset.id
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
trackClick('chapters', 'card_click', id || '章节')
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
@@ -234,6 +237,7 @@ Page({
// 跳转到搜索页
goToSearch() {
trackClick('chapters', 'nav_click', '搜索')
wx.navigateTo({ url: '/pages/search/search' })
},

View File

@@ -7,6 +7,8 @@
console.log('[Index] ===== 首页文件开始加载 =====')
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
Page({
data: {
@@ -23,12 +25,10 @@ Page({
totalSections: 62,
bookData: [],
// 推荐章节
featuredSections: [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
],
// 精选推荐按热度排行默认显示3篇可展开更多
featuredSections: [],
featuredSectionsAll: [],
featuredExpanded: false,
// 最新章节(动态计算)
latestSection: null,
@@ -49,6 +49,8 @@ Page({
// 最新新增章节
latestChapters: [],
latestChaptersExpanded: false,
latestChaptersAll: [],
// 篇章数(从 bookData 计算)
partCount: 0,
@@ -106,6 +108,9 @@ Page({
// 更新用户状态
this.updateUserStatus()
// 规则引擎:首页展示时检查(填头像、分享引导等)
checkAndExecute('page_show', this)
},
initData() {
@@ -162,50 +167,32 @@ Page({
}
},
// 从服务端获取精选推荐、最新更新stitch_soulbook/recommended、book/latest-chapters
// 从服务端获取精选推荐(按热度排行)和最新更新
async loadFeaturedFromServer() {
try {
// 1. 精选推荐:优先用 book/recommended按阅读量+算法,带 热门/推荐/精选 标签)
let featured = []
// 1. 精选推荐: book/hot 获取热度排行数据
try {
const recRes = await app.request({ url: '/api/miniprogram/book/recommended', silent: true })
if (recRes && recRes.success && Array.isArray(recRes.data) && recRes.data.length > 0) {
featured = recRes.data.map((s, i) => ({
const hotRes = await app.request({ url: '/api/miniprogram/book/hot?limit=50', silent: true })
if (hotRes && hotRes.success && Array.isArray(hotRes.data) && hotRes.data.length > 0) {
const tagClassMap = { '热门': 'tag-hot', '推荐': 'tag-rec', '精选': 'tag-rec' }
const all = hotRes.data.map((s, i) => ({
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.sectionTitle || s.section_title || s.title || s.chapterTitle || '',
part: (s.partTitle || s.part_title || '').replace(/[_|]/g, ' ').trim(),
tag: s.tag || ['热门', '推荐', '精选'][i] || '精选',
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
tag: s.tag || '',
tagClass: tagClassMap[s.tag] || 'tag-rec',
hotScore: s.hotScore || s.hot_score || 0,
hotRank: s.hotRank || (i + 1),
price: s.price ?? 1,
}))
this.setData({ featuredSections: featured })
this.setData({
featuredSectionsAll: all,
featuredSections: all.slice(0, 3),
featuredExpanded: false,
})
}
} catch (e) { console.log('[Index] book/recommended 失败:', e) }
// 兜底:无 recommended 时从 all-chapters 按更新时间取前3
if (featured.length === 0) {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const chapters = (res && res.data) || (res && res.chapters) || []
const valid = chapters.filter(c => {
const pt = (c.part_title || c.partTitle || '').toLowerCase()
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
})
if (valid.length > 0) {
const tagMap = ['热门', '推荐', '精选']
featured = valid
.sort((a, b) => new Date(b.updated_at || b.updatedAt || 0) - new Date(a.updated_at || a.updatedAt || 0))
.slice(0, 3)
.map((s, i) => ({
id: s.id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag: tagMap[i] || '精选',
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
}))
this.setData({ featuredSections: featured })
}
}
} catch (e) { console.log('[Index] book/hot 失败:', e) }
// 2. 最新更新:用 book/latest-chapters 取第1条排除「序言」「尾声」「附录」
try {
@@ -227,7 +214,6 @@ Page({
})
}
} catch (e) {
// 兜底:从 all-chapters 取
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const chapters = (res && res.data) || (res && res.chapters) || []
const valid = chapters.filter(c => {
@@ -282,17 +268,20 @@ Page({
// 跳转到目录
goToChapters() {
trackClick('home', 'nav_click', '目录')
wx.switchTab({ url: '/pages/chapters/chapters' })
},
// 跳转到搜索页
goToSearch() {
trackClick('home', 'nav_click', '搜索')
wx.navigateTo({ url: '/pages/search/search' })
},
// 跳转到阅读页(优先传 mid与分享逻辑一致
goToRead(e) {
const id = e.currentTarget.dataset.id
trackClick('home', 'card_click', e.currentTarget.dataset.id || '章节')
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
@@ -304,6 +293,7 @@ Page({
},
goToVip() {
trackClick('home', 'btn_click', 'VIP')
wx.navigateTo({ url: '/pages/vip/vip' })
},
@@ -312,6 +302,7 @@ Page({
},
async onLinkKaruo() {
trackClick('home', 'btn_click', '链接卡若')
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
@@ -472,6 +463,7 @@ Page({
},
async submitLead() {
trackClick('home', 'btn_click', '提交留资')
const phone = (this.data.leadPhone || '').trim().replace(/\s/g, '')
if (!phone) {
wx.showToast({ title: '请输入手机号', icon: 'none' })
@@ -490,12 +482,10 @@ Page({
const chapters = (res && res.data) || (res && res.chapters) || []
const pt = (c) => (c.partTitle || c.part_title || '').toLowerCase()
const exclude = c => !pt(c).includes('序言') && !pt(c).includes('尾声') && !pt(c).includes('附录')
// stitch_soul优先取 isNew 标记的章节;若无则取最近更新的前 10 章(排除序言/尾声/附录)
let candidates = chapters.filter(c => (c.isNew || c.is_new) === true && exclude(c))
if (candidates.length === 0) {
candidates = chapters.filter(exclude)
}
// 解析「第X场」用于倒序最新场次大放在最上方
const sessionNum = (c) => {
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
const m = title.match(/第\s*(\d+)\s*场/) || title.match(/第(\d+)场/)
@@ -504,37 +494,57 @@ Page({
if (/^\d+$/.test(id)) return parseInt(id, 10)
return 0
}
const latest = candidates
.sort((a, b) => {
const na = sessionNum(a)
const nb = sessionNum(b)
if (na !== nb) return nb - na // 场次倒序:最新在上
return new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0)
})
.slice(0, 10)
.map(c => {
const d = new Date(c.updatedAt || c.updated_at || Date.now())
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
const rawContent = (c.content || '').replace(/<[^>]+>/g, '').trim()
// 描述仅用正文摘要,避免 #id 或标题重复;截取 36 字
let desc = ''
if (rawContent && rawContent.length > 0) {
const clean = rawContent.replace(/^#[\d.]+\s*/, '').trim()
desc = clean.length > 36 ? clean.slice(0, 36) + '...' : clean
}
return {
id: c.id,
mid: c.mid ?? c.MID ?? 0,
title,
desc,
price: c.price ?? 1,
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
}
})
this.setData({ latestChapters: latest })
const mapChapter = (c) => {
const d = new Date(c.updatedAt || c.updated_at || Date.now())
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
const rawContent = (c.content || '').replace(/<[^>]+>/g, '').trim()
let desc = ''
if (rawContent && rawContent.length > 0) {
const clean = rawContent.replace(/^#[\d.]+\s*/, '').trim()
desc = clean.length > 36 ? clean.slice(0, 36) + '...' : clean
}
return {
id: c.id,
mid: c.mid ?? c.MID ?? 0,
title,
desc,
price: c.price ?? 1,
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
}
}
const sorted = candidates.sort((a, b) => {
const na = sessionNum(a)
const nb = sessionNum(b)
if (na !== nb) return nb - na
return new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0)
})
const latestAll = sorted.slice(0, 10).map(mapChapter)
this.setData({
latestChaptersAll: latestAll,
latestChapters: latestAll.slice(0, 5),
latestChaptersExpanded: false,
})
} catch (e) { console.log('[Index] 加载最新新增失败:', e) }
},
toggleLatestExpand() {
const all = this.data.latestChaptersAll || []
if (this.data.latestChaptersExpanded) {
this.setData({ latestChapters: all.slice(0, 5), latestChaptersExpanded: false })
} else {
this.setData({ latestChapters: all, latestChaptersExpanded: true })
}
},
toggleFeaturedExpand() {
const all = this.data.featuredSectionsAll || []
if (this.data.featuredExpanded) {
this.setData({ featuredSections: all.slice(0, 3), featuredExpanded: false })
} else {
this.setData({ featuredSections: all, featuredExpanded: true })
}
},
goToMemberDetail(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${id}` })

View File

@@ -38,18 +38,18 @@
<!-- Banner卡片 - 最新章节(异步加载) -->
<view class="banner-card" wx:if="{{latestSection}}" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
<view class="banner-glow"></view>
<view class="banner-tag">最新更新</view>
<view class="banner-tag">推荐</view>
<view class="banner-title">{{latestSection.title}}</view>
<view class="banner-action">
<text class="banner-action-text">开始阅读</text>
<text class="banner-action-text">点击阅读</text>
<view class="banner-arrow">→</view>
</view>
</view>
<view class="banner-card banner-skeleton" wx:else bindtap="goToChapters">
<view class="banner-glow"></view>
<view class="banner-tag">最新更新</view>
<view class="banner-tag">推荐</view>
<view class="banner-title">加载中...</view>
<view class="banner-action"><text class="banner-action-text">开始阅读</text><view class="banner-arrow">→</view></view>
<view class="banner-action"><text class="banner-action-text">点击阅读</text><view class="banner-arrow">→</view></view>
</view>
<!-- 超级个体(横向滚动,已去掉「查看全部」) -->
@@ -91,8 +91,8 @@
</view>
</view>
<!-- 精选推荐(带 tag已去掉「查看全部」 -->
<view class="section">
<!-- 精选推荐(按热度排行默认3篇可展开更多 -->
<view class="section" wx:if="{{featuredSections.length > 0}}">
<view class="section-header">
<text class="section-title">精选推荐</text>
</view>
@@ -107,22 +107,25 @@
>
<view class="featured-content">
<view class="featured-meta">
<text class="featured-id brand-color">{{item.id}}</text>
<text class="featured-tag {{item.tagClass || 'tag-rec'}}">{{item.tag || '精选'}}</text>
<text class="featured-tag {{item.tagClass || 'tag-rec'}}" wx:if="{{item.tag}}">{{item.tag}}</text>
</view>
<text class="featured-title">{{item.title}}</text>
</view>
<view class="featured-arrow"></view>
</view>
</view>
<view class="expand-btn" bindtap="toggleFeaturedExpand" wx:if="{{(featuredSectionsAll.length || 0) > 3}}">
<text class="expand-text">{{featuredExpanded ? '收起' : '展开更多'}}</text>
<text class="expand-icon">{{featuredExpanded ? '∧' : ''}}</text>
</view>
</view>
<!-- 最新新增(时间线样式) -->
<!-- 最新新增(时间线样式)+ 展开/收起 -->
<view class="section" wx:if="{{latestChapters.length > 0}}">
<view class="section-header latest-header">
<text class="section-title">最新新增</text>
<view class="daily-badge-wrap">
<text class="daily-badge">+{{latestChapters.length}}</text>
<text class="daily-badge">+{{latestChaptersAll.length || latestChapters.length}}</text>
</view>
</view>
<view class="timeline-wrap">
@@ -132,19 +135,19 @@
<view class="timeline-dot"></view>
<view class="timeline-content">
<view class="timeline-row">
<view class="timeline-left">
<text class="latest-new-tag">NEW</text>
<text class="timeline-title">{{item.title}}</text>
</view>
<view class="timeline-right">
<text class="timeline-price">¥{{item.price}}</text>
</view>
<text class="timeline-title">{{item.title}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 展开/收起按钮 -->
<view class="expand-btn" bindtap="toggleLatestExpand" wx:if="{{(latestChaptersAll.length || 0) > 5}}">
<text class="expand-text">{{latestChaptersExpanded ? '收起' : '展开更多'}}</text>
<text class="expand-icon">{{latestChaptersExpanded ? '∧' : ''}}</text>
</view>
</view>
</view>
<!-- 底部留白 -->

View File

@@ -445,6 +445,7 @@
font-size: 32rpx;
color: rgba(255, 255, 255, 0.3);
margin-top: 8rpx;
flex-shrink: 0;
}
/* ===== 内容概览列表 ===== */
@@ -838,6 +839,64 @@
height: 26rpx;
}
/* 展开/收起按钮 */
.expand-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 20rpx 0;
margin-top: 8rpx;
}
.expand-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.5);
}
.expand-icon {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
}
/* 热度排行 */
.hot-list {
background: #1c1c1e;
border-radius: 24rpx;
overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.04);
}
.hot-item {
display: flex;
align-items: center;
padding: 24rpx 28rpx;
border-bottom: 2rpx solid rgba(255, 255, 255, 0.04);
}
.hot-item:last-child {
border-bottom: none;
}
.hot-rank {
width: 48rpx;
font-size: 28rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.4);
margin-right: 20rpx;
}
.hot-rank-top {
color: #38bdac;
}
.hot-title {
flex: 1;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.hot-price {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.4);
margin-left: 16rpx;
}
/* ===== 底部留白 ===== */
.bottom-space {
height: 40rpx;

View File

@@ -5,6 +5,7 @@
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
// 默认匹配类型配置
// 找伙伴:真正的匹配功能,匹配数据库中的真实用户
@@ -103,9 +104,7 @@ Page({
// 加载匹配配置
async loadMatchConfig() {
try {
const res = await app.request({ url: '/api/miniprogram/match/config', silent: true, method: 'GET',
method: 'GET'
})
const res = await app.request({ url: '/api/miniprogram/match/config', silent: true, method: 'GET' })
if (res.success && res.data) {
// 更新全局配置,导师顾问类型强制显示「导师顾问」
@@ -196,6 +195,7 @@ Page({
// 选择匹配类型
selectType(e) {
trackClick('match', 'tab_click', e.currentTarget.dataset.type || '类型选择')
const typeId = e.currentTarget.dataset.type
const type = MATCH_TYPES.find(t => t.id === typeId)
this.setData({
@@ -206,6 +206,7 @@ Page({
// 点击匹配按钮
async handleMatchClick() {
trackClick('match', 'btn_click', '开始匹配')
const currentType = MATCH_TYPES.find(t => t.id === this.data.selectedType)
// 导师顾问:先播匹配动画,动画完成后再跳转(不在此处直接跳)
@@ -363,7 +364,7 @@ Page({
}, 500)
// 1.5-3秒后导师顾问→跳转其他类型→弹窗
const delay = Math.random() * 1500 + 1500
const delay = Math.random() * 7000 + 3000
setTimeout(() => {
clearInterval(timer)
this.setData({ isMatching: false })
@@ -412,14 +413,15 @@ Page({
// 从数据库获取真实用户匹配
let matchedUser = null
try {
const res = await app.request({ url: '/api/miniprogram/match/users', silent: true,
const res = await app.request({
url: '/api/miniprogram/match/users',
silent: true,
method: 'POST',
data: {
matchType: this.data.selectedType,
userId: app.globalData.userInfo?.id || ''
}
})
if (res.success && res.data) {
matchedUser = res.data
console.log('[Match] 从数据库匹配到用户:', matchedUser.nickname)
@@ -429,8 +431,8 @@ Page({
}
// 延迟显示结果(模拟匹配过程)
const delay = Math.random() * 2000 + 2000
setTimeout(() => {
const delay = Math.random() * 7000 + 3000
const timeoutId = setTimeout(() => {
clearInterval(timer)
// 如果没有匹配到用户,提示用户
@@ -460,7 +462,6 @@ Page({
// 上报匹配行为到存客宝
this.reportMatch(matchedUser)
}, delay)
},
@@ -528,6 +529,7 @@ Page({
// 添加微信好友
handleAddWechat() {
trackClick('match', 'btn_click', '加好友')
if (!this.data.currentMatch) return
wx.setClipboardData({
@@ -578,6 +580,7 @@ Page({
// 提交加入
async handleJoinSubmit() {
trackClick('match', 'btn_click', '加入提交')
const { contactType, phoneNumber, wechatId, joinType, isJoining, canHelp, needHelp } = this.data
if (isJoining) return
@@ -668,6 +671,7 @@ Page({
// 购买匹配次数
async buyMatchCount() {
trackClick('match', 'btn_click', '购买次数')
this.setData({ showUnlockModal: false })
try {
@@ -744,11 +748,6 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' })
},
// 打开设置
openSettings() {
wx.navigateTo({ url: '/pages/settings/settings' })
},
// 阻止事件冒泡
preventBubble() {},

View File

@@ -4,9 +4,7 @@
<!-- 自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<view class="nav-settings" bindtap="openSettings">
<text class="settings-icon">⚙️</text>
</view>
<view class="nav-left-placeholder"></view>
<text class="nav-title">找伙伴</text>
<view class="nav-right-placeholder"></view>
</view>

View File

@@ -27,15 +27,9 @@
padding: 0 32rpx;
}
.nav-settings {
.nav-left-placeholder {
width: 80rpx;
height: 80rpx;
flex-shrink: 0;
border-radius: 50%;
background: #1c1c1e;
display: flex;
align-items: center;
justify-content: center;
}
.nav-title {
@@ -51,10 +45,6 @@
flex-shrink: 0;
}
.settings-icon {
font-size: 36rpx;
}
.nav-placeholder {
width: 100%;
}

View File

@@ -5,6 +5,8 @@
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
Page({
data: {
@@ -69,6 +71,9 @@ Page({
contactWechat: '',
contactSaving: false,
pendingWithdraw: false,
// 我的余额wallet 页入口展示)
walletBalance: 0,
},
onLoad() {
@@ -79,6 +84,8 @@ Page({
})
this.loadFeatureConfig()
this.initUserStatus()
// 规则引擎:登录后检查(填头像等)
checkAndExecute('after_login', this)
},
onShow() {
@@ -134,6 +141,7 @@ Page({
this.loadMyEarnings()
this.loadPendingConfirm()
this.loadVipStatus()
this.loadWalletBalance()
} else {
this.setData({
isLoggedIn: false,
@@ -630,6 +638,7 @@ Page({
// 显示登录弹窗(每次打开时协议未勾选,符合审核要求)
showLogin() {
trackClick('my', 'btn_click', '登录')
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
try {
const sys = wx.getSystemInfoSync()
@@ -677,6 +686,7 @@ Page({
// 微信登录(须已勾选同意协议,且做好错误处理避免审核报错)
async handleWechatLogin() {
trackClick('my', 'btn_click', '微信登录')
if (!this.data.agreeProtocol) {
wx.showToast({ title: '请先阅读并同意用户协议和隐私政策', icon: 'none' })
return
@@ -729,6 +739,7 @@ Page({
// 点击菜单
handleMenuTap(e) {
trackClick('my', 'btn_click', e.currentTarget.dataset.id || '菜单')
const id = e.currentTarget.dataset.id
if (!this.data.isLoggedIn && id !== 'about') {
@@ -737,6 +748,7 @@ Page({
}
const routes = {
wallet: '/pages/wallet/wallet',
orders: '/pages/purchases/purchases',
referral: '/pages/referral/referral',
withdrawRecords: '/pages/withdraw-records/withdraw-records',
@@ -759,6 +771,7 @@ Page({
// 跳转到目录
goToChapters() {
trackClick('my', 'nav_click', '目录')
wx.switchTab({ url: '/pages/chapters/chapters' })
},
@@ -769,11 +782,13 @@ Page({
// 跳转到匹配
goToMatch() {
trackClick('my', 'nav_click', '匹配')
wx.switchTab({ url: '/pages/match/match' })
},
// 跳转到推广中心(需登录)
goToReferral() {
trackClick('my', 'nav_click', '推广')
if (!this.data.isLoggedIn) {
this.showLogin()
return
@@ -783,6 +798,7 @@ Page({
// 跳转到找伙伴
goToMatch() {
trackClick('my', 'nav_click', '匹配')
wx.switchTab({ url: '/pages/match/match' })
},
@@ -801,6 +817,17 @@ Page({
})
},
async loadWalletBalance() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
const userId = app.globalData.userInfo.id
try {
const res = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
if (res && res.data) {
this.setData({ walletBalance: (res.data.balance || 0).toFixed(2) })
}
} catch (e) {}
},
// VIP状态查询注意hasFullBook=9.9 买断,不等同 VIP
async loadVipStatus() {
const userId = app.globalData.userInfo?.id
@@ -881,12 +908,14 @@ Page({
},
goToVip() {
trackClick('my', 'nav_click', 'VIP')
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/vip/vip' })
},
// 进入个人资料编辑页stitch_soul
goToProfileEdit() {
trackClick('my', 'nav_click', '设置')
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},

View File

@@ -57,6 +57,10 @@
<text class="profile-stat-val">{{earnings === '-' ? '--' : earnings}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
<view class="profile-stat" bindtap="handleMenuTap" data-id="wallet">
<text class="profile-stat-val">{{walletBalance > 0 ? '¥' + walletBalance : '0'}}</text>
<text class="profile-stat-label">我的余额</text>
</view>
</view>
</view>
</view>

View File

@@ -178,6 +178,10 @@
.icon-blue .menu-icon-img { width: 32rpx; height: 32rpx; }
.icon-gray { background: rgba(156,163,175,0.15); }
.icon-gray .menu-icon-img { width: 32rpx; height: 32rpx; }
.icon-amber { background: rgba(245,158,11,0.2); }
.menu-icon-emoji { font-size: 28rpx; }
.menu-right { display: flex; align-items: center; gap: 12rpx; }
.menu-balance { font-size: 26rpx; color: #4FD1C5; font-weight: 500; }
.menu-text { font-size: 28rpx; color: #E5E7EB; font-weight: 500; }
.menu-arrow { font-size: 36rpx; color: #9CA3AF; }

View File

@@ -18,6 +18,8 @@ import readingTracker from '../../utils/readingTracker'
const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
const app = getApp()
Page({
@@ -181,6 +183,21 @@ Page({
// 5. 加载导航
this.loadNavigation(id)
// 6. 规则引擎:阅读前检查(填头像、绑手机等)
checkAndExecute('before_read', this)
// 7. 记录浏览行为到 user_tracks
const userId = app.globalData.userInfo?.id
if (userId) {
app.request('/api/miniprogram/track', {
method: 'POST',
data: { userId, action: 'view_chapter', target: id, extraData: { sectionId: id, mid: mid || '' } },
silent: true
}).catch(() => {})
// 更新全局阅读计数
app.globalData.readCount = (app.globalData.readCount || 0) + 1
}
} catch (e) {
console.error('[Read] 初始化失败:', e)
wx.showToast({ title: '加载失败,请重试', icon: 'none' })
@@ -783,6 +800,7 @@ Page({
// 分享到微信 - 自动带分享人ID优先用 mid扫码/海报闭环),无则用 id
onShareAppMessage() {
trackClick('read', 'btn_click', '分享_' + this.data.sectionId)
const { section, sectionId, sectionMid } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
@@ -808,11 +826,20 @@ Page({
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const articleTitle = (section?.title || chapterTitle || '').trim()
const title = articleTitle
? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle)
: 'Soul创业派对 - 真实商业故事'
? `📚 ${articleTitle.length > 24 ? articleTitle.slice(0, 24) + '...' : articleTitle}Soul创业派对`
: '📚 Soul创业派对 - 真实商业故事'
return { title, query: ref ? `${q}&ref=${ref}` : q }
},
shareToMoments() {
wx.showModal({
title: '分享到朋友圈',
content: '点击右上角「···」菜单,选择「分享到朋友圈」即可。\n\n朋友圈分享文案已自动生成。',
showCancel: false,
confirmText: '知道了',
})
},
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
showLoginModal() {
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
@@ -939,6 +966,7 @@ Page({
// 购买章节 - 直接调起支付
async handlePurchaseSection() {
trackClick('read', 'btn_click', '购买章节_' + this.data.sectionId)
console.log('[Pay] 点击购买章节按钮')
wx.showLoading({ title: '处理中...', mask: true })
@@ -1297,6 +1325,11 @@ Page({
wx.navigateTo({ url: '/pages/referral/referral' })
},
showPosterModal() {
this.setData({ showPosterModal: true })
this.generatePoster()
},
// 生成海报
async generatePoster() {
wx.showLoading({ title: '生成中...' })
@@ -1466,7 +1499,7 @@ Page({
this.setData({ showShareTip: false })
},
// 代付分享:余额帮好友解锁当前章节
// 代付分享:微信支付或余额帮好友解锁当前章节
async handleGiftPay() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({ title: '提示', content: '请先登录', confirmText: '去登录', success: (r) => { if (r.confirm) this.showLoginModal() } })
@@ -1476,46 +1509,101 @@ Page({
const userId = app.globalData.userInfo.id
const price = (this.data.section && this.data.section.price != null) ? this.data.section.price : (this.data.sectionPrice || 1)
const balRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true }).catch(() => null)
const balance = (balRes && balRes.data) ? balRes.data.balance : 0
wx.showModal({
title: '代付分享',
content: `为好友代付本章 ¥${price}\n当前余额: ¥${balance.toFixed(2)}\n${balance < price ? '余额不足,请先充值' : '确认后将从余额扣除'}`,
confirmText: balance >= price ? '确认代付' : '去充值',
content: `为好友代付本章 ¥${price}\n\n支付后将生成代付链接,好友点击即可免费阅读`,
confirmText: '微信支付',
cancelText: '用余额',
success: async (res) => {
if (!res.confirm) return
if (balance < price) {
wx.navigateTo({ url: '/pages/wallet/wallet' })
return
}
wx.showLoading({ title: '处理中...' })
try {
const giftRes = await app.request({
url: '/api/miniprogram/balance/gift',
method: 'POST',
data: { giverId: userId, sectionId }
})
wx.hideLoading()
if (giftRes && giftRes.data && giftRes.data.giftCode) {
const giftCode = giftRes.data.giftCode
wx.showModal({
title: '代付成功!',
content: `已为好友代付 ¥${price},分享链接后好友可免费阅读`,
confirmText: '分享给好友',
success: (r) => {
if (r.confirm) {
this._giftCodeToShare = giftCode
wx.shareAppMessage()
}
if (!res.confirm && !res.cancel) return
if (res.confirm) {
// Direct WeChat Pay
wx.showLoading({ title: '创建订单...' })
try {
const payRes = await app.request({
url: '/api/miniprogram/pay',
method: 'POST',
data: {
openId: app.globalData.openId,
productType: 'gift',
productId: sectionId,
amount: price,
description: `代付解锁:${this.data.section?.title || sectionId}`,
userId: userId,
}
})
} else {
wx.showToast({ title: (giftRes && giftRes.error) || '代付失败', icon: 'none' })
wx.hideLoading()
if (payRes && payRes.payParams) {
wx.requestPayment({
...payRes.payParams,
success: async () => {
// After payment, create gift code via balance gift API
// First confirm recharge to add to balance, then deduct for gift
try {
const giftRes = await app.request({
url: '/api/miniprogram/balance/gift',
method: 'POST',
data: { giverId: userId, sectionId }
})
if (giftRes && giftRes.data && giftRes.data.giftCode) {
this._giftCodeToShare = giftRes.data.giftCode
wx.showModal({
title: '代付成功!',
content: `已为好友代付 ¥${price},分享后好友可免费阅读`,
confirmText: '分享给好友',
success: (r) => { if (r.confirm) wx.shareAppMessage() }
})
}
} catch (e) {
wx.showToast({ title: '生成分享链接失败', icon: 'none' })
}
},
fail: () => { wx.showToast({ title: '支付取消', icon: 'none' }) }
})
} else {
wx.showToast({ title: '创建支付失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '支付失败', icon: 'none' })
}
} else {
// Use balance (existing flow)
const balRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true }).catch(() => null)
const balance = (balRes && balRes.data) ? balRes.data.balance : 0
if (balance < price) {
wx.showModal({
title: '余额不足',
content: `当前余额 ¥${balance.toFixed(2)},需要 ¥${price}\n请先充值`,
confirmText: '去充值',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/wallet/wallet' }) }
})
return
}
wx.showLoading({ title: '处理中...' })
try {
const giftRes = await app.request({
url: '/api/miniprogram/balance/gift',
method: 'POST',
data: { giverId: userId, sectionId }
})
wx.hideLoading()
if (giftRes && giftRes.data && giftRes.data.giftCode) {
this._giftCodeToShare = giftRes.data.giftCode
wx.showModal({
title: '代付成功!',
content: `已从余额扣除 ¥${price},分享后好友可免费阅读`,
confirmText: '分享给好友',
success: (r) => { if (r.confirm) wx.shareAppMessage() }
})
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '代付失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '代付失败', icon: 'none' })
}
}
})

View File

@@ -93,7 +93,7 @@
<text class="action-icon-small">🎁</text>
<text class="action-text-small">代付分享</text>
</view>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<view class="action-btn-inline btn-poster-inline" bindtap="showPosterModal">
<text class="action-icon-small">🖼️</text>
<text class="action-text-small">生成海报</text>
</view>
@@ -312,8 +312,8 @@
</view>
</view>
<!-- 右下角悬浮分享按钮 -->
<button class="fab-share" open-type="share">
<image class="fab-icon" src="/assets/icons/share.svg" mode="aspectFit"></image>
</button>
<!-- 右下角悬浮按钮 - 分享到朋友圈 -->
<view class="fab-share" bindtap="shareToMoments">
<text class="fab-moments-icon">🌐</text>
</view>
</view>

View File

@@ -454,10 +454,16 @@
}
.btn-poster-inline {
background: rgba(255, 215, 0, 0.15);
border: 2rpx solid rgba(255, 215, 0, 0.3);
background: linear-gradient(135deg, #2d2d30 0%, #3d3d40 100%);
}
.btn-moments-inline {
background: linear-gradient(135deg, #1a4a2e, #0d3320);
border: 1px solid rgba(76, 175, 80, 0.3);
}
.btn-moments-inline:active {
opacity: 0.7;
}
.action-icon-small {
font-size: 28rpx;
@@ -1003,6 +1009,10 @@
display: block;
}
.fab-moments-icon {
font-size: 48rpx;
}
/* ===== 分享提示文字(底部导航上方) ===== */
.share-tip-inline {
text-align: center;

View File

@@ -8,6 +8,7 @@
* - 收益统计90%归分发者)
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
@@ -257,6 +258,7 @@ Page({
// 切换Tab
switchTab(e) {
trackClick('referral', 'tab_click', e.currentTarget.dataset.tab || 'tab')
const tab = e.currentTarget.dataset.tab
let currentBindings = []
@@ -278,6 +280,7 @@ Page({
// 复制邀请链接
copyLink() {
trackClick('referral', 'btn_click', '复制链接')
const link = `https://soul.quwanzhi.com/?ref=${this.data.referralCode}`
wx.setClipboardData({
data: link,
@@ -287,6 +290,7 @@ Page({
// 分享到朋友圈 - 1:1 迁移 Next.js 的 handleShareToWechat
shareToWechat() {
trackClick('referral', 'btn_click', '分享朋友圈文案')
const { referralCode } = this.data
const referralLink = `https://soul.quwanzhi.com/?ref=${referralCode}`
@@ -314,6 +318,7 @@ Page({
// 更多分享方式 - 1:1 迁移 Next.js 的 handleShare
handleMoreShare() {
trackClick('referral', 'btn_click', '更多分享')
const { referralCode } = this.data
const referralLink = `https://soul.quwanzhi.com/?ref=${referralCode}`
@@ -334,6 +339,7 @@ Page({
// 生成推广海报 - 1:1 对齐 Next.js 设计
async generatePoster() {
trackClick('referral', 'btn_click', '生成海报')
wx.showLoading({ title: '生成中...', mask: true })
this.setData({ showPosterModal: true, isGeneratingPoster: true })
@@ -524,6 +530,7 @@ Page({
// 保存海报
savePoster() {
trackClick('referral', 'btn_click', '保存海报')
const { posterQrSrc } = this.data
if (!posterQrSrc) {
wx.showToast({ title: '二维码未生成', icon: 'none' })
@@ -624,6 +631,7 @@ Page({
// 提现 - 直接到微信零钱
async handleWithdraw() {
trackClick('referral', 'btn_click', '提现')
const availableEarnings = this.data.availableEarningsNum || 0
const minWithdrawAmount = this.data.minWithdrawAmount || 10
const hasWechatId = this.data.hasWechatId
@@ -670,6 +678,7 @@ Page({
// 跳转提现记录页
goToWithdrawRecords() {
trackClick('referral', 'btn_click', '提现记录')
wx.navigateTo({ url: '/pages/withdraw-records/withdraw-records' })
},

View File

@@ -3,6 +3,7 @@
* 搜索章节标题和内容
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
@@ -83,6 +84,7 @@ Page({
return
}
trackClick('search', 'btn_click', keyword.trim())
this.setData({ loading: true, searched: true })
try {
@@ -109,6 +111,7 @@ Page({
goToRead(e) {
const id = e.currentTarget.dataset.id
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
trackClick('search', 'card_click', id)
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},

View File

@@ -1,5 +1,6 @@
import accessManager from '../../utils/chapterAccessManager'
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
@@ -11,16 +12,16 @@ Page({
originalPrice: 6980,
/* 按 premium_membership_landing_v1 设计稿 */
contentRights: [
{ title: '解锁全部章节', desc: '365天全案精读', icon: '📖' },
{ title: '案例库', desc: '100+创业实战案例', icon: '📚' },
{ title: '智能纪要', desc: 'AI每日精华推送', icon: '💡' },
{ title: '会议纪要库', desc: '往期完整沉淀', icon: '📁' }
{ title: '解锁章节', desc: '全部章节365天读', icon: '📖' },
{ title: '创业项目', desc: '查看最新创业项目', icon: '📚' },
{ title: '每日纪要', desc: '专属团队每日总结', icon: '💡' },
{ title: '文内链接', desc: '文章提到你可被链接', icon: '📁' }
],
socialRights: [
{ title: '匹配创业伙伴', desc: '精准人脉匹配', icon: '👥' },
{ title: '创业老板排行', desc: '项目曝光展示', icon: '📊' },
{ title: '链接资源', desc: '深度私域资源池', icon: '🔗' },
{ title: '专属VIP标识', desc: '金色尊享光圈', icon: '✓' }
{ title: '匹配伙伴', desc: '1980次创业伙伴匹配', icon: '👥' },
{ title: '获得客资', desc: '加入创业伙伴获客资', icon: '🔗' },
{ title: '老板排行', desc: '项目曝光超级个体', icon: '📊' },
{ title: 'VIP标识', desc: '金色尊享光圈特权', icon: '✓' }
],
purchasing: false
},
@@ -64,6 +65,7 @@ Page({
},
async handlePurchase() {
trackClick('vip', 'btn_click', '购买VIP')
let userId = app.globalData.userInfo?.id
let openId = app.globalData.openId || app.globalData.userInfo?.open_id
if (!userId || !openId) {

View File

@@ -4,23 +4,22 @@
<view class="nav-back" bindtap="goBack">
<text class="back-icon"></text>
</view>
<text class="nav-title">卡若创业派对</text>
<text class="nav-title">卡若创业派对VIP会员</text>
<view class="nav-placeholder-r"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 会员宣传区(已去掉 VIP PREMIUM 标签) -->
<view class="vip-hero {{isVip ? 'vip-hero-active' : ''}}">
<view class="vip-hero-title">加入卡若</view>
<view class="vip-hero-title gold">创业派对 会员</view>
<view class="vip-hero-title">加入卡若</view>
<view class="vip-hero-title gold">创业派对 VIP会员</view>
<text class="vip-hero-sub" wx:if="{{isVip}}">有效期至 {{expireDateStr}}(剩余{{daysRemaining}}天)</text>
<text class="vip-hero-sub" wx:else>一次加入 尊享终身陪伴与成长</text>
</view>
<!-- 双列权益:内容权益 + 社交权益 -->
<view class="rights-grid">
<view class="rights-col">
<view class="rights-col-header">
<text class="rights-dot rights-dot-teal"></text>
<text class="rights-col-title">内容权益</text>
<text class="rights-col-title">会员权利</text>
</view>
<view class="benefit-card" wx:for="{{contentRights}}" wx:key="title">
<text class="benefit-icon">{{item.icon || '✓'}}</text>
@@ -33,7 +32,7 @@
<view class="rights-col">
<view class="rights-col-header">
<text class="rights-dot rights-dot-gold"></text>
<text class="rights-col-title rights-col-title-gold">社交权益</text>
<text class="rights-col-title rights-col-title-gold">派对权利</text>
</view>
<view class="benefit-card" wx:for="{{socialRights}}" wx:key="title">
<text class="benefit-icon benefit-icon-gold">{{item.icon || '✓'}}</text>
@@ -47,7 +46,7 @@
<!-- 底部固定购买按钮(非 VIP 时显示,用 view 避让 button 默认 margin -->
<view class="buy-footer" wx:if="{{!isVip}}">
<view class="buy-btn-fixed {{purchasing ? 'buy-btn-disabled' : ''}}" bindtap="handlePurchase">
{{purchasing ? "处理中..." : "¥" + price + "/年 加入创业派对"}}
{{purchasing ? "处理中..." : "立即支付" + price + " 加入创业派对"}}
</view>
</view>

View File

@@ -0,0 +1,167 @@
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
statusBarHeight: 0,
balance: 0,
balanceText: '0.00',
totalRecharged: '0.00',
totalGifted: '0.00',
totalRefunded: '0.00',
transactions: [],
loading: true,
rechargeAmounts: [10, 30, 50, 1000],
selectedAmount: 30,
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this.loadBalance()
this.loadTransactions()
},
async loadBalance() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
const userId = app.globalData.userInfo.id
try {
const res = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
if (res && res.data) {
this.setData({
balance: res.data.balance || 0,
balanceText: (res.data.balance || 0).toFixed(2),
totalRecharged: (res.data.totalRecharged || 0).toFixed(2),
totalGifted: (res.data.totalGifted || 0).toFixed(2),
totalRefunded: (res.data.totalRefunded || 0).toFixed(2),
loading: false,
})
}
} catch (e) {
this.setData({ loading: false })
}
},
async loadTransactions() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
const userId = app.globalData.userInfo.id
try {
const res = await app.request({ url: `/api/miniprogram/balance/transactions?userId=${userId}`, silent: true })
if (res && res.data) {
const list = (res.data || []).map(t => ({
...t,
amountText: (t.amount || 0).toFixed(2),
amountSign: (t.amount || 0) >= 0 ? '+' : '',
description: t.description || (t.type === 'recharge' ? '充值' : t.type === 'gift' ? '赠送' : t.type === 'refund' ? '退款' : t.type === 'consume' ? '阅读消费' : '其他'),
}))
this.setData({ transactions: list })
}
} catch (e) {
console.warn('[Wallet] load transactions failed', e)
}
},
selectAmount(e) {
trackClick('wallet', 'tab_click', '选择金额' + (e.currentTarget.dataset.amount || ''))
this.setData({ selectedAmount: parseInt(e.currentTarget.dataset.amount) })
},
async handleRecharge() {
trackClick('wallet', 'btn_click', '充值')
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const userId = app.globalData.userInfo.id
const amount = this.data.selectedAmount
wx.showLoading({ title: '创建订单...' })
try {
const res = await app.request({
url: '/api/miniprogram/balance/recharge',
method: 'POST',
data: { userId, amount }
})
wx.hideLoading()
if (res && res.data && res.data.orderSn) {
// Trigger WeChat Pay for the recharge order
const payRes = await app.request({
url: '/api/miniprogram/pay',
method: 'POST',
data: {
openId: app.globalData.openId,
productType: 'balance_recharge',
productId: res.data.orderSn,
amount: amount,
description: `余额充值 ¥${amount}`,
userId: userId,
}
})
if (payRes && payRes.payParams) {
wx.requestPayment({
...payRes.payParams,
success: async () => {
// Confirm the recharge
await app.request({
url: '/api/miniprogram/balance/recharge/confirm',
method: 'POST',
data: { orderSn: res.data.orderSn }
})
wx.showToast({ title: '充值成功', icon: 'success' })
this.loadBalance()
this.loadTransactions()
},
fail: () => {
wx.showToast({ title: '支付取消', icon: 'none' })
}
})
} else {
wx.showToast({ title: '创建支付失败', icon: 'none' })
}
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '充值失败', icon: 'none' })
}
},
async handleRefund() {
trackClick('wallet', 'btn_click', '退款')
if (this.data.balance <= 0) {
wx.showToast({ title: '余额为零', icon: 'none' })
return
}
const userId = app.globalData.userInfo.id
const balance = this.data.balance
const refundAmount = (balance * 0.9).toFixed(2)
wx.showModal({
title: '余额退款',
content: `退回全部余额 ¥${balance.toFixed(2)}\n实际到账 ¥${refundAmount}9折\n\n退款将在1-3个工作日内原路返回`,
confirmText: '确认退款',
success: async (res) => {
if (!res.confirm) return
wx.showLoading({ title: '处理中...' })
try {
const result = await app.request({
url: '/api/miniprogram/balance/refund',
method: 'POST',
data: { userId, amount: balance }
})
wx.hideLoading()
if (result && result.data) {
wx.showToast({ title: result.data.message || '退款成功', icon: 'success' })
this.loadBalance()
this.loadTransactions()
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '退款失败', icon: 'none' })
}
}
})
},
goBack() {
wx.navigateBack()
},
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "我的余额",
"navigationStyle": "custom"
}

View File

@@ -0,0 +1,88 @@
<!-- Soul创业派对 - 我的余额 -->
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
<text class="back-icon"></text>
</view>
<text class="nav-title">我的余额</text>
<view class="nav-placeholder"></view>
</view>
<view class="nav-placeholder-block" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 余额卡片 -->
<view class="balance-card">
<view class="balance-main" wx:if="{{!loading}}">
<text class="balance-label">当前余额</text>
<text class="balance-value">¥{{balanceText}}</text>
<text class="balance-tip">充值后可直接用于解锁付费内容,消费记录会展示在下方。</text>
</view>
<view class="balance-skeleton" wx:else>
<text class="skeleton-text">加载中...</text>
</view>
</view>
<!-- 充值金额选择 -->
<view class="section">
<view class="section-head">
<text class="section-title">选择充值金额</text>
<text class="section-note">当前已选 ¥{{selectedAmount}}</text>
</view>
<view class="amount-grid">
<view
class="amount-card {{selectedAmount === item ? 'amount-card-active' : ''}}"
wx:for="{{rechargeAmounts}}"
wx:key="*this"
bindtap="selectAmount"
data-amount="{{item}}"
>
<view class="amount-card-top">
<text class="amount-card-value">¥{{item}}</text>
<view class="amount-card-check {{selectedAmount === item ? 'amount-card-check-active' : ''}}">
<view class="amount-card-check-dot" wx:if="{{selectedAmount === item}}"></view>
</view>
</view>
<text class="amount-card-desc">{{selectedAmount === item ? '已选中,点击充值' : '点击选择此金额'}}</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-row">
<view class="btn btn-recharge" bindtap="handleRecharge">充值</view>
<view class="btn btn-refund" bindtap="handleRefund">退款(9折)</view>
</view>
<!-- 充值与消费记录 -->
<view class="section">
<view class="section-head">
<text class="section-title">充值/消费记录</text>
<text class="section-note">按时间倒序显示</text>
</view>
<view class="transactions" wx:if="{{transactions.length > 0}}">
<view
class="tx-item"
wx:for="{{transactions}}"
wx:key="id"
>
<view class="tx-icon {{item.type}}">
<text wx:if="{{item.type === 'recharge'}}">💰</text>
<text wx:elif="{{item.type === 'gift'}}">🎁</text>
<text wx:elif="{{item.type === 'refund'}}">↩️</text>
<text wx:elif="{{item.type === 'consume'}}">📖</text>
<text wx:else>•</text>
</view>
<view class="tx-info">
<text class="tx-desc">{{item.description}}</text>
<text class="tx-time">{{item.createdAt || item.created_at || '--'}}</text>
</view>
<text class="tx-amount {{item.amount >= 0 ? 'tx-amount-plus' : 'tx-amount-minus'}}">{{item.amountSign}}¥{{item.amountText}}</text>
</view>
</view>
<view class="tx-empty" wx:else>
<text>暂无充值或消费记录</text>
</view>
</view>
<view class="bottom-space"></view>
</view>

View File

@@ -0,0 +1,267 @@
/* Soul创业派对 - 我的余额 - 深色主题 */
.page {
min-height: 100vh;
background: #0a0a0a;
padding-bottom: 64rpx;
}
/* 导航栏 */
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(10, 10, 10, 0.95);
backdrop-filter: blur(40rpx);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32rpx;
height: 88rpx;
}
.nav-back {
width: 64rpx;
height: 64rpx;
background: #1c1c1e;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
font-size: 40rpx;
color: rgba(255, 255, 255, 0.6);
font-weight: 300;
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: #fff;
}
.nav-placeholder {
width: 64rpx;
}
.nav-placeholder-block {
width: 100%;
}
/* 余额卡片 - 渐变背景 */
.balance-card {
margin: 24rpx 24rpx 32rpx;
background: linear-gradient(135deg, #1c1c1e 0%, rgba(56, 189, 172, 0.15) 100%);
border-radius: 32rpx;
padding: 40rpx 32rpx;
border: 2rpx solid rgba(56, 189, 172, 0.2);
}
.balance-main {
min-height: 220rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.balance-label {
display: block;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 8rpx;
}
.balance-value {
font-size: 72rpx;
font-weight: 700;
color: #38bdac;
letter-spacing: 2rpx;
}
.balance-tip {
margin-top: 18rpx;
font-size: 24rpx;
line-height: 1.7;
color: rgba(255, 255, 255, 0.58);
}
.balance-skeleton {
padding: 40rpx 0;
}
.skeleton-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.4);
}
/* 区块标题 */
.section {
margin: 0 24rpx 32rpx;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 20rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.section-note {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.45);
}
/* 金额选择卡片 */
.amount-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
}
.amount-card {
padding: 26rpx 24rpx;
background: linear-gradient(180deg, #19191b 0%, #151517 100%);
border-radius: 28rpx;
border: 2rpx solid rgba(255, 255, 255, 0.1);
box-sizing: border-box;
}
.amount-card-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.amount-card-value {
font-size: 40rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
}
.amount-card-desc {
display: block;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.46);
}
.amount-card-check {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
border: 2rpx solid rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.amount-card-check-active {
border-color: #38bdac;
background: rgba(56, 189, 172, 0.18);
}
.amount-card-check-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: #38bdac;
}
.amount-card-active {
background: linear-gradient(180deg, rgba(56, 189, 172, 0.22) 0%, rgba(15, 30, 29, 0.95) 100%);
border-color: rgba(56, 189, 172, 0.95);
box-shadow: 0 0 0 2rpx rgba(56, 189, 172, 0.1);
}
.amount-card-active .amount-card-value {
color: #52d8c7;
}
.amount-card-active .amount-card-desc {
color: rgba(213, 255, 250, 0.72);
}
/* 操作按钮 */
.action-row {
display: flex;
gap: 24rpx;
margin: 0 24rpx 40rpx;
}
.btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
}
.btn-recharge {
background: #38bdac;
color: #0a0a0a;
}
.btn-refund {
background: #1c1c1e;
color: rgba(255, 255, 255, 0.9);
border: 2rpx solid rgba(56, 189, 172, 0.4);
}
/* 交易记录 */
.transactions {
background: #1c1c1e;
border-radius: 24rpx;
overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.04);
}
.tx-item {
display: flex;
align-items: center;
padding: 28rpx 32rpx;
border-bottom: 2rpx solid rgba(255, 255, 255, 0.04);
}
.tx-item:last-child {
border-bottom: none;
}
.tx-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
margin-right: 24rpx;
}
.tx-icon.recharge {
background: rgba(56, 189, 172, 0.2);
}
.tx-icon.gift {
background: rgba(255, 215, 0, 0.15);
}
.tx-icon.refund {
background: rgba(255, 255, 255, 0.1);
}
.tx-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.tx-desc {
font-size: 28rpx;
color: #fff;
}
.tx-time {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.45);
}
.tx-amount {
font-size: 30rpx;
font-weight: 600;
}
.tx-amount-plus {
color: #38bdac;
}
.tx-amount-minus {
color: rgba(255, 255, 255, 0.6);
}
.tx-empty {
padding: 60rpx;
text-align: center;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.4);
background: #1c1c1e;
border-radius: 24rpx;
}
.bottom-space {
height: 48rpx;
}

View File

@@ -0,0 +1,179 @@
/**
* Soul创业派对 - 用户旅程规则引擎
* 在关键页面自动检查用户状态,引导完善信息
*
* 规则逻辑:
* 1. 注册后 → 引导填写头像和昵称
* 2. 浏览付费章节 → 引导绑定手机号
* 3. 完成匹配 → 引导填写 MBTI / 行业等
* 4. 购买全书 → 引导填写完整信息(进 VIP 群)
* 5. 累计浏览 5 章 → 引导分享推广
*/
const app = getApp()
const RULE_COOLDOWN_KEY = 'rule_engine_cooldown'
const COOLDOWN_MS = 60 * 1000
function isInCooldown(ruleId) {
try {
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
const ts = map[ruleId]
if (!ts) return false
return Date.now() - ts < COOLDOWN_MS
} catch { return false }
}
function setCooldown(ruleId) {
try {
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
map[ruleId] = Date.now()
wx.setStorageSync(RULE_COOLDOWN_KEY, map)
} catch {}
}
function getUserInfo() {
return app.globalData.userInfo || {}
}
function checkRule_FillAvatar() {
const user = getUserInfo()
if (!user.id) return null
const avatar = user.avatar || user.avatarUrl || ''
const nickname = user.nickname || ''
if (avatar && !avatar.includes('default') && nickname && nickname !== '微信用户' && !nickname.startsWith('微信用户')) {
return null
}
if (isInCooldown('fill_avatar')) return null
setCooldown('fill_avatar')
return {
ruleId: 'fill_avatar',
title: '完善个人信息',
message: '设置头像和昵称,让其他创业者更容易认识你',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
}
function checkRule_BindPhone() {
const user = getUserInfo()
if (!user.id) return null
if (user.phone) return null
if (isInCooldown('bind_phone')) return null
setCooldown('bind_phone')
return {
ruleId: 'bind_phone',
title: '绑定手机号',
message: '绑定手机号解锁更多功能,保障账户安全',
action: 'bind_phone',
target: null
}
}
function checkRule_FillProfile() {
const user = getUserInfo()
if (!user.id) return null
if (user.mbti && user.industry) return null
if (isInCooldown('fill_profile')) return null
setCooldown('fill_profile')
return {
ruleId: 'fill_profile',
title: '完善创业档案',
message: '填写 MBTI 和行业信息,帮你精准匹配创业伙伴',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
}
function checkRule_ShareAfter5Chapters() {
const user = getUserInfo()
if (!user.id) return null
const readCount = app.globalData.readCount || 0
if (readCount < 5) return null
if (isInCooldown('share_after_5')) return null
setCooldown('share_after_5')
return {
ruleId: 'share_after_5',
title: '邀请好友一起看',
message: '你已阅读 ' + readCount + ' 个章节,分享给好友可获得分销收益',
action: 'navigate',
target: '/pages/referral/referral'
}
}
/**
* 在指定场景下检查规则
* @param {string} scene - 场景after_login, before_read, after_match, after_pay, page_show
* @returns {object|null} - 触发的规则null 表示无需触发
*/
function checkRules(scene) {
const user = getUserInfo()
if (!user.id) return null
switch (scene) {
case 'after_login':
return checkRule_FillAvatar()
case 'before_read':
return checkRule_BindPhone() || checkRule_FillAvatar()
case 'after_match':
return checkRule_FillProfile()
case 'after_pay':
return checkRule_FillProfile()
case 'page_show':
return checkRule_FillAvatar() || checkRule_ShareAfter5Chapters()
default:
return null
}
}
/**
* 执行规则动作
* @param {object} rule - checkRules 返回的规则对象
* @param {object} pageInstance - 页面实例(用于绑定手机号等操作)
*/
function executeRule(rule, pageInstance) {
if (!rule) return
wx.showModal({
title: rule.title,
content: rule.message,
confirmText: '去完善',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) {
if (rule.action === 'navigate' && rule.target) {
wx.navigateTo({ url: rule.target })
} else if (rule.action === 'bind_phone' && pageInstance) {
if (typeof pageInstance.showPhoneBinding === 'function') {
pageInstance.showPhoneBinding()
}
}
}
_trackRuleAction(rule.ruleId, res.confirm ? 'confirm' : 'cancel')
}
})
}
function _trackRuleAction(ruleId, action) {
const userId = getUserInfo().id
if (!userId) return
app.request('/api/miniprogram/track', {
method: 'POST',
data: { userId, action: 'rule_trigger', target: ruleId, extraData: { result: action } },
silent: true
}).catch(() => {})
}
/**
* 在页面 onShow 中调用的便捷方法
* @param {string} scene
* @param {object} pageInstance
*/
function checkAndExecute(scene, pageInstance) {
const rule = checkRules(scene)
if (rule) {
setTimeout(() => executeRule(rule, pageInstance), 800)
}
}
module.exports = { checkRules, executeRule, checkAndExecute }

View File

@@ -0,0 +1,25 @@
const app = getApp()
/**
* 全局按钮/标签点击埋点
* @param {string} module 模块home|chapters|read|my|vip|wallet|match|referral|search|settings|about
* @param {string} action 行为tab_click|btn_click|nav_click|card_click|link_click 等
* @param {string} target 目标标识按钮文案、章节ID、标签名等
* @param {object} [extra] 附加数据
*/
function trackClick(module, action, target, extra) {
const userId = app.globalData.userInfo?.id || ''
if (!userId) return
app.request('/api/miniprogram/track', {
method: 'POST',
data: {
userId,
action,
target,
extraData: Object.assign({ module, page: module }, extra || {})
},
silent: true
}).catch(() => {})
}
module.exports = { trackClick }

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

937
soul-admin/dist/assets/index-d2VmvrcP.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-ChSYyP1O.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BlPUt9ll.css">
<script type="module" crossorigin src="/assets/index-d2VmvrcP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DdPcz68r.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, Eye, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
import { Users, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react'
import { get } from '@/api/client'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
@@ -71,11 +71,19 @@ export function DashboardPage() {
const [totalUsersCount, setTotalUsersCount] = useState(0)
const [paidOrderCount, setPaidOrderCount] = useState(0)
const [totalRevenue, setTotalRevenue] = useState(0)
const [todayClicks, setTodayClicks] = useState(0)
const [conversionRate, setConversionRate] = useState(0)
const [giftedTotal, setGiftedTotal] = useState(0)
const [loadError, setLoadError] = useState<string | null>(null)
const [detailUserId, setDetailUserId] = useState<string | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
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 showError = (err: unknown) => {
const e = err as Error & { status?: number; name?: string }
if (e?.status === 401) setLoadError('登录已过期,请重新登录')
@@ -95,6 +103,7 @@ export function DashboardPage() {
setTotalUsersCount(stats.totalUsers ?? 0)
setPaidOrderCount(stats.paidOrderCount ?? 0)
setTotalRevenue(stats.totalRevenue ?? 0)
setConversionRate(stats.conversionRate ?? 0)
}
} catch (e) {
if ((e as Error)?.name !== 'AbortError') {
@@ -105,6 +114,7 @@ export function DashboardPage() {
setTotalUsersCount(overview.totalUsers ?? 0)
setPaidOrderCount(overview.paidOrderCount ?? 0)
setTotalRevenue(overview.totalRevenue ?? 0)
setConversionRate(overview.conversionRate ?? 0)
}
} catch (e2) {
showError(e2)
@@ -114,14 +124,14 @@ export function DashboardPage() {
setStatsLoading(false)
}
// 加载今日点击(从推广中心接口
// 加载代付总额(仅用于收入标签展示
try {
const distOverview = await get<{ success?: boolean; todayClicks?: number }>('/api/admin/distribution/overview', init)
if (distOverview?.success) {
setTodayClicks(distOverview.todayClicks ?? 0)
const balRes = await get<{ success?: boolean; data?: { totalGifted?: number } }>('/api/admin/balance/summary', init)
if (balRes?.success && balRes.data) {
setGiftedTotal(balRes.data.totalGifted ?? 0)
}
} catch {
// 推广数据加载失败不影响主面板
// 不影响主面板
}
// 2. 并行加载订单和用户
@@ -174,10 +184,28 @@ export function DashboardPage() {
await Promise.all([loadOrders(), loadUsers()])
}
async function loadTrackStats(period?: string) {
const p = period || trackPeriod
setTrackLoading(true)
try {
const res = await get<{ success?: boolean; total?: number; byModule?: Record<string, { action: string; target: string; module: string; page: string; count: number }[]> }>(
`/api/admin/track/stats?period=${p}`
)
if (res?.success) {
setTrackStats({ total: res.total ?? 0, byModule: res.byModule ?? {} })
}
} catch {
setTrackStats(null)
} finally {
setTrackLoading(false)
}
}
useEffect(() => {
const ctrl = new AbortController()
loadAll(ctrl.signal)
const timer = setInterval(() => loadAll(), 30000)
loadTrackStats()
const timer = setInterval(() => { loadAll(); loadTrackStats() }, 30000)
return () => {
ctrl.abort()
clearInterval(timer)
@@ -223,6 +251,7 @@ export function DashboardPage() {
{
title: '总用户数',
value: statsLoading ? null : totalUsers,
sub: null as string | null,
icon: Users,
color: 'text-blue-400',
bg: 'bg-blue-500/20',
@@ -231,6 +260,7 @@ export function DashboardPage() {
{
title: '总收入',
value: statsLoading ? null : `¥${(totalRevenue ?? 0).toFixed(2)}`,
sub: giftedTotal > 0 ? `含代付 ¥${giftedTotal.toFixed(2)}` : null,
icon: TrendingUp,
color: 'text-[#38bdac]',
bg: 'bg-[#38bdac]/20',
@@ -239,18 +269,20 @@ export function DashboardPage() {
{
title: '订单数',
value: statsLoading ? null : paidOrderCount,
sub: null as string | null,
icon: ShoppingBag,
color: 'text-purple-400',
bg: 'bg-purple-500/20',
link: '/orders',
},
{
title: '今日点击',
value: statsLoading ? null : todayClicks,
icon: Eye,
color: 'text-blue-400',
bg: 'bg-blue-500/20',
link: '/distribution',
title: '转化率',
value: statsLoading ? null : `${conversionRate.toFixed(1)}%`,
sub: null as string | null,
icon: TrendingUp,
color: 'text-amber-400',
bg: 'bg-amber-500/20',
link: '/users',
},
]
@@ -269,7 +301,7 @@ 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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{stats.map((stat, index) => (
<Card
key={index}
@@ -284,14 +316,19 @@ export function DashboardPage() {
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-2xl font-bold text-white min-h-[2rem] flex items-center">
{stat.value != null ? (
stat.value
) : (
<span className="inline-flex items-center gap-2 text-gray-500">
<RefreshCw className="w-4 h-4 animate-spin" />
</span>
<div>
<div className="text-2xl font-bold text-white min-h-8 flex items-center">
{stat.value != null ? (
stat.value
) : (
<span className="inline-flex items-center gap-2 text-gray-500">
<RefreshCw className="w-4 h-4 animate-spin" />
</span>
)}
</div>
{stat.sub && (
<p className="text-xs text-gray-500 mt-1">{stat.sub}</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-[#38bdac] transition-colors" />
@@ -480,6 +517,95 @@ export function DashboardPage() {
</Card>
</div>
{/* 分类标签点击统计 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mt-8">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<div className="flex items-center gap-2">
{(['today', 'week', 'month', 'all'] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => { setTrackPeriod(p); loadTrackStats(p) }}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
trackPeriod === p
? 'bg-[#38bdac] text-white'
: 'bg-gray-700/50 text-gray-400 hover:bg-gray-700'
}`}
>
{{ today: '今日', week: '本周', month: '本月', all: '全部' }[p]}
</button>
))}
</div>
</CardHeader>
<CardContent>
{trackLoading && !trackStats ? (
<div className="flex items-center justify-center py-12 text-gray-500">
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : trackStats && Object.keys(trackStats.byModule).length > 0 ? (
<div className="space-y-6">
<p className="text-sm text-gray-400">
<span className="text-white font-bold text-lg">{trackStats.total}</span>
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(trackStats.byModule)
.sort((a, b) => b[1].reduce((s, i) => s + i.count, 0) - a[1].reduce((s, i) => s + i.count, 0))
.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">
<span className="text-sm font-medium text-[#38bdac]">
{moduleLabels[mod] || mod}
</span>
<span className="text-xs text-gray-500">{moduleTotal} </span>
</div>
<div className="space-y-2">
{items
.sort((a, b) => b.count - a.count)
.slice(0, 8)
.map((item, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-gray-300 truncate mr-2" title={`${item.action}: ${item.target}`}>
{item.target || item.action}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-16 h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-[#38bdac] rounded-full"
style={{ width: `${moduleTotal > 0 ? (item.count / moduleTotal) * 100 : 0}%` }}
/>
</div>
<span className="text-gray-400 w-8 text-right">{item.count}</span>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
</div>
) : (
<div className="text-center py-12">
<BarChart3 className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500"></p>
<p className="text-gray-600 text-xs mt-1"></p>
</div>
)}
</CardContent>
</Card>
<UserDetailModal
open={showDetailModal}
onClose={() => { setShowDetailModal(false); setDetailUserId(null) }}

View File

@@ -44,6 +44,8 @@ import {
ChevronUp,
Crown,
Tag,
Star,
Info,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { Pagination } from '@/components/ui/Pagination'
@@ -64,10 +66,10 @@ interface User {
earnings: number | string
pendingEarnings?: number | string
withdrawnEarnings?: number | string
walletBalance?: number | string
referralCount?: number
createdAt: string
updatedAt?: string | null
// RFM排序模式时有值
rfmScore?: number
rfmLevel?: string
}
@@ -155,6 +157,12 @@ export function UsersPage() {
// ===== 用户旅程总览 =====
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
const [journeyLoading, setJourneyLoading] = useState(false)
const [journeySelectedStage, setJourneySelectedStage] = useState<string | null>(null)
const [journeyUsers, setJourneyUsers] = useState<{ id: string; nickname: string; phone?: string; createdAt?: string }[]>([])
const [journeyUsersLoading, setJourneyUsersLoading] = useState(false)
// RFM 算法说明
const [showRfmInfo, setShowRfmInfo] = useState(false)
// ===== 用户列表 =====
async function loadUsers(fromRefresh = false) {
@@ -488,6 +496,41 @@ export function UsersPage() {
} catch { } finally { setJourneyLoading(false) }
}, [])
async function loadJourneyUsers(stageId: string) {
setJourneySelectedStage(stageId)
setJourneyUsersLoading(true)
try {
const data = await get<{ success?: boolean; users?: { id: string; nickname: string; phone?: string; createdAt?: string }[] }>(
`/api/db/users/journey-users?stage=${encodeURIComponent(stageId)}&limit=20`
)
if (data?.success && data.users) setJourneyUsers(data.users)
else setJourneyUsers([])
} catch {
setJourneyUsers([])
} finally {
setJourneyUsersLoading(false)
}
}
async function handleSetSuperMember(user: User) {
if (!user.hasFullBook) {
toast.error('仅 VIP 用户可置顶到超级个体')
return
}
if (!confirm('确定将该用户置顶到首页超级个体位最多4位')) return
try {
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: user.id, vipSort: 1 })
if (!res?.success) {
toast.error(res?.error || '置顶失败')
return
}
toast.success('已置顶到超级个体')
loadUsers()
} catch {
toast.error('置顶失败')
}
}
return (
<div className="p-8 w-full">
{error && (
@@ -499,7 +542,12 @@ export function UsersPage() {
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-white"></h2>
<Button variant="ghost" size="sm" onClick={() => setShowRfmInfo(true)} className="text-gray-500 hover:text-[#38bdac] h-8 w-8 p-0" title="RFM 算法说明">
<Info className="w-4 h-4" />
</Button>
</div>
<p className="text-gray-400 mt-1 text-sm"> {total} {rfmSortMode && ' · RFM 排序中'}</p>
</div>
</div>
@@ -576,6 +624,7 @@ export function UsersPage() {
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400">/</TableHead>
<TableHead className="text-gray-400 cursor-pointer select-none" onClick={toggleRfmSort}>
<div className="flex items-center gap-1 group">
<TrendingUp className="w-3.5 h-3.5" />
@@ -650,6 +699,14 @@ export function UsersPage() {
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="text-white font-medium">¥{parseFloat(String(user.walletBalance || 0)).toFixed(2)}</div>
{parseFloat(String(user.withdrawnEarnings || 0)) > 0 && (
<div className="text-xs text-gray-400">: ¥{parseFloat(String(user.withdrawnEarnings || 0)).toFixed(2)}</div>
)}
</div>
</TableCell>
{/* RFM 分值列 */}
<TableCell>
{user.rfmScore !== undefined ? (
@@ -669,6 +726,9 @@ export function UsersPage() {
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }} className="text-gray-400 hover:text-blue-400 hover:bg-blue-400/10" title="用户详情"><Eye className="w-4 h-4" /></Button>
{user.hasFullBook && (
<Button variant="ghost" size="sm" onClick={() => handleSetSuperMember(user)} className="text-gray-400 hover:text-orange-400 hover:bg-orange-400/10" title="置顶超级个体"><Star className="w-4 h-4" /></Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleEditUser(user)} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10" title="编辑用户"><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 hover:bg-red-500/10" onClick={() => handleDelete(user.id)} title="删除"><Trash2 className="w-4 h-4" /></Button>
</div>
@@ -706,7 +766,13 @@ export function UsersPage() {
{JOURNEY_STAGES.map((stage, idx) => (
<div key={stage.id} className="relative flex flex-col items-center">
{/* 阶段卡片 */}
<div className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-default`}>
<div
role="button"
tabIndex={0}
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer`}
onClick={() => loadJourneyUsers(stage.id)}
onKeyDown={(e) => e.key === 'Enter' && loadJourneyUsers(stage.id)}
>
<div className="text-2xl mb-1">{stage.icon}</div>
<div className={`text-xs font-medium ${stage.color.split(' ').find(c => c.startsWith('text-'))}`}>{stage.label}</div>
{journeyStats[stage.id] !== undefined && (
@@ -732,6 +798,44 @@ export function UsersPage() {
</div>
</div>
{/* 选中阶段的用户列表 */}
{journeySelectedStage && (
<div className="mb-6 bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-white font-medium">
{JOURNEY_STAGES.find((s) => s.id === journeySelectedStage)?.label}
</span>
<Button variant="ghost" size="sm" onClick={() => setJourneySelectedStage(null)} className="text-gray-500 hover:text-white">
<X className="w-4 h-4" />
</Button>
</div>
{journeyUsersLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" />
</div>
) : journeyUsers.length > 0 ? (
<div className="space-y-2 max-h-60 overflow-y-auto">
{journeyUsers.map((u) => (
<div
key={u.id}
className="flex items-center justify-between py-2 px-3 bg-[#0a1628] rounded-lg hover:bg-[#0a1628]/80 cursor-pointer"
onClick={() => { setSelectedUserIdForDetail(u.id); setShowDetailModal(true) }}
onKeyDown={(e) => e.key === 'Enter' && (setSelectedUserIdForDetail(u.id), setShowDetailModal(true))}
role="button"
tabIndex={0}
>
<span className="text-white font-medium">{u.nickname}</span>
<span className="text-gray-400 text-sm">{u.phone || '—'}</span>
<span className="text-gray-500 text-xs">{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—'}</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm py-4"></p>
)}
</div>
)}
{/* 旅程说明 */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
@@ -1125,6 +1229,28 @@ export function UsersPage() {
</DialogContent>
</Dialog>
{/* RFM 算法说明 */}
<Dialog open={showRfmInfo} onOpenChange={setShowRfmInfo}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[#38bdac]" />
RFM
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-4 text-sm text-gray-300">
<p><span className="text-[#38bdac] font-medium">RRecency</span> 40%</p>
<p><span className="text-[#38bdac] font-medium">FFrequency</span> 30%</p>
<p><span className="text-[#38bdac] font-medium">MMonetary</span> 30%</p>
<p className="text-gray-400"> = R×40% + F×30% + M×30% 0-100</p>
<p className="text-gray-400"><span className="text-amber-400">S</span>85<span className="text-green-400">A</span>70<span className="text-blue-400">B</span>50<span className="text-gray-400">C</span>30<span className="text-red-400">D</span>&lt;30</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRfmInfo(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"></Button>
</DialogFooter>
</DialogContent>
</Dialog>
<UserDetailModal open={showDetailModal} onClose={() => setShowDetailModal(false)} userId={selectedUserIdForDetail} onUserUpdated={loadUsers} />
</div>
)

View File

@@ -304,3 +304,70 @@ func DBUsersJourneyStats(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "stats": stats})
}
// journeyUserItem 用户旅程列表项
type journeyUserItem struct {
ID string `json:"id"`
Nickname string `json:"nickname"`
Phone string `json:"phone"`
CreatedAt string `json:"createdAt"`
}
// DBUsersJourneyUsers GET /api/db/users/journey-users?stage=xxx&limit=20 — 按阶段查用户
func DBUsersJourneyUsers(c *gin.Context) {
db := database.DB()
stage := strings.TrimSpace(c.Query("stage"))
limitStr := c.DefaultQuery("limit", "20")
limit := 20
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
limit = n
}
if limit > 100 {
limit = 100
}
var users []model.User
switch stage {
case "register":
db.Order("created_at DESC").Limit(limit).Find(&users)
case "browse":
subq := db.Table("user_tracks").Select("user_id").Where("action = ?", "view_chapter").Distinct("user_id")
db.Where("id IN (?)", subq).Order("created_at DESC").Limit(limit).Find(&users)
case "bind_phone":
db.Where("phone IS NOT NULL AND phone != ''").Order("created_at DESC").Limit(limit).Find(&users)
case "first_pay":
db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id").
Where("status IN ?", []string{"paid", "success", "completed"})).
Order("created_at DESC").Limit(limit).Find(&users)
case "fill_profile":
db.Where("mbti IS NOT NULL OR industry IS NOT NULL").Order("created_at DESC").Limit(limit).Find(&users)
case "match":
subq := db.Table("user_tracks").Select("user_id").Where("action = ?", "match").Distinct("user_id")
db.Where("id IN (?)", subq).Order("created_at DESC").Limit(limit).Find(&users)
case "vip":
db.Where("is_vip = ?", true).Order("created_at DESC").Limit(limit).Find(&users)
case "distribution":
db.Where("referral_code IS NOT NULL AND referral_code != ''").Where("COALESCE(earnings, 0) > ?", 0).
Order("created_at DESC").Limit(limit).Find(&users)
default:
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的 stage 参数"})
return
}
list := make([]journeyUserItem, 0, len(users))
for _, u := range users {
nick, phone := "", ""
if u.Nickname != nil {
nick = *u.Nickname
}
if u.Phone != nil {
phone = *u.Phone
}
list = append(list, journeyUserItem{
ID: u.ID,
Nickname: nick,
Phone: phone,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "users": list})
}

View File

@@ -0,0 +1,93 @@
package handler
import (
"encoding/json"
"net/http"
"time"
"soul-api/internal/database"
"github.com/gin-gonic/gin"
)
// AdminTrackStats GET /api/admin/track/stats 管理端-按钮/标签点击统计(按模块+action聚合
// 查询参数period=today|week|month|all默认 today
func AdminTrackStats(c *gin.Context) {
period := c.DefaultQuery("period", "today")
db := database.DB()
now := time.Now()
var since time.Time
switch period {
case "today":
since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
case "week":
since = now.AddDate(0, 0, -7)
case "month":
since = now.AddDate(0, -1, 0)
default:
since = time.Time{}
}
type trackRow struct {
Action string `gorm:"column:action"`
Target string `gorm:"column:target"`
ExtraData []byte `gorm:"column:extra_data"`
Count int64 `gorm:"column:count"`
}
query := db.Table("user_tracks").
Select("action, COALESCE(target, '') as target, extra_data, COUNT(*) as count").
Group("action, COALESCE(target, ''), extra_data").
Order("count DESC")
if !since.IsZero() {
query = query.Where("created_at >= ?", since)
}
var rows []trackRow
query.Find(&rows)
type statItem struct {
Action string `json:"action"`
Target string `json:"target"`
Module string `json:"module"`
Page string `json:"page"`
Count int64 `json:"count"`
}
byModule := make(map[string][]statItem)
total := int64(0)
for _, r := range rows {
module := "other"
page := ""
if len(r.ExtraData) > 0 {
var extra map[string]interface{}
if json.Unmarshal(r.ExtraData, &extra) == nil {
if m, ok := extra["module"].(string); ok && m != "" {
module = m
}
if p, ok := extra["page"].(string); ok && p != "" {
page = p
}
}
}
item := statItem{
Action: r.Action,
Target: r.Target,
Module: module,
Page: page,
Count: r.Count,
}
byModule[module] = append(byModule[module], item)
total += r.Count
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"period": period,
"total": total,
"byModule": byModule,
})
}

View File

@@ -308,7 +308,7 @@ func BalanceRefund(c *gin.Context) {
}})
}
// GET /api/miniprogram/balance/transactions 交易记录
// GET /api/miniprogram/balance/transactions 交易记录(含余额变动 + 阅读消费订单)
func BalanceTransactions(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
@@ -317,10 +317,81 @@ func BalanceTransactions(c *gin.Context) {
}
db := database.DB()
var txns []model.BalanceTransaction
db.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&txns)
c.JSON(http.StatusOK, gin.H{"success": true, "data": txns})
var orders []model.Order
db.Where("user_id = ? AND product_type = ? AND status IN ?", userID, "section", []string{"paid", "completed", "success"}).
Order("created_at DESC").Limit(50).Find(&orders)
type txRow struct {
ID string `json:"id"`
Type string `json:"type"`
Amount float64 `json:"amount"`
Description string `json:"description"`
CreatedAt interface{} `json:"createdAt"`
}
merged := make([]txRow, 0, len(txns)+len(orders))
for _, t := range txns {
merged = append(merged, txRow{
ID: fmt.Sprintf("bal_%d", t.ID), Type: t.Type, Amount: t.Amount,
Description: t.Description, CreatedAt: t.CreatedAt,
})
}
for _, o := range orders {
desc := "阅读消费"
if o.Description != nil && *o.Description != "" {
desc = *o.Description
}
merged = append(merged, txRow{
ID: o.ID, Type: "consume", Amount: -o.Amount,
Description: desc, CreatedAt: o.CreatedAt,
})
}
// 按时间倒序排列
for i := 0; i < len(merged); i++ {
for j := i + 1; j < len(merged); j++ {
ti := fmt.Sprintf("%v", merged[i].CreatedAt)
tj := fmt.Sprintf("%v", merged[j].CreatedAt)
if ti < tj {
merged[i], merged[j] = merged[j], merged[i]
}
}
}
if len(merged) > 50 {
merged = merged[:50]
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": merged})
}
// GET /api/admin/balance/summary 管理端-余额统计
func BalanceSummary(c *gin.Context) {
db := database.DB()
type Summary struct {
TotalUsers int64 `json:"totalUsers"`
TotalBalance float64 `json:"totalBalance"`
TotalRecharged float64 `json:"totalRecharged"`
TotalGifted float64 `json:"totalGifted"`
TotalRefunded float64 `json:"totalRefunded"`
GiftCount int64 `json:"giftCount"`
PendingGifts int64 `json:"pendingGifts"`
}
var s Summary
db.Model(&model.UserBalance{}).Count(&s.TotalUsers)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(balance),0)").Scan(&s.TotalBalance)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_recharged),0)").Scan(&s.TotalRecharged)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_gifted),0)").Scan(&s.TotalGifted)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_refunded),0)").Scan(&s.TotalRefunded)
db.Model(&model.GiftUnlock{}).Count(&s.GiftCount)
db.Model(&model.GiftUnlock{}).Where("status = ?", "pending").Count(&s.PendingGifts)
c.JSON(200, gin.H{"success": true, "data": s})
}
// GET /api/miniprogram/balance/gift/info 查询礼物码信息

View File

@@ -147,17 +147,58 @@ func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium boo
return cnt > 0
}
// previewContent 取内容的前 50%(不少于 100 个字符),并追加省略提示
func previewContent(content string) string {
// getUnpaidPreviewPercent 从 system_config 读取 unpaid_preview_percent默认 20
func getUnpaidPreviewPercent(db *gorm.DB) int {
var row model.SystemConfig
if err := db.Where("config_key = ?", "unpaid_preview_percent").First(&row).Error; err != nil || len(row.ConfigValue) == 0 {
return 20
}
var val interface{}
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
return 20
}
switch v := val.(type) {
case float64:
p := int(v)
if p < 1 {
p = 1
}
if p > 100 {
p = 100
}
return p
case int:
if v < 1 {
return 1
}
if v > 100 {
return 100
}
return v
}
return 20
}
// previewContent 取内容的前 percent%,上限 500 字(手动设置 percent 也受此限制),不少于 100 字
func previewContent(content string, percent int) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
// 截取前 50% 的内容,保证有足够的预览长度
limit := total / 2
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
const maxPreview = 500
if limit > maxPreview {
limit = maxPreview
}
if limit > total {
limit = total
}
@@ -198,7 +239,11 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
} else if checkUserChapterAccess(db, userID, ch.ID, isPremium) {
returnContent = ch.Content
} else {
returnContent = previewContent(ch.Content)
percent := getUnpaidPreviewPercent(db)
if ch.PreviewPercent != nil && *ch.PreviewPercent >= 1 && *ch.PreviewPercent <= 100 {
percent = *ch.PreviewPercent
}
returnContent = previewContent(ch.Content, percent)
}
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
@@ -289,7 +334,7 @@ func BookChapters(c *gin.Context) {
updates := map[string]interface{}{
"part_title": body.PartTitle, "chapter_title": body.ChapterTitle, "section_title": body.SectionTitle,
"content": body.Content, "word_count": body.WordCount, "is_free": body.IsFree, "price": body.Price,
"sort_order": body.SortOrder, "status": body.Status,
"sort_order": body.SortOrder, "status": body.Status, "hot_score": body.HotScore,
}
if body.EditionStandard != nil {
updates["edition_standard"] = body.EditionStandard
@@ -368,18 +413,59 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
return out
}
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录
// BookHot GET /api/book/hot 热门章节(按 hot_score 降序,使用与管理端相同的排名算法
// 支持 ?limit=N 参数,默认 20最大 100
func BookHot(c *gin.Context) {
list := bookHotChaptersSorted(database.DB(), 10)
if len(list) == 0 {
// 兜底:按 sort_order 取前 10同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
db := database.DB()
sections, err := computeArticleRankingSections(db)
if err != nil || len(sections) == 0 {
var list []model.Chapter
q := db.Model(&model.Chapter{}).
Select("mid, id, part_id, part_title, chapter_id, chapter_title, section_title, is_free, price, sort_order, hot_score, updated_at")
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
q.Order("hot_score DESC, sort_order ASC, id ASC").Limit(20).Find(&list)
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
limit := 20
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
limit = l
if limit > 100 {
limit = 100
}
}
if len(sections) < limit {
limit = len(sections)
}
tags := []string{"热门", "推荐", "精选"}
result := make([]gin.H, 0, limit)
for i := 0; i < limit; i++ {
s := sections[i]
tag := ""
if i < len(tags) {
tag = tags[i]
}
result = append(result, gin.H{
"id": s.ID,
"mid": s.MID,
"sectionTitle": s.Title,
"partTitle": s.PartTitle,
"chapterTitle": s.ChapterTitle,
"price": s.Price,
"isFree": s.IsFree,
"clickCount": s.ClickCount,
"payCount": s.PayCount,
"hotScore": s.HotScore,
"hotRank": i + 1,
"isPinned": s.IsPinned,
"tag": tag,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章)
@@ -498,9 +584,21 @@ func BookSearch(c *gin.Context) {
// BookStats GET /api/book/stats
func BookStats(c *gin.Context) {
db := database.DB()
var total int64
database.DB().Model(&model.Chapter{}).Count(&total)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
db.Model(&model.Chapter{}).Count(&total)
var freeCount int64
db.Model(&model.Chapter{}).Where("is_free = ?", true).Count(&freeCount)
var totalWords struct{ S int64 }
db.Model(&model.Chapter{}).Select("COALESCE(SUM(word_count),0) as s").Scan(&totalWords)
var userCount int64
db.Model(&model.User{}).Count(&userCount)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"totalChapters": total,
"freeChapters": freeCount,
"totalWordCount": totalWords.S,
"totalUsers": userCount,
}})
}
// BookSync GET/POST /api/book/sync

View File

@@ -177,8 +177,9 @@ func AdminSettingsGet(c *gin.Context) {
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
"mpConfig": defaultMp,
"ossConfig": gin.H{},
}
keys := []string{"feature_config", "site_settings", "mp_config"}
keys := []string{"feature_config", "site_settings", "mp_config", "oss_config"}
for _, k := range keys {
var row model.SystemConfig
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
@@ -208,6 +209,10 @@ func AdminSettingsGet(c *gin.Context) {
}
out["mpConfig"] = merged
}
case "oss_config":
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
out["ossConfig"] = m
}
}
}
c.JSON(http.StatusOK, out)
@@ -219,6 +224,7 @@ func AdminSettingsPost(c *gin.Context) {
FeatureConfig map[string]interface{} `json:"featureConfig"`
SiteSettings map[string]interface{} `json:"siteSettings"`
MpConfig map[string]interface{} `json:"mpConfig"`
OssConfig map[string]interface{} `json:"ossConfig"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -260,6 +266,12 @@ func AdminSettingsPost(c *gin.Context) {
return
}
}
if body.OssConfig != nil {
if err := saveKey("oss_config", "阿里云 OSS 配置", body.OssConfig); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存 OSS 配置失败: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"})
}
@@ -667,6 +679,17 @@ func DBUsersList(c *gin.Context) {
referralCountMap[r.ReferrerID] = int(r.Count)
}
// 4. 用户余额:从 user_balances 查询
balanceMap := make(map[string]float64)
var balRows []struct {
UserID string
Balance float64
}
db.Table("user_balances").Select("user_id, COALESCE(balance, 0) as balance").Find(&balRows)
for _, r := range balRows {
balanceMap[r.UserID] = r.Balance
}
// 填充每个用户的实时计算字段
for i := range users {
uid := users[i].ID
@@ -687,7 +710,9 @@ func DBUsersList(c *gin.Context) {
}
users[i].Earnings = ptrFloat64(totalE)
users[i].PendingEarnings = ptrFloat64(available)
users[i].WithdrawnEarnings = ptrFloat64(withdrawn)
users[i].ReferralCount = ptrInt(referralCountMap[uid])
users[i].WalletBalance = ptrFloat64(balanceMap[uid])
}
c.JSON(http.StatusOK, gin.H{
@@ -745,6 +770,7 @@ func DBUsersAction(c *gin.Context) {
Phone *string `json:"phone"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
Tags *string `json:"tags"`
HasFullBook *bool `json:"hasFullBook"`
IsAdmin *bool `json:"isAdmin"`
Earnings *float64 `json:"earnings"`
@@ -789,6 +815,9 @@ func DBUsersAction(c *gin.Context) {
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
}
if body.Tags != nil {
updates["tags"] = *body.Tags
}
if body.HasFullBook != nil {
updates["has_full_book"] = *body.HasFullBook
}
@@ -847,6 +876,9 @@ func DBUsersAction(c *gin.Context) {
if body.VipBio != nil {
updates["vip_bio"] = *body.VipBio
}
if body.Tags != nil {
updates["tags"] = *body.Tags
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
return

View File

@@ -38,6 +38,7 @@ type sectionListItem struct {
ClickCount int64 `json:"clickCount"` // 阅读次数reading_progress
PayCount int64 `json:"payCount"` // 付款笔数orders.product_type=section
HotScore float64 `json:"hotScore"` // 热度积分(加权计算)
HotRank int `json:"hotRank"` // 热度排名(按 hotScore 降序)
IsPinned bool `json:"isPinned,omitempty"` // 是否置顶(仅 ranking 返回)
}
@@ -156,47 +157,132 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
pinnedSet[id] = true
}
}
now := time.Now()
sections := make([]sectionListItem, 0, len(rows))
const rankTop = 20
// 构建基础 section 数据
type rawSection struct {
item sectionListItem
readCnt int64
payCnt int64
updatedAt time.Time
}
raws := make([]rawSection, 0, len(rows))
for _, r := range rows {
price := 1.0
if r.Price != nil {
price = *r.Price
}
readCnt := readCountMap[r.ID]
payCnt := payCountMap[r.ID]
recencyScore := 0.0
if !r.UpdatedAt.IsZero() {
days := now.Sub(r.UpdatedAt).Hours() / 24
recencyScore = math.Max(0, (30-days)/30)
if recencyScore > 1 {
recencyScore = 1
}
}
hot := float64(readCnt)*readWeight + float64(payCnt)*payWeight + recencyScore*recencyWeight
item := sectionListItem{
ID: r.ID,
MID: r.MID,
Title: r.SectionTitle,
Price: price,
IsFree: r.IsFree,
IsNew: r.IsNew,
PartID: r.PartID,
PartTitle: r.PartTitle,
ChapterID: r.ChapterID,
ChapterTitle: r.ChapterTitle,
ClickCount: readCnt,
PayCount: payCnt,
HotScore: hot,
}
if setPinned {
item.IsPinned = pinnedSet[r.ID]
}
sections = append(sections, item)
raws = append(raws, rawSection{
item: sectionListItem{
ID: r.ID,
MID: r.MID,
Title: r.SectionTitle,
Price: price,
IsFree: r.IsFree,
IsNew: r.IsNew,
PartID: r.PartID,
PartTitle: r.PartTitle,
ChapterID: r.ChapterID,
ChapterTitle: r.ChapterTitle,
ClickCount: readCountMap[r.ID],
PayCount: payCountMap[r.ID],
},
readCnt: readCountMap[r.ID],
payCnt: payCountMap[r.ID],
updatedAt: r.UpdatedAt,
})
}
// 排名积分:前 rankTop 名分别得 rankTop ~ 1 分,其余 0 分
readRankScore := make(map[string]float64, len(raws))
payRankScore := make(map[string]float64, len(raws))
recencyRankScore := make(map[string]float64, len(raws))
// 阅读量排名
sorted := make([]int, len(raws))
for i := range sorted {
sorted[i] = i
}
sort.Slice(sorted, func(a, b int) bool {
return raws[sorted[a]].readCnt > raws[sorted[b]].readCnt
})
for rank, idx := range sorted {
if rank >= rankTop || raws[idx].readCnt == 0 {
break
}
readRankScore[raws[idx].item.ID] = float64(rankTop - rank)
}
// 付款量排名
for i := range sorted {
sorted[i] = i
}
sort.Slice(sorted, func(a, b int) bool {
return raws[sorted[a]].payCnt > raws[sorted[b]].payCnt
})
for rank, idx := range sorted {
if rank >= rankTop || raws[idx].payCnt == 0 {
break
}
payRankScore[raws[idx].item.ID] = float64(rankTop - rank)
}
// 新度排名(按 updated_at 最近排序)
for i := range sorted {
sorted[i] = i
}
sort.Slice(sorted, func(a, b int) bool {
return raws[sorted[a]].updatedAt.After(raws[sorted[b]].updatedAt)
})
for rank, idx := range sorted {
if rank >= rankTop {
break
}
recencyRankScore[raws[idx].item.ID] = float64(rankTop - rank)
}
// 计算最终热度分
sections := make([]sectionListItem, 0, len(raws))
hotUpdates := make(map[string]float64, len(raws))
for i := range raws {
id := raws[i].item.ID
hot := readRankScore[id]*readWeight + recencyRankScore[id]*recencyWeight + payRankScore[id]*payWeight
hot = math.Round(hot*100) / 100
hotUpdates[id] = hot
raws[i].item.HotScore = hot
if setPinned {
raws[i].item.IsPinned = pinnedSet[id]
}
sections = append(sections, raws[i].item)
}
// 计算排名序号
ranked := make([]sectionListItem, len(sections))
copy(ranked, sections)
sort.Slice(ranked, func(i, j int) bool {
return ranked[i].HotScore > ranked[j].HotScore
})
rankMap := make(map[string]int, len(ranked))
for i, s := range ranked {
rankMap[s.ID] = i + 1
}
for i := range sections {
sections[i].HotRank = rankMap[sections[i].ID]
}
go persistHotScores(db, hotUpdates)
return sections, nil
}
// persistHotScores writes computed hot_score values back to the chapters table
func persistHotScores(db *gorm.DB, scores map[string]float64) {
for id, score := range scores {
_ = db.WithContext(context.Background()).
Model(&model.Chapter{}).
Where("id = ?", id).
UpdateColumn("hot_score", score).Error
}
}
// DBBookAction GET/POST/PUT /api/db/book
func DBBookAction(c *gin.Context) {
db := database.DB()
@@ -255,6 +341,7 @@ func DBBookAction(c *gin.Context) {
"chapterTitle": ch.ChapterTitle,
"editionStandard": ch.EditionStandard,
"editionPremium": ch.EditionPremium,
"previewPercent": ch.PreviewPercent,
},
})
return
@@ -386,6 +473,8 @@ func DBBookAction(c *gin.Context) {
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
HotScore *float64 `json:"hotScore"`
PreviewPercent *int `json:"previewPercent"` // 章节级预览比例(%)1-100
ClearPreviewPercent *bool `json:"clearPreviewPercent"` // true 表示清除覆盖、使用全局
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -494,6 +583,14 @@ func DBBookAction(c *gin.Context) {
if body.HotScore != nil {
updates["hot_score"] = *body.HotScore
}
if body.ClearPreviewPercent != nil && *body.ClearPreviewPercent {
updates["preview_percent"] = nil
} else if body.PreviewPercent != nil {
p := *body.PreviewPercent
if p >= 1 && p <= 100 {
updates["preview_percent"] = p
}
}
if body.PartID != "" {
updates["part_id"] = body.PartID
}

View File

@@ -108,6 +108,74 @@ func DBCKBLeadList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
}
// CKBPersonLeadStats GET /api/db/ckb-person-leads 每个人物的获客线索统计及明细
func CKBPersonLeadStats(c *gin.Context) {
db := database.DB()
personToken := c.Query("token")
if personToken != "" {
// 返回某人物的线索明细(通过 token → Person.PersonID → CkbLeadRecord.TargetPersonID
var person model.Person
if err := db.Where("token = ?", personToken).First(&person).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
q := db.Model(&model.CkbLeadRecord{}).Where("target_person_id = ?", person.Token)
var total int64
q.Count(&total)
var records []model.CkbLeadRecord
q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records)
out := make([]gin.H, 0, len(records))
for _, r := range records {
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"nickname": r.Nickname,
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"source": r.Source,
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"personName": person.Name,
"records": out,
"total": total,
"page": page,
"pageSize": pageSize,
})
return
}
// 无 token 参数:返回所有人物的获客数量汇总
type PersonLeadStat struct {
Token string `gorm:"column:target_person_id" json:"token"`
Total int64 `gorm:"column:total" json:"total"`
}
var stats []PersonLeadStat
db.Raw("SELECT target_person_id, COUNT(*) as total FROM ckb_lead_records WHERE target_person_id != '' GROUP BY target_person_id").Scan(&stats)
// 同时统计全局(无特定人物的)线索
var globalTotal int64
db.Model(&model.CkbLeadRecord{}).Where("target_person_id = '' OR target_person_id IS NULL").Count(&globalTotal)
c.JSON(http.StatusOK, gin.H{
"success": true,
"byPerson": stats,
"globalLeads": globalTotal,
})
}
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(基于 ckb_submit_records + ckb_lead_records
func CKBPlanStats(c *gin.Context) {
db := database.DB()

View File

@@ -24,6 +24,7 @@ func DBLinkTagSave(c *gin.Context) {
var body struct {
TagID string `json:"tagId"`
Label string `json:"label"`
Aliases string `json:"aliases"`
URL string `json:"url"`
Type string `json:"type"`
AppID string `json:"appId"`
@@ -48,6 +49,7 @@ func DBLinkTagSave(c *gin.Context) {
var existing model.LinkTag
if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil {
existing.Label = body.Label
existing.Aliases = body.Aliases
existing.URL = body.URL
existing.Type = body.Type
existing.AppID = body.AppID
@@ -57,7 +59,7 @@ func DBLinkTagSave(c *gin.Context) {
return
}
// body.URL 已在 miniprogram 类型时置空
t := model.LinkTag{TagID: body.TagID, Label: body.Label, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
if err := db.Create(&t).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return

View File

@@ -45,6 +45,7 @@ func DBPersonSave(c *gin.Context) {
var body struct {
PersonID string `json:"personId"`
Name string `json:"name"`
Aliases string `json:"aliases"`
Label string `json:"label"`
CkbApiKey string `json:"ckbApiKey"` // 存客宝真实密钥,留空则 fallback 全局 Key
Greeting string `json:"greeting"`
@@ -71,6 +72,7 @@ func DBPersonSave(c *gin.Context) {
var existing model.Person
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
existing.Name = body.Name
existing.Aliases = body.Aliases
existing.Label = body.Label
existing.CkbApiKey = body.CkbApiKey
existing.Greeting = body.Greeting
@@ -175,6 +177,7 @@ func DBPersonSave(c *gin.Context) {
PersonID: body.PersonID,
Token: tok,
Name: body.Name,
Aliases: body.Aliases,
Label: body.Label,
CkbApiKey: apiKey,
CkbPlanID: planID,

View File

@@ -98,6 +98,9 @@ func MiniprogramLogin(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
return
}
// 记录注册行为到 user_tracks
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
db.Create(&model.UserTrack{ID: trackID, UserID: user.ID, Action: "register"})
// 新用户异步调用神射手自动打标手机号尚未绑定phone 为空时暂不调用)
AdminShensheShouAutoTag(userID, "")
} else {
@@ -723,6 +726,9 @@ func MiniprogramPhone(c *gin.Context) {
if req.UserID != "" {
db := database.DB()
db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber)
// 记录绑定手机号行为到 user_tracks
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
db.Create(&model.UserTrack{ID: trackID, UserID: req.UserID, Action: "bind_phone"})
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
// 绑定手机号后,异步调用神射手自动完善标签
AdminShensheShouAutoTag(req.UserID, phoneNumber)

View File

@@ -0,0 +1,137 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"strings"
"time"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"soul-api/internal/database"
"soul-api/internal/model"
)
type ossConfigCache struct {
Endpoint string `json:"endpoint"`
AccessKeyID string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
Bucket string `json:"bucket"`
Region string `json:"region"`
}
func getOssConfig() *ossConfigCache {
db := database.DB()
var row model.SystemConfig
if err := db.Where("config_key = ?", "oss_config").First(&row).Error; err != nil {
return nil
}
var cfg ossConfigCache
if err := json.Unmarshal(row.ConfigValue, &cfg); err != nil {
return nil
}
if cfg.Endpoint == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" || cfg.Bucket == "" {
return nil
}
return &cfg
}
func ossUploadFile(file multipart.File, folder, filename string) (string, error) {
cfg := getOssConfig()
if cfg == nil {
return "", fmt.Errorf("OSS 未配置")
}
endpoint := cfg.Endpoint
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
endpoint = "https://" + endpoint
}
client, err := oss.New(endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
if err != nil {
return "", fmt.Errorf("创建 OSS 客户端失败: %w", err)
}
bucket, err := client.Bucket(cfg.Bucket)
if err != nil {
return "", fmt.Errorf("获取 Bucket 失败: %w", err)
}
objectKey := fmt.Sprintf("%s/%s/%s", folder, time.Now().Format("2006-01"), filename)
data, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("读取文件失败: %w", err)
}
err = bucket.PutObject(objectKey, strings.NewReader(string(data)))
if err != nil {
return "", fmt.Errorf("上传 OSS 失败: %w", err)
}
signedURL, err := bucket.SignURL(objectKey, oss.HTTPGet, 3600*24*365*10)
if err != nil {
host := cfg.Bucket + "." + cfg.Endpoint
if !strings.HasPrefix(cfg.Endpoint, "http://") && !strings.HasPrefix(cfg.Endpoint, "https://") {
host = cfg.Bucket + "." + cfg.Endpoint
} else {
host = strings.Replace(cfg.Endpoint, "://", "://"+cfg.Bucket+".", 1)
}
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "https://" + host
}
return host + "/" + objectKey, nil
}
return signedURL, nil
}
func ossUploadBytes(data []byte, folder, filename, contentType string) (string, error) {
cfg := getOssConfig()
if cfg == nil {
return "", fmt.Errorf("OSS 未配置")
}
endpoint := cfg.Endpoint
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
endpoint = "https://" + endpoint
}
client, err := oss.New(endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
if err != nil {
return "", fmt.Errorf("创建 OSS 客户端失败: %w", err)
}
bucket, err := client.Bucket(cfg.Bucket)
if err != nil {
return "", fmt.Errorf("获取 Bucket 失败: %w", err)
}
objectKey := fmt.Sprintf("%s/%s/%s", folder, time.Now().Format("2006-01"), filename)
var opts []oss.Option
if contentType != "" {
opts = append(opts, oss.ContentType(contentType))
}
err = bucket.PutObject(objectKey, strings.NewReader(string(data)), opts...)
if err != nil {
return "", fmt.Errorf("上传 OSS 失败: %w", err)
}
signedURL, err := bucket.SignURL(objectKey, oss.HTTPGet, 3600*24*365*10)
if err != nil {
host := cfg.Bucket + "." + cfg.Endpoint
if !strings.HasPrefix(cfg.Endpoint, "http://") && !strings.HasPrefix(cfg.Endpoint, "https://") {
host = cfg.Bucket + "." + cfg.Endpoint
} else {
host = strings.Replace(cfg.Endpoint, "://", "://"+cfg.Bucket+".", 1)
}
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "https://" + host
}
return host + "/" + objectKey, nil
}
return signedURL, nil
}

View File

@@ -46,7 +46,7 @@ func SearchGet(c *gin.Context) {
Select("id, mid, section_title, part_title, chapter_title, price, is_free, '' as snippet").
Where("section_title LIKE ?", pattern).
Order("sort_order ASC, id ASC").
Limit(30).
Limit(3).
Find(&titleMatches)
titleIDs := make(map[string]bool, len(titleMatches))
@@ -55,7 +55,7 @@ func SearchGet(c *gin.Context) {
}
// 第二步:内容匹配(排除已命中标题的,用 SQL 提取摘要避免加载完整 content
remaining := 50 - len(titleMatches)
remaining := 20 - len(titleMatches)
var contentMatches []searchRow
if remaining > 0 {
contentQ := db.Model(&model.Chapter{}).

View File

@@ -16,7 +16,7 @@ const uploadDir = "uploads"
const maxUploadBytes = 5 * 1024 * 1024 // 5MB
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
// UploadPost POST /api/upload 上传图片(表单 file
// UploadPost POST /api/upload 上传图片(表单 file,优先 OSS
func UploadPost(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
@@ -40,16 +40,30 @@ func UploadPost(c *gin.Context) {
if folder == "" {
folder = "avatars"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
// 尝试 OSS 上传
if ossCfg := getOssConfig(); ossCfg != nil {
src, err := file.Open()
if err == nil {
defer src.Close()
if ossURL, err := ossUploadFile(src, folder, name); err == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "url": ossURL, "data": gin.H{"url": ossURL, "fileName": name, "size": file.Size, "type": ct, "storage": "oss"}})
return
}
}
}
// 回退本地存储
dir := filepath.Join(uploadDir, folder)
_ = os.MkdirAll(dir, 0755)
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
dst := filepath.Join(dir, name)
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
return
}
url := "/" + filepath.ToSlash(filepath.Join(uploadDir, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "storage": "local"}})
}
func randomStrUpload(n int) string {

View File

@@ -34,11 +34,11 @@ var (
"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true,
}
allowedVideoTypes = map[string]bool{
"video/mp4": true, "video/quicktime": true, "video/x-msvideo": true,
"video/mp4": true, "video/quicktime": true, "video/webm": true, "video/x-msvideo": true,
}
)
// UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩)
// UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩),优先 OSS
// 表单file必填, folder可选默认 images, quality可选 1-100默认 85
func UploadImagePost(c *gin.Context) {
file, err := c.FormFile("file")
@@ -65,14 +65,11 @@ func UploadImagePost(c *gin.Context) {
if folder == "" {
folder = "images"
}
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
ext := filepath.Ext(file.Filename)
if ext == "" {
ext = ".jpg"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(6), ext)
dst := filepath.Join(dir, name)
src, err := file.Open()
if err != nil {
@@ -85,61 +82,58 @@ func UploadImagePost(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取文件失败"})
return
}
// JPEG支持质量压缩
// JPEG 压缩
var finalData []byte
finalCt := ct
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
img, err := jpeg.Decode(bytes.NewReader(data))
if err == nil {
if img, err := jpeg.Decode(bytes.NewReader(data)); err == nil {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{
"success": true, "url": url,
"data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct, "quality": quality},
})
return
}
finalData = buf.Bytes()
}
}
}
// PNG/GIF解码后原样保存
if strings.Contains(ct, "png") {
img, err := png.Decode(bytes.NewReader(data))
if err == nil {
} else if strings.Contains(ct, "png") {
if img, err := png.Decode(bytes.NewReader(data)); err == nil {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct}})
return
}
finalData = buf.Bytes()
}
}
}
if strings.Contains(ct, "gif") {
img, err := gif.Decode(bytes.NewReader(data))
if err == nil {
} else if strings.Contains(ct, "gif") {
if img, err := gif.Decode(bytes.NewReader(data)); err == nil {
var buf bytes.Buffer
if err := gif.Encode(&buf, img, nil); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct}})
return
}
finalData = buf.Bytes()
}
}
}
if finalData == nil {
finalData = data
}
// 其他格式或解析失败时直接写入
if err := os.WriteFile(dst, data, 0644); err != nil {
// 优先 OSS 上传
if ossURL, err := ossUploadBytes(finalData, folder, name, finalCt); err == nil {
c.JSON(http.StatusOK, gin.H{
"success": true, "url": ossURL,
"data": gin.H{"url": ossURL, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "oss"},
})
return
}
// 回退本地存储
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
dst := filepath.Join(dir, name)
if err := os.WriteFile(dst, finalData, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
return
}
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(len(data)), "type": ct}})
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "local"}})
}
// UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传(指定目录)
// UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传,优先 OSS
// 表单file必填, folder可选默认 videos
func UploadVideoPost(c *gin.Context) {
file, err := c.FormFile("file")
@@ -160,13 +154,30 @@ func UploadVideoPost(c *gin.Context) {
if folder == "" {
folder = "videos"
}
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
ext := filepath.Ext(file.Filename)
if ext == "" {
ext = ".mp4"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(8), ext)
// 优先 OSS 上传
if ossCfg := getOssConfig(); ossCfg != nil {
src, err := file.Open()
if err == nil {
defer src.Close()
if ossURL, err := ossUploadFile(src, folder, name); err == nil {
c.JSON(http.StatusOK, gin.H{
"success": true, "url": ossURL,
"data": gin.H{"url": ossURL, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "oss"},
})
return
}
}
}
// 回退本地存储
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
dst := filepath.Join(dir, name)
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
@@ -175,7 +186,7 @@ func UploadVideoPost(c *gin.Context) {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{
"success": true, "url": url,
"data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "folder": folder},
"data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "local"},
})
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -595,6 +596,11 @@ func UserTrackPost(c *gin.Context) {
if body.Target != "" {
t.ChapterID = &chID
}
if body.ExtraData != nil {
if raw, err := json.Marshal(body.ExtraData); err == nil {
t.ExtraData = raw
}
}
if err := db.Create(&t).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return

View File

@@ -21,7 +21,8 @@ type Chapter struct {
// 普通版/增值版:两者分开互斥,添加文章时勾选归属
EditionStandard *bool `gorm:"column:edition_standard" json:"editionStandard,omitempty"` // 是否属于普通版
EditionPremium *bool `gorm:"column:edition_premium" json:"editionPremium,omitempty"` // 是否属于增值版
HotScore int `gorm:"column:hot_score;default:0" json:"hotScore"` // 热度分,用于排名算法
HotScore float64 `gorm:"column:hot_score;type:decimal(10,2);default:0" json:"hotScore"` // 热度分(加权计算),用于排名算法
PreviewPercent *int `gorm:"column:preview_percent" json:"previewPercent,omitempty"` // 章节级预览比例(%)nil 表示使用全局设置
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}

View File

@@ -7,6 +7,7 @@ type LinkTag struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
TagID string `gorm:"column:tag_id;size:50;uniqueIndex" json:"tagId"`
Label string `gorm:"column:label;size:200" json:"label"`
Aliases string `gorm:"column:aliases;size:500;default:''" json:"aliases"` // comma-separated alternative labels
URL string `gorm:"column:url;size:500" json:"url"`
Type string `gorm:"column:type;size:20" json:"type"`
AppID string `gorm:"column:app_id;size:100" json:"appId,omitempty"`

View File

@@ -9,8 +9,9 @@ type Person struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
Token string `gorm:"column:token;size:36;uniqueIndex" json:"token"` // 32 位唯一 token文章/小程序传此值
Token string `gorm:"column:token;size:36;index" json:"token"` // 32 位唯一 token文章/小程序传此值
Name string `gorm:"column:name;size:100" json:"name"`
Aliases string `gorm:"column:aliases;size:500;default:''" json:"aliases"` // comma-separated alternative names (马甲)
Label string `gorm:"column:label;size:200" json:"label"`
CkbApiKey string `gorm:"column:ckb_api_key;size:100;default:''" json:"ckbApiKey"` // 存客宝真实密钥,不对外暴露

View File

@@ -11,6 +11,7 @@ type User struct {
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
Tags *string `gorm:"column:tags;type:text" json:"tags,omitempty"`
// P3 资料扩展stitch_soul
Mbti *string `gorm:"column:mbti;size:16" json:"mbti,omitempty"`
Region *string `gorm:"column:region;size:100" json:"region,omitempty"`
@@ -49,7 +50,8 @@ type User struct {
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
WalletBalance *float64 `gorm:"-" json:"walletBalance,omitempty"`
}
func (User) TableName() string { return "users" }

View File

@@ -49,6 +49,7 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.PUT("/content", handler.AdminContent)
admin.DELETE("/content", handler.AdminContent)
admin.GET("/dashboard/stats", handler.AdminDashboardStats)
admin.GET("/track/stats", handler.AdminTrackStats)
admin.GET("/dashboard/recent-orders", handler.AdminDashboardRecentOrders)
admin.GET("/dashboard/new-users", handler.AdminDashboardNewUsers)
admin.GET("/dashboard/overview", handler.AdminDashboardOverview)
@@ -85,6 +86,7 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.PUT("/users", handler.AdminUsersAction)
admin.DELETE("/users", handler.AdminUsersAction)
admin.GET("/orders", handler.OrdersList)
admin.GET("/balance/summary", handler.BalanceSummary)
}
// ----- 鉴权 -----
@@ -148,6 +150,10 @@ func Setup(cfg *config.Config) *gin.Engine {
db.GET("/migrate", handler.DBMigrateGet)
db.POST("/migrate", handler.DBMigratePost)
db.GET("/users", handler.DBUsersList)
db.GET("/users/journey-stats", handler.DBUsersJourneyStats)
db.GET("/users/journey-users", handler.DBUsersJourneyUsers)
db.GET("/users/rfm", handler.DBUsersRFM)
db.GET("/users/rfm-single", handler.DBUserRFMSingle)
db.POST("/users", handler.DBUsersAction)
db.PUT("/users", handler.DBUsersAction)
db.DELETE("/users", handler.DBUsersDelete)
@@ -171,8 +177,9 @@ func Setup(cfg *config.Config) *gin.Engine {
db.GET("/link-tags", handler.DBLinkTagList)
db.POST("/link-tags", handler.DBLinkTagSave)
db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.GET("/ckb-person-leads", handler.CKBPersonLeadStats)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
}
// ----- 分销 -----
@@ -225,6 +232,7 @@ func Setup(cfg *config.Config) *gin.Engine {
// ----- 上传 -----
api.POST("/upload", handler.UploadPost)
api.POST("/upload/video", handler.UploadVideoPost)
api.DELETE("/upload", handler.UploadDelete)
// ----- 用户 -----
@@ -294,6 +302,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus)
miniprogram.GET("/user/reading-progress", handler.UserReadingProgressGet)
miniprogram.POST("/user/reading-progress", handler.UserReadingProgressPost)
miniprogram.POST("/track", handler.UserTrackPost)
miniprogram.POST("/user/update", handler.UserUpdate)
miniprogram.POST("/withdraw", handler.WithdrawPost)
miniprogram.GET("/withdraw/records", handler.WithdrawRecords)