Refactor user profile handling and navigation logic in the mini program. Introduce functions to ensure user profile completeness after login, update avatar selection process, and enhance navigation between chapters based on backend data. Update API endpoints for user data synchronization and improve user experience with new UI elements for profile editing.

This commit is contained in:
Alex-larget
2026-03-12 11:36:50 +08:00
parent da6d2c0852
commit d3b67681d7
27 changed files with 1464 additions and 393 deletions

View File

@@ -8,9 +8,9 @@ const { parseScene } = require('./utils/scene.js')
App({
globalData: {
// API基础地址 - 连接真实后端
baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'https://souldev.quwanzhi.com',
// baseUrl: 'http://localhost:8080',
baseUrl: 'http://localhost:8080',
// 小程序配置 - 真实AppID
@@ -207,6 +207,31 @@ App({
return code.replace(/[\s\-_]/g, '').toUpperCase().trim()
},
// 判断用户资料是否完善(昵称 + 头像)
_isProfileIncomplete(user) {
if (!user) return true
const nickname = (user.nickname || '').trim()
const avatar = (user.avatar || '').trim()
const isDefaultNickname = !nickname || nickname === '微信用户'
const noAvatar = !avatar
return isDefaultNickname || noAvatar
},
// 登录后若资料未完善,引导跳转到资料编辑页
_ensureProfileCompletedAfterLogin(user) {
try {
if (!user || !this._isProfileIncomplete(user)) return
const pages = getCurrentPages()
const current = pages[pages.length - 1]
// 避免在资料页内重复跳转
if (current && current.route === 'pages/profile-edit/profile-edit') return
wx.showToast({ title: '请先完善头像和昵称', icon: 'none', duration: 2000 })
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
} catch (e) {
console.warn('[App] 跳转资料编辑页失败:', e)
}
},
// 根据业务 id 从 bookData 查 mid用于跳转
getSectionMid(sectionId) {
const list = this.globalData.bookData || []
@@ -479,12 +504,13 @@ App({
// 保存用户信息
if (res.data.user) {
this.globalData.userInfo = res.data.user
const user = res.data.user
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = res.data.user.purchasedSections || []
this.globalData.hasFullBook = res.data.user.hasFullBook || false
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
wx.setStorageSync('userInfo', res.data.user)
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token || '')
// 登录成功后,检查待绑定的推荐码并执行绑定
@@ -493,6 +519,9 @@ App({
console.log('[App] 登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
}
return res.data
@@ -540,17 +569,21 @@ App({
wx.setStorageSync('openId', res.data.openId)
// 接口同时返回 user 时视为登录,补全登录态并从登录开始绑定推荐码
if (res.data.user) {
this.globalData.userInfo = res.data.user
const user = res.data.user
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = res.data.user.purchasedSections || []
this.globalData.hasFullBook = res.data.user.hasFullBook || false
wx.setStorageSync('userInfo', res.data.user)
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token || '')
const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode
if (pendingRef) {
console.log('[App] getOpenId 登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
}
return res.data.openId
}
@@ -587,12 +620,13 @@ App({
})
if (res.success && res.data) {
this.globalData.userInfo = res.data.user
const user = res.data.user
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = res.data.user.purchasedSections || []
this.globalData.hasFullBook = res.data.user.hasFullBook || false
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
wx.setStorageSync('userInfo', res.data.user)
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token)
// 登录成功后绑定推荐码
@@ -601,6 +635,9 @@ App({
console.log('[App] 手机号登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
return res.data
}

View File

@@ -5,6 +5,7 @@
"pages/match/match",
"pages/my/my",
"pages/read/read",
"pages/link-preview/link-preview",
"pages/about/about",
"pages/agreement/agreement",
"pages/privacy/privacy",
@@ -16,7 +17,11 @@
"pages/addresses/edit",
"pages/withdraw-records/withdraw-records",
"pages/vip/vip",
"pages/member-detail/member-detail","pages/mentors/mentors","pages/mentor-detail/mentor-detail","pages/profile-show/profile-show","pages/profile-edit/profile-edit"
"pages/member-detail/member-detail",
"pages/mentors/mentors",
"pages/mentor-detail/mentor-detail",
"pages/profile-show/profile-show",
"pages/profile-edit/profile-edit"
],
"window": {
"backgroundTextStyle": "light",

View File

@@ -205,10 +205,14 @@ Page({
}
}
// 2. 最新更新:用 book/latest-chapters 取第1条
// 2. 最新更新:用 book/latest-chapters 取第1条(排除「序言」「尾声」「附录」)
try {
const latestRes = await app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true })
const latestList = (latestRes && latestRes.data) ? latestRes.data : []
const rawList = (latestRes && latestRes.data) ? latestRes.data : []
const latestList = rawList.filter(l => {
const pt = (l.part_title || l.partTitle || '').toLowerCase()
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
})
if (latestList.length > 0) {
const l = latestList[0]
this.setData({
@@ -341,7 +345,7 @@ Page({
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
url: '/api/miniprogram/ckb/index-lead',
method: 'POST',
data: {
userId,
@@ -426,25 +430,25 @@ Page({
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
url: '/api/miniprogram/ckb/index-lead',
method: 'POST',
data: {
userId,
phone: p,
name: (app.globalData.userInfo?.nickname || '').trim() || undefined
}
name: (app.globalData.userInfo?.nickname || '').trim() || undefined,
},
})
wx.hideLoading()
this.setData({ showLeadModal: false, leadPhone: '' })
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
// 同步手机号到用户资料
try {
const currentPhone = (app.globalData.userInfo?.phone || '').trim()
if (!currentPhone && userId) {
if (userId) {
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: { userId, phone: p }
data: { userId, phone: p },
})
if (app.globalData.userInfo) {
app.globalData.userInfo.phone = p

View File

@@ -0,0 +1,43 @@
const app = getApp()
Page({
data: {
url: '',
title: '链接预览',
statusBarHeight: 44,
navBarHeight: 88,
},
onLoad(options) {
const url = decodeURIComponent(options.url || '')
const title = options.title ? decodeURIComponent(options.title) : '链接预览'
this.setData({
url,
title,
statusBarHeight: app.globalData.statusBarHeight || 44,
navBarHeight: app.globalData.navBarHeight || 88,
})
},
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
wx.navigateBack()
} else {
wx.switchTab({ url: '/pages/index/index' })
}
},
copyLink() {
const url = (this.data.url || '').trim()
if (!url) {
wx.showToast({ title: '暂无可复制链接', icon: 'none' })
return
}
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: '链接已复制', icon: 'none' }),
})
},
})

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {}
}

View File

@@ -0,0 +1,28 @@
<view class="page">
<!-- 简单自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content" style="height: {{navBarHeight - statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
<text class="back-arrow">←</text>
</view>
<view class="nav-title">
<text class="nav-title-text">{{title}}</text>
</view>
<view class="nav-actions">
<view class="copy-btn" bindtap="copyLink">
<text class="copy-text">复制链接</text>
</view>
</view>
</view>
</view>
<view class="nav-placeholder" style="height: {{navBarHeight}}px;"></view>
<!-- 链接预览区域 -->
<view class="webview-wrap" wx:if="{{url}}">
<web-view src="{{url}}"></web-view>
</view>
<view class="empty-wrap" wx:else>
<text class="empty-text">暂无链接地址</text>
</view>
</view>

View File

@@ -0,0 +1,83 @@
.page {
width: 100%;
height: 100%;
background-color: #000;
color: #fff;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: #000;
}
.nav-content {
display: flex;
align-items: center;
padding: 0 12px;
}
.nav-back {
width: 40rpx;
justify-content: center;
align-items: center;
}
.back-arrow {
font-size: 32rpx;
}
.nav-title {
flex: 1;
text-align: center;
}
.nav-title-text {
font-size: 28rpx;
font-weight: 600;
}
.nav-actions {
min-width: 120rpx;
display: flex;
justify-content: flex-end;
}
.copy-btn {
padding: 6rpx 10rpx;
border-radius: 999rpx;
border: 1px solid #444;
}
.copy-text {
font-size: 22rpx;
color: #ccc;
}
.nav-placeholder {
width: 100%;
}
.webview-wrap {
width: 100%;
height: 100%;
}
web-view {
width: 100%;
height: 100%;
}
.empty-wrap {
padding: 40rpx;
text-align: center;
color: #888;
}
.empty-text {
font-size: 26rpx;
}

View File

@@ -596,8 +596,19 @@ Page({
}
},
// 复制用户ID
// 复制联系方式:优先复制微信号,其次复制用户ID
copyUserId() {
const userWechat = (this.data.userWechat || '').trim()
if (userWechat) {
wx.setClipboardData({
data: userWechat,
success: () => {
wx.showToast({ title: '微信号已复制', icon: 'success' })
}
})
return
}
const userId = this.data.userInfo?.id || ''
if (!userId) {
wx.showToast({ title: '暂无ID', icon: 'none' })

View File

@@ -41,7 +41,7 @@
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToMatch">匹配</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
</view>
<text class="user-wechat" bindtap="copyUserId">微信号: {{userWechat || userIdShort || '--'}}</text>
<text class="user-wechat" wx:if="{{userWechat}}" bindtap="copyUserId">微信号: {{userWechat}}</text>
</view>
</view>
<view class="profile-stats-row">

View File

@@ -37,6 +37,7 @@ Page({
showMbtiPicker: false,
saving: false,
loading: true,
showAvatarModal: false,
},
onLoad() {
@@ -95,6 +96,7 @@ Page({
goBack() { getApp().goBackOrToHome() },
onNicknameInput(e) { this.setData({ nickname: e.detail.value }) },
onNicknameChange(e) { this.setData({ nickname: e.detail.value }) },
onRegionInput(e) { this.setData({ region: e.detail.value }) },
onIndustryInput(e) { this.setData({ industry: e.detail.value }) },
onBusinessScaleInput(e) { this.setData({ businessScale: e.detail.value }) },
@@ -114,7 +116,26 @@ Page({
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] })
},
chooseAvatar() {
// 点击头像:选择微信头像或从相册选择
onAvatarTap() {
wx.showActionSheet({
itemList: ['使用微信头像', '从相册选择'],
success: (res) => {
if (res.tapIndex === 0) {
this.setData({ showAvatarModal: true })
} else if (res.tapIndex === 1) {
this.chooseAvatarFromAlbum()
}
},
})
},
closeAvatarModal() {
this.setData({ showAvatarModal: false })
},
// 从相册/相机选择头像
chooseAvatarFromAlbum() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
@@ -160,6 +181,52 @@ Page({
})
},
// 微信原生 chooseAvatar 回调:使用当前微信头像
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
this.setData({ showAvatarModal: false })
if (!tempAvatarUrl) return
wx.showLoading({ title: '上传中...', mask: true })
try {
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempAvatarUrl,
name: 'file',
formData: { folder: 'avatars' },
success: (r) => {
try {
const data = JSON.parse(r.data)
if (data.success) resolve(data)
else reject(new Error(data.error || '上传失败'))
} catch {
reject(new Error('解析失败'))
}
},
fail: reject,
})
})
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
this.setData({ avatar: avatarUrl })
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: { userId: app.globalData.userInfo?.id, avatar: avatarUrl },
})
if (app.globalData.userInfo) {
app.globalData.userInfo.avatar = avatarUrl
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
} catch (err) {
wx.hideLoading()
wx.showToast({ title: err.message || '上传失败,请重试', icon: 'none' })
}
},
async saveProfile() {
const userId = app.globalData.userInfo?.id
if (!userId) {

View File

@@ -17,7 +17,7 @@
<!-- 头像 -->
<view class="avatar-section">
<view class="avatar-wrap" bindtap="chooseAvatar">
<view class="avatar-wrap" bindtap="onAvatarTap">
<view class="avatar-inner">
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
@@ -31,7 +31,18 @@
<view class="section">
<view class="form-row">
<text class="form-label">昵称</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="请输入昵称" value="{{nickname}}" bindinput="onNicknameInput"/></view>
<view class="form-input-wrap">
<input
class="form-input-inner"
type="nickname"
placeholder="请输入昵称"
value="{{nickname}}"
bindinput="onNicknameInput"
bindchange="onNicknameChange"
maxlength="20"
/>
</view>
<text class="input-tip">微信用户可点击自动填充昵称,或手动输入</text>
</view>
<view class="form-row form-row-2">
<view class="form-item">
@@ -134,4 +145,15 @@
</view>
<view class="bottom-space"></view>
</scroll-view>
<!-- 头像弹窗:通过 button 获取微信头像 -->
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
<view class="modal-content avatar-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeAvatarModal">✕</view>
<text class="avatar-modal-title">使用微信头像</text>
<text class="avatar-modal-desc">点击下方按钮,一键同步当前微信头像</text>
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
<view class="avatar-modal-cancel" bindtap="closeAvatarModal">取消</view>
</view>
</view>
</view>

View File

@@ -101,3 +101,83 @@
}
.save-btn[disabled] { opacity: 0.6; }
.bottom-space { height: 120rpx; }
/* 昵称提示文案 */
.input-tip {
margin-top: 8rpx;
font-size: 22rpx;
color: #94A3B8;
margin-left: 8rpx;
}
/* 头像弹窗样式,复用我的页风格 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(16rpx);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 48rpx;
box-sizing: border-box;
}
.modal-content {
width: 100%;
max-width: 640rpx;
background: #0b1220;
border-radius: 32rpx;
padding: 48rpx;
position: relative;
box-sizing: border-box;
}
.modal-close {
position: absolute;
top: 24rpx;
right: 24rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(255,255,255,0.08);
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: rgba(255,255,255,0.7);
}
.avatar-modal-title {
display: block;
font-size: 36rpx;
font-weight: 700;
text-align: center;
margin-bottom: 12rpx;
}
.avatar-modal-desc {
display: block;
font-size: 26rpx;
color: #94A3B8;
text-align: center;
margin-bottom: 32rpx;
}
.btn-choose-avatar {
width: 100%;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: #5EEAD4;
color: #050B14;
font-size: 30rpx;
font-weight: 600;
border-radius: 44rpx;
border: none;
}
.btn-choose-avatar::after {
border: none;
}
.avatar-modal-cancel {
margin-top: 24rpx;
text-align: center;
font-size: 28rpx;
color: #9CA3AF;
}

View File

@@ -421,31 +421,47 @@ Page({
},
// 加载导航
loadNavigation(id) {
const sectionOrder = [
'preface', '1.1', '1.2', '1.3', '1.4', '1.5',
'2.1', '2.2', '2.3', '2.4', '2.5',
'3.1', '3.2', '3.3', '3.4',
'4.1', '4.2', '4.3', '4.4', '4.5',
'5.1', '5.2', '5.3', '5.4', '5.5',
'6.1', '6.2', '6.3', '6.4',
'7.1', '7.2', '7.3', '7.4', '7.5',
'8.1', '8.2', '8.3', '8.4', '8.5', '8.6',
'9.1', '9.2', '9.3', '9.4', '9.5', '9.6', '9.7', '9.8', '9.9', '9.10', '9.11', '9.12', '9.13', '9.14',
'10.1', '10.2', '10.3', '10.4',
'11.1', '11.2', '11.3', '11.4', '11.5',
'epilogue'
]
const currentIndex = sectionOrder.indexOf(id)
const prevId = currentIndex > 0 ? sectionOrder[currentIndex - 1] : null
const nextId = currentIndex < sectionOrder.length - 1 ? sectionOrder[currentIndex + 1] : null
this.setData({
prevSection: prevId ? { id: prevId, title: this.getSectionTitle(prevId) } : null,
nextSection: nextId ? { id: nextId, title: this.getSectionTitle(nextId) } : null
})
// 加载导航:基于后端章节顺序计算上一篇/下一篇
async loadNavigation(id) {
try {
// 优先使用全局缓存的 bookData
let chapters = app.globalData.bookData || []
if (!chapters || !Array.isArray(chapters) || chapters.length === 0) {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
chapters = (res && (res.data || res.chapters)) || []
}
if (!chapters || chapters.length === 0) {
this.setData({ prevSection: null, nextSection: null })
return
}
// 过滤掉没有 id 的记录,并按 sort_order + id 排序
const ordered = chapters
.filter(c => c.id)
.sort((a, b) => {
const soA = typeof a.sort_order === 'number' ? a.sort_order : (typeof a.sortOrder === 'number' ? a.sortOrder : 0)
const soB = typeof b.sort_order === 'number' ? b.sort_order : (typeof b.sortOrder === 'number' ? b.sortOrder : 0)
if (soA !== soB) return soA - soB
return String(a.id).localeCompare(String(b.id), 'zh-Hans-CN')
})
const index = ordered.findIndex(c => String(c.id) === String(id))
const prev = index > 0 ? ordered[index - 1] : null
const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : null
this.setData({
prevSection: prev ? {
id: prev.id,
mid: prev.mid ?? prev.MID ?? null,
title: prev.section_title || prev.sectionTitle || prev.title || this.getSectionTitle(prev.id),
} : null,
nextSection: next ? {
id: next.id,
mid: next.mid ?? next.MID ?? null,
title: next.section_title || next.sectionTitle || next.title || this.getSectionTitle(next.id),
} : null,
})
} catch (e) {
console.warn('[Read] loadNavigation failed:', e)
this.setData({ prevSection: null, nextSection: null })
}
},
// 返回(从分享进入无栈时回首页)
@@ -453,7 +469,7 @@ Page({
getApp().goBackOrToHome()
},
// 点击正文中的 #链接标签:外链复制到剪贴板,小程序内页直接跳转
// 点击正文中的 #链接标签:小程序内页/预览页跳转
onLinkTagTap(e) {
let url = (e.currentTarget.dataset.url || '').trim()
const label = (e.currentTarget.dataset.label || '').trim()
@@ -484,25 +500,13 @@ Page({
return
}
// 外部 URL优先用 wx.openLink 在浏览器打开,旧版微信降级复制
// 外部 URL跳转到内置预览页,由 web-view 打开
if (url) {
if (typeof wx.openLink === 'function') {
wx.openLink({
url,
fail: () => {
// openLink 不支持(如不在微信内),降级复制
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: `链接已复制`, icon: 'none', duration: 2000 })
})
}
})
} else {
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: `链接已复制`, icon: 'none', duration: 2000 })
})
}
const encodedUrl = encodeURIComponent(url)
const encodedTitle = encodeURIComponent(label || '链接预览')
wx.navigateTo({
url: `/pages/link-preview/link-preview?url=${encodedUrl}&title=${encodedTitle}`,
})
return
}
@@ -1141,14 +1145,18 @@ Page({
// 跳转到上一篇
goToPrev() {
if (this.data.prevSection) {
wx.redirectTo({ url: `/pages/read/read?id=${this.data.prevSection.id}` })
const { id, mid } = this.data.prevSection
const query = mid ? `mid=${mid}` : `id=${id}`
wx.redirectTo({ url: `/pages/read/read?${query}` })
}
},
// 跳转到下一篇
goToNext() {
if (this.data.nextSection) {
wx.redirectTo({ url: `/pages/read/read?id=${this.data.nextSection.id}` })
const { id, mid } = this.data.nextSection
const query = mid ? `mid=${mid}` : `id=${id}`
wx.redirectTo({ url: `/pages/read/read?${query}` })
}
},

View File

@@ -85,9 +85,9 @@
<!-- 分享操作区 -->
<view class="action-section">
<view class="action-row-inline">
<button class="action-btn-inline btn-share-inline" open-type="share">
<text class="action-icon-small">💬</text>
<text class="action-text-small">推荐给好友</text>
<button class="action-btn-inline btn-share-inline" open-type="shareTimeline">
<text class="action-icon-small">📣</text>
<text class="action-text-small">分享到朋友圈</text>
</button>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<text class="action-icon-small">🖼️</text>

View File

@@ -2,5 +2,7 @@
# 宝塔部署:若 API 站点开启了强制 HTTPS这里必须用 https否则预检请求会被重定向导致 CORS 报错
# VITE_API_BASE_URL=http://localhost:3006
VITE_API_BASE_URL=http://localhost:8080
# VITE_API_BASE_URL=https://soulapi.quwanzhi.com
# VITE_API_BASE_URL=https://souldev.quwanzhi.com

View File

@@ -226,6 +226,8 @@ export function ContentPage() {
const [rankingWeightsLoading, setRankingWeightsLoading] = useState(false)
const [rankingWeightsSaving, setRankingWeightsSaving] = useState(false)
const [rankingPage, setRankingPage] = useState(1)
const [rankedSectionsList, setRankedSectionsList] = useState<SectionListItem[]>([])
const [rankingLoading, setRankingLoading] = useState(false)
const [pinnedSectionIds, setPinnedSectionIds] = useState<string[]>([])
const [pinnedLoading, setPinnedLoading] = useState(false)
const [previewPercent, setPreviewPercent] = useState(20)
@@ -236,21 +238,28 @@ export function ContentPage() {
const [newPerson, setNewPerson] = useState({ personId: '', name: '', label: '', ckbApiKey: '' })
const [editingPersonKey, setEditingPersonKey] = useState<string | null>(null) // 正在编辑密钥的 personId
const [editingPersonKeyValue, setEditingPersonKeyValue] = useState('')
const [newLinkTag, setNewLinkTag] = useState({ tagId: '', label: '', url: '', type: 'url' as 'url' | 'miniprogram' | 'ckb', appId: '', pagePath: '' })
const [newLinkTag, setNewLinkTag] = useState({
tagId: '',
label: '',
url: '',
type: 'url' as 'url' | 'miniprogram' | 'ckb',
appId: '',
pagePath: '',
})
const [editingLinkTagId, setEditingLinkTagId] = useState<string | null>(null)
const richEditorRef = useRef<RichEditorRef>(null)
const tree = buildTree(sectionsList)
const totalSections = sectionsList.length
const rankedSections = [...sectionsList].sort((a, b) => (b.hotScore ?? 0) - (a.hotScore ?? 0))
// 内容排行榜:排序与置顶由后端 API 统一计算,前端只展示
const RANKING_PAGE_SIZE = 10
const rankingTotalPages = Math.max(1, Math.ceil(rankedSections.length / RANKING_PAGE_SIZE))
const rankingPageSections = rankedSections.slice((rankingPage - 1) * RANKING_PAGE_SIZE, rankingPage * RANKING_PAGE_SIZE)
const rankingTotalPages = Math.max(1, Math.ceil(rankedSectionsList.length / RANKING_PAGE_SIZE))
const rankingPageSections = rankedSectionsList.slice((rankingPage - 1) * RANKING_PAGE_SIZE, rankingPage * RANKING_PAGE_SIZE)
const loadList = async () => {
setLoading(true)
try {
// 每次请求均从服务端拉取最新数据,确保点击量/付款数与 reading_progress、orders 表直接捆绑
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
'/api/db/book?action=list',
{ cache: 'no-store' as RequestCache },
@@ -264,8 +273,29 @@ export function ContentPage() {
}
}
const loadRanking = async () => {
setRankingLoading(true)
try {
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
'/api/db/book?action=ranking',
{ cache: 'no-store' as RequestCache },
)
const sections = Array.isArray(data?.sections) ? data.sections : []
setRankedSectionsList(sections)
// 同步置顶配置(后端为唯一数据源)
const pinned = sections.filter((s) => s.isPinned).map((s) => s.id)
setPinnedSectionIds(pinned)
} catch (e) {
console.error(e)
setRankedSectionsList([])
} finally {
setRankingLoading(false)
}
}
useEffect(() => {
loadList()
loadRanking()
}, [])
@@ -310,6 +340,7 @@ export function ContentPage() {
if (res && (res as { success?: boolean }).success !== false) {
toast.success('已删除')
loadList()
loadRanking()
} else {
toast.error('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
@@ -394,9 +425,25 @@ export function ContentPage() {
const loadLinkTags = useCallback(async () => {
try {
const data = await get<{ success?: boolean; linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[] }>('/api/db/link-tags')
if (data?.success && data.linkTags) setLinkTags(data.linkTags.map(t => ({ id: t.tagId, label: t.label, url: t.url, type: t.type as 'url' | 'miniprogram', appId: t.appId, pagePath: t.pagePath })))
} catch { /* ignore */ }
const data = await get<{
success?: boolean
linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[]
}>('/api/db/link-tags')
if (data?.success && data.linkTags) {
setLinkTags(
data.linkTags.map((t) => ({
id: t.tagId,
label: t.label,
url: t.url,
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
appId: t.appId || '',
pagePath: t.pagePath || '',
})),
)
}
} catch {
/* ignore */
}
}, [])
const handleTogglePin = async (sectionId: string) => {
@@ -410,6 +457,7 @@ export function ContentPage() {
value: next,
description: '强制置顶章节ID列表精选推荐/首页最新更新)',
})
loadRanking() // 置顶配置变更后重新拉取排行榜(后端统一计算排序)
} catch { setPinnedSectionIds(pinnedSectionIds) }
}
@@ -1984,13 +2032,23 @@ export function ContentPage() {
<CardTitle className="text-white text-base flex items-center gap-2">
<Trophy className="w-4 h-4 text-amber-400" />
<span className="text-xs text-gray-500 font-normal ml-2"> · {rankedSections.length} </span>
<span className="text-xs text-gray-500 font-normal ml-2"> · {rankedSectionsList.length} </span>
</CardTitle>
<div className="flex items-center gap-1 text-sm">
<Button
variant="ghost"
size="sm"
disabled={rankingPage <= 1}
onClick={() => loadRanking()}
disabled={rankingLoading}
className="text-gray-400 hover:text-white h-7 w-7 p-0"
title="刷新排行榜"
>
<RefreshCw className={`w-4 h-4 ${rankingLoading ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="sm"
disabled={rankingPage <= 1 || rankingLoading}
onClick={() => setRankingPage((p) => Math.max(1, p - 1))}
className="text-gray-400 hover:text-white h-7 w-7 p-0"
>
@@ -2000,7 +2058,7 @@ export function ContentPage() {
<Button
variant="ghost"
size="sm"
disabled={rankingPage >= rankingTotalPages}
disabled={rankingPage >= rankingTotalPages || rankingLoading}
onClick={() => setRankingPage((p) => Math.min(rankingTotalPages, p + 1))}
className="text-gray-400 hover:text-white h-7 w-7 p-0"
>
@@ -2023,7 +2081,7 @@ export function ContentPage() {
</div>
{rankingPageSections.map((s, idx) => {
const globalRank = (rankingPage - 1) * RANKING_PAGE_SIZE + idx + 1
const isPinned = pinnedSectionIds.includes(s.id)
const isPinned = s.isPinned ?? pinnedSectionIds.includes(s.id)
return (
<div
key={s.id}
@@ -2217,35 +2275,102 @@ export function ContentPage() {
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="pages/index/index" value={newLinkTag.pagePath} onChange={e => setNewLinkTag({ ...newLinkTag, pagePath: e.target.value })} />
</div>
)}
<Button size="sm" className="bg-amber-500 hover:bg-amber-600 text-white h-8" onClick={async () => {
if (!newLinkTag.tagId || !newLinkTag.label) { toast.error('标签ID和显示文字必填'); return }
const payload = { ...newLinkTag }
if (payload.type === 'miniprogram' && payload.appId) payload.url = `weixin://dl/business/?appid=${payload.appId}&path=${payload.pagePath}`
await post('/api/db/link-tags', payload)
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
loadLinkTags()
}}>
<Plus className="w-3 h-3 mr-1" />
<Button
size="sm"
className="bg-amber-500 hover:bg-amber-600 text-white h-8"
onClick={async () => {
if (!newLinkTag.tagId || !newLinkTag.label) {
toast.error('标签ID和显示文字必填')
return
}
const payload = { ...newLinkTag }
await post('/api/db/link-tags', payload)
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
setEditingLinkTagId(null)
loadLinkTags()
}}
>
<Plus className="w-3 h-3 mr-1" />
{editingLinkTagId ? '保存' : '添加'}
</Button>
</div>
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{linkTags.map(t => (
{linkTags.map((t) => (
<div key={t.id} className="flex items-center justify-between bg-[#0a1628] rounded px-3 py-2">
<div className="flex items-center gap-3 text-sm">
<span className="text-amber-400 font-bold text-base">#{t.label}</span>
<Badge variant="secondary" className={`text-[10px] ${t.type === 'ckb' ? 'bg-green-500/20 text-green-300 border-green-500/30' : 'bg-gray-700 text-gray-300'}`}>
<button
type="button"
className="text-amber-400 font-bold text-base hover:underline"
onClick={() => {
setNewLinkTag({
tagId: t.id,
label: t.label,
url: t.url,
type: t.type,
appId: t.appId,
pagePath: t.pagePath,
})
setEditingLinkTagId(t.id)
}}
>
#{t.label}
</button>
<Badge
variant="secondary"
className={`text-[10px] ${
t.type === 'ckb'
? 'bg-green-500/20 text-green-300 border-green-500/30'
: 'bg-gray-700 text-gray-300'
}`}
>
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
</Badge>
<a href={t.url} target="_blank" rel="noreferrer" className="text-blue-400 text-xs truncate max-w-[250px] hover:underline flex items-center gap-1">
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
{t.url && (
<a
href={t.url}
target="_blank"
rel="noreferrer"
className="text-blue-400 text-xs truncate max-w-[250px] hover:underline flex items-center gap-1"
>
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="text-gray-300 hover:text-white h-6 px-2"
onClick={() => {
setNewLinkTag({
tagId: t.id,
label: t.label,
url: t.url,
type: t.type,
appId: t.appId,
pagePath: t.pagePath,
})
setEditingLinkTagId(t.id)
}}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-400 hover:text-red-300 h-6 px-2"
onClick={async () => {
await del(`/api/db/link-tags?tagId=${t.id}`)
if (editingLinkTagId === t.id) {
setEditingLinkTagId(null)
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
}
loadLinkTags()
}}
>
<X className="w-3 h-3" />
</Button>
</div>
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
await del(`/api/db/link-tags?tagId=${t.id}`)
loadLinkTags()
}}>
<X className="w-3 h-3" />
</Button>
</div>
))}
{linkTags.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">使 # </div>}

View File

@@ -1,4 +1,4 @@
import toast from '@/utils/toast'
import toast from '@/utils/toast'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -43,6 +43,7 @@ import {
ChevronDown,
ChevronUp,
Crown,
Tag,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { Pagination } from '@/components/ui/Pagination'
@@ -81,11 +82,12 @@ interface UserRule {
createdAt?: string
}
interface VipRole {
id: number
interface VipMember {
id: string
name: string
sort: number
createdAt?: string
avatar?: string | null
vipRole?: string | null
vipSort?: number | null
}
// 用户旅程阶段定义
@@ -144,12 +146,11 @@ export function UsersPage() {
const [editingRule, setEditingRule] = useState<UserRule | null>(null)
const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', sort: 0, enabled: true })
// ===== VIP 角色 =====
const [vipRoles, setVipRoles] = useState<VipRole[]>([])
const [vipRolesLoading, setVipRolesLoading] = useState(false)
const [showVipRoleModal, setShowVipRoleModal] = useState(false)
const [editingVipRole, setEditingVipRole] = useState<VipRole | null>(null)
const [vipRoleForm, setVipRoleForm] = useState({ name: '', sort: 0 })
// ===== 超级个体VIP 用户列表) =====
const [vipMembers, setVipMembers] = useState<VipMember[]>([])
const [vipMembersLoading, setVipMembersLoading] = useState(false)
const [draggingVipId, setDraggingVipId] = useState<string | null>(null)
const [dragOverVipId, setDragOverVipId] = useState<string | null>(null)
// ===== 用户旅程总览 =====
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
@@ -313,36 +314,169 @@ export function UsersPage() {
try { await put('/api/db/user-rules', { id: rule.id, enabled: !rule.enabled }); loadRules() } catch { }
}
// ===== VIP 角色 =====
const loadVipRoles = useCallback(async () => {
setVipRolesLoading(true)
// ===== 超级个体VIP 用户列表) =====
const loadVipMembers = useCallback(async () => {
setVipMembersLoading(true)
try {
const data = await get<{ success?: boolean; roles?: VipRole[] }>('/api/db/vip-roles')
if (data?.success) setVipRoles(data.roles || [])
} catch { } finally { setVipRolesLoading(false) }
const data = await get<{ success?: boolean; data?: VipMember[]; error?: string }>(
'/api/db/vip-members?limit=500',
)
if (data?.success && data.data) {
const list = [...data.data].map((m, idx) => ({
...m,
vipSort: typeof (m as any).vipSort === 'number' ? (m as any).vipSort : idx + 1,
}))
list.sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999))
setVipMembers(list)
} else if (data && data.error) {
toast.error(data.error)
}
} catch {
toast.error('加载超级个体列表失败')
} finally {
setVipMembersLoading(false)
}
}, [])
async function handleSaveVipRole() {
if (!vipRoleForm.name) { toast.error('请填写角色名称'); return }
setIsSaving(true)
try {
if (editingVipRole) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/vip-roles', { id: editingVipRole.id, ...vipRoleForm })
if (!data?.success) { toast.error('更新失败'); return }
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', vipRoleForm)
if (!data?.success) { toast.error('创建失败'); return }
}
setShowVipRoleModal(false); loadVipRoles()
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
const [showVipRoleModal, setShowVipRoleModal] = useState(false)
const [vipRoleModalMember, setVipRoleModalMember] = useState<VipMember | null>(null)
const [vipRoleInput, setVipRoleInput] = useState('')
const [vipRoleSaving, setVipRoleSaving] = useState(false)
const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手']
const openVipRoleModal = (member: VipMember) => {
setVipRoleModalMember(member)
setVipRoleInput(member.vipRole || '')
setShowVipRoleModal(true)
}
async function handleDeleteVipRole(id: number) {
if (!confirm('确定删除?')) return
const handleSetVipRole = async (value: string) => {
const trimmed = value.trim()
if (!vipRoleModalMember) return
if (!trimmed) {
toast.error('请选择或输入标签')
return
}
setVipRoleSaving(true)
try {
const data = await del<{ success?: boolean }>(`/api/db/vip-roles?id=${id}`)
if (data?.success) loadVipRoles()
} catch { }
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', {
id: vipRoleModalMember.id,
vipRole: trimmed,
})
if (!res?.success) {
toast.error(res?.error || '更新超级个体标签失败')
return
}
toast.success('已更新超级个体标签')
setShowVipRoleModal(false)
setVipRoleModalMember(null)
await loadVipMembers()
} catch {
toast.error('更新超级个体标签失败')
} finally {
setVipRoleSaving(false)
}
}
const [showVipSortModal, setShowVipSortModal] = useState(false)
const [vipSortModalMember, setVipSortModalMember] = useState<VipMember | null>(null)
const [vipSortInput, setVipSortInput] = useState('')
const [vipSortSaving, setVipSortSaving] = useState(false)
const openVipSortModal = (member: VipMember) => {
setVipSortModalMember(member)
setVipSortInput(member.vipSort != null ? String(member.vipSort) : '')
setShowVipSortModal(true)
}
const handleSetVipSort = async () => {
if (!vipSortModalMember) return
const num = Number(vipSortInput)
if (!Number.isFinite(num)) {
toast.error('请输入有效的数字序号')
return
}
setVipSortSaving(true)
try {
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', {
id: vipSortModalMember.id,
vipSort: num,
})
if (!res?.success) {
toast.error(res?.error || '更新排序序号失败')
return
}
toast.success('已更新排序序号')
setShowVipSortModal(false)
setVipSortModalMember(null)
await loadVipMembers()
} catch {
toast.error('更新排序序号失败')
} finally {
setVipSortSaving(false)
}
}
const handleVipRowDragStart = (e: React.DragEvent<HTMLTableRowElement>, id: string) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', id)
setDraggingVipId(id)
}
const handleVipRowDragOver = (e: React.DragEvent<HTMLTableRowElement>, id: string) => {
e.preventDefault()
if (dragOverVipId !== id) setDragOverVipId(id)
}
const handleVipRowDragEnd = () => {
setDraggingVipId(null)
setDragOverVipId(null)
}
const handleVipRowDrop = async (e: React.DragEvent<HTMLTableRowElement>, targetId: string) => {
e.preventDefault()
const fromId = e.dataTransfer.getData('text/plain') || draggingVipId
setDraggingVipId(null)
setDragOverVipId(null)
if (!fromId || fromId === targetId) return
const fromMember = vipMembers.find((m) => m.id === fromId)
const targetMember = vipMembers.find((m) => m.id === targetId)
if (!fromMember || !targetMember) return
const fromSort = fromMember.vipSort ?? vipMembers.findIndex((m) => m.id === fromId) + 1
const targetSort = targetMember.vipSort ?? vipMembers.findIndex((m) => m.id === targetId) + 1
// 本地先交换顺序,提升交互流畅度
setVipMembers((prev) => {
const list = [...prev]
const fromIdx = list.findIndex((m) => m.id === fromId)
const toIdx = list.findIndex((m) => m.id === targetId)
if (fromIdx === -1 || toIdx === -1) return prev
const next = [...list]
const [m1, m2] = [next[fromIdx], next[toIdx]]
next[fromIdx] = { ...m2, vipSort: fromSort }
next[toIdx] = { ...m1, vipSort: targetSort }
return next
})
try {
const [res1, res2] = await Promise.all([
put<{ success?: boolean; error?: string }>('/api/db/users', { id: fromId, vipSort: targetSort }),
put<{ success?: boolean; error?: string }>('/api/db/users', { id: targetId, vipSort: fromSort }),
])
if (!res1?.success || !res2?.success) {
toast.error(res1?.error || res2?.error || '更新排序失败')
await loadVipMembers()
return
}
toast.success('已更新排序')
await loadVipMembers()
} catch {
toast.error('更新排序失败')
await loadVipMembers()
}
}
// ===== 用户旅程总览 =====
@@ -381,8 +515,8 @@ export function UsersPage() {
<TabsTrigger value="rules" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadRules}>
<Settings className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="vip-roles" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadVipRoles}>
<Crown className="w-4 h-4" /> VIP
<TabsTrigger value="vip-roles" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadVipMembers}>
<Crown className="w-4 h-4" />
</TabsTrigger>
</TabsList>
@@ -707,46 +841,136 @@ export function UsersPage() {
)}
</TabsContent>
{/* ===== VIP 角色 ===== */}
{/* ===== 超级个体列表(VIP 用户) ===== */}
<TabsContent value="vip-roles">
<div className="mb-4 flex items-center justify-between">
<p className="text-gray-400 text-sm"> VIP 使</p>
<div className="space-y-1">
<p className="text-gray-400 text-sm">
VIP
</p>
<p className="text-xs text-[#38bdac]">
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadVipRoles} disabled={vipRolesLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${vipRolesLoading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={() => { setEditingVipRole(null); setVipRoleForm({ name: '', sort: 0 }); setShowVipRoleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
<Button
variant="outline"
onClick={loadVipMembers}
disabled={vipMembersLoading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw
className={`w-4 h-4 mr-2 ${vipMembersLoading ? 'animate-spin' : ''}`}
/>{' '}
</Button>
</div>
</div>
{vipRolesLoading ? (
<div className="flex items-center justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /></div>
) : vipRoles.length === 0 ? (
{vipMembersLoading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : vipMembers.length === 0 ? (
<div className="text-center py-16 bg-[#0f2137] rounded-lg border border-gray-700/50">
<Crown className="w-12 h-12 text-amber-400/30 mx-auto mb-4" />
<p className="text-gray-400 mb-4"> VIP </p>
<Button onClick={() => { setEditingVipRole(null); setVipRoleForm({ name: '', sort: 0 }); setShowVipRoleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Plus className="w-4 h-4 mr-2" /> </Button>
<p className="text-gray-400 mb-4"></p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{vipRoles.map((role) => (
<div key={role.id} className="p-4 bg-[#0f2137] border border-amber-500/20 rounded-xl hover:border-amber-500/40 transition-all group">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Crown className="w-4 h-4 text-amber-400" />
<span className="text-white font-medium">{role.name}</span>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button type="button" onClick={() => { setEditingVipRole(role); setVipRoleForm({ name: role.name, sort: role.sort }); setShowVipRoleModal(true) }} className="text-gray-500 hover:text-[#38bdac]"><Edit3 className="w-3.5 h-3.5" /></button>
<button type="button" onClick={() => handleDeleteVipRole(role.id)} className="text-gray-500 hover:text-red-400"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
<p className="text-gray-600 text-xs">: {role.sort}</p>
</div>
))}
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400 w-16"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 w-40"></TableHead>
<TableHead className="text-gray-400 w-24"></TableHead>
<TableHead className="text-gray-400 w-40 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vipMembers.map((m, index) => {
const isDragging = draggingVipId === m.id
const isOver = dragOverVipId === m.id
return (
<TableRow
key={m.id}
draggable
onDragStart={(e) => handleVipRowDragStart(e, m.id)}
onDragOver={(e) => handleVipRowDragOver(e, m.id)}
onDrop={(e) => handleVipRowDrop(e, m.id)}
onDragEnd={handleVipRowDragEnd}
className={`border-gray-700/50 cursor-grab active:cursor-grabbing select-none ${
isDragging ? 'opacity-60' : ''
} ${isOver ? 'bg-[#38bdac]/10' : ''}`}
>
<TableCell className="text-gray-300">{index + 1}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
{m.avatar ? (
// eslint-disable-next-line jsx-a11y/alt-text
<img
src={m.avatar}
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
/>
) : (
<div className="w-8 h-8 rounded-full bg-amber-500/20 border border-amber-400/60 flex items-center justify-center text-amber-300 text-sm">
{m.name?.[0] || '创'}
</div>
)}
<div className="min-w-0">
<div className="text-white text-sm truncate">{m.name}</div>
</div>
</div>
</TableCell>
<TableCell className="text-gray-300">
{m.vipRole || <span className="text-gray-500"></span>}
</TableCell>
<TableCell className="text-gray-300">
{m.vipSort ?? index + 1}
</TableCell>
<TableCell className="text-right text-xs text-gray-300">
<div className="inline-flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 px-0 text-amber-300 hover:text-amber-200"
onClick={() => openVipRoleModal(m)}
title="设置超级个体标签"
>
<Tag className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 px-0 text-[#38bdac] hover:text-[#5fe0cd]"
onClick={() => {
setSelectedUserIdForDetail(m.id)
setShowDetailModal(true)
}}
title="编辑资料"
>
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 px-0 text-sky-300 hover:text-sky-200"
onClick={() => openVipSortModal(m)}
title="设置排序序号"
>
<ArrowUpDown className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
@@ -754,6 +978,81 @@ export function UsersPage() {
{/* ===== 弹框组件 ===== */}
{/* 添加/编辑用户 */}
{/* 设置排序 */}
<Dialog open={showVipSortModal} onOpenChange={(open) => { setShowVipSortModal(open); if (!open) setVipSortModalMember(null) }}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<ArrowUpDown className="w-5 h-5 text-[#38bdac]" />
{vipSortModalMember?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Label className="text-gray-300 text-sm"></Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如1"
value={vipSortInput}
onChange={(e) => setVipSortInput(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowVipSortModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSetVipSort} disabled={vipSortSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-4 h-4 mr-2" />{vipSortSaving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 设置超级个体标签 */}
<Dialog open={showVipRoleModal} onOpenChange={(open) => { setShowVipRoleModal(open); if (!open) setVipRoleModalMember(null) }}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Crown className="w-5 h-5 text-amber-400" />
{vipRoleModalMember?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Label className="text-gray-300 text-sm"></Label>
<div className="flex flex-wrap gap-2">
{VIP_ROLE_PRESETS.map((preset) => (
<Button
key={preset}
variant={vipRoleInput === preset ? 'default' : 'outline'}
size="sm"
className={vipRoleInput === preset ? 'bg-[#38bdac] hover:bg-[#2da396] text-white' : 'border-gray-600 text-gray-300 hover:bg-gray-700/50'}
onClick={() => setVipRoleInput(preset)}
>
{preset}
</Button>
))}
</div>
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:创业者、资源整合者等"
value={vipRoleInput}
onChange={(e) => setVipRoleInput(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowVipRoleModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={() => handleSetVipRole(vipRoleInput)} disabled={vipRoleSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-4 h-4 mr-2" />{vipRoleSaving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2">{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}{editingUser ? '编辑用户' : '添加用户'}</DialogTitle></DialogHeader>
@@ -788,21 +1087,6 @@ export function UsersPage() {
</DialogContent>
</Dialog>
{/* 添加/编辑 VIP 角色 */}
<Dialog open={showVipRoleModal} onOpenChange={setShowVipRoleModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><Crown className="w-5 h-5 text-amber-400" />{editingVipRole ? '编辑 VIP 角色' : '添加 VIP 角色'}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2"><Label className="text-gray-300"> *</Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="例:创业者、资源整合者、技术咖" value={vipRoleForm.name} onChange={(e) => setVipRoleForm({ ...vipRoleForm, name: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input type="number" className="bg-[#0a1628] border-gray-700 text-white" value={vipRoleForm.sort} onChange={(e) => setVipRoleForm({ ...vipRoleForm, sort: parseInt(e.target.value) || 0 })} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowVipRoleModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"><X className="w-4 h-4 mr-2" /></Button>
<Button onClick={handleSaveVipRole} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Save className="w-4 h-4 mr-2" />{isSaving ? '保存中...' : '保存'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 绑定关系 */}
<Dialog open={showReferralsModal} onOpenChange={setShowReferralsModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-auto">

View File

@@ -1,9 +1,6 @@
import toast from '@/utils/toast'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
@@ -12,130 +9,59 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Crown, Plus, Edit3, Trash2, X, Save } from 'lucide-react'
import { get, post, put, del } from '@/api/client'
import { Crown } from 'lucide-react'
import { get } from '@/api/client'
interface VipRole {
id: number
interface VipMember {
id: string
name: string
sort: number
createdAt?: string
updatedAt?: string
avatar?: string
vipRole?: string
vipSort?: number
}
export function VipRolesPage() {
const [roles, setRoles] = useState<VipRole[]>([])
const [members, setMembers] = useState<VipMember[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingRole, setEditingRole] = useState<VipRole | null>(null)
const [formName, setFormName] = useState('')
const [formSort, setFormSort] = useState(0)
const [saving, setSaving] = useState(false)
async function loadRoles() {
async function loadMembers() {
setLoading(true)
try {
const data = await get<{ success?: boolean; data?: VipRole[] }>('/api/db/vip-roles')
if (data?.success && data.data) setRoles(data.data)
const data = await get<{ success?: boolean; data?: VipMember[] }>(
'/api/db/vip-members?limit=100',
)
if (data?.success && data.data) {
const list = [...data.data].map((m, idx) => ({
...m,
vipSort: typeof (m as any).vipSort === 'number' ? (m as any).vipSort : idx + 1,
}))
list.sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999))
setMembers(list)
}
} catch (e) {
console.error('Load roles error:', e)
console.error('Load VIP members error:', e)
toast.error('加载 VIP 成员失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadRoles()
loadMembers()
}, [])
const handleAdd = () => {
setEditingRole(null)
setFormName('')
setFormSort(roles.length > 0 ? Math.max(...roles.map((r) => r.sort)) + 1 : 0)
setShowModal(true)
}
const handleEdit = (role: VipRole) => {
setEditingRole(role)
setFormName(role.name)
setFormSort(role.sort)
setShowModal(true)
}
const handleSave = async () => {
if (!formName.trim()) {
toast.error('角色名称不能为空')
return
}
setSaving(true)
try {
if (editingRole) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/vip-roles', {
id: editingRole.id,
name: formName.trim(),
sort: formSort,
})
if (data?.success) {
setShowModal(false)
loadRoles()
} else {
toast.error('更新失败: ' + (data as { error?: string })?.error)
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', {
name: formName.trim(),
sort: formSort,
})
if (data?.success) {
setShowModal(false)
loadRoles()
} else {
toast.error('新增失败: ' + (data as { error?: string })?.error)
}
}
} catch (e) {
console.error('Save error:', e)
toast.error('保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该角色?已设置该角色的 VIP 用户将保留角色名称。')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/vip-roles?id=${id}`)
if (data?.success) loadRoles()
else toast.error('删除失败: ' + (data as { error?: string })?.error)
} catch (e) {
console.error('Delete error:', e)
toast.error('删除失败')
}
}
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Crown className="w-5 h-5 text-amber-400" />
VIP
/
</h2>
<p className="text-gray-400 mt-1">
VIP
</p>
</div>
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
@@ -146,42 +72,44 @@ export function VipRolesPage() {
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead>
<TableHead className="text-gray-400 w-20"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 w-40"></TableHead>
<TableHead className="text-gray-400 w-28"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((r) => (
<TableRow key={r.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{r.id}</TableCell>
<TableCell className="text-white">{r.name}</TableCell>
<TableCell className="text-gray-400">{r.sort}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(r)}
className="text-gray-400 hover:text-[#38bdac]"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(r.id)}
className="text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</Button>
{members.map((m, index) => (
<TableRow key={m.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{index + 1}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
{m.avatar ? (
// eslint-disable-next-line jsx-a11y/alt-text
<img
src={m.avatar}
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
/>
) : (
<div className="w-8 h-8 rounded-full bg-amber-500/20 border border-amber-400/60 flex items-center justify-center text-amber-300 text-sm">
{m.name?.[0] || '创'}
</div>
)}
<div className="min-w-0">
<div className="text-white text-sm truncate">{m.name}</div>
</div>
</div>
</TableCell>
<TableCell className="text-gray-300">
{m.vipRole || <span className="text-gray-500"></span>}
</TableCell>
<TableCell className="text-gray-300">{m.vipSort ?? index + 1}</TableCell>
</TableRow>
))}
{roles.length === 0 && (
{members.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-12 text-gray-500">
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
@@ -190,54 +118,7 @@ export function VipRolesPage() {
)}
</CardContent>
</Card>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
<DialogHeader>
<DialogTitle className="text-white">
{editingRole ? '编辑角色' : '新增角色'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:创始人、投资人"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
value={formSort}
onChange={(e) => setFormSort(parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowModal(false)}
className="border-gray-600 text-gray-300"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

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

View File

@@ -18,6 +18,9 @@ SKIP_AUTO_MIGRATE=1
# 统一 API 域名(测试环境)
API_BASE_URL=https://souldev.quwanzhi.com
#添加卡若
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c

View File

@@ -14,6 +14,9 @@ DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/sou
# 统一 API 域名支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
API_BASE_URL=https://soulapi.quwanzhi.com
#添加卡若
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c

View File

@@ -382,9 +382,42 @@ func BookHot(c *gin.Context) {
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章,带 热门/推荐/精选 标签)
func BookRecommended(c *gin.Context) {
// 优先复用管理端内容排行榜的算法,保证与后台「内容排行榜」顺序一致
sections, err := computeArticleRankingSections(database.DB())
if err == nil && len(sections) > 0 {
// 前 3 名作为精选推荐
limit := 3
if len(sections) < limit {
limit = len(sections)
}
tags := []string{"热门", "推荐", "精选"}
out := make([]gin.H, 0, limit)
for i := 0; i < limit; i++ {
s := sections[i]
tag := "精选"
if i < len(tags) {
tag = tags[i]
}
out = append(out, gin.H{
"id": s.ID,
"mid": s.MID,
"sectionTitle": s.Title,
"partTitle": s.PartTitle,
"chapterTitle": s.ChapterTitle,
"tag": tag,
"isFree": s.IsFree,
"price": s.Price,
"isNew": s.IsNew,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
return
}
// 兜底:沿用原有热门章节算法,至少保证有推荐
list := bookHotChaptersSorted(database.DB(), 3)
if len(list) == 0 {
// 兜底:按 updated_at 取前 3同样排除序言/尾声/附录
// 第二层兜底:按 updated_at 取前 3同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")

View File

@@ -290,6 +290,134 @@ func CKBSync(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// CKBIndexLead POST /api/miniprogram/ckb/index-lead 小程序首页「点击链接卡若」专用留资接口
// - 固定使用全局 CKB_LEAD_API_KEY不受文章 @ 人物的 ckb_api_key 影响
// - 请求体userId可选用于补全昵称、phone/wechatId至少一个、name可选
func CKBIndexLead(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
}
_ = c.ShouldBindJSON(&body)
phone := strings.TrimSpace(body.Phone)
wechatId := strings.TrimSpace(body.WechatID)
// 存客宝侧仅接收手机号,不接收微信号;首页入口必须提供手机号
if phone == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请先填写手机号"})
return
}
name := strings.TrimSpace(body.Name)
db := database.DB()
if name == "" && body.UserID != "" {
var u model.User
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
name = *u.Nickname
}
}
if name == "" {
name = "小程序用户"
}
// 首页固定使用全局密钥CKB_LEAD_API_KEY.env或代码内置 ckbAPIKey
leadKey := ckbAPIKey
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
leadKey = cfg.CkbLeadAPIKey
}
// 去重限频2 分钟内同一用户/手机/微信只能提交一次
var cond []string
var args []interface{}
if body.UserID != "" {
cond = append(cond, "user_id = ?")
args = append(args, body.UserID)
}
cond = append(cond, "phone = ?")
args = append(args, phone)
cutoff := time.Now().Add(-2 * time.Minute)
var recentCount int64
if db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Where("created_at > ?", cutoff).Count(&recentCount) == nil && recentCount > 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "您操作太频繁请2分钟后再试"})
return
}
repeatedSubmit := false
var existCount int64
repeatedSubmit = db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Count(&existCount) == nil && existCount > 0
source := "index_link_button"
paramsJSON, _ := json.Marshal(map[string]interface{}{
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
"source": source,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Source: source,
Params: string(paramsJSON),
}).Error
ts := time.Now().Unix()
params := map[string]interface{}{
"name": name,
"timestamp": ts,
"apiKey": leadKey,
}
params["phone"] = phone
params["sign"] = ckbSign(params, leadKey)
q := url.Values{}
q.Set("name", name)
q.Set("timestamp", strconv.FormatInt(ts, 10))
q.Set("apiKey", leadKey)
q.Set("phone", phone)
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
msg := "提交成功,卡若会尽快联系您"
if repeatedSubmit {
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
}
data := gin.H{}
if result.Data != nil {
if m, ok := result.Data.(map[string]interface{}); ok {
data = m
}
}
data["repeatedSubmit"] = repeatedSubmit
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
return
}
// 存客宝返回失败,透传其错误信息与 code便于前端/运营判断原因
errMsg := strings.TrimSpace(result.Message)
if errMsg == "" {
errMsg = "提交失败,请稍后重试"
}
fmt.Printf("[CKBIndexLead] 存客宝返回异常 code=%d message=%s raw=%s\n", result.Code, result.Message, string(b))
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errMsg,
"ckbCode": result.Code,
"ckbMessage": result.Message,
})
}
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)或文章@某人(点击 mention
// 请求体phone/wechatId至少一个、userId补全昵称、targetUserId被@的 personId、targetNickname、source
func CKBLead(c *gin.Context) {

View File

@@ -2,7 +2,11 @@ package handler
import (
"context"
"encoding/json"
"math"
"net/http"
"sort"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -13,13 +17,15 @@ import (
// listSelectCols 列表/导出不加载 content大幅加速
var listSelectCols = []string{
"id", "section_title", "price", "is_free", "is_new",
"id", "mid", "section_title", "price", "is_free", "is_new",
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
"hot_score", "updated_at",
}
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
type sectionListItem struct {
ID string `json:"id"`
MID int `json:"mid,omitempty"` // 自增主键,小程序跳转用
Title string `json:"title"`
Price float64 `json:"price"`
IsFree *bool `json:"isFree,omitempty"`
@@ -29,6 +35,166 @@ type sectionListItem struct {
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
FilePath *string `json:"filePath,omitempty"`
ClickCount int64 `json:"clickCount"` // 阅读次数reading_progress
PayCount int64 `json:"payCount"` // 付款笔数orders.product_type=section
HotScore float64 `json:"hotScore"` // 热度积分(加权计算)
IsPinned bool `json:"isPinned,omitempty"` // 是否置顶(仅 ranking 返回)
}
// computeSectionListWithHotScore 计算章节列表(含 hotScore保持 sort_order 顺序,供 章节管理 树使用
func computeSectionListWithHotScore(db *gorm.DB) ([]sectionListItem, error) {
sections, err := computeSectionsWithHotScore(db, false)
if err != nil {
return nil, err
}
return sections, nil
}
// computeArticleRankingSections 统一计算内容排行榜:置顶优先 + 按 hotScore 降序
// 供管理端内容排行榜页与小程序首页精选推荐共用,排序与置顶均在后端计算
func computeArticleRankingSections(db *gorm.DB) ([]sectionListItem, error) {
sections, err := computeSectionsWithHotScore(db, true)
if err != nil {
return nil, err
}
// 读取置顶配置 pinned_section_ids
pinnedIDs := []string{}
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
_ = json.Unmarshal(cfg.ConfigValue, &pinnedIDs)
}
pinnedSet := make(map[string]int) // id -> 置顶顺序
for i, id := range pinnedIDs {
if id != "" {
pinnedSet[id] = i
}
}
// 排序:置顶优先(按置顶顺序),其次按 hotScore 降序
sort.Slice(sections, func(i, j int) bool {
pi, pj := pinnedSet[sections[i].ID], pinnedSet[sections[j].ID]
piOk, pjOk := sections[i].IsPinned, sections[j].IsPinned
if piOk && !pjOk {
return true
}
if !piOk && pjOk {
return false
}
if piOk && pjOk {
return pi < pj
}
return sections[i].HotScore > sections[j].HotScore
})
return sections, nil
}
// computeSectionsWithHotScore 内部:计算 hotScore可选设置 isPinned
func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem, error) {
var rows []model.Chapter
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
return nil, err
}
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
readCountMap := make(map[string]int64)
if len(ids) > 0 {
var rp []struct {
SectionID string `gorm:"column:section_id"`
Cnt int64 `gorm:"column:cnt"`
}
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
Where("section_id IN ?", ids).Group("section_id").Scan(&rp)
for _, r := range rp {
readCountMap[r.SectionID] = r.Cnt
}
}
payCountMap := make(map[string]int64)
if len(ids) > 0 {
var op []struct {
ProductID string `gorm:"column:product_id"`
Cnt int64 `gorm:"column:cnt"`
}
db.Model(&model.Order{}).
Select("product_id, COUNT(*) as cnt").
Where("product_type = ? AND product_id IN ? AND status IN ?", "section", ids, []string{"paid", "completed", "success"}).
Group("product_id").Scan(&op)
for _, r := range op {
payCountMap[r.ProductID] = r.Cnt
}
}
readWeight, payWeight, recencyWeight := 0.5, 0.3, 0.2
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "article_ranking_weights").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
var v struct {
ReadWeight float64 `json:"readWeight"`
RecencyWeight float64 `json:"recencyWeight"`
PayWeight float64 `json:"payWeight"`
}
if err := json.Unmarshal(cfg.ConfigValue, &v); err == nil {
if v.ReadWeight > 0 {
readWeight = v.ReadWeight
}
if v.PayWeight > 0 {
payWeight = v.PayWeight
}
if v.RecencyWeight > 0 {
recencyWeight = v.RecencyWeight
}
}
}
pinnedIDs := []string{}
if setPinned {
var cfg2 model.SystemConfig
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg2).Error; err == nil && len(cfg2.ConfigValue) > 0 {
_ = json.Unmarshal(cfg2.ConfigValue, &pinnedIDs)
}
}
pinnedSet := make(map[string]bool)
for _, id := range pinnedIDs {
if id != "" {
pinnedSet[id] = true
}
}
now := time.Now()
sections := make([]sectionListItem, 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)
}
return sections, nil
}
// DBBookAction GET/POST/PUT /api/db/book
@@ -40,28 +206,20 @@ func DBBookAction(c *gin.Context) {
id := c.Query("id")
switch action {
case "list":
var rows []model.Chapter
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
// 章节管理树:按 sort_order 顺序,含 hotScore
sections, err := computeSectionListWithHotScore(db)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
return
}
sections := make([]sectionListItem, 0, len(rows))
for _, r := range rows {
price := 1.0
if r.Price != nil {
price = *r.Price
}
sections = append(sections, sectionListItem{
ID: r.ID,
Title: r.SectionTitle,
Price: price,
IsFree: r.IsFree,
IsNew: r.IsNew,
PartID: r.PartID,
PartTitle: r.PartTitle,
ChapterID: r.ChapterID,
ChapterTitle: r.ChapterTitle,
})
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
return
case "ranking":
// 内容排行榜:置顶优先 + hotScore 降序,排序由后端统一计算,前端只展示
sections, err := computeArticleRankingSections(db)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
return

View File

@@ -312,6 +312,10 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
if u.VipRole != nil {
vipRole = *u.VipRole
}
vipSort := 0
if u.VipSort != nil {
vipSort = *u.VipSort
}
return gin.H{
"id": u.ID,
"name": name,
@@ -343,12 +347,14 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
"story_achievement": getStringValue(u.StoryAchievement),
"storyTurning": getStringValue(u.StoryTurning),
"story_turning": getStringValue(u.StoryTurning),
"helpOffer": getStringValue(u.HelpOffer),
"helpOffer": getStringValue(u.HelpOffer),
"help_offer": getStringValue(u.HelpOffer),
"helpNeed": getStringValue(u.HelpNeed),
"help_need": getStringValue(u.HelpNeed),
"projectIntro": getStringValue(u.ProjectIntro),
"project_intro": getStringValue(u.ProjectIntro),
"project_intro": getStringValue(u.ProjectIntro),
"vipSort": vipSort,
"vip_sort": vipSort,
"is_vip": isVip,
}
}

View File

@@ -0,0 +1,54 @@
package handler
import (
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// DBVipMembersList GET /api/db/vip-members 管理端 - VIP 成员列表(用于超级个体排序)
// 与小程序端 VipMembers 的列表逻辑保持一致:仅列出仍在有效期内的 VIP 用户。
func DBVipMembersList(c *gin.Context) {
limit := 200
if l := c.Query("limit"); l != "" {
if n, err := parseInt(l); err == nil && n > 0 && n <= 500 {
limit = n
}
}
db := database.DB()
// 与 VipMembers 一致:优先 users 表is_vip=1 且 vip_expire_date>NOW排序使用 vip_sort
var users []model.User
err := db.Table("users").
Select("id", "nickname", "avatar", "vip_name", "vip_role", "vip_project", "vip_avatar", "vip_bio", "vip_activated_at", "vip_sort", "vip_expire_date", "is_vip", "phone", "wechat_id").
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
Order("COALESCE(vip_sort, 999999) ASC, COALESCE(vip_activated_at, vip_expire_date) DESC").
Limit(limit).
Find(&users).Error
if err != nil || len(users) == 0 {
// 兜底:从 orders 查,逻辑与 VipMembers 保持一致
var userIDs []string
db.Model(&model.Order{}).Select("DISTINCT user_id").
Where("(status = ? OR status = ?) AND (product_type = ? OR product_type = ?)", "paid", "completed", "fullbook", "vip").
Pluck("user_id", &userIDs)
if len(userIDs) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}, "total": 0})
return
}
db.Where("id IN ?", userIDs).Find(&users)
}
list := make([]gin.H, 0, len(users))
for i := range users {
list = append(list, formatVipMember(&users[i], true))
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
}

View File

@@ -149,6 +149,7 @@ func Setup(cfg *config.Config) *gin.Engine {
db.POST("/vip-roles", handler.DBVipRolesAction)
db.PUT("/vip-roles", handler.DBVipRolesAction)
db.DELETE("/vip-roles", handler.DBVipRolesAction)
db.GET("/vip-members", handler.DBVipMembersList)
db.GET("/match-records", handler.DBMatchRecordsList)
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
db.GET("/mentors", handler.DBMentorsList)
@@ -267,6 +268,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.POST("/ckb/join", handler.CKBJoin)
miniprogram.POST("/ckb/match", handler.CKBMatch)
miniprogram.POST("/ckb/lead", handler.CKBLead)
miniprogram.POST("/ckb/index-lead", handler.CKBIndexLead)
miniprogram.POST("/upload", handler.UploadPost)
miniprogram.DELETE("/upload", handler.UploadDelete)
miniprogram.GET("/user/addresses", handler.UserAddressesGet)