diff --git a/.cursor/scripts/gitea-sync-launchd.err.log b/.cursor/scripts/gitea-sync-launchd.err.log index 7455b655..a09b9066 100644 --- a/.cursor/scripts/gitea-sync-launchd.err.log +++ b/.cursor/scripts/gitea-sync-launchd.err.log @@ -1 +1,8 @@ /bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted +/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted +/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted +/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted +/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted +/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted +/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted +/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted diff --git a/.cursor/scripts/gitea-sync.log b/.cursor/scripts/gitea-sync.log index 00b9df88..1edae9c5 100644 --- a/.cursor/scripts/gitea-sync.log +++ b/.cursor/scripts/gitea-sync.log @@ -4,3 +4,45 @@ From http://192.168.1.201:3000/fnvtk/soul-yongping * [new branch] main -> gitea-local/main error: cannot pull with rebase: You have unstaged changes. error: Please commit or stash them. +[devlop 28a69cbc] sync: 2026-03-19 14:54 + Committer: 卡若 +Your name and email address were configured automatically based +on your username and hostname. Please check that they are accurate. +You can suppress this message by setting them explicitly: + + git config --global user.name "Your Name" + git config --global user.email you@example.com + +After doing this, you may fix the identity used for this commit with: + + git commit --amend --reset-author + + 26 files changed, 164 insertions(+), 2133 deletions(-) + create mode 100644 .cursor/scripts/README-gitea-sync.md + create mode 100644 .cursor/scripts/gitea-sync-launchd.err.log + create mode 100644 .cursor/scripts/gitea-sync-launchd.log + create mode 100644 .cursor/scripts/gitea-sync.log + create mode 100755 .cursor/scripts/gitea-sync.sh + create mode 100644 project.config.json + delete mode 100644 开发文档/1、需求/文章详情-阅读页线框图.md + delete mode 100644 开发文档/1、需求/链接人与事-所有同步需求.md + delete mode 100644 开发文档/代付功能-美团式方案与场景清单.md + delete mode 100644 开发文档/全站测试报告_20260315.md + delete mode 100644 开发文档/存客宝对接逻辑图.md + delete mode 100644 开发文档/小程序管理/scripts/reports/体验版二维码_soul-party_20260315_2344.png + delete mode 100644 开发文档/小程序管理/scripts/reports/体验版二维码_soul-party_20260316_0221.png + delete mode 100644 开发文档/小程序管理/scripts/reports/体验版二维码_soul-party_20260316_1804.png + delete mode 100644 开发文档/找朋友代付-流程与配置.md + delete mode 100644 开发文档/新版管理端迁移到稳定版-需求评估.md + delete mode 100644 开发文档/新版迁移-开发方案与清单.md + delete mode 100644 开发文档/稳定版-小程序与API对比.md + delete mode 100644 开发文档/稳定版-源码质量分析报告.md + delete mode 100644 开发文档/稳定版-管理端与小程序对接分析.md + delete mode 100644 开发文档/稳定版适配新界面-调整清单.md + delete mode 100644 开发文档/管理端两版界面差异-新需求参考.md + delete mode 100644 开发文档/管理端迁移分析-基于小程序功能.md + delete mode 100644 开发文档/规则引擎迁移-影响分析.md + delete mode 100644 开发文档/迁移完成度与待办清单.md +remote: Failed to authenticate user +fatal: Authentication failed for 'http://192.168.1.201:3000/fnvtk/soul-yongping.git/' +[2026-03-19 14:54:02] --- sync end --- diff --git a/.cursor/scripts/gitea-sync.sh b/.cursor/scripts/gitea-sync.sh index 418c4316..1cc4c19a 100755 --- a/.cursor/scripts/gitea-sync.sh +++ b/.cursor/scripts/gitea-sync.sh @@ -14,13 +14,16 @@ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; } log "--- sync start (branch=$BRANCH, remote=$REMOTE) ---" -# 1. 拉取远程更新(若远程无此分支则仅 fetch) +# 1. 拉取远程更新(若有未提交变更则先 stash,pull 后再 pop) +STASHED="" +if [ -n "$(git status -s)" ]; then + git stash push -u -m "gitea-sync $(date +%s)" 2>/dev/null && STASHED=1 || true +fi git fetch "$REMOTE" 2>&1 | tee -a "$LOG_FILE" || true if git ls-remote --exit-code --heads "$REMOTE" "$BRANCH" &>/dev/null; then git pull "$REMOTE" "$BRANCH" --no-edit 2>&1 | tee -a "$LOG_FILE" || log "pull 失败或冲突,继续尝试推送本地变更" -else - log "远程无 $REMOTE/$BRANCH,仅 fetch" fi +[ -n "$STASHED" ] && git stash pop 2>/dev/null || true # 2. 若有本地未提交变更,则提交并推送 STATUS=$(git status -s) diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 2ea75040..bf059ba1 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -33,7 +33,7 @@ Page({ // 最新章节(动态计算) latestSection: null, - latestLabel: '最新更新', + latestLabel: '推荐', // 内容概览 partsList: [ @@ -135,29 +135,63 @@ Page({ async loadSuperMembers() { this.setData({ superMembersLoading: true }) try { - // 并行请求 VIP 会员和普通用户,合并后取前 4 个(VIP 优先) - const [vipRes, usersRes] = await Promise.all([ + const [pinnedRes, vipRes, usersRes] = await Promise.all([ + app.request({ url: '/api/miniprogram/persons/pinned', silent: true }).catch(() => null), app.request({ url: '/api/miniprogram/vip/members', silent: true }).catch(() => null), app.request({ url: '/api/miniprogram/users?limit=20', silent: true }).catch(() => null) ]) let members = [] - if (vipRes && vipRes.success && Array.isArray(vipRes.data) && vipRes.data.length > 0) { - members = vipRes.data.slice(0, 4).map(u => ({ - id: u.id, - name: u.nickname || u.vipName || u.vip_name || '会员', - avatar: u.avatar || '', - isVip: true - })) - if (members.length > 0) console.log('[Index] 超级个体加载成功:', members.length, '人') + const usedIds = new Set() + + // 1. 后台置顶人物优先(最多 4 个) + if (pinnedRes && pinnedRes.success && Array.isArray(pinnedRes.persons)) { + pinnedRes.persons.slice(0, 4).forEach(p => { + const id = p.userId || p.personId + members.push({ + id, + personId: p.personId, + name: p.nickname || p.name || '置顶', + avatar: p.avatar || '', + isVip: true, + isPinned: true + }) + usedIds.add(id) + }) } + + // 2. VIP 会员补位 + if (members.length < 4 && vipRes && vipRes.success && Array.isArray(vipRes.data)) { + vipRes.data.forEach(u => { + if (members.length >= 4) return + if (usedIds.has(u.id)) return + members.push({ + id: u.id, + name: u.nickname || u.vipName || u.vip_name || '会员', + avatar: u.avatar || '', + isVip: true, + isPinned: false + }) + usedIds.add(u.id) + }) + } + + // 3. 普通用户兜底 if (members.length < 4 && usersRes && usersRes.success && Array.isArray(usersRes.data)) { - const existIds = new Set(members.map(m => m.id)) - const extra = usersRes.data - .filter(u => u.avatar && u.nickname && !existIds.has(u.id)) - .slice(0, 4 - members.length) - .map(u => ({ id: u.id, name: u.nickname, avatar: u.avatar, isVip: u.is_vip === 1 })) - members = members.concat(extra) + usersRes.data + .filter(u => u.avatar && u.nickname && !usedIds.has(u.id)) + .forEach(u => { + if (members.length >= 4) return + members.push({ + id: u.id, + name: u.nickname, + avatar: u.avatar, + isVip: u.is_vip === 1, + isPinned: false + }) + }) } + + if (members.length > 0) console.log('[Index] 超级个体加载成功:', members.length, '人 (置顶', members.filter(m => m.isPinned).length, '人)') this.setData({ superMembers: members, superMembersLoading: false }) } catch (e) { console.log('[Index] 加载超级个体失败:', e) diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index b6040be0..2b419fb3 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -38,18 +38,18 @@ @@ -70,15 +70,16 @@ - + {{item.name[0] || '会'}} + {{item.name}} diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index 625d679a..cab55e0d 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -634,10 +634,11 @@ gap: 10rpx; } .super-avatar { + position: relative; width: 108rpx; height: 108rpx; border-radius: 50%; - overflow: hidden; + overflow: visible; background: rgba(0,206,209,0.1); display: flex; align-items: center; @@ -648,10 +649,33 @@ border: 3rpx solid #FFD700; box-shadow: 0 0 12rpx rgba(255,215,0,0.3); } +.super-avatar-pinned { + border: 3rpx solid #38bdac; + box-shadow: 0 0 16rpx rgba(56, 189, 172, 0.4); +} +.super-item-pinned .super-name { + color: #38bdac; +} +.pinned-badge { + position: absolute; + bottom: -4rpx; + right: -4rpx; + width: 28rpx; + height: 28rpx; + background: #38bdac; + border-radius: 50%; + font-size: 18rpx; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} .super-avatar-img { width: 100%; height: 100%; object-fit: cover; + border-radius: 50%; } .super-avatar-text { font-size: 40rpx; diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js index 6e405af1..0214dce8 100644 --- a/miniprogram/pages/read/read.js +++ b/miniprogram/pages/read/read.js @@ -13,12 +13,13 @@ * - contentSegments 解析每行,mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead */ -import accessManager from '../../utils/chapterAccessManager' -import readingTracker from '../../utils/readingTracker' +const accessManager = require('../../utils/chapterAccessManager') +const readingTracker = require('../../utils/readingTracker') const { parseScene } = require('../../utils/scene.js') const contentParser = require('../../utils/contentParser.js') -const { trackClick } = require('../../utils/trackClick') +const { trackClick } = require('../../utils/trackClick') +const { checkAndExecute } = require('../../utils/ruleEngine') const app = getApp() Page({ @@ -62,84 +63,62 @@ Page({ // 价格 sectionPrice: 1, fullBookPrice: 9.9, - totalSections: 62, + totalSections: 0, // 弹窗 showShareModal: false, - showGiftModal: false, - giftQuantity: 6, - giftUnitPrice: 0, - giftTotalPrice: '0.00', - giftPaying: false, - giftPaid: false, - giftRequestSn: '', showLoginModal: false, agreeProtocol: false, showPosterModal: false, isPaying: false, isGeneratingPoster: false, + showShareTip: false, + _shareTipShown: false, + _lastScrollTop: 0, // 章节 mid(扫码/海报分享用,便于分享 path 带 mid) - sectionMid: null, - - // 余额(用于余额支付) - walletBalance: 0, - - // 审核模式:隐藏购买按钮 - auditMode: false, - - // 好友从代付分享进入:待自动领取的 requestSn - pendingGiftRequestSn: '', - }, - - onShow() { - this.setData({ auditMode: app.globalData.auditMode || false }) + sectionMid: null }, async onLoad(options) { - wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] }) + wx.showShareMenu({ withShareTimeline: true }) - // 预加载:core+auditMode(getConfig)+ read-extras 懒加载(linkTags、linkedMiniprograms) - Promise.all([ - app.getConfig(), - app.getReadExtras() - ]).then(([cfg, extras]) => { - if (cfg) { - const mp = (cfg && cfg.mpConfig) || {} - const auditMode = !!mp.auditMode - app.globalData.auditMode = auditMode - if (typeof this.setData === 'function') this.setData({ auditMode }) - } - if (extras && Array.isArray(extras.linkTags)) { - app.globalData.linkTagsConfig = extras.linkTags - app.globalData.linkedMiniprograms = extras.linkedMiniprograms || [] - } - }).catch(() => {}) + // 预加载 linkTags、linkedMiniprograms、persons(供 onLinkTagTap / onMentionTap 和内容自动匹配用) + if (!app.globalData.linkTagsConfig || !app.globalData.linkedMiniprograms || !app.globalData.personsConfig) { + try { + const cfg = await app.request({ url: '/api/miniprogram/config', silent: true }) + if (cfg) { + if (Array.isArray(cfg.linkTags)) app.globalData.linkTagsConfig = cfg.linkTags + if (Array.isArray(cfg.linkedMiniprograms)) app.globalData.linkedMiniprograms = cfg.linkedMiniprograms + if (Array.isArray(cfg.persons)) app.globalData.personsConfig = cfg.persons + } + } catch (e) {} + } - // 支持 scene(扫码)、mid、id、ref、gift(代付) + // 支持 scene(扫码)、mid、id、ref const sceneStr = (options && options.scene) || '' const parsed = parseScene(sceneStr) - const isGift = options.gift === '1' || options.gift === 'true' - // 代付:分享链路使用 requestSn(优先 options.requestSn;兼容旧链路 gift=1&ref=requestSn) - const giftRequestSn = (options.requestSn || (isGift ? (options.ref || parsed.ref) : '') || '').trim() - // 推荐码:仅在非代付链路使用 ref - const ref = (!isGift ? (options.ref || parsed.ref) : '') || '' const mid = options.mid ? parseInt(options.mid, 10) : (parsed.mid || app.globalData.initialSectionMid || 0) let id = options.id || parsed.id || app.globalData.initialSectionId + const ref = options.ref || parsed.ref if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid if (app.globalData.initialSectionId) delete app.globalData.initialSectionId - console.log("页面:",mid); - - // 兼容:mid 有值但无 id 时,用 by-mid 解析 id;有 id 无 mid 时,后续用 by-id 请求 + // mid 有值但无 id 时,从 bookData 或 API 解析 id if (mid && !id) { - try { - const resolveUrl = `/api/miniprogram/book/chapter/by-mid/${mid}` - const uid = app.globalData.userInfo?.id - const chRes = await app.request({ url: uid ? resolveUrl + '?userId=' + encodeURIComponent(uid) : resolveUrl, silent: true }) - if (chRes && chRes.id) id = chRes.id - } catch (e) { - console.warn('[Read] by-mid 解析失败:', e) + const bookData = app.globalData.bookData || [] + const ch = bookData.find(c => c.mid == mid || (c.mid && Number(c.mid) === Number(mid))) + if (ch?.id) { + id = ch.id + } else { + try { + const resolveUrl = `/api/miniprogram/book/chapter/by-mid/${mid}` + const uid = app.globalData.userInfo?.id + const chRes = await app.request({ url: uid ? resolveUrl + '?userId=' + encodeURIComponent(uid) : resolveUrl, silent: true }) + if (chRes && chRes.id) id = chRes.id + } catch (e) { + console.warn('[Read] by-mid 解析失败:', e) + } } } @@ -155,8 +134,7 @@ Page({ sectionId: id, sectionMid: mid || null, loading: true, - accessState: 'unknown', - pendingGiftRequestSn: giftRequestSn || '' + accessState: 'unknown' }) if (ref) { @@ -165,6 +143,11 @@ Page({ app.handleReferralCode({ query: { ref } }) } + const giftCode = options.gift || '' + if (giftCode) { + this._pendingGiftCode = giftCode + } + try { const config = await accessManager.fetchLatestConfig() this.setData({ @@ -174,8 +157,8 @@ Page({ // 统一:先拉章节数据,用 isFree/price===0 判断免费 const chapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true }) - let accessState = await accessManager.determineAccessState(id, chapterRes) - let canAccess = accessManager.canAccessFullContent(accessState) + const accessState = await accessManager.determineAccessState(id, chapterRes) + const canAccess = accessManager.canAccessFullContent(accessState) this.setData({ accessState, @@ -186,31 +169,36 @@ Page({ // 加载内容(复用已拉取的章节数据,避免二次请求) await this.loadContent(id, accessState, chapterRes) - - // 代付自动领取:好友打开阅读页时自动领取并解锁 - if (this.data.pendingGiftRequestSn) { - const redeemed = await this._tryAutoRedeemGift(this.data.pendingGiftRequestSn) - if (redeemed) { - // 领取成功后刷新章节与权限(保守:重新拉章节数据 + 重新判断权限) - await accessManager.refreshUserPurchaseStatus() - const freshChapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true }) - accessState = await accessManager.determineAccessState(id, freshChapterRes) - canAccess = accessManager.canAccessFullContent(accessState) - this.setData({ accessState, canAccess, showPaywall: !canAccess, pendingGiftRequestSn: '' }) - if (canAccess) { - await this.loadContent(id, accessState, freshChapterRes) - readingTracker.init(id) - } - } - } + // 自动领取礼物码(代付解锁) + if (this._pendingGiftCode && !canAccess && app.globalData.isLoggedIn) { + await this._redeemGiftCode(this._pendingGiftCode) + this._pendingGiftCode = null + return + } + // 【标准流程】4. 如果有权限,初始化阅读追踪 if (canAccess) { readingTracker.init(id) } - // 5. 导航:文章详情已带 prev/next - this._applyPrevNext(chapterRes) + // 5. 加载导航 + this.loadNavigation(id) + + // 6. 规则引擎:阅读前检查(填头像、绑手机等) + checkAndExecute('before_read', this) + + // 7. 记录浏览行为到 user_tracks + const userId = app.globalData.userInfo?.id + if (userId) { + app.request('/api/miniprogram/track', { + method: 'POST', + data: { userId, action: 'view_chapter', target: id, extraData: { sectionId: id, mid: mid || '' } }, + silent: true + }).catch(() => {}) + // 更新全局阅读计数 + app.globalData.readCount = (app.globalData.readCount || 0) + 1 + } } catch (e) { console.error('[Read] 初始化失败:', e) @@ -220,58 +208,6 @@ Page({ this.setData({ loading: false }) } }, - - _getGiftUnitPrice() { - const p = this.data.section?.price - const cfg = this.data.sectionPrice - const v = (p != null && p !== '') ? Number(p) : Number(cfg || 0) - return isNaN(v) ? 0 : v - }, - - _updateGiftTotalPrice() { - const unit = this.data.giftUnitPrice || this._getGiftUnitPrice() - const q = parseInt(this.data.giftQuantity, 10) || 0 - const total = unit * q - this.setData({ - giftUnitPrice: unit, - giftTotalPrice: (isNaN(total) ? 0 : total).toFixed(2) - }) - }, - - async _tryAutoRedeemGift(requestSn) { - // 单页模式(朋友圈)不做自动领取,避免隐式登录/支付能力限制 - try { - const sys = wx.getSystemInfoSync() - const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode - if (isSinglePage) return false - } catch (e) {} - - const userId = app.globalData.userInfo?.id - if (!userId) { - // 记住 requestSn,登录后自动领取 - this.setData({ pendingGiftRequestSn: requestSn }) - wx.showToast({ title: '登录后将自动领取并解锁', icon: 'none', duration: 2500 }) - this.showLoginModal() - return false - } - try { - const res = await app.request({ - url: '/api/miniprogram/gift-pay/redeem', - method: 'POST', - data: { requestSn, userId } - }) - if (res && res.success) return true - // 已领取/已无名额等都视为无需再重试 - if (res && (res.error || res.message)) { - wx.showToast({ title: res.error || res.message || '领取失败', icon: 'none' }) - } - this.setData({ pendingGiftRequestSn: '' }) - return false - } catch (e) { - console.warn('[Read][Gift] 自动领取失败:', e) - return false - } - }, // 从后端加载免费章节配置 onPageScroll(e) { @@ -280,6 +216,11 @@ Page({ return } + const currentScrollTop = e.scrollTop || 0 + const lastScrollTop = this.data._lastScrollTop || 0 + const isScrollingDown = currentScrollTop < lastScrollTop + this.setData({ _lastScrollTop: currentScrollTop }) + // 获取滚动信息并更新追踪器 const query = wx.createSelectorQuery() query.select('.page').boundingClientRect() @@ -298,6 +239,12 @@ Page({ ? Math.min((scrollInfo.scrollTop / totalScrollable) * 100, 100) : 0 this.setData({ readingProgress: progress }) + + // 阅读超过20%且向上滑动时,弹出一次分享提示 + if (progress >= 20 && isScrollingDown && !this.data._shareTipShown) { + this.setData({ showShareTip: true, _shareTipShown: true }) + setTimeout(() => { this.setData({ showShareTip: false }) }, 4000) + } // 更新阅读追踪器(记录最大进度、判断是否读完) readingTracker.updateProgress(scrollInfo) @@ -325,7 +272,8 @@ Page({ // 已解锁用 data.content(完整内容),未解锁用 content(预览);先 determineAccessState 再 loadContent 保证顺序正确 const displayContent = accessManager.canAccessFullContent(accessState) ? (res.data?.content ?? res.content) : res.content if (res && displayContent) { - const { lines, segments } = contentParser.parseContent(displayContent) + const parserConfig = { persons: app.globalData.personsConfig || [], linkTags: app.globalData.linkTagsConfig || [] } + const { lines, segments } = contentParser.parseContent(displayContent, parserConfig) // 预览内容由后端统一截取比例,这里展示全部预览内容 const previewCount = lines.length const updates = { @@ -349,7 +297,8 @@ Page({ try { const cached = wx.getStorageSync(cacheKey) if (cached && cached.content) { - const { lines, segments } = contentParser.parseContent(cached.content) + const cachedParserConfig = { persons: app.globalData.personsConfig || [], linkTags: app.globalData.linkTagsConfig || [] } + const { lines, segments } = contentParser.parseContent(cached.content, cachedParserConfig) // 预览内容由后端统一截取比例,这里展示全部预览内容 const previewCount = lines.length this.setData({ @@ -372,52 +321,34 @@ Page({ // 获取章节信息 getSectionInfo(id) { - // 特殊章节 - if (id === 'preface') { - return { id: 'preface', title: '为什么我每天早上6点在Soul开播?', isFree: true, price: 0 } - } - if (id === 'epilogue') { - return { id: 'epilogue', title: '这本书的真实目的', isFree: true, price: 0 } - } - if (id.startsWith('appendix')) { - const appendixTitles = { - 'appendix-1': 'Soul派对房精选对话', - 'appendix-2': '创业者自检清单', - 'appendix-3': '本书提到的工具和资源' + const cachedSection = (app.globalData.bookData || []).find((item) => item.id === id) + if (cachedSection) { + return { + id, + title: cachedSection.sectionTitle || cachedSection.section_title || cachedSection.title || cachedSection.chapterTitle || `章节 ${id}`, + isFree: cachedSection.isFree === true || cachedSection.is_free === true || cachedSection.price === 0, + price: cachedSection.price ?? 1 } - return { id, title: appendixTitles[id] || '附录', isFree: true, price: 0 } } - - // 普通章节 + return { - id: id, + id, title: this.getSectionTitle(id), - isFree: id === '1.1', + isFree: false, price: 1 } }, // 获取章节标题 getSectionTitle(id) { - const titles = { - '1.1': '荷包:电动车出租的被动收入模式', - '1.2': '老墨:资源整合高手的社交方法', - '1.3': '笑声背后的MBTI', - '1.4': '人性的三角结构:利益、情感、价值观', - '1.5': '沟通差的问题:为什么你说的别人听不懂', - '2.1': '相亲故事:你以为找的是人,实际是在找模式', - '2.2': '找工作迷茫者:为什么简历解决不了人生', - '2.3': '撸运费险:小钱困住大脑的真实心理', - '2.4': '游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力', - '2.5': '健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒', - '3.1': '3000万流水如何跑出来(退税模式解析)', - '8.1': '流量杠杆:抖音、Soul、飞书', - '9.14': '大健康私域:一个月150万的70后' + const cachedSection = (app.globalData.bookData || []).find((item) => item.id === id) + if (cachedSection) { + return cachedSection.sectionTitle || cachedSection.section_title || cachedSection.title || cachedSection.chapterTitle || `章节 ${id}` } - return titles[id] || `章节 ${id}` + return `章节 ${id}` }, - // 根据 id/mid 构造章节接口路径:优先 mid(by-mid),否则用 id(by-id,兼容旧链接) + // 根据 id/mid 构造章节接口路径(优先使用 mid)。必须带 userId 才能让后端正确判断付费用户并返回完整内容 _getChapterUrl(params = {}) { const { id, mid } = params const finalMid = (mid !== undefined && mid !== null) ? mid : this.data.sectionMid @@ -426,7 +357,7 @@ Page({ url = `/api/miniprogram/book/chapter/by-mid/${finalMid}` } else { const finalId = id || this.data.sectionId - url = `/api/miniprogram/book/chapter/by-id/${encodeURIComponent(finalId)}` + url = `/api/miniprogram/book/chapter/${finalId}` } const userId = app.globalData.userInfo?.id if (userId) url += (url.includes('?') ? '&' : '?') + 'userId=' + encodeURIComponent(userId) @@ -521,21 +452,47 @@ Page({ }, - _applyPrevNext(res) { - const prev = res?.prev - const next = res?.next - this.setData({ - prevSection: prev ? { - id: prev.id, - mid: prev.mid ?? null, - title: prev.title || this.getSectionTitle(prev.id), - } : null, - nextSection: next ? { - id: next.id, - mid: next.mid ?? null, - title: next.title || this.getSectionTitle(next.id), - } : 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 }) + } }, // 返回(从分享进入无栈时回首页) @@ -562,33 +519,53 @@ Page({ } } - // CKB 类型:复用 @mention 加好友流程,弹出留资表单 + // CKB 类型:走「链接卡若」留资流程(与首页 onLinkKaruo 一致) if (tagType === 'ckb') { - // 触发通用加好友(无特定 personId,使用全局 CKB Key) - this.onMentionTap({ currentTarget: { dataset: { userId: '', nickname: label } } }) + this._doCkbLead(label) return } - // 小程序类型:用密钥查 linkedMiniprograms 得 appId,再唤醒(需在 app.json 的 navigateToMiniProgramAppIdList 中配置) + // 小程序类型:先查 linkedMiniprograms 得 appId,降级直接用 mpKey/appId 字段 if (tagType === 'miniprogram') { + let appId = (e.currentTarget.dataset.appId || '').trim() if (!mpKey && label) { const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label) - if (cached) mpKey = cached.mpKey || '' + if (cached) { + mpKey = cached.mpKey || '' + if (!appId && cached.appId) appId = cached.appId + } } const linked = (app.globalData.linkedMiniprograms || []).find(m => m.key === mpKey) - if (linked && linked.appId) { + const targetAppId = (linked && linked.appId) ? linked.appId : (appId || mpKey || '') + const selfAppId = (app.globalData.config?.mpConfig?.appId || app.globalData.appId || 'wxb8bbb2b10dec74aa') + const targetPath = pagePath || (linked && linked.path) || '' + if (targetAppId === selfAppId || !targetAppId) { + if (targetPath) { + const navPath = targetPath.startsWith('/') ? targetPath : '/' + targetPath + wx.navigateTo({ url: navPath, fail: () => wx.switchTab({ url: navPath }) }) + } else { + wx.switchTab({ url: '/pages/index/index' }) + } + return + } + if (targetAppId) { wx.navigateToMiniProgram({ - appId: linked.appId, - path: pagePath || linked.path || '', + appId: targetAppId, + path: targetPath, envVersion: 'release', success: () => {}, fail: (err) => { - wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' }) + console.warn('[LinkTag] 小程序跳转失败:', err) + if (targetPath) { + wx.navigateTo({ url: targetPath.startsWith('/') ? targetPath : '/' + targetPath, fail: () => {} }) + } else { + wx.showToast({ title: '跳转失败,请检查小程序配置', icon: 'none' }) + } }, }) return } - if (mpKey) wx.showToast({ title: '未找到关联小程序配置', icon: 'none' }) + wx.showToast({ title: '未配置关联小程序', icon: 'none' }) } // 小程序内部路径(pagePath 或 url 以 /pages/ 开头) @@ -620,9 +597,17 @@ Page({ // 点击正文中的 @某人:确认弹窗 → 登录/资料校验 → 调用 ckb/lead 加好友留资 onMentionTap(e) { - const userId = e.currentTarget.dataset.userId + let userId = e.currentTarget.dataset.userId const nickname = (e.currentTarget.dataset.nickname || '').trim() || 'TA' - if (!userId) return + if (!userId && nickname !== 'TA') { + const persons = app.globalData.personsConfig || [] + const match = persons.find(p => p.name === nickname || (p.aliases || '').split(',').map(a => a.trim()).includes(nickname)) + if (match) userId = match.personId || '' + } + if (!userId) { + wx.showToast({ title: `暂无 @${nickname} 的信息`, icon: 'none' }) + return + } wx.showModal({ title: '添加好友', content: `是否添加 @${nickname} ?`, @@ -653,19 +638,21 @@ Page({ const myUserId = app.globalData.userInfo.id let phone = (app.globalData.userInfo.phone || '').trim() let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim() + let avatar = (app.globalData.userInfo.avatar || app.globalData.userInfo.avatarUrl || '').trim() if (!phone && !wechatId) { try { const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true }) if (profileRes?.success && profileRes.data) { phone = (profileRes.data.phone || '').trim() wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim() + if (!avatar) avatar = (profileRes.data.avatar || '').trim() } } catch (e) {} } - if (!phone && !wechatId) { + if ((!phone && !wechatId) || !avatar) { wx.showModal({ title: '完善资料', - content: '请先填写手机号或微信号,以便对方联系您', + content: !avatar ? '请先设置头像和填写联系方式,以便对方联系您' : '请先填写手机号或微信号,以便对方联系您', confirmText: '去填写', cancelText: '取消', success: (res) => { @@ -674,12 +661,6 @@ Page({ }) return } - // 2 分钟内只能点一次(与后端限频一致,与首页链接卡若共用) - const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0 - if (Date.now() - leadLastTs < 2 * 60 * 1000) { - wx.showToast({ title: '操作太频繁,请2分钟后再试', icon: 'none' }) - return - } wx.showLoading({ title: '提交中...', mask: true }) try { const res = await app.request({ @@ -697,8 +678,84 @@ Page({ }) wx.hideLoading() if (res && res.success) { - wx.setStorageSync('lead_last_submit_ts', Date.now()) - wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' }) + const who = targetNickname || '对方' + wx.showModal({ + title: '提交成功', + content: `${who} 会主动添加你微信,请注意你的微信消息`, + showCancel: false, + confirmText: '好的' + }) + } else { + wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' }) + } + } catch (e) { + wx.hideLoading() + wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' }) + } + }, + + async _doCkbLead(label) { + const app = getApp() + if (!app.globalData.isLoggedIn || !app.globalData.userInfo) { + wx.showModal({ + title: '提示', + content: '请先登录后再链接', + confirmText: '去登录', + cancelText: '取消', + success: (res) => { + if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) + } + }) + return + } + const userId = app.globalData.userInfo.id + let phone = (app.globalData.userInfo.phone || '').trim() + let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim() + let avatar = (app.globalData.userInfo.avatar || app.globalData.userInfo.avatarUrl || '').trim() + if (!phone && !wechatId) { + try { + const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }) + if (profileRes?.success && profileRes.data) { + phone = (profileRes.data.phone || '').trim() + wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim() + if (!avatar) avatar = (profileRes.data.avatar || '').trim() + } + } catch (e) {} + } + if ((!phone && !wechatId) || !avatar) { + wx.showModal({ + title: '完善资料', + content: !avatar ? '请先设置头像和填写联系方式,以便对方联系您' : '请先填写手机号或微信号,以便对方联系您', + confirmText: '去填写', + cancelText: '取消', + success: (res) => { + if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) + } + }) + return + } + wx.showLoading({ title: '提交中...', mask: true }) + try { + const res = await app.request({ + url: '/api/miniprogram/ckb/index-lead', + method: 'POST', + data: { + userId, + phone: phone || undefined, + wechatId: wechatId || undefined, + name: (app.globalData.userInfo.nickname || '').trim() || undefined, + source: 'article_ckb_tag', + tagLabel: label || undefined + } + }) + wx.hideLoading() + if (res && res.success) { + wx.showModal({ + title: '提交成功', + content: '卡若会主动添加你微信,请注意你的微信消息', + showCancel: false, + confirmText: '好的' + }) } else { wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' }) } @@ -717,136 +774,6 @@ Page({ this.setData({ showShareModal: false }) }, - // 代付分享:直接跳转代付页,在代付页输入数量并支付(简化流程) - showGiftShareModal() { - if (!app.globalData.userInfo?.id) { - wx.showToast({ title: '请先登录', icon: 'none' }) - return - } - const { sectionId } = this.data - if (!sectionId) { - wx.showToast({ title: '章节信息异常', icon: 'none' }) - return - } - this.setData({ - showGiftModal: true, - giftPaid: false, - giftRequestSn: '', - giftPaying: false, - giftQuantity: 6 - }) - this._updateGiftTotalPrice() - }, - - closeGiftModal() { - this.setData({ showGiftModal: false }) - }, - - selectGiftQuantity(e) { - const q = parseInt(e.currentTarget.dataset.q, 10) - if (!q || q < 1) return - this.setData({ giftQuantity: q }) - this._updateGiftTotalPrice() - }, - - async confirmGiftPay() { - if (this.data.giftPaying) return - // 朋友圈单页模式禁止支付 - try { - const sys = wx.getSystemInfoSync() - const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode - if (isSinglePage) { - wx.showModal({ - title: '朋友圈单页', - content: '当前为朋友圈单页,无法发起代付支付。请点击底部「前往小程序」进入完整版后再操作。', - showCancel: false - }) - return - } - } catch (e) {} - - const userId = app.globalData.userInfo?.id - if (!userId) { - wx.showToast({ title: '请先登录', icon: 'none' }) - return - } - const sectionId = this.data.sectionId - const quantity = parseInt(this.data.giftQuantity, 10) - if (!sectionId || !quantity) { - wx.showToast({ title: '参数异常', icon: 'none' }) - return - } - - let openId = app.globalData.openId || wx.getStorageSync('openId') - if (!openId) { - wx.showLoading({ title: '获取支付凭证...', mask: true }) - openId = await app.getOpenId() - wx.hideLoading() - } - if (!openId) { - wx.showToast({ title: '请先登录', icon: 'none' }) - return - } - - this.setData({ giftPaying: true }) - wx.showLoading({ title: '创建订单中...', mask: true }) - try { - // 1) 创建代付请求 - const createRes = await app.request({ - url: '/api/miniprogram/gift-pay/create', - method: 'POST', - data: { userId, productType: 'section', productId: sectionId, quantity } - }) - if (!createRes?.success || !createRes.requestSn) { - throw new Error(createRes?.error || '创建失败') - } - const requestSn = createRes.requestSn - - // 2) 发起人支付(微信支付) - const payRes = await app.request({ - url: '/api/miniprogram/gift-pay/initiator-pay', - method: 'POST', - data: { requestSn, openId, userId } - }) - wx.hideLoading() - if (!payRes || !payRes.success || !payRes.data?.payParams) { - throw new Error(payRes?.error || '创建订单失败') - } - const payParams = payRes.data.payParams - const orderSn = payRes.data.orderSn - await new Promise((resolve, reject) => { - wx.requestPayment({ - timeStamp: payParams.timeStamp, - nonceStr: payParams.nonceStr, - package: payParams.package, - signType: payParams.signType || 'RSA', - paySign: payParams.paySign, - success: resolve, - fail: reject - }) - }) - - // 3) 主动同步(与其他支付流程一致) - if (orderSn) { - try { - await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true }) - } catch (e) {} - } - - wx.showToast({ title: '支付成功', icon: 'success' }) - this.setData({ giftPaid: true, giftRequestSn: requestSn, giftPaying: false }) - } catch (e) { - wx.hideLoading() - const msg = e?.message || e?.error || e?.errMsg || '支付失败' - if (e?.errMsg && String(e.errMsg).includes('cancel')) { - wx.showToast({ title: '已取消支付', icon: 'none' }) - } else { - wx.showToast({ title: msg, icon: 'none', duration: 2500 }) - } - this.setData({ giftPaying: false }) - } - }, - // 复制链接 copyLink() { const userInfo = app.globalData.userInfo @@ -864,16 +791,15 @@ Page({ // 复制分享文案(朋友圈风格) copyShareText() { - const { section } = this.data - - const shareText = `🔥 刚看完这篇《${section?.title || 'Soul创业派对'}》,太上头了! - -62个真实商业案例,每个都是从0到1的实战经验。私域运营、资源整合、商业变现,干货满满。 - -推荐给正在创业或想创业的朋友,搜"Soul创业派对"小程序就能看! - -#创业派对 #私域运营 #商业案例` - + const title = this.data.section?.title || this.data.chapterTitle || '好文推荐' + const raw = (this.data.content || '') + .replace(/<[^>]+>/g, '\n') + .replace(/ /g, ' ') + .replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"') + .replace(/[#@]\S+/g, '') + const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4) + const picked = sentences.slice(0, 5) + const shareText = title + '\n\n' + picked.join('\n\n') wx.setClipboardData({ data: shareText, success: () => { @@ -882,40 +808,39 @@ Page({ }) }, - // 分享到微信 - 自动带分享人ID - onShareAppMessage(e) { + // 分享到微信 - 自动带分享人ID;优先用 mid(扫码/海报闭环),无则用 id + onShareAppMessage() { trackClick('read', 'btn_click', '分享_' + this.data.sectionId) const { section, sectionId, sectionMid } = this.data const ref = app.getMyReferralCode() const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}` - // 代付分享按钮(支付后):好友打开阅读页自动领取解锁 - const isGiftShare = e?.from === 'button' && e?.target?.dataset?.gift === '1' - const requestSn = (e?.target?.dataset?.requestSn || '').trim() - if (isGiftShare && requestSn) { - let path = `/pages/read/read?${q}&gift=1&requestSn=${encodeURIComponent(requestSn)}` - if (ref) path += `&ref=${encodeURIComponent(ref)}` - const t = section?.title || 'Soul创业派对' - const title = `我已为你买单:${t.length > 18 ? t.slice(0, 18) + '...' : t}` - return { title, path } - } + const giftCode = this._giftCodeToShare || '' + this._giftCodeToShare = null - const path = ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}` - const title = section?.title + let shareTitle = section?.title ? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}` : '📚 Soul创业派对 - 真实商业故事' - return { title, path } + if (giftCode) shareTitle = `🎁 好友已为你解锁:${section?.title || '精选文章'}` + + let path = `/pages/read/read?${q}` + if (ref) path += `&ref=${ref}` + if (giftCode) path += `&gift=${giftCode}` + + return { title: shareTitle, path } }, - // 底部「分享到朋友圈」按钮点击:微信不支持 button open-type=shareTimeline,只能通过右上角菜单分享,点击时引导用户 - onShareTimelineTap() { - wx.showToast({ - title: '请点击右上角「...」→ 分享到朋友圈', - icon: 'none', - duration: 2500 - }) + // 分享到朋友圈:带文章标题,过长时截断(朋友圈卡片标题显示有限) + onShareTimeline() { + const { section, sectionId, sectionMid, chapterTitle } = this.data + const ref = app.getMyReferralCode() + const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}` + const articleTitle = (section?.title || chapterTitle || '').trim() + const title = articleTitle + ? `📚 ${articleTitle.length > 24 ? articleTitle.slice(0, 24) + '...' : articleTitle}|Soul创业派对` + : '📚 Soul创业派对 - 真实商业故事' + return { title, query: ref ? `${q}&ref=${ref}` : q } }, - // 右下角悬浮按钮:分享到朋友圈(复制文案 + 引导点右上角) shareToMoments() { const title = this.data.section?.title || this.data.chapterTitle || '好文推荐' const raw = (this.data.content || '') @@ -925,7 +850,7 @@ Page({ .replace(/[#@]\S+/g, '') const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4) const picked = sentences.slice(0, 5) - const copyText = picked.length > 0 ? title + '\n\n' + picked.join('\n\n') : `🔥 刚看完这篇《${title}》,推荐给你!\n\n#Soul创业派对 #真实商业故事` + const copyText = title + '\n\n' + picked.join('\n\n') wx.setClipboardData({ data: copyText, success: () => { @@ -942,19 +867,6 @@ Page({ }) }, - // 分享到朋友圈:带文章标题,过长时截断 - onShareTimeline() { - const { section, sectionId, sectionMid, chapterTitle } = this.data - const ref = app.getMyReferralCode() - const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}` - const query = ref ? `${q}&ref=${ref}` : q - const articleTitle = (section?.title || chapterTitle || '').trim() - const title = articleTitle - ? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle) - : 'Soul创业派对 - 真实商业故事' - return { title, query } - }, - // 显示登录弹窗(每次打开协议未勾选,符合审核要求) showLoginModal() { // 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」 @@ -1043,25 +955,6 @@ Page({ wx.showLoading({ title: '更新状态中...', mask: true }) try { - // 0. 若有代付待领取,先领取再刷新购买状态 - if (this.data.pendingGiftRequestSn) { - try { - const userId = app.globalData.userInfo?.id - const requestSn = this.data.pendingGiftRequestSn - if (userId && requestSn) { - const res = await app.request({ - url: '/api/miniprogram/gift-pay/redeem', - method: 'POST', - data: { requestSn, userId } - }) - if (res && res.success) { - this.setData({ pendingGiftRequestSn: '' }) - } - } - } catch (e) { - console.warn('[Read][Gift] 登录后自动领取失败:', e) - } - } // 1. 刷新用户购买状态(从 orders 表拉取最新) await accessManager.refreshUserPurchaseStatus() @@ -1183,39 +1076,6 @@ Page({ wx.showLoading({ title: '正在发起支付...', mask: true }) try { - // 0. 尝试余额支付(若余额足够) - const userId = app.globalData.userInfo?.id - const referralCode = wx.getStorageSync('referral_code') || '' - if (userId) { - try { - const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true }) - const balance = balanceRes?.data?.balance || 0 - if (balance >= amount) { - const productId = type === 'section' ? sectionId : (type === 'fullbook' ? 'fullbook' : '') - const consumeRes = await app.request({ - url: '/api/miniprogram/balance/consume', - method: 'POST', - data: { - userId, - productType: type, - productId: type === 'section' ? sectionId : (type === 'fullbook' ? 'fullbook' : 'vip_annual'), - amount, - referralCode: referralCode || undefined - } - }) - if (consumeRes?.success) { - wx.hideLoading() - this.setData({ isPaying: false }) - wx.showToast({ title: '购买成功', icon: 'success' }) - await this.onPaymentSuccess() - return - } - } - } catch (e) { - console.warn('[Pay] 余额支付失败,改用微信支付:', e) - } - } - // 1. 先获取openId (支付必需) let openId = app.globalData.openId || wx.getStorageSync('openId') @@ -1283,15 +1143,18 @@ Page({ console.error('[Pay] API创建订单失败:', apiError) wx.hideLoading() // 支付接口失败时,显示客服联系方式 + const supportWechat = app.globalData.supportWechat || '' wx.showModal({ title: '支付通道维护中', - content: '微信支付正在审核中,请添加客服微信(28533368)手动购买,感谢理解!', - confirmText: '复制微信号', + content: supportWechat + ? `微信支付正在审核中,请添加客服微信(${supportWechat})手动购买,感谢理解!` + : '微信支付正在审核中,请联系管理员手动购买,感谢理解!', + confirmText: supportWechat ? '复制微信号' : '我知道了', cancelText: '稍后再说', success: (res) => { - if (res.confirm) { + if (res.confirm && supportWechat) { wx.setClipboardData({ - data: '28533368', + data: supportWechat, success: () => { wx.showToast({ title: '微信号已复制', icon: 'success' }) } @@ -1331,15 +1194,18 @@ Page({ wx.showToast({ title: '已取消支付', icon: 'none' }) } else if (payErr.errMsg && payErr.errMsg.includes('requestPayment:fail')) { // 支付失败,可能是参数错误或权限问题 + const supportWechat = app.globalData.supportWechat || '' wx.showModal({ title: '支付失败', - content: '微信支付暂不可用,请添加客服微信(28533368)手动购买', - confirmText: '复制微信号', + content: supportWechat + ? `微信支付暂不可用,请添加客服微信(${supportWechat})手动购买` + : '微信支付暂不可用,请稍后重试或联系管理员', + confirmText: supportWechat ? '复制微信号' : '我知道了', cancelText: '取消', success: (res) => { - if (res.confirm) { + if (res.confirm && supportWechat) { wx.setClipboardData({ - data: '28533368', + data: supportWechat, success: () => wx.showToast({ title: '微信号已复制', icon: 'success' }) }) } @@ -1492,6 +1358,11 @@ Page({ wx.navigateTo({ url: '/pages/referral/referral' }) }, + showPosterModal() { + this.setData({ showPosterModal: true }) + this.generatePoster() + }, + // 生成海报 async generatePoster() { wx.showLoading({ title: '生成中...' }) @@ -1499,15 +1370,14 @@ Page({ try { const ctx = wx.createCanvasContext('posterCanvas', this) - const { section, contentParagraphs, sectionId, sectionMid } = this.data + const { section, contentParagraphs, sectionId } = this.data const userInfo = app.globalData.userInfo const userId = userInfo?.id || '' - // 获取小程序码(带推荐人参数,优先 mid 与新链接一致) + // 获取小程序码(带推荐人参数) let qrcodeImage = null try { - const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}` - const scene = userId ? `${q}&ref=${userId.slice(0,10)}` : q + const scene = userId ? `id=${sectionId}&ref=${userId.slice(0,10)}` : `id=${sectionId}` const qrRes = await app.request('/api/miniprogram/qrcode', { method: 'POST', data: { scene, page: 'pages/read/read', width: 280 } @@ -1657,7 +1527,160 @@ Page({ closePosterModal() { this.setData({ showPosterModal: false }) }, - + + closeShareTip() { + this.setData({ showShareTip: false }) + }, + + // 代付分享:微信支付或余额帮好友解锁当前章节 + async handleGiftPay() { + if (!app.globalData.isLoggedIn || !app.globalData.userInfo) { + wx.showModal({ title: '提示', content: '请先登录', confirmText: '去登录', success: (r) => { if (r.confirm) this.showLoginModal() } }) + return + } + const sectionId = this.data.sectionId + const userId = app.globalData.userInfo.id + const price = (this.data.section && this.data.section.price != null) ? this.data.section.price : (this.data.sectionPrice || 1) + + wx.showModal({ + title: '代付分享', + content: `为好友代付本章 ¥${price}\n支付后将生成代付链接,好友点击即可免费阅读`, + confirmText: '确认代付', + cancelText: '取消', + success: async (res) => { + if (!res.confirm) return + wx.showActionSheet({ + itemList: ['微信支付', '用余额支付'], + success: async (actionRes) => { + if (actionRes.tapIndex === 0) { + this._giftPayViaWechat(sectionId, userId, price) + } else if (actionRes.tapIndex === 1) { + this._giftPayViaBalance(sectionId, userId, price) + } + } + }) + } + }) + }, + + async _giftPayViaWechat(sectionId, userId, price) { + let openId = app.globalData.openId || wx.getStorageSync('openId') + if (!openId) { openId = await app.getOpenId() } + if (!openId) { wx.showToast({ title: '获取支付凭证失败,请重新登录', icon: 'none' }); return } + wx.showLoading({ title: '创建订单...' }) + try { + const payRes = await app.request({ + url: '/api/miniprogram/pay', + method: 'POST', + data: { + openId: openId, + productType: 'gift', + productId: sectionId, + amount: price, + description: `代付解锁:${this.data.section?.title || sectionId}`, + userId: userId, + } + }) + wx.hideLoading() + const params = (payRes && payRes.data && payRes.data.payParams) ? payRes.data.payParams : (payRes && payRes.payParams ? payRes.payParams : null) + if (params) { + wx.requestPayment({ + ...params, + success: async () => { + wx.showLoading({ title: '生成分享链接...' }) + try { + const giftRes = await app.request({ + url: '/api/miniprogram/balance/gift', + method: 'POST', + data: { giverId: userId, sectionId, paidViaWechat: true } + }) + wx.hideLoading() + if (giftRes && giftRes.data && giftRes.data.giftCode) { + this._giftCodeToShare = giftRes.data.giftCode + wx.showModal({ + title: '代付成功', + content: `已为好友代付 ¥${price},分享后好友可免费阅读`, + confirmText: '分享给好友', + cancelText: '稍后分享', + success: (r) => { if (r.confirm) wx.shareAppMessage() } + }) + } else { + wx.showToast({ title: '支付成功,请手动分享', icon: 'none' }) + } + } catch (e) { + wx.hideLoading() + wx.showToast({ title: '支付成功,生成链接失败', icon: 'none' }) + } + }, + fail: () => { wx.showToast({ title: '支付已取消', icon: 'none' }) } + }) + } else { + wx.showToast({ title: '创建支付失败', icon: 'none' }) + } + } catch (e) { + wx.hideLoading() + console.error('[GiftPay] WeChat pay error:', e) + wx.showToast({ title: '支付失败,请重试', icon: 'none' }) + } + }, + + async _giftPayViaBalance(sectionId, userId, price) { + const balRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true }).catch(() => null) + const balance = (balRes && balRes.data) ? balRes.data.balance : 0 + + if (balance < price) { + wx.showModal({ + title: '余额不足', + content: `当前余额 ¥${balance.toFixed(2)},需要 ¥${price}\n请先充值`, + confirmText: '去充值', + cancelText: '取消', + success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/wallet/wallet' }) } + }) + return + } + + wx.showLoading({ title: '处理中...' }) + try { + const giftRes = await app.request({ + url: '/api/miniprogram/balance/gift', + method: 'POST', + data: { giverId: userId, sectionId } + }) + wx.hideLoading() + if (giftRes && giftRes.data && giftRes.data.giftCode) { + this._giftCodeToShare = giftRes.data.giftCode + wx.showModal({ + title: '代付成功', + content: `已从余额扣除 ¥${price},分享后好友可免费阅读`, + confirmText: '分享给好友', + cancelText: '稍后分享', + success: (r) => { if (r.confirm) wx.shareAppMessage() } + }) + } + } catch (e) { + wx.hideLoading() + wx.showToast({ title: '代付失败', icon: 'none' }) + } + }, + + // 领取礼物码解锁 + async _redeemGiftCode(giftCode) { + if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return + try { + const res = await app.request({ + url: '/api/miniprogram/balance/gift/redeem', + method: 'POST', + data: { giftCode, receiverId: app.globalData.userInfo.id } + }) + if (res && res.data) { + wx.showToast({ title: '好友已为你解锁!', icon: 'success' }) + this.onLoad({ id: this.data.sectionId }) + } + } catch (e) { + console.warn('[Gift] 领取失败:', e) + } + }, + // 保存海报到相册 savePoster() { wx.canvasToTempFilePath({ @@ -1741,7 +1764,8 @@ Page({ readingTracker.init(this.data.sectionId) } - this._applyPrevNext(chapterRes) + // 加载导航 + this.loadNavigation(this.data.sectionId) wx.hideLoading() wx.showToast({ title: '加载成功', icon: 'success' }) diff --git a/soul-admin/src/components/RichEditor.tsx b/soul-admin/src/components/RichEditor.tsx index 71856914..d7666528 100644 --- a/soul-admin/src/components/RichEditor.tsx +++ b/soul-admin/src/components/RichEditor.tsx @@ -25,6 +25,7 @@ export interface PersonItem { startTime?: string endTime?: string deviceGroups?: string + isPinned?: boolean } export interface LinkTagItem { diff --git a/soul-admin/src/pages/content/ChapterTree.tsx b/soul-admin/src/pages/content/ChapterTree.tsx index 15d9b3dc..466f9fbd 100644 --- a/soul-admin/src/pages/content/ChapterTree.tsx +++ b/soul-admin/src/pages/content/ChapterTree.tsx @@ -347,8 +347,8 @@ export function ChapterTree({ ) } - // 2026每日派对干货:独立篇章,带六点拖拽、可拖可放(以 part_id 识别,标题来自 DB) - const is2026Daily = part.id === 'part-2026-daily' + // 2026每日派对干货:独立篇章,带六点拖拽、可拖可放 + const is2026Daily = part.title === '2026每日派对干货' || part.title.includes('2026每日派对干货') if (is2026Daily) { const partDragOver = isDragOver('part', part.id) return ( diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index 7469d706..d64c60cc 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -28,7 +28,6 @@ import { DialogFooter, DialogDescription, } from '@/components/ui/dialog' -import { Pagination } from '@/components/ui/Pagination' import { BookOpen, Settings2, @@ -49,7 +48,7 @@ import { Pencil, Smartphone, Copy, - Users, + Zap, } from 'lucide-react' import { LinkedMpPage } from '@/pages/linked-mp/LinkedMpPage' import { get, put, post, del, SAVE_REQUEST_TIMEOUT } from '@/api/client' @@ -160,21 +159,32 @@ function buildTree(sections: SectionListItem[]): Part[] { hotRank: s.hotRank ?? 0, }) } + // 确保「2026每日派对干货」篇章存在(不在第六篇编号体系内) + const DAILY_PART_ID = 'part-2026-daily' + const DAILY_PART_TITLE = '2026每日派对干货' + const hasDailyPart = Array.from(partMap.values()).some((p) => p.title === DAILY_PART_TITLE || p.title.includes(DAILY_PART_TITLE)) + if (!hasDailyPart) { + partMap.set(DAILY_PART_ID, { + id: DAILY_PART_ID, + title: DAILY_PART_TITLE, + chapters: new Map([['chapter-2026-daily', { id: 'chapter-2026-daily', title: DAILY_PART_TITLE, sections: [] }]]), + }) + } const parts = Array.from(partMap.values()).map((p) => ({ ...p, chapters: Array.from(p.chapters.values()), })) - // 固定顺序:序言首位,part-2026-daily(附录前),附录/尾声末位;标题均来自 DB - const orderKey = (p: { id: string; title: string }) => { - if (p.title.includes('序言')) return 0 - if (p.id === 'part-2026-daily') return 1.5 - if (p.title.includes('附录')) return 2 - if (p.title.includes('尾声')) return 3 + // 固定顺序:序言首位,2026每日派对干货(附录前),附录/尾声末位 + const orderKey = (t: string) => { + if (t.includes('序言')) return 0 + if (t.includes(DAILY_PART_TITLE)) return 1.5 + if (t.includes('附录')) return 2 + if (t.includes('尾声')) return 3 return 1 } return parts.sort((a, b) => { - const ka = orderKey(a) - const kb = orderKey(b) + const ka = orderKey(a.title) + const kb = orderKey(b.title) if (ka !== kb) return ka - kb return 0 }) @@ -235,16 +245,10 @@ export function ContentPage() { const [previewPercentSaving, setPreviewPercentSaving] = useState(false) const [persons, setPersons] = useState([]) const [linkTags, setLinkTags] = useState([]) - const [linkTagList, setLinkTagList] = useState([]) - const [linkTagListLoading, setLinkTagListLoading] = useState(false) - const [linkTagPage, setLinkTagPage] = useState(1) - const [linkTagPageSize, setLinkTagPageSize] = useState(20) - const [linkTagTotal, setLinkTagTotal] = useState(0) - const [linkTagTotalPages, setLinkTagTotalPages] = useState(1) - const [linkTagSearch, setLinkTagSearch] = useState('') - const [linkTagModalOpen, setLinkTagModalOpen] = useState(false) - const [linkTagEditing, setLinkTagEditing] = useState(null) - const [linkTagForm, setLinkTagForm] = useState({ + const [personModalOpen, setPersonModalOpen] = useState(false) + const [editingPerson, setEditingPerson] = useState(null) + const [personToDelete, setPersonToDelete] = useState(null) + const [newLinkTag, setNewLinkTag] = useState({ tagId: '', label: '', url: '', @@ -252,19 +256,7 @@ export function ContentPage() { appId: '', pagePath: '', }) - const [linkTagSaving, setLinkTagSaving] = useState(false) - const [personModalOpen, setPersonModalOpen] = useState(false) - const [editingPerson, setEditingPerson] = useState(null) - const [personToDelete, setPersonToDelete] = useState(null) - // CKB 获客统计(按人物 token 聚合) - const [ckbLeadCounts, setCkbLeadCounts] = useState>({}) - const [ckbLeadDetailOpen, setCkbLeadDetailOpen] = useState(false) - const [ckbLeadDetailToken, setCkbLeadDetailToken] = useState('') - const [ckbLeadDetailName, setCkbLeadDetailName] = useState('') - const [ckbLeadRecords, setCkbLeadRecords] = useState<{ id: number; userId: string; nickname: string; phone: string; wechatId: string; name: string; source: string; createdAt: string }[]>([]) - const [ckbLeadTotal, setCkbLeadTotal] = useState(0) - const [ckbLeadPage, setCkbLeadPage] = useState(1) - const [ckbLeadLoading, setCkbLeadLoading] = useState(false) + const [editingLinkTagId, setEditingLinkTagId] = useState(null) const richEditorRef = useRef(null) const tree = buildTree(sectionsList) @@ -442,7 +434,6 @@ export function ContentPage() { personId: string token?: string name: string - aliases?: string label?: string ckbApiKey?: string ckbPlanId?: number @@ -452,6 +443,7 @@ export function ContentPage() { startTime?: string endTime?: string deviceGroups?: string | number[] + isPinned?: boolean } const data = await get<{ success?: boolean; persons?: PersonResp[] }>('/api/db/persons') if (data?.success && data.persons) { @@ -463,7 +455,6 @@ export function ContentPage() { id: p.token ?? p.personId ?? '', personId: p.personId, name: p.name, - aliases: p.aliases ?? '', label: p.label ?? '', ckbApiKey: p.ckbApiKey ?? '', ckbPlanId: p.ckbPlanId, @@ -473,6 +464,7 @@ export function ContentPage() { startTime: p.startTime, endTime: p.endTime, deviceGroups: deviceGroupsStr, + isPinned: p.isPinned ?? false, } }), ) @@ -503,80 +495,6 @@ export function ContentPage() { } }, []) - const loadCkbLeadCounts = useCallback(async () => { - try { - const data = await get<{ success?: boolean; byPerson?: { token: string; total: number }[] }>('/api/db/ckb-person-leads') - if (data?.success && data.byPerson) { - const m: Record = {} - for (const item of data.byPerson) m[item.token] = item.total - setCkbLeadCounts(m) - } - } catch { /* ignore */ } - }, []) - - const openCkbLeadDetail = useCallback(async (token: string, name: string, page = 1) => { - setCkbLeadDetailToken(token) - setCkbLeadDetailName(name) - setCkbLeadDetailOpen(true) - setCkbLeadPage(page) - setCkbLeadLoading(true) - try { - const data = await get<{ success?: boolean; records?: typeof ckbLeadRecords; total?: number; personName?: string; error?: string }>( - `/api/db/ckb-person-leads?token=${encodeURIComponent(token)}&page=${page}&pageSize=20`, - ) - if (data?.success) { - setCkbLeadRecords(data.records || []) - setCkbLeadTotal(data.total || 0) - } else { - toast.error(data?.error || '加载获客详情失败') - } - } catch (e) { - toast.error(e instanceof Error ? e.message : '加载获客详情失败') - } finally { - setCkbLeadLoading(false) - } - }, []) - - const loadLinkTagList = useCallback(async () => { - setLinkTagListLoading(true) - try { - const qs = new URLSearchParams({ - page: String(linkTagPage), - pageSize: String(linkTagPageSize), - }) - const s = linkTagSearch.trim() - if (s) qs.set('search', s) - const data = await get<{ - success?: boolean - linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[] - total?: number - page?: number - pageSize?: number - totalPages?: number - }>(`/api/db/link-tags?${qs.toString()}`) - if (data?.success) { - const items = Array.isArray(data.linkTags) ? data.linkTags : [] - setLinkTagList( - items.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 || '', - })), - ) - setLinkTagTotal(typeof data.total === 'number' ? data.total : 0) - setLinkTagTotalPages(typeof data.totalPages === 'number' && data.totalPages > 0 ? data.totalPages : 1) - } - } catch (e) { - console.error(e) - toast.error('加载链接标签失败') - } finally { - setLinkTagListLoading(false) - } - }, [linkTagPage, linkTagPageSize, linkTagSearch]) - const [linkedMps, setLinkedMps] = useState<{ key: string; name: string; appId: string; path?: string }[]>([]) const [mpSearchQuery, setMpSearchQuery] = useState('') const [mpDropdownOpen, setMpDropdownOpen] = useState(false) @@ -647,13 +565,8 @@ export function ContentPage() { loadPreviewPercent() loadPersons() loadLinkTags() - loadCkbLeadCounts() loadLinkedMps() - }, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadCkbLeadCounts, loadLinkedMps]) - - useEffect(() => { - loadLinkTagList() - }, [loadLinkTagList]) + }, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadLinkedMps]) const handleShowSectionOrders = async (section: Section & { filePath?: string }) => { setSectionOrdersModal({ section, orders: [] }) @@ -2336,6 +2249,33 @@ export function ContentPage() {

添加人物时同步创建存客宝场景获客计划,配置与存客宝 API 获客一致

+
+ + +
- + )) + )} +
+ )} +
+ ) : ( { - setLinkTagSearch(e.target.value) - setLinkTagPage(1) + if (newLinkTag.type === 'url' || newLinkTag.type === 'ckb') setNewLinkTag({ ...newLinkTag, url: e.target.value }) + else setNewLinkTag({ ...newLinkTag, appId: e.target.value }) }} /> - - + )} - + {newLinkTag.type === 'miniprogram' && ( +
+ + setNewLinkTag({ ...newLinkTag, pagePath: e.target.value })} /> +
+ )} + + - -
-
- - - - - - - - - - - {linkTagListLoading ? ( - - - - ) : linkTagList.length === 0 ? ( - - - - ) : ( - linkTagList.map((t) => ( - - - - - - - )) - )} - -
标签类型目标操作
- 加载中... -
- 暂无链接标签,添加后可在编辑器中使用 #标签 跳转 -
-
#{t.label}
-
tagId: {t.id}
-
- - {t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'} - - - {t.type === 'miniprogram' ? ( - - {t.appId || '—'} {t.pagePath ? `· ${t.pagePath}` : ''} - - ) : t.url ? ( - - {t.url} - - ) : ( - - )} - -
- - -
-
-
- setLinkTagPage(p)} - onPageSizeChange={(s) => { - setLinkTagPageSize(s) - setLinkTagPage(1) - }} - /> +
+ {linkTags.map((t) => ( +
+
+ + + {t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'} + + {t.type === 'miniprogram' ? ( + {t.appId} {t.pagePath ? `· ${t.pagePath}` : ''} + ) : t.url ? ( + + {t.url} + + ) : null} +
+
+ + +
+
+ ))} + {linkTags.length === 0 &&
暂无链接标签,添加后可在编辑器中使用 #标签 跳转
}
- - - - - {linkTagEditing ? '编辑链接标签' : '添加链接标签'} - - 配置后可在富文本编辑器中通过 #标签 插入,并在小程序端点击跳转 - - - -
-
-
- - setLinkTagForm((p) => ({ ...p, tagId: e.target.value }))} - /> -
-
- - setLinkTagForm((p) => ({ ...p, label: e.target.value }))} - /> -
-
- -
-
- - -
-
- - {linkTagForm.type === 'miniprogram' && linkedMps.length > 0 ? ( -
- { - const v = e.target.value - setMpSearchQuery(v) - setMpDropdownOpen(true) - if (!linkedMps.some((m) => m.key === v)) setLinkTagForm((p) => ({ ...p, appId: v })) - }} - onFocus={() => { - setMpSearchQuery(linkTagForm.appId) - setMpDropdownOpen(true) - }} - onBlur={() => setTimeout(() => setMpDropdownOpen(false), 150)} - /> - {mpDropdownOpen && ( -
- {filteredLinkedMps.length === 0 ? ( -
无匹配,可手动输入密钥
- ) : ( - filteredLinkedMps.map((m) => ( - - )) - )} -
- )} -
- ) : ( - { - if (linkTagForm.type === 'url' || linkTagForm.type === 'ckb') - setLinkTagForm((p) => ({ ...p, url: e.target.value })) - else setLinkTagForm((p) => ({ ...p, appId: e.target.value })) - }} - /> - )} -
-
- - {linkTagForm.type === 'miniprogram' && ( -
- - setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))} - /> -
- )} -
- - - - - -
-
@@ -2923,7 +2698,6 @@ export function ContentPage() { const payload = { personId: data.personId || (data.name.toLowerCase().replace(/\s+/g, '_') + '_' + Date.now().toString(36)), name: data.name, - aliases: data.aliases || undefined, label: data.label, ckbApiKey: data.ckbApiKey || undefined, greeting: data.greeting || undefined, @@ -2973,7 +2747,7 @@ export function ContentPage() { 确认删除 - + {personToDelete && ( <>

确定删除「SOUL链接人与事-{personToDelete.name}」?将同时删除存客宝对应获客计划。

@@ -3003,78 +2777,6 @@ export function ContentPage() {
- - {/* CKB 获客详情弹窗 */} - - - - - - {ckbLeadDetailName} — 获客详情(共 {ckbLeadTotal} 条) - - -
- {ckbLeadLoading ? ( -
- - 加载中... -
- ) : ckbLeadRecords.length === 0 ? ( -
暂无获客记录
- ) : ( - <> -
- # - 昵称/姓名 - 手机 - 微信 - 来源 - 时间 -
- {ckbLeadRecords.map((r, i) => ( -
- {(ckbLeadPage - 1) * 20 + i + 1} - {r.nickname || r.name || r.userId || '-'} - {r.phone || '-'} - {r.wechatId || '-'} - {r.source === 'article_mention' ? '文章@' : r.source === 'index_lead' ? '首页' : r.source || '-'} - - {r.createdAt - ? new Date(r.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) - : '-'} - -
- ))} - - )} -
- {ckbLeadTotal > 20 && ( -
- - - {ckbLeadPage} / {Math.ceil(ckbLeadTotal / 20)} - - -
- )} -
-
) } diff --git a/soul-admin/src/pages/dashboard/DashboardPage.tsx b/soul-admin/src/pages/dashboard/DashboardPage.tsx index 89f95ca1..d9e77b64 100644 --- a/soul-admin/src/pages/dashboard/DashboardPage.tsx +++ b/soul-admin/src/pages/dashboard/DashboardPage.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3, Phone, MessageSquare, UserCheck } from 'lucide-react' import { get } from '@/api/client' import { UserDetailModal } from '@/components/modules/user/UserDetailModal' @@ -77,6 +78,12 @@ export function DashboardPage() { const [showDetailModal, setShowDetailModal] = useState(false) const [giftedTotal, setGiftedTotal] = useState(0) const [ordersExpanded, setOrdersExpanded] = useState(false) + // 客资中心 + const [leadsLoading, setLeadsLoading] = useState(false) + const [leads, setLeads] = useState<{ id: string; type: string; userId?: string; userNickname?: string; userAvatar?: string; phone?: string; wechatId?: string; name?: string; source?: string; sourceLabel?: string; matchType?: string; createdAt?: string; dupCount?: number }[]>([]) + const [leadStats, setLeadStats] = useState<{ totalLeads: number; totalSubmits: number; withPhone: number; total: number }>({ totalLeads: 0, totalSubmits: 0, withPhone: 0, total: 0 }) + const [leadsExpanded, setLeadsExpanded] = useState(false) + const [trackPeriod, setTrackPeriod] = useState('week') const [trackStats, setTrackStats] = useState<{ total: number @@ -184,6 +191,23 @@ export function DashboardPage() { await Promise.all([loadOrders(), loadUsers()]) } + async function loadLeads() { + setLeadsLoading(true) + try { + const res = await get<{ success?: boolean; leads?: typeof leads; totalLeads?: number; totalSubmits?: number; withPhone?: number; total?: number }>('/api/admin/dashboard/leads?limit=20') + if (res?.success) { + setLeads(res.leads || []) + setLeadStats({ + totalLeads: res.totalLeads ?? 0, + totalSubmits: res.totalSubmits ?? 0, + withPhone: res.withPhone ?? 0, + total: res.total ?? 0, + }) + } + } catch { } + finally { setLeadsLoading(false) } + } + async function loadTrackStats(period?: string) { const p = period || trackPeriod setTrackLoading(true) @@ -205,7 +229,8 @@ export function DashboardPage() { const ctrl = new AbortController() loadAll(ctrl.signal) loadTrackStats() - const timer = setInterval(() => { loadAll(); loadTrackStats() }, 30000) + loadLeads() + const timer = setInterval(() => { loadAll(); loadTrackStats(); loadLeads() }, 30000) return () => { ctrl.abort() clearInterval(timer) @@ -303,6 +328,15 @@ export function DashboardPage() { bg: 'bg-orange-500/20', link: '/distribution', }, + { + title: '总客资', + value: leadStats.total > 0 ? leadStats.total : (leadsLoading ? null : 0), + sub: leadStats.withPhone > 0 ? `有手机号 ${leadStats.withPhone}` : null, + icon: UserCheck, + color: 'text-cyan-400', + bg: 'bg-cyan-500/20', + link: '/find-partner', + }, ] return ( @@ -320,7 +354,7 @@ export function DashboardPage() { )} -
+
{stats.map((stat, index) => (
+ + + + + 客资中心 + +
+
+ 总客资 {leadStats.total} + 有手机号 {leadStats.withPhone} + {leads.filter(l => (l.dupCount ?? 0) > 0).length > 0 && ( + 重复 {leads.filter(l => (l.dupCount ?? 0) > 0).length} + )} +
+ +
+
+ + {leadsLoading && leads.length === 0 ? ( +
+ + 加载中... +
+ ) : leads.length === 0 ? ( +
+ +

暂无客资数据

+
+ ) : ( +
+ {leads.slice(0, leadsExpanded ? 20 : 6).map((lead) => ( +
+
+ {lead.userAvatar ? ( + { e.currentTarget.style.display = 'none' }} /> + ) : ( +
+ {(lead.userNickname || lead.name || '?').charAt(0)} +
+ )} +
+
+ + + {lead.sourceLabel || lead.source || lead.type} + + {(lead.dupCount ?? 0) > 0 && ( + + 重复{lead.dupCount}次 + + )} +
+
+ {lead.phone && ( + + {lead.phone} + + )} + {lead.wechatId && ( + + {lead.wechatId} + + )} + {lead.createdAt ? new Date(lead.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''} +
+
+
+
+ ))} + {leads.length > 6 && !leadsExpanded && ( + + )} +
+ )} +
+
+ diff --git a/soul-api/internal/handler/admin_dashboard.go b/soul-api/internal/handler/admin_dashboard.go index 90538f94..4a2e9dbe 100644 --- a/soul-api/internal/handler/admin_dashboard.go +++ b/soul-api/internal/handler/admin_dashboard.go @@ -188,91 +188,6 @@ func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H { return out } -// AdminTrackStats GET /api/admin/track/stats?period=today|week|month|all -// 埋点统计:按 extra_data->module 分组,按 action+target 聚合 count -func AdminTrackStats(c *gin.Context) { - period := c.DefaultQuery("period", "week") - if period != "today" && period != "week" && period != "month" && period != "all" { - period = "week" - } - now := time.Now() - var start time.Time - switch period { - case "today": - start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - case "week": - weekday := int(now.Weekday()) - if weekday == 0 { - weekday = 7 - } - start = time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location()) - case "month": - start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - case "all": - start = time.Time{} - } - db := database.DB() - var tracks []model.UserTrack - q := db.Model(&model.UserTrack{}) - if !start.IsZero() { - q = q.Where("created_at >= ?", start) - } - if err := q.Find(&tracks).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - // byModule: module -> map[key] -> count, key = action + "|" + target - type item struct { - Action string `json:"action"` - Target string `json:"target"` - Module string `json:"module"` - Page string `json:"page"` - Count int `json:"count"` - } - byModule := make(map[string]map[string]*item) - total := 0 - for _, t := range tracks { - total++ - module := "other" - page := "" - if len(t.ExtraData) > 0 { - var extra map[string]interface{} - if err := json.Unmarshal(t.ExtraData, &extra); err == nil { - if m, ok := extra["module"].(string); ok && m != "" { - module = m - } - if p, ok := extra["page"].(string); ok { - page = p - } - } - } - target := "" - if t.Target != nil { - target = *t.Target - } - key := t.Action + "|" + target - if byModule[module] == nil { - byModule[module] = make(map[string]*item) - } - if byModule[module][key] == nil { - byModule[module][key] = &item{Action: t.Action, Target: target, Module: module, Page: page, Count: 0} - } - byModule[module][key].Count++ - } - // 转为前端期望格式:byModule[module] = [{action,target,module,page,count},...] - out := make(map[string][]gin.H) - for mod, m := range byModule { - list := make([]gin.H, 0, len(m)) - for _, v := range m { - list = append(list, gin.H{ - "action": v.Action, "target": v.Target, "module": v.Module, "page": v.Page, "count": v.Count, - }) - } - out[mod] = list - } - c.JSON(http.StatusOK, gin.H{"success": true, "total": total, "byModule": out}) -} - // AdminBalanceSummary GET /api/admin/balance/summary // 汇总代付金额(product_type 为 gift_pay 或 gift_pay_batch 的已支付订单),用于 Dashboard 显示「含代付 ¥xx」 func AdminBalanceSummary(c *gin.Context) { @@ -303,6 +218,196 @@ func AdminDashboardMerchantBalance(c *gin.Context) { }) } +// AdminDashboardLeads GET /api/admin/dashboard/leads?limit=20 +// 管理端-首页客资中心:聚合 ckb_lead_records(链接卡若留资)+ ckb_submit_records(join/match), +// 联表 users 补齐头像/昵称,按时间倒序,每条包含联系方式(phone/wechatId)与来源。 +func AdminDashboardLeads(c *gin.Context) { + db := database.DB() + limit := 20 + if l := c.Query("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n >= 1 && n <= 100 { + limit = n + } + } + search := c.Query("search") + + // 1. ckb_lead_records(链接卡若 / 文章@) + var leads []model.CkbLeadRecord + qLead := db.Model(&model.CkbLeadRecord{}).Order("created_at DESC") + if search != "" { + qLead = qLead.Where("nickname LIKE ? OR phone LIKE ? OR name LIKE ? OR wechat_id LIKE ?", + "%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%") + } + qLead.Limit(limit).Find(&leads) + + // 2. ckb_submit_records(join/match) + var submits []model.CkbSubmitRecord + qSub := db.Model(&model.CkbSubmitRecord{}).Order("created_at DESC") + if search != "" { + qSub = qSub.Where("nickname LIKE ? OR params LIKE ?", "%"+search+"%", "%"+search+"%") + } + qSub.Limit(limit).Find(&submits) + + // 收集所有 userID 关联用户信息 + userIDs := make(map[string]bool) + for _, l := range leads { + if l.UserID != "" { + userIDs[l.UserID] = true + } + } + for _, s := range submits { + if s.UserID != "" { + userIDs[s.UserID] = true + } + } + ids := make([]string, 0, len(userIDs)) + for id := range userIDs { + ids = append(ids, id) + } + var users []model.User + if len(ids) > 0 { + db.Select("id", "nickname", "avatar", "phone", "wechat_id", "is_vip", "tags", "ckb_tags").Where("id IN ?", ids).Find(&users) + } + userMap := make(map[string]*model.User) + for i := range users { + userMap[users[i].ID] = &users[i] + } + + // 统计 + var totalLeads, totalSubmits int64 + db.Model(&model.CkbLeadRecord{}).Count(&totalLeads) + db.Model(&model.CkbSubmitRecord{}).Count(&totalSubmits) + var withPhone int64 + db.Model(&model.CkbLeadRecord{}).Where("phone != '' AND phone IS NOT NULL").Count(&withPhone) + + // 去重统计:按 userId/phone/wechatId 聚合重复次数 + dupCounts := make(map[string]int64) + for _, l := range leads { + key := l.UserID + if key == "" { + key = l.Phone + } + if key == "" { + key = l.WechatID + } + if key != "" { + if _, ok := dupCounts[key]; !ok { + var cnt int64 + q := db.Model(&model.CkbLeadRecord{}) + if l.UserID != "" { + q = q.Where("user_id = ?", l.UserID) + } else if l.Phone != "" { + q = q.Where("phone = ?", l.Phone) + } else { + q = q.Where("wechat_id = ?", l.WechatID) + } + q.Count(&cnt) + dupCounts[key] = cnt + } + } + } + + // 构造输出 + type leadOut struct { + SortTime time.Time + Data gin.H + } + all := make([]leadOut, 0, len(leads)+len(submits)) + for _, l := range leads { + u := userMap[l.UserID] + avatar := "" + userNickname := l.Nickname + if u != nil { + avatar = dashStr(u.Avatar) + if dashStr(u.Nickname) != "" { + userNickname = dashStr(u.Nickname) + } + } + sourceLabel := "链接卡若" + if l.Source == "article_mention" { + sourceLabel = "文章@" + } else if l.Source == "index_link_button" { + sourceLabel = "首页链接" + } + key := l.UserID + if key == "" { + key = l.Phone + } + if key == "" { + key = l.WechatID + } + dupCount := dupCounts[key] + if dupCount <= 1 { + dupCount = 0 + } + all = append(all, leadOut{ + SortTime: l.CreatedAt, + Data: gin.H{ + "id": l.ID, + "type": "lead", + "userId": l.UserID, + "userNickname": userNickname, + "userAvatar": avatar, + "phone": l.Phone, + "wechatId": l.WechatID, + "name": l.Name, + "source": l.Source, + "sourceLabel": sourceLabel, + "createdAt": l.CreatedAt, + "dupCount": dupCount, + }, + }) + } + for _, s := range submits { + u := userMap[s.UserID] + avatar := "" + userNickname := s.Nickname + if u != nil { + avatar = dashStr(u.Avatar) + if dashStr(u.Nickname) != "" { + userNickname = dashStr(u.Nickname) + } + } + all = append(all, leadOut{ + SortTime: s.CreatedAt, + Data: gin.H{ + "id": s.ID, + "type": "submit", + "userId": s.UserID, + "userNickname": userNickname, + "userAvatar": avatar, + "matchType": s.Action, + "source": s.Action, + "sourceLabel": ckbSourceMap[s.Action], + "createdAt": s.CreatedAt, + }, + }) + } + // 按时间倒序合并 + for i := 0; i < len(all); i++ { + for j := i + 1; j < len(all); j++ { + if all[j].SortTime.After(all[i].SortTime) { + all[i], all[j] = all[j], all[i] + } + } + } + if len(all) > limit { + all = all[:limit] + } + out := make([]gin.H, 0, len(all)) + for _, a := range all { + out = append(out, a.Data) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "leads": out, + "totalLeads": totalLeads, + "totalSubmits": totalSubmits, + "withPhone": withPhone, + "total": totalLeads + totalSubmits, + }) +} + func buildNewUsersOut(newUsers []model.User) []gin.H { out := make([]gin.H, 0, len(newUsers)) for _, u := range newUsers { diff --git a/soul-api/internal/handler/db_ckb_leads.go b/soul-api/internal/handler/db_ckb_leads.go index b1024b05..c496c392 100644 --- a/soul-api/internal/handler/db_ckb_leads.go +++ b/soul-api/internal/handler/db_ckb_leads.go @@ -43,8 +43,46 @@ func DBCKBLeadList(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } + // 查询每条记录的重复次数 + dupCounts := make(map[string]int64) + if dedup == "true" && len(records) > 0 { + for _, r := range records { + key := r.UserID + if key == "" { + key = r.Phone + } + if key == "" { + key = r.WechatID + } + if key != "" { + var cnt int64 + cntQ := db.Model(&model.CkbLeadRecord{}) + if r.UserID != "" { + cntQ = cntQ.Where("user_id = ?", r.UserID) + } else if r.Phone != "" { + cntQ = cntQ.Where("phone = ?", r.Phone) + } else if r.WechatID != "" { + cntQ = cntQ.Where("wechat_id = ?", r.WechatID) + } + cntQ.Count(&cnt) + dupCounts[key] = cnt + } + } + } + out := make([]gin.H, 0, len(records)) for _, r := range records { + key := r.UserID + if key == "" { + key = r.Phone + } + if key == "" { + key = r.WechatID + } + dupCount := dupCounts[key] + if dupCount <= 1 { + dupCount = 0 + } out = append(out, gin.H{ "id": r.ID, "userId": r.UserID, @@ -54,6 +92,7 @@ func DBCKBLeadList(c *gin.Context) { "wechatId": r.WechatID, "name": r.Name, "createdAt": r.CreatedAt, + "dupCount": dupCount, }) } c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize}) diff --git a/soul-api/internal/handler/db_person.go b/soul-api/internal/handler/db_person.go index 0ed11056..c96bb30f 100644 --- a/soul-api/internal/handler/db_person.go +++ b/soul-api/internal/handler/db_person.go @@ -404,6 +404,100 @@ func genPersonToken() (string, error) { return s + "0123456789abcdefghijklmnopqrstuv"[:(32-len(s))], nil } +// DBPersonPin PUT /api/db/persons/pin 管理端-置顶/取消置顶人物到小程序首页 +func DBPersonPin(c *gin.Context) { + var body struct { + PersonID string `json:"personId" binding:"required"` + IsPinned *bool `json:"isPinned"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) + return + } + db := database.DB() + var row model.Person + if err := db.Where("person_id = ? OR token = ?", body.PersonID, body.PersonID).First(&row).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"}) + return + } + pinned := true + if body.IsPinned != nil { + pinned = *body.IsPinned + } else { + pinned = !row.IsPinned + } + if err := db.Model(&row).Update("is_pinned", pinned).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "isPinned": pinned}) +} + +// DBPersonPinnedList GET /api/db/persons/pinned 管理端/小程序-获取置顶人物列表 +func DBPersonPinnedList(c *gin.Context) { + var rows []model.Person + if err := database.DB().Where("is_pinned = ?", true).Order("updated_at DESC").Find(&rows).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + out := make([]gin.H, 0, len(rows)) + db := database.DB() + for _, p := range rows { + item := gin.H{ + "personId": p.PersonID, + "token": p.Token, + "name": p.Name, + "label": p.Label, + "isPinned": p.IsPinned, + } + if p.UserID != nil && *p.UserID != "" { + var u model.User + if db.Select("id", "nickname", "avatar").Where("id = ?", *p.UserID).First(&u).Error == nil { + item["userId"] = u.ID + item["avatar"] = getUrlValue(u.Avatar) + item["nickname"] = getStringValue(u.Nickname) + } + } + out = append(out, item) + } + c.JSON(http.StatusOK, gin.H{"success": true, "persons": out}) +} + +// AdminCKBPlanCheck GET /api/admin/ckb/plan-check 管理端-检查存客宝计划在线状态 +// 查询所有有 ckb_plan_id 的 Person,对每个计划调用存客宝获取状态 +func AdminCKBPlanCheck(c *gin.Context) { + db := database.DB() + var persons []model.Person + db.Where("ckb_plan_id > 0").Find(&persons) + if len(persons) == 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "plans": []interface{}{}, "message": "暂无配置了存客宝计划的人物"}) + return + } + token, err := ckbOpenGetToken() + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + out := make([]gin.H, 0, len(persons)) + for _, p := range persons { + item := gin.H{ + "personId": p.PersonID, + "name": p.Name, + "ckbPlanId": p.CkbPlanID, + "status": "unknown", + } + // 尝试启用计划 + if enableErr := setCkbPlanEnabled(token, p.CkbPlanID, true); enableErr != nil { + item["status"] = "error" + item["error"] = enableErr.Error() + } else { + item["status"] = "online" + } + out = append(out, item) + } + c.JSON(http.StatusOK, gin.H{"success": true, "plans": out}) +} + // DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物 // 若有 ckb_plan_id,先调存客宝删除计划,再删本地 func DBPersonDelete(c *gin.Context) { diff --git a/soul-api/internal/model/person.go b/soul-api/internal/model/person.go index d821b039..8465f727 100644 --- a/soul-api/internal/model/person.go +++ b/soul-api/internal/model/person.go @@ -32,6 +32,8 @@ type Person struct { EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"` DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表 + IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"` // 置顶到小程序首页 + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` } diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index 5508ed8e..38f3cb4d 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -106,6 +106,8 @@ func Setup(cfg *config.Config) *gin.Engine { admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList) admin.GET("/user/track", handler.UserTrackGet) admin.GET("/track/stats", handler.AdminTrackStats) + admin.GET("/dashboard/leads", handler.AdminDashboardLeads) + admin.GET("/ckb/plan-check", handler.AdminCKBPlanCheck) } // ----- 鉴权 ----- @@ -199,6 +201,8 @@ func Setup(cfg *config.Config) *gin.Engine { db.GET("/link-tags", handler.DBLinkTagList) db.POST("/link-tags", handler.DBLinkTagSave) db.DELETE("/link-tags", handler.DBLinkTagDelete) + db.PUT("/persons/pin", handler.DBPersonPin) + db.GET("/persons/pinned", handler.DBPersonPinnedList) db.GET("/ckb-leads", handler.DBCKBLeadList) db.GET("/ckb-person-leads", handler.DBCKBPersonLeads) db.GET("/ckb-plan-stats", handler.CKBPlanStats) @@ -352,6 +356,7 @@ func Setup(cfg *config.Config) *gin.Engine { miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail) miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook) miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor) + miniprogram.GET("/persons/pinned", handler.DBPersonPinnedList) // 埋点 miniprogram.POST("/track", handler.MiniprogramTrackPost) // 规则引擎(用户旅程引导) diff --git a/soul-api/server b/soul-api/server index d0cfa9b4..8356b3e5 100755 Binary files a/soul-api/server and b/soul-api/server differ