feat: 数据概览简化 + 用户管理增加余额/提现列
- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额 - 数据概览:移除余额统计区块(余额改在用户管理中展示) - 数据概览:恢复转化率卡片(唯一付费用户/总用户) - 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额 - 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段 - 后端:User model 添加 WalletBalance 非数据库字段 - 包含之前的小程序埋点和管理后台点击统计面板 Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' })
|
||||
},
|
||||
|
||||
|
||||
@@ -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_soul:book/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}` })
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 底部留白 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
},
|
||||
|
||||
|
||||
@@ -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}` })
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
167
miniprogram/pages/wallet/wallet.js
Normal file
167
miniprogram/pages/wallet/wallet.js
Normal 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()
|
||||
},
|
||||
})
|
||||
4
miniprogram/pages/wallet/wallet.json
Normal file
4
miniprogram/pages/wallet/wallet.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "我的余额",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
88
miniprogram/pages/wallet/wallet.wxml
Normal file
88
miniprogram/pages/wallet/wallet.wxml
Normal 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>
|
||||
267
miniprogram/pages/wallet/wallet.wxss
Normal file
267
miniprogram/pages/wallet/wallet.wxss
Normal 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;
|
||||
}
|
||||
179
miniprogram/utils/ruleEngine.js
Normal file
179
miniprogram/utils/ruleEngine.js
Normal 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 }
|
||||
25
miniprogram/utils/trackClick.js
Normal file
25
miniprogram/utils/trackClick.js
Normal 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 }
|
||||
1
soul-admin/dist/assets/index-BlPUt9ll.css
vendored
1
soul-admin/dist/assets/index-BlPUt9ll.css
vendored
File diff suppressed because one or more lines are too long
792
soul-admin/dist/assets/index-ChSYyP1O.js
vendored
792
soul-admin/dist/assets/index-ChSYyP1O.js
vendored
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-DdPcz68r.css
vendored
Normal file
1
soul-admin/dist/assets/index-DdPcz68r.css
vendored
Normal file
File diff suppressed because one or more lines are too long
937
soul-admin/dist/assets/index-d2VmvrcP.js
vendored
Normal file
937
soul-admin/dist/assets/index-d2VmvrcP.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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">R(Recency)</span>:距最近购买天数,越近分越高,权重 40%</p>
|
||||
<p><span className="text-[#38bdac] font-medium">F(Frequency)</span>:购买频次,越多分越高,权重 30%</p>
|
||||
<p><span className="text-[#38bdac] font-medium">M(Monetary)</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>(<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>
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
93
soul-api/internal/handler/admin_track.go
Normal file
93
soul-api/internal/handler/admin_track.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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 查询礼物码信息
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
137
soul-api/internal/handler/oss.go
Normal file
137
soul-api/internal/handler/oss.go
Normal 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
|
||||
}
|
||||
@@ -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{}).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"` // 存客宝真实密钥,不对外暴露
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user