diff --git a/miniprogram/app.js b/miniprogram/app.js index 7b3f158b..336ad0eb 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -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 } diff --git a/miniprogram/app.json b/miniprogram/app.json index 4e3147da..330b0065 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -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", diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index beaed367..994bf21e 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -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 diff --git a/miniprogram/pages/link-preview/link-preview.js b/miniprogram/pages/link-preview/link-preview.js new file mode 100644 index 00000000..60245778 --- /dev/null +++ b/miniprogram/pages/link-preview/link-preview.js @@ -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' }), + }) + }, +}) + diff --git a/miniprogram/pages/link-preview/link-preview.json b/miniprogram/pages/link-preview/link-preview.json new file mode 100644 index 00000000..1ef3c8e4 --- /dev/null +++ b/miniprogram/pages/link-preview/link-preview.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {} +} + diff --git a/miniprogram/pages/link-preview/link-preview.wxml b/miniprogram/pages/link-preview/link-preview.wxml new file mode 100644 index 00000000..36696e10 --- /dev/null +++ b/miniprogram/pages/link-preview/link-preview.wxml @@ -0,0 +1,28 @@ + + + + + + + + + {{title}} + + + + 复制链接 + + + + + + + + + + + + 暂无链接地址 + + + diff --git a/miniprogram/pages/link-preview/link-preview.wxss b/miniprogram/pages/link-preview/link-preview.wxss new file mode 100644 index 00000000..e47a2e2e --- /dev/null +++ b/miniprogram/pages/link-preview/link-preview.wxss @@ -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; +} + diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index 213b700e..171e48d8 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -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' }) diff --git a/miniprogram/pages/my/my.wxml b/miniprogram/pages/my/my.wxml index ab91fc48..80371793 100644 --- a/miniprogram/pages/my/my.wxml +++ b/miniprogram/pages/my/my.wxml @@ -41,7 +41,7 @@ 匹配 排行 - 微信号: {{userWechat || userIdShort || '--'}} + 微信号: {{userWechat}} diff --git a/miniprogram/pages/profile-edit/profile-edit.js b/miniprogram/pages/profile-edit/profile-edit.js index 7f3ce334..bd93e0cd 100644 --- a/miniprogram/pages/profile-edit/profile-edit.js +++ b/miniprogram/pages/profile-edit/profile-edit.js @@ -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) { diff --git a/miniprogram/pages/profile-edit/profile-edit.wxml b/miniprogram/pages/profile-edit/profile-edit.wxml index f01469a4..8c6d57ad 100644 --- a/miniprogram/pages/profile-edit/profile-edit.wxml +++ b/miniprogram/pages/profile-edit/profile-edit.wxml @@ -17,7 +17,7 @@ - + {{nickname ? nickname[0] : '?'}} @@ -31,7 +31,18 @@ 昵称 - + + + + 微信用户可点击自动填充昵称,或手动输入 @@ -134,4 +145,15 @@ + + + + + + 使用微信头像 + 点击下方按钮,一键同步当前微信头像 + + 取消 + + diff --git a/miniprogram/pages/profile-edit/profile-edit.wxss b/miniprogram/pages/profile-edit/profile-edit.wxss index 29aed01f..ea0ad739 100644 --- a/miniprogram/pages/profile-edit/profile-edit.wxss +++ b/miniprogram/pages/profile-edit/profile-edit.wxss @@ -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; +} diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js index d784641b..6df70dd0 100644 --- a/miniprogram/pages/read/read.js +++ b/miniprogram/pages/read/read.js @@ -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}` }) } }, diff --git a/miniprogram/pages/read/read.wxml b/miniprogram/pages/read/read.wxml index 72837c20..9452353c 100644 --- a/miniprogram/pages/read/read.wxml +++ b/miniprogram/pages/read/read.wxml @@ -85,9 +85,9 @@ - 🖼️ diff --git a/soul-admin/.env.development b/soul-admin/.env.development index f0c51361..2bd247f8 100644 --- a/soul-admin/.env.development +++ b/soul-admin/.env.development @@ -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 diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index a1881967..db04c67e 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -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([]) + const [rankingLoading, setRankingLoading] = useState(false) const [pinnedSectionIds, setPinnedSectionIds] = useState([]) 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(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(null) const richEditorRef = useRef(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() { 内容排行榜 - 按热度排行 · 共 {rankedSections.length} 节 + 按热度排行 · 共 {rankedSectionsList.length} 节
+
{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 (
setNewLinkTag({ ...newLinkTag, pagePath: e.target.value })} />
)} -
- {linkTags.map(t => ( + {linkTags.map((t) => (
- #{t.label} - + + {t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'} - - {t.url} - + {t.url && ( + + {t.url} + + )} +
+
+ +
-
))} {linkTags.length === 0 &&
暂无链接标签,添加后可在编辑器中使用 #标签 跳转
} diff --git a/soul-admin/src/pages/users/UsersPage.tsx b/soul-admin/src/pages/users/UsersPage.tsx index 589e9894..106d4ea4 100644 --- a/soul-admin/src/pages/users/UsersPage.tsx +++ b/soul-admin/src/pages/users/UsersPage.tsx @@ -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(null) const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', sort: 0, enabled: true }) - // ===== VIP 角色 ===== - const [vipRoles, setVipRoles] = useState([]) - const [vipRolesLoading, setVipRolesLoading] = useState(false) - const [showVipRoleModal, setShowVipRoleModal] = useState(false) - const [editingVipRole, setEditingVipRole] = useState(null) - const [vipRoleForm, setVipRoleForm] = useState({ name: '', sort: 0 }) + // ===== 超级个体(VIP 用户列表) ===== + const [vipMembers, setVipMembers] = useState([]) + const [vipMembersLoading, setVipMembersLoading] = useState(false) + const [draggingVipId, setDraggingVipId] = useState(null) + const [dragOverVipId, setDragOverVipId] = useState(null) // ===== 用户旅程总览 ===== const [journeyStats, setJourneyStats] = useState>({}) @@ -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(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(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, id: string) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', id) + setDraggingVipId(id) + } + + const handleVipRowDragOver = (e: React.DragEvent, id: string) => { + e.preventDefault() + if (dragOverVipId !== id) setDragOverVipId(id) + } + + const handleVipRowDragEnd = () => { + setDraggingVipId(null) + setDragOverVipId(null) + } + + const handleVipRowDrop = async (e: React.DragEvent, 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() { 规则配置 - - VIP 角色 + + 超级个体列表 @@ -707,46 +841,136 @@ export function UsersPage() { )} - {/* ===== VIP 角色 ===== */} + {/* ===== 超级个体列表(VIP 用户) ===== */}
-

管理用户 VIP 角色分类,这些角色将在用户详情和会员展示中使用

+
+

+ 展示当前所有有效的超级个体(VIP 用户),用于检查会员信息与排序值。 +

+

+ 提示:按住任意一行即可拖拽排序,释放后将同步更新小程序展示顺序。 +

+
- -
- {vipRolesLoading ? ( -
- ) : vipRoles.length === 0 ? ( + {vipMembersLoading ? ( +
+ + 加载中... +
+ ) : vipMembers.length === 0 ? (
-

暂无 VIP 角色

- +

当前没有有效的超级个体用户。

) : ( -
- {vipRoles.map((role) => ( -
-
-
- - {role.name} -
-
- - -
-
-

排序: {role.sort}

-
- ))} -
+ + + + + + 序号 + 成员 + 超级个体标签 + 排序值 + 操作 + + + + {vipMembers.map((m, index) => { + const isDragging = draggingVipId === m.id + const isOver = dragOverVipId === m.id + return ( + 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' : ''}`} + > + {index + 1} + +
+ {m.avatar ? ( + // eslint-disable-next-line jsx-a11y/alt-text + + ) : ( +
+ {m.name?.[0] || '创'} +
+ )} +
+
{m.name}
+
+
+
+ + {m.vipRole || (未设置超级个体标签)} + + + {m.vipSort ?? index + 1} + + +
+ + + +
+
+
+ )})} +
+
+
+
)}
@@ -754,6 +978,81 @@ export function UsersPage() { {/* ===== 弹框组件 ===== */} {/* 添加/编辑用户 */} + {/* 设置排序 */} + { setShowVipSortModal(open); if (!open) setVipSortModalMember(null) }}> + + + + + 设置排序 — {vipSortModalMember?.name} + + +
+ + setVipSortInput(e.target.value)} + /> +
+ + + + +
+
+ + {/* 设置超级个体标签 */} + { setShowVipRoleModal(open); if (!open) setVipRoleModalMember(null) }}> + + + + + 设置超级个体标签 — {vipRoleModalMember?.name} + + +
+ +
+ {VIP_ROLE_PRESETS.map((preset) => ( + + ))} +
+
+ + setVipRoleInput(e.target.value)} + /> +
+
+ + + + +
+
+ {editingUser ? : }{editingUser ? '编辑用户' : '添加用户'} @@ -788,21 +1087,6 @@ export function UsersPage() { - {/* 添加/编辑 VIP 角色 */} - - - {editingVipRole ? '编辑 VIP 角色' : '添加 VIP 角色'} -
-
setVipRoleForm({ ...vipRoleForm, name: e.target.value })} />
-
setVipRoleForm({ ...vipRoleForm, sort: parseInt(e.target.value) || 0 })} />
-
- - - - -
-
- {/* 绑定关系 */} diff --git a/soul-admin/src/pages/vip-roles/VipRolesPage.tsx b/soul-admin/src/pages/vip-roles/VipRolesPage.tsx index 8f4be8fb..a942914d 100644 --- a/soul-admin/src/pages/vip-roles/VipRolesPage.tsx +++ b/soul-admin/src/pages/vip-roles/VipRolesPage.tsx @@ -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([]) + const [members, setMembers] = useState([]) const [loading, setLoading] = useState(true) - const [showModal, setShowModal] = useState(false) - const [editingRole, setEditingRole] = useState(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 (

- VIP 角色管理 + 用户管理 / 超级个体列表

- 超级个体固定角色,在「设置 VIP」时可选择或手动填写 + 这里展示所有有效超级个体用户,仅用于查看其基本信息与排序值。

-
@@ -146,42 +72,44 @@ export function VipRolesPage() { - ID - 角色名称 - 排序 - 操作 + 序号 + 成员 + 超级个体 + 排序值 - {roles.map((r) => ( - - {r.id} - {r.name} - {r.sort} - - - + {members.map((m, index) => ( + + {index + 1} + +
+ {m.avatar ? ( + // eslint-disable-next-line jsx-a11y/alt-text + + ) : ( +
+ {m.name?.[0] || '创'} +
+ )} +
+
{m.name}
+
+
+ + {m.vipRole || (未设置超级个体)} + + {m.vipSort ?? index + 1}
))} - {roles.length === 0 && ( + {members.length === 0 && ( - - 暂无角色,点击「新增角色」添加 + + 当前没有有效的超级个体用户。 )} @@ -190,54 +118,7 @@ export function VipRolesPage() { )} - - - - - - {editingRole ? '编辑角色' : '新增角色'} - - -
-
- - setFormName(e.target.value)} - /> -
-
- - setFormSort(parseInt(e.target.value, 10) || 0)} - /> -
-
- - - - -
-
) } + diff --git a/soul-admin/tsconfig.tsbuildinfo b/soul-admin/tsconfig.tsbuildinfo index 40e1957d..e899de5d 100644 --- a/soul-admin/tsconfig.tsbuildinfo +++ b/soul-admin/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/soul-api/.env.development b/soul-api/.env.development index 62ead130..2a4efd4c 100644 --- a/soul-api/.env.development +++ b/soul-api/.env.development @@ -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 diff --git a/soul-api/.env.production b/soul-api/.env.production index f4dc0896..f378e8a4 100644 --- a/soul-api/.env.production +++ b/soul-api/.env.production @@ -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 diff --git a/soul-api/internal/handler/book.go b/soul-api/internal/handler/book.go index 9c06b295..4d0a9a24 100644 --- a/soul-api/internal/handler/book.go +++ b/soul-api/internal/handler/book.go @@ -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+"%") diff --git a/soul-api/internal/handler/ckb.go b/soul-api/internal/handler/ckb.go index 65a51b9d..a0338742 100644 --- a/soul-api/internal/handler/ckb.go +++ b/soul-api/internal/handler/ckb.go @@ -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) { diff --git a/soul-api/internal/handler/db_book.go b/soul-api/internal/handler/db_book.go index 5d2405fc..fb58df50 100644 --- a/soul-api/internal/handler/db_book.go +++ b/soul-api/internal/handler/db_book.go @@ -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 diff --git a/soul-api/internal/handler/vip.go b/soul-api/internal/handler/vip.go index 27112bb4..d6e5a097 100644 --- a/soul-api/internal/handler/vip.go +++ b/soul-api/internal/handler/vip.go @@ -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, } } diff --git a/soul-api/internal/handler/vip_members_admin.go b/soul-api/internal/handler/vip_members_admin.go new file mode 100644 index 00000000..9814b909 --- /dev/null +++ b/soul-api/internal/handler/vip_members_admin.go @@ -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)}) +} + diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index c5f41346..16de24fc 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -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)