优化小程序推荐码处理逻辑,支持通过扫码场景解析推荐码和初始章节ID。新增获取用户邀请码的功能以便于分享。更新分享配置,确保分享时自动带上推荐码。调整部分页面逻辑以提升用户体验。

This commit is contained in:
乘风
2026-02-12 15:09:52 +08:00
parent c57866ffe0
commit 448e908855
40 changed files with 1068 additions and 318 deletions

View File

@@ -82,10 +82,24 @@ App({
this.handleReferralCode(options)
},
// 处理推荐码绑定
// 处理推荐码绑定(支持 query.ref 与扫码 scenescene 可能为 ref=SOULXXX 或 id=1.1_ref=xxx后端会把 & 转成 _
handleReferralCode(options) {
const query = options?.query || {}
const refCode = query.ref || query.referralCode
let refCode = query.ref || query.referralCode
if (options?.scene) {
const scene = (typeof options.scene === 'string' ? decodeURIComponent(options.scene) : '').trim()
// 支持 _ 或 & 作为参数分隔符(后端生成小程序码时会把 & 转为 _
const parts = scene.split(/[&_]/)
for (const part of parts) {
const eq = part.indexOf('=')
if (eq > 0) {
const key = part.slice(0, eq)
const val = part.slice(eq + 1)
if (key === 'ref') refCode = val
if (key === 'id' && val) this.globalData.initialSectionId = val
}
}
}
if (refCode) {
console.log('[App] 检测到推荐码:', refCode)
@@ -161,6 +175,15 @@ App({
}
},
// 获取当前用户的邀请码(用于分享带 ref未登录返回空字符串
getMyReferralCode() {
const user = this.globalData.userInfo
if (!user) return ''
if (user.referralCode) return user.referralCode
if (user.id) return 'SOUL' + String(user.id).toUpperCase().slice(-6)
return ''
},
// 获取系统信息
getSystemInfo() {
try {

View File

@@ -13,7 +13,7 @@
"pages/settings/settings",
"pages/search/search",
"pages/addresses/addresses",
"pages/addresses/edit","pages/withdraw-records/withdraw-records"
"pages/addresses/edit","pages/withdraw-records/withdraw-records","pages/scan/scan"
],
"window": {
"backgroundTextStyle": "light",

View File

@@ -77,5 +77,13 @@ Page({
// 返回
goBack() {
wx.navigateBack()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 关于作者',
path: ref ? `/pages/about/about?ref=${ref}` : '/pages/about/about'
}
}
})

View File

@@ -119,5 +119,13 @@ Page({
// 返回
goBack() {
wx.navigateBack()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 收货地址',
path: ref ? `/pages/addresses/addresses?ref=${ref}` : '/pages/addresses/addresses'
}
}
})

View File

@@ -197,5 +197,13 @@ Page({
// 返回
goBack() {
wx.navigateBack()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 编辑地址',
path: ref ? `/pages/addresses/edit?ref=${ref}` : '/pages/addresses/edit'
}
}
})

View File

@@ -17,5 +17,13 @@ Page({
goBack() {
wx.navigateBack()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 用户协议',
path: ref ? `/pages/agreement/agreement?ref=${ref}` : '/pages/agreement/agreement'
}
}
})

View File

@@ -257,5 +257,13 @@ Page({
// 跳转到搜索页
goToSearch() {
wx.navigateTo({ url: '/pages/search/search' })
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 目录',
path: ref ? `/pages/chapters/chapters?ref=${ref}` : '/pages/chapters/chapters'
}
}
})

View File

@@ -56,10 +56,9 @@ Page({
navBarHeight: app.globalData.navBarHeight
})
// 处理分享参数(推荐码绑定)
if (options && options.ref) {
console.log('[Index] 检测到推荐码:', options.ref)
app.handleReferralCode({ query: options })
// 处理分享参数与扫码 scene(推荐码绑定)
if (options && (options.ref || options.scene)) {
app.handleReferralCode(options)
}
// 初始化数据
@@ -211,5 +210,13 @@ Page({
await this.initData()
this.updateUserStatus()
wx.stopPullDownRefresh()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 真实商业故事',
path: ref ? `/pages/index/index?ref=${ref}` : '/pages/index/index'
}
}
})

View File

@@ -891,5 +891,13 @@ Page({
},
// 阻止事件冒泡
preventBubble() {}
preventBubble() {},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 找伙伴',
path: ref ? `/pages/match/match?ref=${ref}` : '/pages/match/match'
}
}
})

View File

@@ -676,5 +676,13 @@ Page({
},
// 阻止冒泡
stopPropagation() {}
stopPropagation() {},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 我的',
path: ref ? `/pages/my/my?ref=${ref}` : '/pages/my/my'
}
}
})

View File

@@ -17,5 +17,13 @@ Page({
goBack() {
wx.navigateBack()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 隐私政策',
path: ref ? `/pages/privacy/privacy?ref=${ref}` : '/pages/privacy/privacy'
}
}
})

View File

@@ -45,5 +45,13 @@ Page({
wx.navigateTo({ url: `/pages/read/read?id=${id}` })
},
goBack() { wx.navigateBack() }
goBack() { wx.navigateBack() },
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 我的订单',
path: ref ? `/pages/purchases/purchases?ref=${ref}` : '/pages/purchases/purchases'
}
}
})

View File

@@ -66,12 +66,18 @@ Page({
isGeneratingPoster: false,
// 免费章节
freeIds: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3']
freeIds: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
// 分享卡片图canvas 生成后写入,供 onShareAppMessage 使用)
shareImagePath: ''
},
async onLoad(options) {
const { id, ref } = options
// 扫码进入时 id 可能在 app.globalData.initialSectionIdscene 里 id=1.1 由 app 解析)
let id = options.id || app.globalData.initialSectionId
if (app.globalData.initialSectionId) delete app.globalData.initialSectionId
const ref = options.ref
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight,
@@ -268,10 +274,11 @@ Page({
try {
const res = await this.fetchChapterWithTimeout(id, 5000)
if (res && res.content) {
this.setData({ section: this.getSectionInfo(id) })
this.setChapterContent(res)
// 成功后缓存到本地
wx.setStorageSync(cacheKey, res)
console.log('[Read] 从API加载成功:', id)
setTimeout(() => this.drawShareCard(), 600)
return
}
} catch (e) {
@@ -282,10 +289,11 @@ Page({
try {
const cached = wx.getStorageSync(cacheKey)
if (cached && cached.content) {
this.setData({ section: this.getSectionInfo(id) })
this.setChapterContent(cached)
console.log('[Read] 从本地缓存加载成功:', id)
// 后台静默刷新
this.silentRefresh(id)
setTimeout(() => this.drawShareCard(), 600)
return
}
} catch (e) {
@@ -363,9 +371,11 @@ Page({
try {
const res = await this.fetchChapterWithTimeout(id, 8000)
if (res && res.content) {
this.setData({ section: this.getSectionInfo(id) })
this.setChapterContent(res)
wx.setStorageSync(`chapter_${id}`, res)
console.log('[Read] 重试成功:', id, '第', currentRetry + 1, '次')
setTimeout(() => this.drawShareCard(), 600)
return
}
} catch (e) {
@@ -454,33 +464,91 @@ Page({
})
},
// 分享到微信 - 自动带分享人ID
onShareAppMessage() {
const { section, sectionId } = this.data
const userInfo = app.globalData.userInfo
const referralCode = userInfo?.referralCode || wx.getStorageSync('referralCode') || ''
// 分享标题优化
const shareTitle = section?.title
// 绘制分享卡片图(标题+正文摘要),生成后供 onShareAppMessage 使用
drawShareCard() {
const { section, sectionId, contentParagraphs } = this.data
const title = section?.title || this.getSectionTitle(sectionId) || '精彩内容'
const raw = (contentParagraphs && contentParagraphs.length)
? contentParagraphs.slice(0, 4).join(' ').replace(/\s+/g, ' ').trim()
: ''
const excerpt = raw.length > 120 ? raw.slice(0, 120) + '...' : (raw || '来自派对房的真实商业故事')
const ctx = wx.createCanvasContext('shareCardCanvas', this)
const w = 500
const h = 400
// 白底
ctx.setFillStyle('#ffffff')
ctx.fillRect(0, 0, w, h)
// 顶部:平台名
ctx.setFillStyle('#333333')
ctx.setFontSize(14)
ctx.fillText('📚 Soul 创业派对 - 真实商业故事', 24, 36)
// 深色内容区(模拟参考图效果)
const boxX = 24
const boxY = 52
const boxW = w - 48
const boxH = 300
ctx.setFillStyle('#2c2c2e')
ctx.fillRect(boxX, boxY, boxW, boxH)
// 文章标题(白字)
ctx.setFillStyle('#ffffff')
ctx.setFontSize(15)
const titleLines = this.wrapText(ctx, title.length > 50 ? title.slice(0, 50) + '...' : title, boxW - 32, 15)
let y = boxY + 28
titleLines.slice(0, 2).forEach(line => {
ctx.fillText(line, boxX + 16, y)
y += 22
})
y += 8
// 正文摘要(浅灰)
ctx.setFillStyle('rgba(255,255,255,0.88)')
ctx.setFontSize(12)
const excerptLines = this.wrapText(ctx, excerpt, boxW - 32, 12)
excerptLines.slice(0, 8).forEach(line => {
ctx.fillText(line, boxX + 16, y)
y += 20
})
// 底部:小程序标识
ctx.setFillStyle('#999999')
ctx.setFontSize(11)
ctx.fillText('小程序', 24, h - 16)
ctx.draw(false, () => {
wx.canvasToTempFilePath({
canvasId: 'shareCardCanvas',
fileType: 'png',
success: (res) => {
this.setData({ shareImagePath: res.tempFilePath })
}
}, this)
})
},
// 统一分享配置(底部「推荐给好友」与右下角分享按钮均走此配置,由 onShareAppMessage 使用)
getShareConfig() {
const { section, sectionId, shareImagePath } = this.data
const ref = app.getMyReferralCode()
const shareTitle = section?.title
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
: '📚 Soul创业派对 - 真实商业故事'
const path = ref
? `/pages/read/read?id=${sectionId}&ref=${ref}`
: `/pages/read/read?id=${sectionId}`
return {
title: shareTitle,
path: `/pages/read/read?id=${sectionId}${referralCode ? '&ref=' + referralCode : ''}`,
imageUrl: '/assets/share-cover.png' // 可配置分享封面图
path,
imageUrl: shareImagePath || undefined
}
},
// 分享到朋友圈
onShareAppMessage() {
return this.getShareConfig()
},
onShareTimeline() {
const { section, sectionId } = this.data
const userInfo = app.globalData.userInfo
const referralCode = userInfo?.referralCode || ''
const ref = app.getMyReferralCode()
return {
title: `${section?.title || 'Soul创业派对'} - 来自派对房的真实故事`,
query: `id=${sectionId}${referralCode ? '&ref=' + referralCode : ''}`
query: ref ? `id=${sectionId}&ref=${ref}` : `id=${sectionId}`
}
},
@@ -734,7 +802,8 @@ Page({
if (res.success && res.data?.payParams) {
paymentData = res.data.payParams
console.log('[Pay] 获取支付参数成功:', paymentData)
paymentData._orderSn = res.data.orderSn
console.log('[Pay] 获取支付参数成功, orderSn:', res.data.orderSn)
} else {
throw new Error(res.error || res.message || '创建订单失败')
}
@@ -767,11 +836,12 @@ Page({
console.log('[Pay] 调起微信支付, paymentData:', paymentData)
try {
const orderSn = paymentData._orderSn
await this.callWechatPay(paymentData)
// 4. 【标准流程】支付成功后刷新权限并解锁内容
// 4. 轮询订单状态确认已支付后刷新并解锁(不依赖 PayNotify 回调时机)
console.log('[Pay] 微信支付成功!')
await this.onPaymentSuccess()
await this.onPaymentSuccess(orderSn)
} catch (payErr) {
console.error('[Pay] 微信支付调起失败:', payErr)
@@ -807,13 +877,34 @@ Page({
}
},
// 轮询订单状态,确认 paid 后刷新权限并解锁
async pollOrderUntilPaid(orderSn) {
const maxAttempts = 15
const interval = 800
for (let i = 0; i < maxAttempts; i++) {
try {
const r = await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { method: 'GET', silent: true })
if (r?.data?.status === 'paid') return true
} catch (_) {}
if (i < maxAttempts - 1) await this.sleep(interval)
}
return false
},
// 【新增】支付成功后的标准处理流程
async onPaymentSuccess() {
async onPaymentSuccess(orderSn) {
wx.showLoading({ title: '确认购买中...', mask: true })
try {
// 1. 等待服务端处理支付回调1-2秒
await this.sleep(2000)
// 1. 轮询订单状态直到已支付GET pay 会主动同步本地订单,不依赖 PayNotify
if (orderSn) {
const paid = await this.pollOrderUntilPaid(orderSn)
if (!paid) {
console.warn('[Pay] 轮询超时,仍尝试刷新')
}
} else {
await this.sleep(1500)
}
// 2. 刷新用户购买状态
await accessManager.refreshUserPurchaseStatus()
@@ -936,134 +1027,115 @@ Page({
wx.navigateTo({ url: '/pages/referral/referral' })
},
// 生成海报
// 生成海报(弹窗先展示,延迟再绘制,确保 canvas 已渲染)
async generatePoster() {
wx.showLoading({ title: '生成中...' })
this.setData({ showPosterModal: true, isGeneratingPoster: true })
const { section, contentParagraphs, sectionId } = this.data
const userInfo = app.globalData.userInfo
const userId = userInfo?.id || ''
const safeParagraphs = contentParagraphs || []
// 通过 GET 接口下载二维码图片,得到 tempFilePath 便于开发工具与真机统一用 drawImage 绘制
let qrcodeTempPath = null
try {
const ctx = wx.createCanvasContext('posterCanvas', this)
const { section, contentParagraphs, sectionId } = this.data
const userInfo = app.globalData.userInfo
const userId = userInfo?.id || ''
// 获取小程序码(带推荐人参数)
let qrcodeImage = null
try {
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 }
const scene = userId ? `id=${sectionId}&ref=${userId.slice(0, 10)}` : `id=${sectionId}`
const baseUrl = app.globalData.baseUrl || ''
const url = `${baseUrl}/api/miniprogram/qrcode/image?scene=${encodeURIComponent(scene)}&page=${encodeURIComponent('pages/read/read')}&width=280`
qrcodeTempPath = await new Promise((resolve) => {
wx.downloadFile({
url,
success: (res) => resolve(res.statusCode === 200 ? res.tempFilePath : null),
fail: () => resolve(null)
})
if (qrRes.success && qrRes.image) {
qrcodeImage = qrRes.image
}
} catch (e) {
console.log('[Poster] 获取小程序码失败,使用占位符')
}
// 海报尺寸 300x450
const width = 300
const height = 450
// 背景渐变
const grd = ctx.createLinearGradient(0, 0, 0, height)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.setFillStyle(grd)
ctx.fillRect(0, 0, width, height)
// 顶部装饰条
ctx.setFillStyle('#00CED1')
ctx.fillRect(0, 0, width, 4)
// 标题区域
ctx.setFillStyle('#ffffff')
ctx.setFontSize(14)
ctx.fillText('📚 Soul创业派对', 20, 35)
// 章节标题
ctx.setFontSize(18)
ctx.setFillStyle('#ffffff')
const title = section?.title || '精彩内容'
const titleLines = this.wrapText(ctx, title, width - 40, 18)
let y = 70
titleLines.forEach(line => {
ctx.fillText(line, 20, y)
y += 26
})
// 分隔线
ctx.setStrokeStyle('rgba(255,255,255,0.1)')
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(width - 20, y + 10)
ctx.stroke()
// 内容摘要
ctx.setFontSize(12)
ctx.setFillStyle('rgba(255,255,255,0.8)')
y += 30
const summary = contentParagraphs.slice(0, 3).join(' ').slice(0, 150) + '...'
const summaryLines = this.wrapText(ctx, summary, width - 40, 12)
summaryLines.slice(0, 6).forEach(line => {
ctx.fillText(line, 20, y)
y += 20
})
// 底部区域背景
ctx.setFillStyle('rgba(0,206,209,0.1)')
ctx.fillRect(0, height - 100, width, 100)
// 左侧提示文字
ctx.setFillStyle('#ffffff')
ctx.setFontSize(13)
ctx.fillText('长按识别小程序码', 20, height - 60)
ctx.setFillStyle('rgba(255,255,255,0.6)')
ctx.setFontSize(11)
ctx.fillText('长按小程序码阅读全文', 20, height - 38)
// 绘制小程序码或占位符
const drawQRCode = () => {
return new Promise((resolve) => {
if (qrcodeImage) {
// 下载base64图片并绘制
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFile({
filePath,
data: base64Data,
encoding: 'base64',
success: () => {
ctx.drawImage(filePath, width - 85, height - 85, 70, 70)
resolve()
},
fail: () => {
this.drawQRPlaceholder(ctx, width, height)
resolve()
}
})
} else {
this.drawQRPlaceholder(ctx, width, height)
resolve()
}
})
}
await drawQRCode()
ctx.draw(true, () => {
wx.hideLoading()
this.setData({ isGeneratingPoster: false })
})
} catch (e) {
console.error('生成海报失败:', e)
wx.hideLoading()
wx.showToast({ title: '生成失败', icon: 'none' })
this.setData({ showPosterModal: false, isGeneratingPoster: false })
console.log('[Poster] 获取小程序码失败,使用占位符')
}
const doDraw = () => {
try {
const ctx = wx.createCanvasContext('posterCanvas', this)
const width = 300
const height = 450
const grd = ctx.createLinearGradient(0, 0, 0, height)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.setFillStyle(grd)
ctx.fillRect(0, 0, width, height)
ctx.setFillStyle('#00CED1')
ctx.fillRect(0, 0, width, 4)
ctx.setFillStyle('#ffffff')
ctx.setFontSize(14)
ctx.fillText('📚 Soul创业派对', 20, 35)
ctx.setFontSize(18)
ctx.setFillStyle('#ffffff')
const title = section?.title || this.getSectionTitle(sectionId) || '精彩内容'
const titleLines = this.wrapText(ctx, title, width - 40, 18)
let y = 70
titleLines.forEach(line => {
ctx.fillText(line, 20, y)
y += 26
})
ctx.setStrokeStyle('rgba(255,255,255,0.1)')
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(width - 20, y + 10)
ctx.stroke()
ctx.setFontSize(12)
ctx.setFillStyle('rgba(255,255,255,0.8)')
y += 30
const summary = safeParagraphs.slice(0, 3).join(' ').replace(/\s+/g, ' ').trim().slice(0, 150)
const summaryText = summary ? summary + (summary.length >= 150 ? '...' : '') : '来自派对房的真实商业故事'
const summaryLines = this.wrapText(ctx, summaryText, width - 40, 12)
summaryLines.slice(0, 6).forEach(line => {
ctx.fillText(line, 20, y)
y += 20
})
ctx.setFillStyle('rgba(0,206,209,0.1)')
ctx.fillRect(0, height - 100, width, 100)
ctx.setFillStyle('#ffffff')
ctx.setFontSize(13)
ctx.fillText('长按识别小程序码', 20, height - 60)
ctx.setFillStyle('rgba(255,255,255,0.6)')
ctx.setFontSize(11)
ctx.fillText('长按小程序码阅读全文', 20, height - 38)
const drawQRCode = () => {
return new Promise((resolve) => {
if (qrcodeTempPath) {
ctx.drawImage(qrcodeTempPath, width - 85, height - 85, 70, 70)
} else {
this.drawQRPlaceholder(ctx, width, height)
}
resolve()
})
}
drawQRCode().then(() => {
ctx.draw(true, () => {
wx.hideLoading()
this.setData({ isGeneratingPoster: false })
})
})
} catch (e) {
console.error('生成海报失败:', e)
wx.hideLoading()
wx.showToast({ title: '生成失败', icon: 'none' })
this.setData({ showPosterModal: false, isGeneratingPoster: false })
}
}
setTimeout(doDraw, 400)
},
// 绘制小程序码占位符
@@ -1101,11 +1173,20 @@ Page({
this.setData({ showPosterModal: false })
},
// 保存海报到相册
// 保存海报到相册(与海报绘制尺寸一致,必须传 destWidth/destHeight 否则部分机型导出失败)
savePoster() {
const width = 300
const height = 450
wx.canvasToTempFilePath({
canvasId: 'posterCanvas',
destWidth: width,
destHeight: height,
fileType: 'png',
success: (res) => {
if (!res.tempFilePath) {
wx.showToast({ title: '生成图片失败', icon: 'none' })
return
}
wx.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
@@ -1113,25 +1194,25 @@ Page({
this.setData({ showPosterModal: false })
},
fail: (err) => {
if (err.errMsg.includes('auth deny')) {
console.error('[savePoster] saveImageToPhotosAlbum fail:', err)
if (err.errMsg && (err.errMsg.includes('auth deny') || err.errMsg.includes('authorize'))) {
wx.showModal({
title: '提示',
content: '需要相册权限才能保存海报',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
wx.openSetting()
}
success: (sres) => {
if (sres.confirm) wx.openSetting()
}
})
} else {
wx.showToast({ title: '保存失败', icon: 'none' })
wx.showToast({ title: err.errMsg || '保存失败', icon: 'none' })
}
}
})
},
fail: () => {
wx.showToast({ title: '生成图片失败', icon: 'none' })
fail: (err) => {
console.error('[savePoster] canvasToTempFilePath fail:', err)
wx.showToast({ title: err.errMsg || '生成图片失败', icon: 'none' })
}
}, this)
},

View File

@@ -297,4 +297,7 @@
<button class="fab-share" open-type="share">
<image class="fab-icon" src="/assets/icons/share.svg" mode="aspectFit"></image>
</button>
<!-- 分享卡片用 canvas离屏绘制用于生成分享图 -->
<canvas canvas-id="shareCardCanvas" class="share-card-canvas" style="width: 500px; height: 400px;"></canvas>
</view>

View File

@@ -912,6 +912,14 @@
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.5);
}
/* 分享卡片 canvas离屏绘制不展示给用户 */
.share-card-canvas {
position: fixed;
left: -600px;
top: 0;
z-index: -1;
}
.poster-actions {
display: flex;
gap: 24rpx;

View File

@@ -796,14 +796,13 @@ Page({
})
},
// 分享 - 带推荐码
// 分享 - 带自己的邀请码(与 app.getMyReferralCode 一致)
onShareAppMessage() {
console.log('[Referral] 分享给好友,推荐码:', this.data.referralCode)
const app = getApp()
const ref = app.getMyReferralCode() || this.data.referralCode
return {
title: 'Soul创业派对 - 来自派对房的真实商业故事',
path: `/pages/index/index?ref=${this.data.referralCode}`
// 不设置 imageUrl使用小程序默认截图
// 如需自定义图片,请将图片放在 /assets/ 目录并配置路径
path: ref ? `/pages/index/index?ref=${ref}` : '/pages/index/index'
}
},

View File

@@ -0,0 +1,157 @@
/**
* Soul创业派对 - 扫码解析页
* 扫描二维码/条形码,展示解析内容
*/
const app = getApp()
Page({
data: {
statusBarHeight: 44,
// 最近一次解析结果
lastResult: null,
scanType: '',
charSet: '',
// 小程序码解析(路径、参数)
parsedPath: null,
parsedQuery: [],
canNavigate: false,
// 历史记录
history: []
},
onLoad() {
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44
})
this.loadHistory()
},
loadHistory() {
try {
const history = wx.getStorageSync('scanHistory') || []
this.setData({ history })
} catch (e) {
console.log('加载扫码历史失败:', e)
}
},
saveToHistory(result, scanType, charSet) {
const item = { result, scanType, charSet, time: new Date().toLocaleString() }
let history = wx.getStorageSync('scanHistory') || []
history = [item, ...history].slice(0, 10)
wx.setStorageSync('scanHistory', history)
this.setData({ history })
},
// 解析小程序码内容path?key=val 或 path
parseMiniProgramCode(result) {
if (!result || typeof result !== 'string') return { path: null, query: [], canNavigate: false }
const idx = result.indexOf('?')
let path = idx >= 0 ? result.slice(0, idx) : result
const qs = idx >= 0 ? result.slice(idx + 1) : ''
path = path.replace(/^\//, '').trim()
const query = []
if (qs) {
qs.split('&').forEach(pair => {
const eq = pair.indexOf('=')
const k = eq >= 0 ? pair.slice(0, eq) : pair
const v = eq >= 0 ? pair.slice(eq + 1) : ''
try {
if (k) query.push({ key: decodeURIComponent(k), value: decodeURIComponent(v) })
} catch (_) {
if (k) query.push({ key: k, value: v })
}
})
}
const isMiniProgramPath = /^pages\/[\w-]+\/[\w-]+$/.test(path)
return { path: path || null, query, canNavigate: isMiniProgramPath }
},
// 发起扫码(支持小程序码)
doScan() {
wx.scanCode({
onlyFromCamera: false,
scanType: ['qrCode', 'barCode'],
success: (res) => {
const { result, scanType, charSet } = res
const parsed = this.parseMiniProgramCode(result)
this.setData({
lastResult: result,
scanType: scanType || '未知',
charSet: charSet || '',
parsedPath: parsed.path,
parsedQuery: parsed.query,
canNavigate: parsed.canNavigate
})
this.saveToHistory(result, scanType, charSet)
},
fail: (err) => {
if (err.errMsg && err.errMsg.includes('cancel')) {
return
}
wx.showToast({ title: err.errMsg || '扫码失败', icon: 'none' })
}
})
},
// 复制内容
copyResult() {
const { lastResult } = this.data
if (!lastResult) {
wx.showToast({ title: '暂无解析内容', icon: 'none' })
return
}
wx.setClipboardData({
data: lastResult,
success: () => wx.showToast({ title: '已复制', icon: 'success' })
})
},
// 清空当前结果
clearResult() {
this.setData({
lastResult: null, scanType: '', charSet: '',
parsedPath: null, parsedQuery: [], canNavigate: false
})
},
// 打开解析出的小程序页面
openParsedPage() {
const { parsedPath, parsedQuery } = this.data
if (!parsedPath || !this.data.canNavigate) {
wx.showToast({ title: '非本小程序页面', icon: 'none' })
return
}
const queryStr = parsedQuery.length
? '?' + parsedQuery.map(q => `${encodeURIComponent(q.key)}=${encodeURIComponent(q.value)}`).join('&')
: ''
const url = `/${parsedPath}${queryStr}`
const tabPages = ['pages/index/index', 'pages/chapters/chapters', 'pages/match/match', 'pages/my/my']
if (tabPages.includes(parsedPath)) {
wx.switchTab({ url: `/${parsedPath}` })
} else {
wx.navigateTo({ url })
}
},
// 清空历史
clearHistory() {
wx.setStorageSync('scanHistory', [])
this.setData({ history: [], lastResult: null, scanType: '', charSet: '' })
wx.showToast({ title: '已清空', icon: 'success' })
},
// 点击历史项复制
onHistoryItemTap(e) {
const result = e.currentTarget.dataset.result
if (!result) return
wx.setClipboardData({
data: result,
success: () => wx.showToast({ title: '已复制', icon: 'success' })
})
},
goBack() {
wx.navigateBack()
}
})

View File

@@ -0,0 +1 @@
{"usingComponents":{},"navigationStyle":"custom","navigationBarTitleText":"扫码解析"}

View File

@@ -0,0 +1,81 @@
<!--pages/scan/scan.wxml-->
<!--扫码解析页 - 扫描二维码/条形码展示解析内容-->
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<view class="back-btn" bindtap="goBack">
<text class="back-icon">←</text>
</view>
<text class="nav-title">扫码解析</text>
</view>
</view>
<!-- 主内容 -->
<view class="main-content" style="padding-top: {{statusBarHeight + 56}}px;">
<!-- 扫码按钮 -->
<view class="scan-action">
<view class="scan-btn" bindtap="doScan">
<text class="scan-icon">📷</text>
<text class="scan-text">扫描小程序码 / 二维码</text>
</view>
</view>
<!-- 解析结果 -->
<view class="result-card" wx:if="{{lastResult}}">
<view class="result-header">
<text class="result-label">解析内容</text>
<view class="result-actions">
<view class="action-btn" bindtap="copyResult">复制</view>
<view class="action-btn" wx:if="{{canNavigate}}" bindtap="openParsedPage">打开</view>
<view class="action-btn secondary" bindtap="clearResult">清空</view>
</view>
</view>
<!-- 小程序码解析:路径 + 参数(仅当为 pages/ 路径时展示) -->
<view class="parsed-section" wx:if="{{parsedPath && (parsedPath.indexOf('pages/') === 0 || parsedQuery.length > 0)}}">
<view class="parsed-row">
<text class="parsed-label">路径</text>
<text class="parsed-value">{{parsedPath}}</text>
</view>
<view class="parsed-row" wx:for="{{parsedQuery}}" wx:key="key" wx:for-item="q">
<text class="parsed-label">{{q.key}}</text>
<text class="parsed-value">{{q.value}}</text>
</view>
</view>
<view class="result-meta" wx:if="{{scanType}}">
<text class="meta-item">类型: {{scanType}}</text>
<text class="meta-item" wx:if="{{charSet}}">字符集: {{charSet}}</text>
</view>
<scroll-view class="result-content" scroll-y="{{true}}">
<text class="result-text" selectable="{{true}}">{{lastResult}}</text>
</scroll-view>
</view>
<!-- 无结果提示 -->
<view class="empty-tip" wx:else>
<text class="empty-icon">📷</text>
<text class="empty-text">点击上方按钮扫码</text>
<text class="empty-desc">支持小程序码、二维码、条形码</text>
</view>
<!-- 扫码历史 -->
<view class="history-section" wx:if="{{history.length > 0}}">
<view class="history-header">
<text class="history-title">扫码历史</text>
<view class="clear-history" bindtap="clearHistory">清空</view>
</view>
<view class="history-list">
<view
class="history-item"
wx:for="{{history}}"
wx:key="index"
bindtap="onHistoryItemTap"
data-result="{{item.result}}"
>
<text class="history-content">{{item.result}}</text>
<text class="history-time">{{item.time}}</text>
</view>
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,248 @@
/* 扫码解析页样式 */
.page {
min-height: 100vh;
background: linear-gradient(180deg, #0a0a0a 0%, #111111 100%);
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(10, 10, 10, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.nav-content {
display: flex;
align-items: center;
padding: 8rpx 24rpx;
height: 88rpx;
}
.back-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
font-size: 40rpx;
color: #00CED1;
}
.nav-title {
flex: 1;
font-size: 34rpx;
font-weight: 600;
color: #fff;
text-align: center;
margin-right: 60rpx;
}
.main-content {
padding: 24rpx;
}
/* 扫码按钮 */
.scan-action {
padding: 60rpx 0;
}
.scan-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(0, 206, 209, 0.2) 0%, rgba(32, 178, 170, 0.15) 100%);
border: 2rpx solid rgba(0, 206, 209, 0.4);
border-radius: 32rpx;
padding: 80rpx 48rpx;
}
.scan-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
}
.scan-text {
font-size: 32rpx;
color: #00CED1;
font-weight: 500;
}
/* 解析结果卡片 */
.result-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 24rpx;
padding: 32rpx;
margin-top: 32rpx;
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.result-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
font-weight: 500;
}
.result-actions {
display: flex;
gap: 24rpx;
}
.action-btn {
font-size: 26rpx;
color: #00CED1;
padding: 8rpx 20rpx;
}
.action-btn.secondary {
color: rgba(255, 255, 255, 0.5);
}
/* 小程序码解析:路径+参数 */
.parsed-section {
background: rgba(0, 206, 209, 0.08);
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 24rpx;
border: 1rpx solid rgba(0, 206, 209, 0.2);
}
.parsed-row {
display: flex;
align-items: baseline;
margin-bottom: 12rpx;
}
.parsed-row:last-child {
margin-bottom: 0;
}
.parsed-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
min-width: 120rpx;
flex-shrink: 0;
}
.parsed-value {
font-size: 26rpx;
color: #00CED1;
word-break: break-all;
}
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 20rpx;
}
.meta-item {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.4);
}
.result-content {
max-height: 400rpx;
background: rgba(0, 0, 0, 0.3);
border-radius: 16rpx;
padding: 24rpx;
}
.result-text {
font-size: 28rpx;
color: #fff;
line-height: 1.6;
word-break: break-all;
white-space: pre-wrap;
}
/* 无结果提示 */
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
opacity: 0.5;
}
.empty-text {
font-size: 30rpx;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 8rpx;
}
.empty-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
}
/* 扫码历史 */
.history-section {
margin-top: 48rpx;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.history-title {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
}
.clear-history {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.5);
}
.history-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.history-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 16rpx;
padding: 24rpx;
border: 1rpx solid rgba(255, 255, 255, 0.06);
}
.history-content {
font-size: 26rpx;
color: #fff;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-time {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.4);
margin-top: 8rpx;
display: block;
}

View File

@@ -105,5 +105,13 @@ Page({
// 返回上一页
goBack() {
wx.navigateBack()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 搜索',
path: ref ? `/pages/search/search?ref=${ref}` : '/pages/search/search'
}
}
})

View File

@@ -493,5 +493,13 @@ Page({
// 跳转到地址管理页
goToAddresses() {
wx.navigateTo({ url: '/pages/addresses/addresses' })
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 设置',
path: ref ? `/pages/settings/settings?ref=${ref}` : '/pages/settings/settings'
}
}
})

View File

@@ -119,5 +119,13 @@ Page({
wx.hideLoading()
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
}
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 提现记录',
path: ref ? `/pages/withdraw-records/withdraw-records?ref=${ref}` : '/pages/withdraw-records/withdraw-records'
}
}
})

View File

@@ -20,73 +20,5 @@
"useIsolateContext": true
},
"libVersion": "3.13.2",
"condition": {
"miniprogram": {
"list": [
{
"name": "找伙伴",
"pathName": "pages/match/match",
"query": "",
"scene": null,
"launchMode": "default"
},
{
"name": "pages/read/read",
"pathName": "pages/read/read",
"query": "id=1.1",
"launchMode": "default",
"scene": null
},
{
"name": "pages/match/match",
"pathName": "pages/match/match",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "看书",
"pathName": "pages/read/read",
"query": "id=1.4",
"launchMode": "default",
"scene": null
},
{
"name": "分销中心",
"pathName": "pages/referral/referral",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "阅读",
"pathName": "pages/read/read",
"query": "id=1.1",
"launchMode": "default",
"scene": null
},
{
"name": "分销中心",
"pathName": "pages/referral/referral",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "我的",
"pathName": "pages/my/my",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "新增地址",
"pathName": "pages/addresses/edit",
"query": "",
"launchMode": "default",
"scene": null
}
]
}
}
"condition": {}
}

View File

@@ -1,4 +1,7 @@
"use client";
import {
Presence
} from "./chunk-NOU7F7EJ.js";
import {
Combination_default,
DismissableLayer,
@@ -7,9 +10,6 @@ import {
hideOthers,
useFocusGuards
} from "./chunk-TQPPICSF.js";
import {
Presence
} from "./chunk-NOU7F7EJ.js";
import {
useId
} from "./chunk-LAKFU2YZ.js";

View File

@@ -1,10 +1,10 @@
"use client";
import {
require_react_dom
} from "./chunk-4GC24YIX.js";
import {
createSlot
} from "./chunk-GDI5LHIV.js";
import {
require_react_dom
} from "./chunk-4GC24YIX.js";
import "./chunk-H5WV2N77.js";
import {
require_jsx_runtime

View File

@@ -2,14 +2,14 @@
import {
clamp
} from "./chunk-QSHREGVI.js";
import {
createCollection,
useDirection
} from "./chunk-WRFKI3SR.js";
import {
usePrevious,
useSize
} from "./chunk-L3UKNFUA.js";
import {
createCollection,
useDirection
} from "./chunk-CEO7JH3I.js";
import {
Combination_default,
DismissableLayer,

View File

@@ -2,14 +2,14 @@
import {
clamp
} from "./chunk-QSHREGVI.js";
import {
createCollection,
useDirection
} from "./chunk-WRFKI3SR.js";
import {
usePrevious,
useSize
} from "./chunk-L3UKNFUA.js";
import {
createCollection,
useDirection
} from "./chunk-CEO7JH3I.js";
import {
Primitive,
composeEventHandlers,

View File

@@ -2,7 +2,7 @@
import {
createCollection,
useDirection
} from "./chunk-CEO7JH3I.js";
} from "./chunk-WRFKI3SR.js";
import {
Presence
} from "./chunk-NOU7F7EJ.js";

View File

@@ -1,109 +1,109 @@
{
"hash": "13546d7b",
"hash": "bc53cdae",
"configHash": "c74fa922",
"lockfileHash": "816dbc6e",
"browserHash": "392799fb",
"lockfileHash": "e3f3f1b6",
"browserHash": "7326a949",
"optimized": {
"react": {
"src": "../../.pnpm/react@18.3.1/node_modules/react/index.js",
"file": "react.js",
"fileHash": "ee001604",
"fileHash": "843e764f",
"needsInterop": true
},
"react-dom": {
"src": "../../.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/index.js",
"file": "react-dom.js",
"fileHash": "dae2d510",
"fileHash": "f71c92bb",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../.pnpm/react@18.3.1/node_modules/react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "c7694ff5",
"fileHash": "9eca8055",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../.pnpm/react@18.3.1/node_modules/react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "f3e00475",
"fileHash": "0dbe7d5b",
"needsInterop": true
},
"@radix-ui/react-dialog": {
"src": "../../.pnpm/@radix-ui+react-dialog@1.1._9cd126aa2880bf880e57e64087c058d6/node_modules/@radix-ui/react-dialog/dist/index.mjs",
"file": "@radix-ui_react-dialog.js",
"fileHash": "0416d3c6",
"fileHash": "3b38be5d",
"needsInterop": false
},
"@radix-ui/react-label": {
"src": "../../.pnpm/@radix-ui+react-label@2.1.8_8915bfc4015ea7714adf8fc777d50ad8/node_modules/@radix-ui/react-label/dist/index.mjs",
"file": "@radix-ui_react-label.js",
"fileHash": "3b5cd4b9",
"fileHash": "aa320a14",
"needsInterop": false
},
"@radix-ui/react-select": {
"src": "../../.pnpm/@radix-ui+react-select@2.2._f9b05e8db7247fc075715ae52301583d/node_modules/@radix-ui/react-select/dist/index.mjs",
"file": "@radix-ui_react-select.js",
"fileHash": "eec510f7",
"fileHash": "490b1f6f",
"needsInterop": false
},
"@radix-ui/react-slider": {
"src": "../../.pnpm/@radix-ui+react-slider@1.3._447f5338eb4e3826348b8f99da1e7596/node_modules/@radix-ui/react-slider/dist/index.mjs",
"file": "@radix-ui_react-slider.js",
"fileHash": "349206b0",
"fileHash": "980a24c0",
"needsInterop": false
},
"@radix-ui/react-slot": {
"src": "../../.pnpm/@radix-ui+react-slot@1.2.4_@types+react@18.3.28_react@18.3.1/node_modules/@radix-ui/react-slot/dist/index.mjs",
"file": "@radix-ui_react-slot.js",
"fileHash": "a24a0b17",
"fileHash": "e1819b67",
"needsInterop": false
},
"@radix-ui/react-switch": {
"src": "../../.pnpm/@radix-ui+react-switch@1.2._0225fbd6ccc84d9eda18b8a03485bb75/node_modules/@radix-ui/react-switch/dist/index.mjs",
"file": "@radix-ui_react-switch.js",
"fileHash": "b750fdf3",
"fileHash": "915561ff",
"needsInterop": false
},
"@radix-ui/react-tabs": {
"src": "../../.pnpm/@radix-ui+react-tabs@1.1.13_6db026cd1527317527c6849c7bd26c2f/node_modules/@radix-ui/react-tabs/dist/index.mjs",
"file": "@radix-ui_react-tabs.js",
"fileHash": "65c9dd67",
"fileHash": "13909eaa",
"needsInterop": false
},
"class-variance-authority": {
"src": "../../.pnpm/class-variance-authority@0.7.1/node_modules/class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js",
"fileHash": "2f219f9c",
"fileHash": "c8668335",
"needsInterop": false
},
"clsx": {
"src": "../../.pnpm/clsx@2.1.1/node_modules/clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "3ac7f8dd",
"fileHash": "cbd98ff1",
"needsInterop": false
},
"lucide-react": {
"src": "../../.pnpm/lucide-react@0.562.0_react@18.3.1/node_modules/lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "ca2692bf",
"fileHash": "d7138360",
"needsInterop": false
},
"react-dom/client": {
"src": "../../.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "5b781ac3",
"fileHash": "fa1c6ee3",
"needsInterop": true
},
"react-router-dom": {
"src": "../../.pnpm/react-router-dom@6.30.3_rea_8738f2f356869a9d467b32612b8c1bd5/node_modules/react-router-dom/dist/index.js",
"file": "react-router-dom.js",
"fileHash": "b98b9236",
"fileHash": "e77ab7bf",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../.pnpm/tailwind-merge@3.4.0/node_modules/tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "a33bcba1",
"fileHash": "4493c29c",
"needsInterop": false
}
},
@@ -111,18 +111,21 @@
"chunk-QSHREGVI": {
"file": "chunk-QSHREGVI.js"
},
"chunk-WRFKI3SR": {
"file": "chunk-WRFKI3SR.js"
},
"chunk-GDI5LHIV": {
"file": "chunk-GDI5LHIV.js"
},
"chunk-L3UKNFUA": {
"file": "chunk-L3UKNFUA.js"
},
"chunk-CEO7JH3I": {
"file": "chunk-CEO7JH3I.js"
"chunk-NOU7F7EJ": {
"file": "chunk-NOU7F7EJ.js"
},
"chunk-TQPPICSF": {
"file": "chunk-TQPPICSF.js"
},
"chunk-NOU7F7EJ": {
"file": "chunk-NOU7F7EJ.js"
},
"chunk-LAKFU2YZ": {
"file": "chunk-LAKFU2YZ.js"
},
@@ -132,9 +135,6 @@
"chunk-4GC24YIX": {
"file": "chunk-4GC24YIX.js"
},
"chunk-GDI5LHIV": {
"file": "chunk-GDI5LHIV.js"
},
"chunk-H5WV2N77": {
"file": "chunk-H5WV2N77.js"
},

View File

@@ -32,8 +32,8 @@ function Slider({
)}
{...props}
>
<SliderPrimitive.Track className="bg-muted relative grow overflow-hidden rounded-full h-1.5 w-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
<SliderPrimitive.Track className="bg-gray-600 relative grow overflow-hidden rounded-full h-1.5 w-full">
<SliderPrimitive.Range className="bg-[#38bdac] absolute h-full rounded-full" />
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb

View File

@@ -1,18 +1,13 @@
# 服务(监听端口,修改后重启 soul-api 生效)
# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效)
PORT=8080
GIN_MODE=debug
# 版本号(打包 zip 前改这里,上传后访问 /health 可看到)
# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本
APP_VERSION=0.0.0
# 数据air库(与 Next 现网一致:腾讯云 CDB soul_miniprogram
# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
# 可选:管理端鉴权密钥(若用 JWT
# JWT_SECRET=your-secret
# 可选:信任代理 IP逗号分隔部署在 Nginx 后时填写
# TRUSTED_PROXIES=127.0.0.1,::1
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
@@ -22,9 +17,20 @@ WECHAT_NOTIFY_URL=https://soul.quwanzhi.com/api/miniprogram/pay/notify
# 微信转账配置API v3
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
# 公钥证书(本地或 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
WECHAT_CERT_PATH=certs/apiclient_cert.pem
# 私钥(线上用 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem
WECHAT_KEY_PATH=certs/apiclient_key.pem
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
WECHAT_TRANSFER_URL=https://soul.quwanzhi.com/api/payment/wechat/transfer/notify
WECHAT_TRANSFER_URL=https://souladmin.quwanzhi.com/api/payment/wechat/transfer/notify
# 管理端登录(与 next-project 一致,默认 admin / admin123
# ADMIN_USERNAME=admin
# ADMIN_PASSWORD=admin123
# ADMIN_SESSION_SECRET=soul-admin-secret-change-in-prod
# 可选:信任代理 IP逗号分隔部署在 Nginx 后时填写
# TRUSTED_PROXIES=127.0.0.1,::1
# 跨域 CORS允许的源逗号分隔。未设置时使用默认值含 localhost、soul.quwanzhi.com
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com

View File

@@ -32,6 +32,5 @@ WECHAT_TRANSFER_URL=https://soul.quwanzhi.com/api/payment/wechat/transfer/notify
# 可选:信任代理 IP逗号分隔部署在 Nginx 后时填写
# TRUSTED_PROXIES=127.0.0.1,::1
# 可选:CORS 允许的源逗号分隔。未设置时默认含 localhost:5174 与 soul.quwanzhi.com
# 宝塔部署时若前端在别的域名,在此追加,例如:
# CORS_ORIGINS=http://localhost:5174,https://soul.quwanzhi.com,https://admin.quwanzhi.com
# 跨域 CORS允许的源逗号分隔。未设置时使用默认值(含 localhostsoul.quwanzhi.com
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com

View File

@@ -1,14 +1,72 @@
package handler
import (
"context"
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
// CronSyncOrders GET/POST /api/cron/sync-orders
// 对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
func CronSyncOrders(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
db := database.DB()
var createdOrders []model.Order
// 只处理最近 24 小时内创建的未支付订单
cutoff := time.Now().Add(-24 * time.Hour)
if err := db.Where("status = ? AND created_at > ?", "created", cutoff).Find(&createdOrders).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
synced := 0
ctx := context.Background()
for _, o := range createdOrders {
tradeState, transactionID, totalFee, err := wechat.QueryOrderByOutTradeNo(ctx, o.OrderSN)
if err != nil {
fmt.Printf("[SyncOrders] 查询订单 %s 失败: %v\n", o.OrderSN, err)
continue
}
if tradeState != "SUCCESS" {
continue
}
// 微信已支付,本地未更新 → 补齐
totalAmount := float64(totalFee) / 100
now := time.Now()
if err := db.Model(&o).Updates(map[string]interface{}{
"status": "paid",
"transaction_id": transactionID,
"pay_time": now,
"updated_at": now,
}).Error; err != nil {
fmt.Printf("[SyncOrders] 更新订单 %s 失败: %v\n", o.OrderSN, err)
continue
}
synced++
fmt.Printf("[SyncOrders] 补齐漏单: %s, amount=%.2f\n", o.OrderSN, totalAmount)
// 同步后续逻辑(全书、分销等)
pt := "fullbook"
if o.ProductType != "" {
pt = o.ProductType
}
if pt == "fullbook" {
db.Model(&model.User{}).Where("id = ?", o.UserID).Update("has_full_book", true)
}
processReferralCommission(db, o.UserID, totalAmount, o.OrderSN)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"synced": synced,
"total": len(createdOrders),
})
}
// CronUnbindExpired GET/POST /api/cron/unbind-expired

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
@@ -425,15 +426,21 @@ func MiniprogramPayNotify(c *gin.Context) {
TransactionID: &transactionID,
PayTime: &now,
}
db.Create(&order)
if err := db.Create(&order).Error; err != nil {
fmt.Printf("[PayNotify] 补记订单失败: %s, err=%v\n", orderSn, err)
return fmt.Errorf("create order: %w", err)
}
} else if *order.Status != "paid" {
status := "paid"
now := time.Now()
db.Model(&order).Updates(map[string]interface{}{
if err := db.Model(&order).Updates(map[string]interface{}{
"status": status,
"transaction_id": transactionID,
"pay_time": now,
})
}).Error; err != nil {
fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err)
return fmt.Errorf("update order: %w", err)
}
fmt.Printf("[PayNotify] 订单状态已更新为已支付: %s\n", orderSn)
} else {
fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn)
@@ -666,6 +673,30 @@ func MiniprogramQrcode(c *gin.Context) {
})
}
// MiniprogramQrcodeImage GET /api/miniprogram/qrcode/image?scene=xxx&page=xxx&width=280
// 直接返回 image/png供小程序 wx.downloadFile 使用,便于开发工具与真机统一用 tempFilePath 绘制
func MiniprogramQrcodeImage(c *gin.Context) {
scene := c.Query("scene")
if scene == "" {
scene = "soul"
}
page := c.DefaultQuery("page", "pages/read/read")
width, _ := strconv.Atoi(c.DefaultQuery("width", "280"))
if width <= 0 {
width = 280
}
imageData, err := wechat.GenerateMiniProgramCode(scene, page, width)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": fmt.Sprintf("生成小程序码失败: %v", err),
})
return
}
c.Header("Content-Type", "image/png")
c.Data(http.StatusOK, "image/png", imageData)
}
// base64 编码
func base64Encode(data []byte) string {
const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

View File

@@ -215,6 +215,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.POST("/pay", handler.MiniprogramPay)
miniprogram.POST("/pay/notify", handler.MiniprogramPayNotify) // 微信支付回调URL 需在商户平台配置
miniprogram.POST("/qrcode", handler.MiniprogramQrcode)
miniprogram.GET("/qrcode/image", handler.MiniprogramQrcodeImage)
miniprogram.GET("/book/all-chapters", handler.BookAllChapters)
miniprogram.GET("/book/chapter/:id", handler.BookChapterByID)
miniprogram.GET("/book/hot", handler.BookHot)

View File

@@ -210,10 +210,23 @@ func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
if page == "" {
page = "pages/index/index"
}
// 微信建议 scene 仅含英文字母、数字;& 和 = 可能导致异常,将 & 转为 _ 再传给微信
scene = strings.ReplaceAll(scene, "&", "_")
if len(scene) > 32 {
scene = scene[:32]
}
envVersion := "release"
if cfg != nil && cfg.WechatMiniProgramState != "" {
switch cfg.WechatMiniProgramState {
case "developer":
envVersion = "develop"
case "trial":
envVersion = "trial"
default:
envVersion = "release"
}
}
reqBody := map[string]interface{}{
"scene": scene,
"page": page,
@@ -221,9 +234,8 @@ func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
"auto_color": false,
"line_color": map[string]int{"r": 0, "g": 206, "b": 209},
"is_hyaline": false,
"env_version": "trial", // 体验版,正式发布后改为 release
"env_version": envVersion,
}
jsonData, _ := json.Marshal(reqBody)
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
if err != nil {
@@ -232,22 +244,17 @@ func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// 检查是否是 JSON 错误返回
if resp.Header.Get("Content-Type") == "application/json" {
var errResult struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &errResult); err == nil && errResult.ErrCode != 0 {
return nil, fmt.Errorf("生成小程序码失败: %d - %s", errResult.ErrCode, errResult.ErrMsg)
}
// 无论 Content-Type先尝试按 JSON 解析:微信错误时返回小体积 JSON否则会误报「图片数据异常(太小)」
var errResult struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if json.Unmarshal(body, &errResult) == nil && errResult.ErrCode != 0 {
return nil, fmt.Errorf("生成小程序码失败: %d - %s", errResult.ErrCode, errResult.ErrMsg)
}
if len(body) < 1000 {
return nil, fmt.Errorf("返回的图片数据异常(太小)")
return nil, fmt.Errorf("返回的图片数据异常(太小),可能未发布对应版本或参数错误")
}
return body, nil
}

View File

@@ -0,0 +1,12 @@
#!/bin/bash
# 订单对账防漏单 - 宝塔定时任务用
# 建议每 10 分钟执行一次
URL="${SYNC_ORDERS_URL:-https://soul.quwanzhi.com/api/cron/sync-orders}"
curl -s -X GET "$URL" \
-H "User-Agent: Baota-Cron/1.0" \
--connect-timeout 10 \
--max-time 30
echo ""

Binary file not shown.

View File

@@ -1 +1 @@
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1