This commit is contained in:
Alex-larget
2026-03-17 18:22:06 +08:00
parent 88915276d1
commit f276595ad6
50 changed files with 2246 additions and 1223 deletions

View File

@@ -6,22 +6,58 @@
const { parseScene } = require('./utils/scene.js')
const { checkAndExecute } = require('./utils/ruleEngine.js')
const PRODUCTION_URL = 'https://soulapi.quwanzhi.com'
const TEST_URL = 'https://souldev.quwanzhi.com'
const LOCAL_URL = 'http://localhost:8080'
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
function getRuntimeBootstrapConfig() {
try {
const accountInfo = wx.getAccountInfoSync?.()
const envVersion = accountInfo?.miniProgram?.envVersion || 'release'
const extCfg = wx.getExtConfigSync ? (wx.getExtConfigSync() || {}) : {}
// 按运行环境自动切换 API 地址
let baseUrl
if (envVersion === 'release') {
baseUrl = PRODUCTION_URL
} else if (envVersion === 'trial') {
baseUrl = extCfg.apiBaseUrl || wx.getStorageSync('apiBaseUrl') || TEST_URL
} else {
// develop不使用 storage避免被 loadMpConfig 曾写入的生产地址污染env-switch 仍可运行时覆盖
baseUrl = extCfg.apiBaseUrl || LOCAL_URL
}
return {
baseUrl,
appId: extCfg.appId || DEFAULT_APP_ID,
mchId: extCfg.mchId || DEFAULT_MCH_ID,
withdrawSubscribeTmplId: extCfg.withdrawSubscribeTmplId || DEFAULT_WITHDRAW_TMPL_ID
}
} catch (_) {
return {
baseUrl: PRODUCTION_URL,
appId: DEFAULT_APP_ID,
mchId: DEFAULT_MCH_ID,
withdrawSubscribeTmplId: DEFAULT_WITHDRAW_TMPL_ID
}
}
}
const bootstrapConfig = getRuntimeBootstrapConfig()
App({
globalData: {
// API 基础地址(切换环境时注释/取消注释)
// baseUrl: 'https://soulapi.quwanzhi.com',
baseUrl: 'http://localhost:8080', // 本地调试
// baseUrl: 'https://souldev.quwanzhi.com', // 测试环境
// API 基础地址:优先外部配置/缓存,其次默认生产环境
baseUrl: bootstrapConfig.baseUrl,
// 小程序配置 - 真实AppID
appId: 'wxb8bbb2b10dec74aa',
appId: bootstrapConfig.appId,
// 订阅消息:用户点击「申请提现」→「立即提现」时会先弹出订阅授权窗
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE',
withdrawSubscribeTmplId: bootstrapConfig.withdrawSubscribeTmplId,
// 微信支付配置
mchId: '1318592501', // 商户号
mchId: bootstrapConfig.mchId,
// 用户信息
userInfo: null,
@@ -67,7 +103,16 @@ App({
isSinglePageMode: false,
// 更新检测:上次检测时间戳,避免频繁请求
lastUpdateCheck: 0
lastUpdateCheck: 0,
// mpConfig 上次刷新时间戳onShow 节流,避免频繁请求)
lastMpConfigCheck: 0,
// 审核模式:后端 /api/miniprogram/config 返回 auditMode=true 时隐藏所有支付相关UI
auditMode: false,
// 客服/微信mp_config 返回 supportWechat
supportWechat: '',
// API 域名loadMpConfig 从 config 更新
apiDomain: ''
},
onLaunch(options) {
@@ -102,10 +147,16 @@ App({
this.handleReferralCode(options)
},
// 小程序显示时:处理分享参数、检测更新(从后台切回时)
// 小程序显示时:处理分享参数、检测更新、刷新 mpConfig(从后台切回时)
onShow(options) {
this.handleReferralCode(options)
this.checkUpdate()
// 从后台切回时刷新审核模式等配置(节流 30 秒,避免频繁请求)
const now = Date.now()
if (!this.globalData.lastMpConfigCheck || now - this.globalData.lastMpConfigCheck > 30 * 1000) {
this.globalData.lastMpConfigCheck = now
this.loadMpConfig()
}
},
// 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref与 utils/scene 解析闭环
@@ -330,15 +381,35 @@ App({
}
},
// 加载 mpConfigappId、mchId、withdrawSubscribeTmplId 等),失败时保留 globalData 默认值
// 加载 mpConfigappId、mchId、withdrawSubscribeTmplId、auditMode、supportWechat、apiDomain 等),失败时保留 globalData 默认值
async loadMpConfig() {
try {
const res = await this.request({ url: '/api/miniprogram/config', silent: true })
const res = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config)
if (mp && typeof mp === 'object') {
if (mp.appId) this.globalData.appId = mp.appId
if (mp.mchId) this.globalData.mchId = mp.mchId
if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId
// 仅正式版使用后端 apiDomain开发/体验版保持 bootstrap 的 baseUrl避免被生产地址覆盖
try {
const envVersion = wx.getAccountInfoSync?.()?.miniProgram?.envVersion || 'release'
if (envVersion === 'release' && mp.apiDomain) {
this.globalData.baseUrl = mp.apiDomain
this.globalData.apiDomain = mp.apiDomain
try { wx.setStorageSync('apiBaseUrl', mp.apiDomain) } catch (_) {}
}
} catch (_) {}
this.globalData.auditMode = !!mp.auditMode
this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || ''
// 通知当前已加载的页面刷新 auditMode从后台切回时配置更新后立即生效
try {
const pages = getCurrentPages()
pages.forEach(p => {
if (p && p.data && 'auditMode' in p.data) {
p.setData({ auditMode: this.globalData.auditMode || false })
}
})
} catch (_) {}
}
} catch (e) {
console.warn('[App] loadMpConfig 失败,使用默认值:', e?.message || e)
@@ -426,6 +497,7 @@ App({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || 15000,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',

View File

@@ -59,7 +59,9 @@
}
]
},
"usingComponents": {},
"usingComponents": {
"env-switch": "/components/env-switch/env-switch"
},
"navigateToMiniProgramAppIdList": [
"wx6489c26045912fe1",
"wx3d15ed02e98b04e3"

View File

@@ -0,0 +1,82 @@
/**
* 开发环境专用:可拖拽的 baseURL 切换悬浮按钮
* 正式环境release不显示
*/
const PRODUCTION_URL = 'https://soulapi.quwanzhi.com'
const STORAGE_KEY = 'apiBaseUrl'
const POSITION_KEY = 'envSwitchPosition'
const URL_OPTIONS = [
{ label: '生产', url: PRODUCTION_URL },
{ label: '本地', url: 'http://localhost:8080' },
{ label: '测试', url: 'https://souldev.quwanzhi.com' },
]
Component({
data: {
visible: false,
x: 20,
y: 120,
currentLabel: '生产',
areaWidth: 375,
areaHeight: 812,
},
lifetimes: {
attached() {
try {
const accountInfo = wx.getAccountInfoSync?.()
const envVersion = accountInfo?.miniProgram?.envVersion || 'release'
if (envVersion === 'release') {
return
}
const sys = wx.getSystemInfoSync?.() || {}
const areaWidth = sys.windowWidth || 375
const areaHeight = sys.windowHeight || 812
const saved = wx.getStorageSync(POSITION_KEY)
const pos = saved ? JSON.parse(saved) : { x: 20, y: 120 }
// 与 app.js 一致storage 优先,否则用 globalData已按 env 自动切换)
const current = wx.getStorageSync(STORAGE_KEY) || getApp().globalData?.baseUrl || PRODUCTION_URL
const opt = URL_OPTIONS.find(o => o.url === current) || URL_OPTIONS[0]
this.setData({
visible: true,
x: pos.x ?? 20,
y: pos.y ?? 120,
currentLabel: opt.label,
areaWidth,
areaHeight,
})
} catch (_) {
this.setData({ visible: false })
}
},
},
methods: {
onTap() {
const items = URL_OPTIONS.map(o => o.label)
const current = wx.getStorageSync(STORAGE_KEY) || PRODUCTION_URL
const idx = URL_OPTIONS.findIndex(o => o.url === current)
wx.showActionSheet({
itemList: items,
success: (res) => {
const opt = URL_OPTIONS[res.tapIndex]
wx.setStorageSync(STORAGE_KEY, opt.url)
const app = getApp()
if (app && app.globalData) {
app.globalData.baseUrl = opt.url
}
this.setData({ currentLabel: opt.label })
wx.showToast({ title: `已切到${opt.label}`, icon: 'none', duration: 1500 })
},
})
},
onMovableChange(e) {
const { x, y } = e.detail
if (typeof x === 'number' && typeof y === 'number') {
wx.setStorageSync(POSITION_KEY, JSON.stringify({ x, y }))
}
},
},
})

View File

@@ -0,0 +1,3 @@
{
"component": true
}

View File

@@ -0,0 +1,13 @@
<movable-area wx:if="{{visible}}" class="env-area" style="width:{{areaWidth}}px;height:{{areaHeight}}px;">
<movable-view
class="env-btn"
direction="all"
inertia
x="{{x}}"
y="{{y}}"
bindchange="onMovableChange"
bindtap="onTap"
>
<view class="env-btn-inner">{{currentLabel}}</view>
</movable-view>
</movable-area>

View File

@@ -0,0 +1,30 @@
.env-area {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 9999;
}
.env-btn {
width: 72rpx;
height: 72rpx;
pointer-events: auto;
}
.env-btn-inner {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 600;
color: #fff;
box-shadow: 0 4rpx 12rpx rgba(34, 197, 94, 0.4);
border: 2rpx solid rgba(255, 255, 255, 0.3);
}

View File

@@ -40,9 +40,6 @@ Page({
{ id: 'appendix-3', title: '附录3本书提到的工具和资源' }
],
// 每日新增章节(懒加载后暂无,可后续用 latest-chapters 补充)
dailyChapters: [],
// book/parts 加载中
partsLoading: true,
@@ -59,7 +56,6 @@ Page({
this.updateUserStatus()
this.loadVipStatus()
this.loadParts()
this.loadDailyChapters()
this.loadFeatureConfig()
},
@@ -197,36 +193,11 @@ Page({
},
onPullDownRefresh() {
Promise.all([this.loadParts(), this.loadDailyChapters()])
this.loadParts()
.then(() => wx.stopPullDownRefresh())
.catch(() => wx.stopPullDownRefresh())
},
// 每日新增:用 latest-chapters 接口,展示最近更新章节
async loadDailyChapters() {
try {
const res = await app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true })
const list = (res && res.data) ? res.data : []
const pt = (c) => (c.partTitle || c.part_title || '').toLowerCase()
const exclude = c => !pt(c).includes('序言') && !pt(c).includes('尾声') && !pt(c).includes('附录')
const daily = list
.filter(exclude)
.slice(0, 10)
.map(c => {
const d = new Date(c.updatedAt || c.updated_at || Date.now())
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
return {
id: c.id,
mid: c.mid ?? c.MID ?? 0,
title,
price: c.price ?? 1,
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
}
})
this.setData({ dailyChapters: daily })
} catch (e) { console.log('[Chapters] 加载每日新增失败:', e) }
},
onShow() {
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {

View File

@@ -40,31 +40,6 @@
<!-- 目录内容 -->
<view class="chapters-content" wx:if="{{!partsLoading}}">
<!-- 每日新增(最近更新章节快捷入口) -->
<view class="daily-section" wx:if="{{dailyChapters.length > 0}}">
<view class="daily-header">
<text class="daily-title">每日新增</text>
<text class="daily-badge">+{{dailyChapters.length}}</text>
</view>
<view class="daily-list">
<view
class="daily-item"
wx:for="{{dailyChapters}}"
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<view class="daily-dot"></view>
<view class="daily-content">
<text class="daily-item-title">{{item.title}}</text>
<text class="daily-item-meta">{{item.dateStr}} · ¥{{item.price}}</text>
</view>
<text class="daily-arrow"></text>
</view>
</view>
</view>
<!-- 序言(优先传 mid阅读页用 by-mid 请求) -->
<view class="chapter-item" bindtap="goToRead" data-id="preface" data-mid="{{fixedSectionsMap.preface}}">
<view class="item-left">
@@ -158,4 +133,5 @@
<!-- 底部留白 -->
<view class="bottom-space"></view>
<env-switch />
</view>

View File

@@ -174,89 +174,6 @@
box-sizing: border-box;
}
/* ===== 每日新增 ===== */
.daily-section {
margin-bottom: 32rpx;
padding: 24rpx;
background: #1c1c1e;
border-radius: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.05);
}
.daily-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 24rpx;
}
.daily-title {
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
}
.daily-badge {
font-size: 22rpx;
padding: 4rpx 12rpx;
background: #F6AD55;
color: #ffffff;
border-radius: 20rpx;
}
.daily-list {
display: flex;
flex-direction: column;
gap: 0;
}
.daily-item {
display: flex;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.daily-item:last-child {
border-bottom: none;
}
.daily-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: rgba(0, 206, 209, 0.6);
margin-right: 20rpx;
flex-shrink: 0;
}
.daily-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.daily-item-title {
font-size: 26rpx;
color: #ffffff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.daily-item-meta {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.4);
}
.daily-arrow {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.4);
margin-left: 16rpx;
}
/* ===== 章节项 ===== */
.chapter-item {
display: flex;
@@ -609,21 +526,6 @@
color: rgba(255, 255, 255, 0.3);
}
/* ===== 每日新增章节 ===== */
.daily-section { margin: 20rpx 0; padding: 24rpx; background: rgba(255,215,0,0.04); border: 1rpx solid rgba(255,215,0,0.12); border-radius: 16rpx; }
.daily-header { display: flex; align-items: center; gap: 12rpx; margin-bottom: 16rpx; }
.daily-title { font-size: 30rpx; font-weight: 600; color: #FFD700; }
.daily-badge { font-size: 22rpx; background: #FFD700; color: #000; padding: 2rpx 12rpx; border-radius: 20rpx; font-weight: bold; }
.daily-list { display: flex; flex-direction: column; gap: 12rpx; }
.daily-item { display: flex; justify-content: space-between; align-items: center; padding: 16rpx; background: rgba(255,255,255,0.03); border-radius: 12rpx; }
.daily-left { display: flex; align-items: center; gap: 10rpx; flex: 1; min-width: 0; }
.daily-new-tag { font-size: 18rpx; background: #FF4444; color: #fff; padding: 2rpx 8rpx; border-radius: 6rpx; font-weight: bold; flex-shrink: 0; }
.daily-item-title { font-size: 26rpx; color: rgba(255,255,255,0.85); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.daily-right { display: flex; align-items: center; gap: 12rpx; flex-shrink: 0; }
.daily-price { font-size: 26rpx; color: #FFD700; font-weight: 600; }
.daily-date { font-size: 20rpx; color: rgba(255,255,255,0.35); }
.daily-note { display: block; font-size: 22rpx; color: rgba(255,215,0,0.5); margin-top: 12rpx; text-align: center; }
/* ===== 底部留白 ===== */
.bottom-space {
height: 40rpx;

View File

@@ -106,4 +106,5 @@
<view class="bg-glow bg-glow-2"></view>
<view class="bg-dots"></view>
</view>
<env-switch />
</view>

View File

@@ -7,6 +7,7 @@
console.log('[Index] ===== 首页文件开始加载 =====')
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
@@ -68,7 +69,10 @@ Page({
featuredExpandedLoading: false,
// 功能配置(搜索开关)
searchEnabled: true
searchEnabled: true,
// 审核模式:隐藏支付相关入口
auditMode: false
},
onLoad(options) {
@@ -93,6 +97,7 @@ Page({
onShow() {
console.log('[Index] onShow 触发')
this.setData({ auditMode: app.globalData.auditMode || false })
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
@@ -309,35 +314,45 @@ Page({
// 跳转到目录
goToChapters() {
trackClick('home', 'nav_click', '阅读进度')
wx.switchTab({ url: '/pages/chapters/chapters' })
},
async loadFeatureConfig() {
try {
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean'
if (hasCachedFeatures) {
this.setData({
searchEnabled: app.globalData.features.searchEnabled,
auditMode: app.globalData.auditMode || false
})
return
}
const res = await app.request({ url: '/api/miniprogram/config', silent: true })
const features = (res && res.features) || {}
const mp = (res && res.mpConfig) || {}
const searchEnabled = features.searchEnabled !== false
const auditMode = !!mp.auditMode
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
this.setData({ searchEnabled })
app.globalData.auditMode = auditMode
this.setData({ searchEnabled, auditMode })
} catch (e) {
this.setData({ searchEnabled: true })
this.setData({ searchEnabled: true, auditMode: app.globalData.auditMode || false })
}
},
// 跳转到搜索页
goToSearch() {
if (!this.data.searchEnabled) return
trackClick('home', 'nav_click', '搜索')
wx.navigateTo({ url: '/pages/search/search' })
},
// 跳转到阅读页(优先传 mid与分享逻辑一致
goToRead(e) {
const id = e.currentTarget.dataset.id
trackClick('home', 'card_click', id || '章节')
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
@@ -349,10 +364,12 @@ Page({
},
goToVip() {
trackClick('home', 'btn_click', '加入创业派对')
wx.navigateTo({ url: '/pages/vip/vip' })
},
async onLinkKaruo() {
trackClick('home', 'btn_click', '链接卡若')
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
@@ -528,6 +545,7 @@ Page({
// 精选推荐:展开/折叠
async toggleFeaturedExpanded() {
if (this.data.featuredExpandedLoading) return
trackClick('home', 'tab_click', this.data.featuredExpanded ? '精选收起' : '精选展开')
if (this.data.featuredExpanded) {
const collapsed = this.data.featuredSectionsFull.length > 0 ? this.data.featuredSectionsFull.slice(0, 3) : this.data.featuredSections
this.setData({ featuredExpanded: false, featuredSections: collapsed })
@@ -564,6 +582,7 @@ Page({
// 最新新增:展开/折叠(默认 5 条,点击展开剩余)
toggleLatestExpanded() {
trackClick('home', 'tab_click', this.data.latestExpanded ? '最新收起' : '最新展开')
const expanded = !this.data.latestExpanded
const display = expanded ? this.data.latestChapters : this.data.latestChapters.slice(0, 5)
this.setData({ latestExpanded: expanded, displayLatestChapters: display })
@@ -598,6 +617,7 @@ Page({
goToMemberDetail(e) {
const id = e.currentTarget.dataset.id
trackClick('home', 'card_click', '超级个体_' + (id || ''))
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${id}` })
},

View File

@@ -65,8 +65,8 @@
</view>
</view>
<!-- 超级个体(横向滚动,已去掉「查看全部」) -->
<view class="section">
<!-- 超级个体(横向滚动,已去掉「查看全部」;审核模式隐藏 -->
<view class="section" wx:if="{{!auditMode}}">
<view class="section-header">
<text class="section-title">超级个体</text>
</view>
@@ -190,4 +190,5 @@
</view>
</view>
</view>
<env-switch />
</view>

View File

@@ -6,6 +6,7 @@
const app = getApp()
const { checkAndExecute } = require('../../utils/ruleEngine.js')
const { trackClick } = require('../../utils/trackClick')
// 默认匹配类型配置
// 找伙伴:真正的匹配功能,匹配数据库中的真实用户
@@ -198,6 +199,7 @@ Page({
// 选择匹配类型
selectType(e) {
const typeId = e.currentTarget.dataset.type
trackClick('match', 'tab_click', typeId || '类型')
const type = MATCH_TYPES.find(t => t.id === typeId)
this.setData({
selectedType: typeId,
@@ -207,6 +209,7 @@ Page({
// 点击匹配按钮
async handleMatchClick() {
trackClick('match', 'btn_click', '匹配_' + (this.data.selectedType || ''))
const currentType = MATCH_TYPES.find(t => t.id === this.data.selectedType)
// 导师顾问:先播匹配动画,动画完成后再跳转(不在此处直接跳)

View File

@@ -325,4 +325,5 @@
<!-- 底部留白 -->
<view class="bottom-space"></view>
<env-switch />
</view>

View File

@@ -6,6 +6,7 @@
const app = getApp()
const { formatStatNum } = require('../../utils/util.js')
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
@@ -39,6 +40,7 @@ Page({
// 功能配置
matchEnabled: false,
referralEnabled: true,
auditMode: false,
searchEnabled: true,
// VIP状态
@@ -98,6 +100,7 @@ Page({
},
onShow() {
this.setData({ auditMode: app.globalData.auditMode || false })
// 设置TabBar选中状态根据 matchEnabled 动态设置)
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
@@ -118,8 +121,11 @@ Page({
const matchEnabled = features.matchEnabled === true
const referralEnabled = features.referralEnabled !== false
const searchEnabled = features.searchEnabled !== false
const mp = (res && res.mpConfig) || {}
const auditMode = !!mp.auditMode
app.globalData.auditMode = auditMode
app.globalData.features = { matchEnabled, referralEnabled, searchEnabled }
this.setData({ matchEnabled, referralEnabled, searchEnabled })
this.setData({ matchEnabled, referralEnabled, searchEnabled, auditMode })
} catch (error) {
console.log('加载功能配置失败:', error)
this.setData({ matchEnabled: false, referralEnabled: true, searchEnabled: true })
@@ -322,6 +328,7 @@ Page({
// 一键收款:逐条调起微信收款页(有上一页则返回,无则回首页)
async handleOneClickReceive() {
trackClick('my', 'btn_click', '一键收款')
if (!this.data.isLoggedIn) { this.showLogin(); return }
if (this.data.receivingAll) return
@@ -664,6 +671,7 @@ Page({
// 显示登录弹窗(每次打开时协议未勾选,符合审核要求)
showLogin() {
trackClick('my', 'btn_click', '点击登录')
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
try {
const sys = wx.getSystemInfoSync()
@@ -764,6 +772,7 @@ Page({
// 点击菜单
handleMenuTap(e) {
const id = e.currentTarget.dataset.id
trackClick('my', 'nav_click', id || '菜单')
if (!this.data.isLoggedIn) {
this.showLogin()
@@ -787,6 +796,7 @@ Page({
// 跳转到阅读页(优先传 mid与分享逻辑一致
goToRead(e) {
const id = e.currentTarget.dataset.id
trackClick('my', 'card_click', id || '章节')
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
@@ -794,16 +804,19 @@ Page({
// 跳转到目录
goToChapters() {
trackClick('my', 'nav_click', '已读章节')
wx.switchTab({ url: '/pages/chapters/chapters' })
},
// 跳转到匹配
goToMatch() {
trackClick('my', 'nav_click', '匹配伙伴')
wx.switchTab({ url: '/pages/match/match' })
},
// 跳转到推广中心(需登录)
goToReferral() {
trackClick('my', 'nav_click', '推广中心')
if (!this.data.isLoggedIn) {
this.showLogin()
return
@@ -919,18 +932,21 @@ Page({
},
goToVip() {
trackClick('my', 'btn_click', '会员中心')
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/vip/vip' })
},
// 进入个人资料编辑页stitch_soul
goToProfileEdit() {
trackClick('my', 'nav_click', '资料编辑')
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
// 进入个人资料展示页enhanced_professional_profile展示页内可再进编辑
goToProfileShow() {
trackClick('my', 'btn_click', '编辑')
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/profile-show/profile-show' })
},

View File

@@ -39,10 +39,10 @@
<image class="profile-edit-icon" src="/assets/icons/edit-gray.svg" mode="aspectFit"/>
<text class="profile-edit-text">编辑</text>
</view>
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" wx:if="{{!auditMode}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
</view>
</view>
<view class="vip-tags">
<view class="vip-tags" wx:if="{{!auditMode}}">
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToMatch">匹配</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
@@ -63,7 +63,7 @@
<text class="profile-stat-val">{{earnings === '-' ? '--' : earnings}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
<view class="profile-stat" bindtap="handleMenuTap" data-id="wallet">
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="handleMenuTap" data-id="wallet">
<text class="profile-stat-val">{{walletBalanceText}}</text>
<text class="profile-stat-label">我的余额</text>
</view>
@@ -73,8 +73,8 @@
<!-- 已登录:内容区 -->
<view class="main-content" wx:if="{{isLoggedIn}}">
<!-- 一键收款(仅在有待确认收款时显示) -->
<view class="card receive-card" wx:if="{{pendingConfirmList.length > 0}}">
<!-- 一键收款(仅在有待确认收款时显示;审核模式隐藏 -->
<view class="card receive-card" wx:if="{{pendingConfirmList.length > 0 && !auditMode}}">
<view class="receive-top">
<view class="receive-left">
<view class="receive-title-row">
@@ -253,4 +253,5 @@
</view>
<view class="bottom-space"></view>
<env-switch />
</view>

View File

@@ -77,20 +77,29 @@ Page({
// 余额(用于余额支付)
walletBalance: 0,
// 审核模式:隐藏购买按钮
auditMode: false,
},
onShow() {
this.setData({ auditMode: app.globalData.auditMode || false })
},
async onLoad(options) {
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
// 预加载 linkTags、linkedMiniprograms供 onLinkTagTap 用密钥查 appId
if (!app.globalData.linkTagsConfig || !app.globalData.linkedMiniprograms) {
app.request({ url: '/api/miniprogram/config', silent: true }).then(cfg => {
if (cfg) {
if (Array.isArray(cfg.linkTags)) app.globalData.linkTagsConfig = cfg.linkTags
if (Array.isArray(cfg.linkedMiniprograms)) app.globalData.linkedMiniprograms = cfg.linkedMiniprograms
}
}).catch(() => {})
}
// 预加载 configlinkTags、auditMode 等(阅读页直接进入时需主动拉取最新审核状态
app.request({ url: '/api/miniprogram/config', silent: true }).then(cfg => {
if (cfg) {
if (Array.isArray(cfg.linkTags)) app.globalData.linkTagsConfig = cfg.linkTags
if (Array.isArray(cfg.linkedMiniprograms)) app.globalData.linkedMiniprograms = cfg.linkedMiniprograms
const mp = (cfg && cfg.mpConfig) || {}
const auditMode = !!mp.auditMode
app.globalData.auditMode = auditMode
if (typeof this.setData === 'function') this.setData({ auditMode })
}
}).catch(() => {})
// 支持 scene扫码、mid、id、ref、gift代付
const sceneStr = (options && options.scene) || ''
@@ -749,6 +758,33 @@ Page({
})
},
// 右下角悬浮按钮:分享到朋友圈(复制文案 + 引导点右上角)
shareToMoments() {
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
const raw = (this.data.content || '')
.replace(/<[^>]+>/g, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/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 copyText = picked.length > 0 ? title + '\n\n' + picked.join('\n\n') : `🔥 刚看完这篇《${title}》,推荐给你!\n\n#Soul创业派对 #真实商业故事`
wx.setClipboardData({
data: copyText,
success: () => {
wx.showModal({
title: '文案已复制',
content: '请点击右上角「···」菜单,选择「分享到朋友圈」即可发布',
showCancel: false,
confirmText: '知道了'
})
},
fail: () => {
wx.showToast({ title: '复制失败,请手动复制', icon: 'none' })
}
})
},
// 分享到朋友圈:带文章标题,过长时截断
onShareTimeline() {
const { section, sectionId, sectionMid, chapterTitle } = this.data

View File

@@ -93,11 +93,14 @@
<text class="action-icon-small">🖼️</text>
<text class="action-text-small">生成海报</text>
</view>
<view class="action-btn-inline btn-gift-inline" bindtap="showGiftShareModal" wx:if="{{isLoggedIn}}">
<view class="action-btn-inline btn-gift-inline" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<text class="action-icon-small">🎁</text>
<text class="action-text-small">代付分享</text>
</view>
</view>
<view class="share-tip-inline" wx:if="{{!auditMode}}">
<text class="share-tip-text">分享后好友购买,你可获得 90% 收益</text>
</view>
</view>
</view>
@@ -169,8 +172,8 @@
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
<!-- 购买选项 -->
<view class="purchase-options">
<!-- 购买选项(审核模式隐藏) -->
<view class="purchase-options" wx:if="{{!auditMode}}">
<!-- 购买本章 - 直接调起支付 -->
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
@@ -189,10 +192,11 @@
</view>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-tip">分享给好友一起学习,还能赚取佣金</text>
<!-- 代付分享:让好友帮我买 -->
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn}}">
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<!-- 代付分享:让好友帮我买(审核模式隐藏) -->
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<text class="gift-share-icon">🎁</text>
<text class="gift-share-text">找好友代付</text>
</view>
@@ -306,8 +310,9 @@
</view>
</view>
<!-- 右下角悬浮分享按钮 -->
<button class="fab-share" open-type="share">
<image class="fab-icon" src="/assets/icons/share.svg" mode="aspectFit"></image>
</button>
<!-- 右下角悬浮按钮 - 分享到朋友圈 -->
<view class="fab-share" bindtap="shareToMoments">
<text class="fab-moments-icon">🌐</text>
</view>
<env-switch />
</view>

View File

@@ -336,6 +336,12 @@
text-align: center;
display: block;
}
.paywall-audit-tip {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.5);
text-align: center;
padding: 24rpx 0;
}
/* ===== 章节导航 ===== */
.chapter-nav {
@@ -475,6 +481,15 @@
font-weight: 500;
}
.share-tip-inline {
margin-top: 16rpx;
text-align: center;
}
.share-tip-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
}
/* ===== 推广提示区 ===== */
.promo-section {
margin-top: 32rpx;
@@ -1051,3 +1066,8 @@
display: block;
}
.fab-moments-icon {
font-size: 44rpx;
line-height: 1;
}

View File

@@ -8,6 +8,7 @@
* - 收益统计90%归分发者)
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
@@ -258,6 +259,7 @@ Page({
// 切换Tab
switchTab(e) {
const tab = e.currentTarget.dataset.tab
trackClick('referral', 'tab_click', tab || '绑定列表')
let currentBindings = []
if (tab === 'active') {
@@ -278,6 +280,7 @@ Page({
// 复制邀请链接
copyLink() {
trackClick('referral', 'btn_click', '复制链接')
const link = `https://soul.quwanzhi.com/?ref=${this.data.referralCode}`
wx.setClipboardData({
data: link,
@@ -287,6 +290,7 @@ Page({
// 分享到朋友圈 - 1:1 迁移 Next.js 的 handleShareToWechat
shareToWechat() {
trackClick('referral', 'btn_click', '分享朋友圈')
const { referralCode } = this.data
const referralLink = `https://soul.quwanzhi.com/?ref=${referralCode}`
@@ -314,6 +318,7 @@ Page({
// 更多分享方式 - 1:1 迁移 Next.js 的 handleShare
handleMoreShare() {
trackClick('referral', 'btn_click', '更多分享')
const { referralCode } = this.data
const referralLink = `https://soul.quwanzhi.com/?ref=${referralCode}`
@@ -334,6 +339,7 @@ Page({
// 生成推广海报 - 1:1 对齐 Next.js 设计
async generatePoster() {
trackClick('referral', 'btn_click', '生成海报')
wx.showLoading({ title: '生成中...', mask: true })
this.setData({ showPosterModal: true, isGeneratingPoster: true })
@@ -624,6 +630,7 @@ Page({
// 提现 - 直接到微信零钱
async handleWithdraw() {
trackClick('referral', 'btn_click', '申请提现')
const availableEarnings = this.data.availableEarningsNum || 0
const minWithdrawAmount = this.data.minWithdrawAmount || 10
const hasWechatId = this.data.hasWechatId
@@ -670,6 +677,7 @@ Page({
// 跳转提现记录页
goToWithdrawRecords() {
trackClick('referral', 'nav_click', '提现记录')
wx.navigateTo({ url: '/pages/withdraw-records/withdraw-records' })
},

View File

@@ -329,4 +329,5 @@
</view>
</view>
</view>
<env-switch />
</view>

View File

@@ -3,6 +3,7 @@
* 搜索章节标题和内容
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
@@ -71,6 +72,7 @@ Page({
// 点击热门关键词
onHotKeyword(e) {
const keyword = e.currentTarget.dataset.keyword
trackClick('search', 'tab_click', keyword || '关键词')
this.setData({ keyword })
this.doSearch()
},
@@ -78,6 +80,7 @@ Page({
// 执行搜索
async doSearch() {
const { keyword } = this.data
if (keyword && keyword.trim().length >= 1) trackClick('search', 'btn_click', '搜索_' + keyword.trim())
if (!keyword || keyword.trim().length < 1) {
wx.showToast({ title: '请输入搜索关键词', icon: 'none' })
return
@@ -108,6 +111,7 @@ Page({
// 跳转阅读(优先传 mid与分享逻辑一致
goToRead(e) {
const id = e.currentTarget.dataset.id
trackClick('search', 'card_click', id || '章节')
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })

View File

@@ -112,4 +112,5 @@
</view>
</view>
</view>
<env-switch />
</view>

View File

@@ -177,4 +177,5 @@
</view>
</view>
</view>
<env-switch />
</view>

View File

@@ -1,5 +1,6 @@
import accessManager from '../../utils/chapterAccessManager'
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
@@ -64,6 +65,7 @@ Page({
},
async handlePurchase() {
trackClick('vip', 'btn_click', '开通VIP')
let userId = app.globalData.userInfo?.id
let openId = app.globalData.openId || app.globalData.userInfo?.open_id
if (!userId || !openId) {

View File

@@ -52,4 +52,5 @@
</view>
<view class="bottom-space"></view>
<env-switch />
</view>

View File

@@ -10,14 +10,22 @@ Page({
loading: true,
rechargeAmounts: [10, 30, 50, 100],
selectedAmount: 30,
auditMode: false,
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
auditMode: app.globalData.auditMode || false,
})
this.loadBalance()
this.loadTransactions()
},
onShow() {
this.setData({ auditMode: app.globalData.auditMode || false })
},
async loadBalance() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
const userId = app.globalData.userInfo.id

View File

@@ -20,7 +20,7 @@
</view>
</view>
<view class="section">
<view class="section" wx:if="{{!auditMode}}">
<view class="section-head">
<text class="section-title">选择充值金额</text>
<text class="section-note">当前已选 ¥{{selectedAmount}}</text>
@@ -44,9 +44,12 @@
</view>
</view>
<view class="action-row">
<view class="action-row" wx:if="{{!auditMode}}">
<view class="btn btn-recharge" bindtap="handleRecharge">充值</view>
</view>
<view class="action-row" wx:elif="{{auditMode}}">
<view class="audit-tip">审核中,暂不支持充值</view>
</view>
<view class="section">
<view class="section-head">
@@ -75,4 +78,5 @@
</view>
<view class="bottom-space"></view>
<env-switch />
</view>

View File

@@ -76,6 +76,12 @@
line-height: 1.7;
color: rgba(255, 255, 255, 0.58);
}
.audit-tip {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.5);
text-align: center;
padding: 24rpx;
}
.balance-skeleton {
padding: 40rpx 0;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

914
soul-admin/dist/assets/index-DyqIjjBz.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-D5RkA1Qc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BcWuvM_a.css">
<script type="module" crossorigin src="/assets/index-DyqIjjBz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-o3d5k2lQ.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react'
import { get } from '@/api/client'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
@@ -75,6 +75,14 @@ export function DashboardPage() {
const [loadError, setLoadError] = useState<string | null>(null)
const [detailUserId, setDetailUserId] = useState<string | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [giftedTotal, setGiftedTotal] = useState(0)
const [ordersExpanded, setOrdersExpanded] = useState(false)
const [trackPeriod, setTrackPeriod] = useState<string>('week')
const [trackStats, setTrackStats] = useState<{
total: number
byModule: Record<string, { action: string; target: string; module: string; page: string; count: number }[]>
} | null>(null)
const [trackLoading, setTrackLoading] = useState(false)
const showError = (err: unknown) => {
const e = err as Error & { status?: number; name?: string }
@@ -116,13 +124,23 @@ export function DashboardPage() {
setStatsLoading(false)
}
// 加载代付总额(仅用于收入标签展示)
try {
const balRes = await get<{ success?: boolean; data?: { totalGifted?: number } }>('/api/admin/balance/summary', init)
if (balRes?.success && balRes.data) {
setGiftedTotal(balRes.data.totalGifted ?? 0)
}
} catch {
// 不影响主面板
}
// 2. 并行加载订单和用户
setOrdersLoading(true)
setUsersLoading(true)
const loadOrders = async () => {
try {
const res = await get<{ success?: boolean; recentOrders?: OrderRow[] }>(
'/api/admin/dashboard/recent-orders',
'/api/admin/dashboard/recent-orders?limit=10',
init
)
if (res?.success && res.recentOrders) setPurchases(res.recentOrders)
@@ -166,10 +184,28 @@ export function DashboardPage() {
await Promise.all([loadOrders(), loadUsers()])
}
async function loadTrackStats(period?: string) {
const p = period || trackPeriod
setTrackLoading(true)
try {
const res = await get<{ success?: boolean; total?: number; byModule?: Record<string, { action: string; target: string; module: string; page: string; count: number }[]> }>(
`/api/admin/track/stats?period=${p}`
)
if (res?.success) {
setTrackStats({ total: res.total ?? 0, byModule: res.byModule ?? {} })
}
} catch {
setTrackStats(null)
} finally {
setTrackLoading(false)
}
}
useEffect(() => {
const ctrl = new AbortController()
loadAll(ctrl.signal)
const timer = setInterval(() => loadAll(), 30000)
loadTrackStats()
const timer = setInterval(() => { loadAll(); loadTrackStats() }, 30000)
return () => {
ctrl.abort()
clearInterval(timer)
@@ -185,6 +221,10 @@ export function DashboardPage() {
const amount = typeof p.amount === 'number' ? p.amount.toFixed(2) : parseFloat(String(p.amount || '0')).toFixed(2)
return { title: `余额充值 ¥${amount}`, subtitle: '余额充值' }
}
if (type === 'gift_pay') {
const amount = typeof p.amount === 'number' ? p.amount.toFixed(2) : parseFloat(String(p.amount || '0')).toFixed(2)
return { title: `代付 ¥${amount}`, subtitle: '好友代付' }
}
if (desc) {
if (type === 'section' && desc.includes('章节')) {
if (desc.includes('-')) {
@@ -223,6 +263,7 @@ export function DashboardPage() {
{
title: '总用户数',
value: statsLoading ? null : totalUsers,
sub: null as string | null,
icon: Users,
color: 'text-blue-400',
bg: 'bg-blue-500/20',
@@ -231,6 +272,7 @@ export function DashboardPage() {
{
title: '总收入',
value: statsLoading ? null : `¥${(totalRevenue ?? 0).toFixed(2)}`,
sub: giftedTotal > 0 ? `含代付 ¥${giftedTotal.toFixed(2)}` : null,
icon: TrendingUp,
color: 'text-[#38bdac]',
bg: 'bg-[#38bdac]/20',
@@ -239,6 +281,7 @@ export function DashboardPage() {
{
title: '订单数',
value: statsLoading ? null : paidOrderCount,
sub: null as string | null,
icon: ShoppingBag,
color: 'text-purple-400',
bg: 'bg-purple-500/20',
@@ -247,6 +290,7 @@ export function DashboardPage() {
{
title: '转化率',
value: statsLoading ? null : `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
sub: null as string | null,
icon: BookOpen,
color: 'text-orange-400',
bg: 'bg-orange-500/20',
@@ -284,14 +328,19 @@ export function DashboardPage() {
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-2xl font-bold text-white min-h-[2rem] flex items-center">
{stat.value != null ? (
stat.value
) : (
<span className="inline-flex items-center gap-2 text-gray-500">
<RefreshCw className="w-4 h-4 animate-spin" />
</span>
<div>
<div className="text-2xl font-bold text-white min-h-8 flex items-center">
{stat.value != null ? (
stat.value
) : (
<span className="inline-flex items-center gap-2 text-gray-500">
<RefreshCw className="w-4 h-4 animate-spin" />
</span>
)}
</div>
{stat.sub && (
<p className="text-xs text-gray-500 mt-1">{stat.sub}</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-[#38bdac] transition-colors" />
@@ -330,7 +379,7 @@ export function DashboardPage() {
) : (
<>
{purchases
.slice(0, 5)
.slice(0, ordersExpanded ? 10 : 4)
.map((p) => {
const referrer: UserRow | undefined = p.referrerId
? users.find((u) => u.id === p.referrerId)
@@ -416,6 +465,15 @@ export function DashboardPage() {
</div>
)
})}
{purchases.length > 4 && !ordersExpanded && (
<button
type="button"
onClick={() => setOrdersExpanded(true)}
className="w-full py-2 text-sm text-[#38bdac] hover:text-[#2da396] border border-dashed border-gray-600 rounded-lg hover:border-[#38bdac]/50 transition-colors"
>
</button>
)}
{purchases.length === 0 && !ordersLoading && (
<div className="text-center py-12">
<ShoppingBag className="w-12 h-12 text-gray-600 mx-auto mb-3" />
@@ -480,6 +538,114 @@ export function DashboardPage() {
</Card>
</div>
<Card className="mt-8 bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<div className="flex items-center gap-2">
{(['today', 'week', 'month', 'all'] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => { setTrackPeriod(p); loadTrackStats(p) }}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
trackPeriod === p
? 'bg-[#38bdac] text-white'
: 'bg-gray-700/50 text-gray-400 hover:bg-gray-700'
}`}
>
{{ today: '今日', week: '本周', month: '本月', all: '全部' }[p]}
</button>
))}
</div>
</CardHeader>
<CardContent>
{trackLoading && !trackStats ? (
<div className="flex items-center justify-center py-12 text-gray-500">
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : trackStats && Object.keys(trackStats.byModule).length > 0 ? (
<div className="space-y-6">
<p className="text-sm text-gray-400">
<span className="text-white font-bold text-lg">{trackStats.total}</span>
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(trackStats.byModule)
.sort((a, b) => b[1].reduce((s, i) => s + i.count, 0) - a[1].reduce((s, i) => s + i.count, 0))
.map(([mod, items]) => {
const moduleTotal = items.reduce((s, i) => s + i.count, 0)
const moduleLabels: Record<string, string> = {
home: '首页', chapters: '目录', read: '阅读', my: '我的',
vip: 'VIP', wallet: '钱包', match: '找伙伴', referral: '推广',
search: '搜索', settings: '设置', about: '关于', other: '其他',
}
return (
<div key={mod} className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-[#38bdac]">
{moduleLabels[mod] || mod}
</span>
<span className="text-xs text-gray-500">{moduleTotal} </span>
</div>
<div className="space-y-2">
{items
.sort((a, b) => b.count - a.count)
.slice(0, 8)
.map((item, i) => {
const targetLabels: Record<string, string> = {
'开始匹配': '开始匹配', 'mentor': '导师顾问', 'team': '团队招募',
'investor': '资源对接', '充值': '充值', '退款': '退款',
'wallet': '钱包', '设置': '设置', 'VIP': 'VIP会员',
'推广': '推广中心', '目录': '目录', '搜索': '搜索',
'匹配': '找伙伴', 'settings': '设置', 'expired': '已过期',
'active': '活跃', 'converted': '已转化', 'fill_profile': '完善资料',
'register': '注册', 'purchase': '购买', 'btn_click': '按钮点击',
'nav_click': '导航点击', 'card_click': '卡片点击', 'tab_click': '标签切换',
'rule_trigger': '规则触发', 'view_chapter': '浏览章节',
'链接卡若': '链接卡若', '更多分享': '更多分享', '分享朋友圈文案': '分享朋友圈',
'选择金额10': '选择金额10元',
}
const actionLabels: Record<string, string> = {
'btn_click': '按钮点击', 'nav_click': '导航点击', 'card_click': '卡片点击',
'tab_click': '标签切换', 'purchase': '购买', 'register': '注册',
'rule_trigger': '规则触发', 'view_chapter': '浏览章节',
}
const label = targetLabels[item.target] || item.target || actionLabels[item.action] || item.action
return (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-gray-300 truncate mr-2" title={`${item.action}: ${item.target}`}>
{label}
</span>
<div className="flex items-center gap-2 shrink-0">
<div className="w-16 h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-[#38bdac] rounded-full"
style={{ width: `${moduleTotal > 0 ? (item.count / moduleTotal) * 100 : 0}%` }}
/>
</div>
<span className="text-gray-400 w-8 text-right">{item.count}</span>
</div>
</div>
)})}
</div>
</div>
)
})}
</div>
</div>
) : (
<div className="text-center py-12">
<BarChart3 className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500"></p>
<p className="text-gray-600 text-xs mt-1"></p>
</div>
)}
</CardContent>
</Card>
<UserDetailModal
open={showDetailModal}
onClose={() => { setShowDetailModal(false); setDetailUserId(null) }}

View File

@@ -63,6 +63,7 @@ interface FeatureConfig {
matchEnabled: boolean
referralEnabled: boolean
searchEnabled: boolean
aboutEnabled: boolean
}
interface MpConfig {
@@ -70,6 +71,7 @@ interface MpConfig {
withdrawSubscribeTmplId?: string
mchId?: string
minWithdraw?: number
auditMode?: boolean
}
interface OssConfig {
@@ -108,6 +110,7 @@ const defaultFeatures: FeatureConfig = {
matchEnabled: true,
referralEnabled: true,
searchEnabled: true,
aboutEnabled: true,
}
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
@@ -205,6 +208,30 @@ export function SettingsPage() {
saveFeatureConfigOnly(next, () => setFeatureConfig(prev))
}
const [auditModeSaving, setAuditModeSaving] = useState(false)
const handleAuditModeSwitch = async (checked: boolean) => {
const prev = mpConfig
const next = { ...prev, auditMode: checked }
setMpConfig(next)
setAuditModeSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
mpConfig: next,
})
if (!res || (res as { success?: boolean }).success === false) {
setMpConfig(prev)
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
return
}
showResult('已保存', checked ? '审核模式已开启,小程序将隐藏所有支付入口。' : '审核模式已关闭,支付功能已恢复。')
} catch (error) {
setMpConfig(prev)
showResult('保存失败', error instanceof Error ? error.message : String(error), true)
} finally {
setAuditModeSaving(false)
}
}
const handleSave = async () => {
setIsSaving(true)
try {
@@ -223,6 +250,7 @@ export function SettingsPage() {
withdrawSubscribeTmplId: mpConfig.withdrawSubscribeTmplId || '',
mchId: mpConfig.mchId || '',
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
auditMode: mpConfig.auditMode ?? false,
},
ossConfig: Object.keys(ossConfig).length
? {
@@ -570,78 +598,6 @@ export function SettingsPage() {
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Settings className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
/
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="match-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">Web端的找伙伴功能显示</p>
</div>
<Switch
id="match-enabled"
checked={featureConfig.matchEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('matchEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Gift className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="referral-enabled" className="text-white font-medium cursor-pointer">
广
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">广</p>
</div>
<Switch
id="referral-enabled"
checked={featureConfig.referralEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('referralEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="search-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6"></p>
</div>
<Switch
id="search-enabled"
checked={featureConfig.searchEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('searchEnabled', checked)}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
<p className="text-xs text-blue-300">
💡
</p>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -713,6 +669,130 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
<Card className={`bg-[#0f2137] shadow-xl ${mpConfig.auditMode ? 'border-amber-500/50 border-2' : 'border-gray-700/50'}`}>
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-amber-400" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent>
<div className={`flex items-center justify-between p-4 rounded-lg border ${mpConfig.auditMode ? 'bg-amber-500/10 border-amber-500/30' : 'bg-[#0a1628] border-gray-700/50'}`}>
<div className="space-y-1">
<div className="flex items-center gap-2">
<ShieldCheck className={`w-4 h-4 ${mpConfig.auditMode ? 'text-amber-400' : 'text-gray-400'}`} />
<Label htmlFor="audit-mode" className="text-white font-medium cursor-pointer">
{mpConfig.auditMode ? '审核模式(已开启)' : '审核模式(已关闭)'}
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
{mpConfig.auditMode
? '当前已隐藏所有支付、VIP、充值、收益等入口审核员看不到任何付费内容'
: '关闭状态小程序正常显示所有功能含支付、VIP 等)'}
</p>
</div>
<Switch
id="audit-mode"
checked={mpConfig.auditMode ?? false}
disabled={auditModeSaving}
onCheckedChange={handleAuditModeSwitch}
/>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Settings className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
/
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="match-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">Web端的找伙伴功能显示</p>
</div>
<Switch
id="match-enabled"
checked={featureConfig.matchEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('matchEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Gift className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="referral-enabled" className="text-white font-medium cursor-pointer">
广
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">广</p>
</div>
<Switch
id="referral-enabled"
checked={featureConfig.referralEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('referralEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="search-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6"></p>
</div>
<Switch
id="search-enabled"
checked={featureConfig.searchEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('searchEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="about-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">访</p>
</div>
<Switch
id="about-enabled"
checked={featureConfig.aboutEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('aboutEnabled', checked)}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
<p className="text-xs text-blue-300">
💡
</p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>

View File

@@ -236,9 +236,17 @@ export function UsersPage() {
if (!confirm('确定要删除这个用户吗?')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/users?id=${encodeURIComponent(userId)}`)
if (data?.success) loadUsers()
else toast.error('删除失败: ' + (data?.error || ''))
} catch { toast.error('删除失败') }
if (data?.success) {
toast.success('删除')
loadUsers()
} else {
toast.error('删除失败: ' + (data?.error || '未知错误'))
}
} catch (e) {
const err = e as Error & { data?: { error?: string } }
const msg = err?.data?.error || err?.message || '网络错误'
toast.error('删除失败: ' + msg)
}
}
const handleEditUser = (user: User) => {

View File

@@ -3,7 +3,9 @@ package handler
import (
"encoding/json"
"net/http"
"strconv"
"sync"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -54,11 +56,17 @@ func AdminDashboardStats(c *gin.Context) {
})
}
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders?limit=10
func AdminDashboardRecentOrders(c *gin.Context) {
db := database.DB()
limit := 5
if l := c.Query("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n >= 1 && n <= 20 {
limit = n
}
}
var recentOrders []model.Order
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(5).Find(&recentOrders)
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(limit).Find(&recentOrders)
c.JSON(http.StatusOK, gin.H{"success": true, "recentOrders": buildRecentOrdersOut(db, recentOrders)})
}
@@ -180,6 +188,101 @@ 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 的已支付订单金额),用于 Dashboard 显示「含代付 ¥xx」
func AdminBalanceSummary(c *gin.Context) {
db := database.DB()
var totalGifted float64
db.Model(&model.Order{}).Where("product_type = ? AND status IN ?", "gift_pay", paidStatuses).
Select("COALESCE(SUM(amount), 0)").Scan(&totalGifted)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalGifted": totalGifted}})
}
// AdminDashboardMerchantBalance GET /api/admin/dashboard/merchant-balance
// 查询微信商户号实时余额(可用余额、待结算余额),用于看板展示
// 注意:普通商户可能需向微信申请开通权限,未开通时返回 error

View File

@@ -39,6 +39,8 @@ func GetPublicDBConfig(c *gin.Context) {
"minWithdraw": 10,
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
"mchId": "1318592501",
"auditMode": false,
"supportWechat": true,
}
out := gin.H{
@@ -134,6 +136,14 @@ func GetPublicDBConfig(c *gin.Context) {
if _, has := out["linkedMiniprograms"]; !has {
out["linkedMiniprograms"] = []gin.H{}
}
// 明确归一化 auditMode仅当 DB 显式为 true 时返回 true否则一律 false避免历史脏数据/类型异常导致误判)
if mp, ok := out["mpConfig"].(gin.H); ok {
if v, ok := mp["auditMode"].(bool); ok && v {
mp["auditMode"] = true
} else {
mp["auditMode"] = false
}
}
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
c.JSON(http.StatusOK, out)
}
@@ -179,10 +189,12 @@ func AdminSettingsGet(c *gin.Context) {
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
"mchId": "1318592501",
"minWithdraw": float64(10),
"auditMode": false,
"supportWechat": true,
}
out := gin.H{
"success": true,
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true},
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
"mpConfig": defaultMp,
"ossConfig": gin.H{},
@@ -902,18 +914,24 @@ func randomSuffix() string {
return fmt.Sprintf("%d%x", time.Now().UnixNano()%100000, time.Now().UnixNano()&0xfff)
}
// DBUsersDelete DELETE /api/db/users
// DBUsersDelete DELETE /api/db/users(软删除:仅设置 deleted_at用户再次登录会新建账号
func DBUsersDelete(c *gin.Context) {
id := c.Query("id")
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
return
}
if err := database.DB().Where("id = ?", id).Delete(&model.User{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
db := database.DB()
result := db.Where("id = ?", id).Delete(&model.User{})
if result.Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户删除成功"})
if result.RowsAffected == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在或已被删除"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户已删除(假删除),该用户再次登录将创建新账号"})
}
// DBUsersReferrals GET /api/db/users/referrals绑定关系详情弹窗收益与「已付费」与小程序口径一致订单+提现表实时计算)

View File

@@ -69,8 +69,8 @@ func MiniprogramLogin(c *gin.Context) {
isNewUser := result.Error != nil
if isNewUser {
// 创建新用户
userID := openID // 直接使用 openid 作为用户 ID
// 创建新用户(含软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突)
userID := "user_" + randomSuffix()
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
nickname := "微信用户" + openID[len(openID)-4:]
avatar := ""
@@ -393,9 +393,17 @@ func miniprogramPayPost(c *gin.Context) {
clientIP = "127.0.0.1"
}
// userID优先用客户端传入为空时按 openid 查用户(排除软删除,避免订单归属到旧账号)
userID := req.UserID
if userID == "" {
userID = req.OpenID
if userID == "" && req.OpenID != "" {
var u model.User
if err := db.Where("open_id = ?", req.OpenID).First(&u).Error; err == nil {
userID = u.ID
} else {
// 查不到用户:可能是未登录或软删除后未重新登录,避免用 openid 导致订单归属到旧账号
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请先登录后再支付"})
return
}
}
productID := req.ProductID

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -658,6 +659,11 @@ func MiniprogramTrackPost(c *gin.Context) {
if body.Target != "" {
t.ChapterID = &chID
}
if body.ExtraData != nil {
if b, err := json.Marshal(body.ExtraData); err == nil {
t.ExtraData = b
}
}
if err := db.Create(&t).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return

View File

@@ -54,6 +54,8 @@ func WechatPhoneLogin(c *gin.Context) {
isNewUser := result.Error != nil
if isNewUser {
// 软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突
userID := "user_" + randomSuffix()
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
nickname := "微信用户" + openID[len(openID)-4:]
avatar := ""
@@ -67,7 +69,7 @@ func WechatPhoneLogin(c *gin.Context) {
phone = "+" + countryCode + " " + phoneNumber
}
user = model.User{
ID: openID,
ID: userID,
OpenID: &openID,
SessionKey: &sessionKey,
Nickname: &nickname,

View File

@@ -1,8 +1,13 @@
package model
import "time"
import (
"time"
"gorm.io/gorm"
)
// User 对应表 usersJSON 输出与现网接口 1:1小写驼峰
// 软删除:管理端删除仅设置 deleted_at用户再次登录会创建新账号
type User struct {
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
@@ -50,6 +55,9 @@ type User struct {
VipContact *string `gorm:"column:vip_contact;size:100" json:"vipContact,omitempty"`
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
// 软删除:管理端假删除,用户再次登录会新建账号
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
}

View File

@@ -93,6 +93,7 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.PUT("/orders/refund", handler.AdminOrderRefund)
admin.GET("/users/:id/balance", handler.AdminUserBalanceGet)
admin.POST("/users/:id/balance/adjust", handler.AdminUserBalanceAdjust)
admin.GET("/balance/summary", handler.AdminBalanceSummary)
admin.GET("/users", handler.AdminUsersList)
admin.POST("/users", handler.AdminUsersAction)
admin.PUT("/users", handler.AdminUsersAction)
@@ -100,6 +101,7 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/orders", handler.OrdersList)
admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList)
admin.GET("/user/track", handler.UserTrackGet)
admin.GET("/track/stats", handler.AdminTrackStats)
}
// ----- 鉴权 -----

View File

@@ -0,0 +1,5 @@
-- 用户软删除:管理端假删除,用户再次登录会新建账号
-- 执行后DELETE 操作改为 SET deleted_at不再物理删除避免外键约束
ALTER TABLE users ADD COLUMN deleted_at DATETIME(3) NULL DEFAULT NULL COMMENT '软删除时间' AFTER updated_at;
CREATE INDEX idx_users_deleted_at ON users (deleted_at);

View File

@@ -37411,3 +37411,13 @@
{"level":"debug","timestamp":"2026-03-17T15:11:49+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 52\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Tue, 17 Mar 2026 07:11:49 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08B5FDE3CD0610B50418D8AAC05520C4ED0628CC8C03-0\r\nServer: nginx\r\nWechatpay-Nonce: 3c7de243555486019ee5407a5befc17e\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: E6BMV0sFgHY9cq/I15VUAgmiZJty5KXHl9aSVPEheYnbUcve6J9VN4bqLkYQt2lq6iilE1AifBmGxBjek/XAdixo2RbJ1yiCr9yM+Yxdi3RrgHXYMYURq9QK8gflbIH5GZS7eg+kYKueYPaAIRq1LHAD3aCfnzcD0fq49GlMGOi7bVQw1v6wW3cNy94cJZ5e32VsFmcXXjybe7CxYnLSo4s5kOsdjSRQJiJOSazplDyKHgeqHpflqFO6SkJvROAWOTFJQVYXgylR6Vr/ZEPDPZntiMGqRYpblFoY8wakewOGg+qZKzBW7DUTkmQUK7B8564oD6+E+mebdC0f8HhW3w==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773731509\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"prepay_id\":\"wx17151149756818d4257dcb4e2b83250001\"}"}
{"level":"debug","timestamp":"2026-03-17T15:17:26+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260317151148855100?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"iXHM3yIdYUhXvQC03FIKemHVDccXRE6r\",timestamp=\"1773731845\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"2KL23qFk/Gs8t0qsYz2Xuy5FemFByzUW5uvS39TBcRn6ziT1ANz1T2U2ZxUuiAXRt99ewXC8Pnsu/XZSf6UCP1vqwFFIcrZ3Wz+1FHjdvhPUL8ZIqD5ymCwRZANoZtaPSd3h7C8f4JT+ET+Inn16v7ezyUDY7TdaFCVvhhCy84/NDyvGIw41i2cF+jA4wySEJ15Lg0Tp7sgOglhYdR8tpngN2qIjUsCl1yToDAU3MvKUp3DhKh5HnMGAdSuENCyFEdL0RYU5mqbggtFnlV0WODsORJ+MKrbfvuA4Q0zVZZnF16OWwP2pfJ32RF6U4eFDb07ICI0hrVgKaHq+AAnOCw==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-17T15:17:26+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 249\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Tue, 17 Mar 2026 07:17:26 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 088680E4CD0610FB0218DD9C85AB0120F8DE0528F19905-0\r\nServer: nginx\r\nWechatpay-Nonce: 1179b7f5d9c0211c6769de56735f874f\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: VrORKkCkmK8WqBCTWhl0w9Y5rKzRfoDATiKJASSb6qwsctuJqhBdmRZ8IhAyAxM0t7hDCQOvxRxbklVYYlGsYk8Ozsf2VvoH3dBkXlMO8TYMKEkuB/dgQ6VO2fKswvG72dIv6bXfhU+GE8Eu6ENiAfD5eGTjjQAPbFXiJTctcvhggta/vKTPBBnsI47bFWeHzKSVMzkyE+uXpuo97o++TDu/s8C6l+Z+mG/01LE2k4OfvHkXifpARlIyDZr3MQFCGacDniWDr0vqj6Zlaf5PwIZhjEhjcpWbLoSHMSfb6Pt00wunMZLS20m+t/GKYJ3Qz9z3fC+A0Z1j/uS7UJlGVQ==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773731846\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":100},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260317151148855100\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-17T15:42:50+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260317151148855100?mchid=1318592501 request header: { Authorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"k2Ec5z8tXyopDUfxB1ShfFtpTdUsYK79\",timestamp=\"1773733369\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"MvS1MBSKDn91cAA99yEzIBO0d/lLdn+y4yyyNcRmoF6MZpHvmQYoZDgIOh/P3MVOJljPBd3sh+YNNQGgTPUmEG6B62Cz1QfsuYIH21dMnc/AMeHTJy0AZy1tpaerPfdT8nCK00l/wWcYl/Yr41of9U1rKgWAjv5xtPju3vVdBKwUD06klN6PsyttdNsMd7pq3tIp6bUx9+84WOX/a5W3/fm1kXTUlH5vFHQ6RB698zgp4O2QbAlEy2aFla1RJwfc+BXVWn0pMm5Ko9esFnGF8juiO7DkNcBkvF3sOpDOS8UrubMI/Q36sjstTLzws5vfZ9eITB+3QqAZJLcvpMZQGA==\"Accept:*/*Content-Type:application/json} request body:"}
{"level":"debug","timestamp":"2026-03-17T15:42:50+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 249\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Tue, 17 Mar 2026 07:42:50 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08FA8BE4CD06108E0418FEC1C055208EFA0428D5E002-0\r\nServer: nginx\r\nWechatpay-Nonce: a0d4c4e850561fc58f5272cd45e4ecc5\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: t7nSQEDzhKrd6LKnoQveSP7DWn3sCWme5tx+X5lZC2EziBEjekQHwc1J0TpFjGd+4mZYYMEpFBeHOKMAwkLOOqIXxGqB5H1Pe4orLmW/lwqF6V4uLOKtmFdNszDHEWZQyUykLw9fBegpegF2k9iNmf3oWUHaIobdti0QD2d16WeiVWzEg6EVhDYQZxxrTpTdpDWLl85tqgIKv1OGQ3I4qnPfHMsB+D3/CelmEeemSwn9otBS825CjSj8hXdYcI2MKlmdMLcm/ZO/gOJI3SM5AV3nobqE6yCCbq0mcwUiIjoLM/gFYqgM9rqDrEdiFcbF7LVxV/9jtM1YAeVpXmEhyg==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773733370\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":100},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260317151148855100\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-17T16:33:25+08:00","caller":"kernel/accessToken.go:381","content":"GET https://api.weixin.qq.com/cgi-bin/token?appid=wxb8bbb2b10dec74aa&grant_type=client_credential&neededText=&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-17T16:33:25+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Tue, 17 Mar 2026 08:33:25 GMT\r\n\r\n{\"access_token\":\"102_x2PDa5E3z33zzL-P19BDZ685W7ItA8HHxiSgq_oz9yy9BAs5cEvsDL7m4pInyC7LhIpnUQ3Sa_2_DCdpzMF0VxQnuNM___aJlchBAXpa5EGnF2VfUI1GypVxvOkNNAcAAAFFS\",\"expires_in\":7200}"}
{"level":"debug","timestamp":"2026-03-17T16:33:25+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_x2PDa5E3z33zzL-P19BDZ685W7ItA8HHxiSgq_oz9yy9BAs5cEvsDL7m4pInyC7LhIpnUQ3Sa_2_DCdpzMF0VxQnuNM___aJlchBAXpa5EGnF2VfUI1GypVxvOkNNAcAAAFFS&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0e33HYFa1EKLnL0I1RGa1EvoK113HYFW&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-17T16:33:25+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Tue, 17 Mar 2026 08:33:26 GMT\r\n\r\n{\"session_key\":\"pjoOisYFMOpimtJsFYj8yA==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}
{"level":"debug","timestamp":"2026-03-17T17:40:08+08:00","caller":"kernel/accessToken.go:381","content":"GET https://api.weixin.qq.com/cgi-bin/token?appid=wxb8bbb2b10dec74aa&grant_type=client_credential&neededText=&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-17T17:40:08+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Tue, 17 Mar 2026 09:40:08 GMT\r\n\r\n{\"access_token\":\"102_qKu7EbmqaaPGhTC2LXZkSg0zAoyqTVs6EHwGSES_0Qj1oSKagRwZNq91KfcsJIeEIQbrH3vzUBBAnCOTZKPMTQkpa8TTl6PqO7kBOAPT8A0Wt1MaIwnD3NEibzYHNMbAFAWCB\",\"expires_in\":7200}"}
{"level":"debug","timestamp":"2026-03-17T17:40:08+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_qKu7EbmqaaPGhTC2LXZkSg0zAoyqTVs6EHwGSES_0Qj1oSKagRwZNq91KfcsJIeEIQbrH3vzUBBAnCOTZKPMTQkpa8TTl6PqO7kBOAPT8A0Wt1MaIwnD3NEibzYHNMbAFAWCB&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0b3L3mll2tGInh4UuUll2YDmWU0L3mlq&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-17T17:40:08+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Tue, 17 Mar 2026 09:40:09 GMT\r\n\r\n{\"session_key\":\"BvvCTmjR14OphaYQdgJ9Ow==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}

View File

@@ -0,0 +1,185 @@
# 新版迁移 - 小程序功能差异清单(稳定版 vs 体验版)
> 乘风分析稳定版miniprogram与体验版new-soul/miniprogram的差异。
> 更新日期2026-03-17
**目录说明**`miniprogram` = 稳定版(生产);`new-soul/miniprogram` = 体验版(新版参考)
---
## 一、差异摘要(按页面/模块)
| 页面/模块 | 稳定版 miniprogram | 体验版 new-soul/miniprogram | 迁移建议 |
|-----------|-------------------|-----------------------------|----------|
| **app.js** | 硬编码 baseUrl、无 auditMode | loadRuntimeConfig、auditMode、timeout | 迁运行时配置 |
| **index** | 无 auditMode | auditMode 控制超级个体按钮 | 迁 auditMode |
| **gift-pay/detail** | hero-card、requester-card、完整支付流程 | 简单 card | **勿回退**,稳定版更完善 |
| **read** | 无 auditMode、无 showShareTip | auditMode、personsConfig、showShareTip | 迁 auditMode |
| **my** | profile-name-actions编辑、referralEnabled | auditMode 控制支付 UI | **保留**稳定版编辑入口 |
| **wallet** | 无 auditMode | auditMode | 迁 auditMode |
| **chapters** | 每日新增已删除 | 每日新增区块 | 按需求 |
| **about** | ✅ | ✅ | 一致 |
| **utils** | ruleEngine | 无 | **保留**稳定版 |
---
## 二、需迁移的细节(体验版有、稳定版缺)
### 2.1 app.js - 运行时配置与审核模式
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **baseUrl 配置** | 硬编码 localhost | getRuntimeBootstrapConfigextConfig/Storage/默认生产 | **P0** |
| **loadRuntimeConfig** | 仅 appId/mchId 等 | 含 apiDomain、auditMode、supportWechat | **P0** |
| **globalData** | 无 auditMode | auditMode、supportWechat | **P0** |
| **request timeout** | 无 | 15000ms | **P2** |
| **loadBookData** | 已类似 | 同 | 已对齐 |
**新版关键代码getRuntimeBootstrapConfig**
```javascript
function getRuntimeBootstrapConfig() {
try {
const extCfg = wx.getExtConfigSync ? (wx.getExtConfigSync() || {}) : {}
return {
baseUrl: extCfg.apiBaseUrl || wx.getStorageSync('apiBaseUrl') || DEFAULT_BASE_URL,
appId: extCfg.appId || DEFAULT_APP_ID,
mchId: extCfg.mchId || DEFAULT_MCH_ID,
withdrawSubscribeTmplId: extCfg.withdrawSubscribeTmplId || DEFAULT_WITHDRAW_TMPL_ID
}
} catch (_) {
return { baseUrl: DEFAULT_BASE_URL, appId: DEFAULT_APP_ID, mchId: DEFAULT_MCH_ID, withdrawSubscribeTmplId: DEFAULT_WITHDRAW_TMPL_ID }
}
}
```
**soul-api 支持**`GET /api/miniprogram/config` 已存在,返回 mpConfig含 apiDomain、appId、mchId 等)。需在 mp_config 或 defaultMp 中增加 `auditMode``supportWechat` 字段,供管理端配置。
---
### 2.2 index 页面 - 审核模式
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **超级个体空态按钮** | 始终显示 | 按 auditMode 隐藏 | **P1** |
| **data** | 无 auditMode | auditMode: false | - |
| **onShow** | 无 | 同步 app.globalData.auditMode | - |
---
### 2.3 my 页面 - 审核模式
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **become-member-btn** | 无 auditMode | wx:if="{{!auditMode}}" | **P1** |
| **vip-tags** | 无 | wx:if="{{!auditMode}}" | **P1** |
| **profile-stat 推荐/收益** | referralEnabled | auditMode | **保留**稳定版 referralEnabled |
| **profile-stat 余额** | 无 | wx:if="{{!auditMode}}" | **P1** |
| **receive-card** | 无 auditMode | wx:if="{{... && !auditMode}}" | **P1** |
| **profile-name-actions** | ✅ 有编辑按钮 | ❌ 无 | **勿回退** |
---
### 2.4 wallet 页面 - 审核模式
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **data** | 无 auditMode | auditMode: false | **P1** |
| **onLoad** | 无 | 同步 app.globalData.auditMode | **P1** |
---
### 2.5 read 页面 - 分享功能与图标
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **操作区分享** | 朋友圈→海报→代付 | 分享→代付→海报 | 按产品需求 |
| **share-tip-inline** | 无 | 有「分享后好友购买,你可获得 90% 收益」 | 可选 |
| **右下角悬浮按钮** | share.svg + open-type="share" | 🌐 emoji + shareToMoments | 已迁为体验版 |
---
### 2.6 read 页面 - 审核模式与配置
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **config 预加载** | linkTags、linkedMiniprograms | 含 personsConfig | **P3** |
| **data** | walletBalance、totalSections | auditMode、showShareTip | **P2** auditMode |
| **wx.showShareMenu** | menus: ['shareAppMessage','shareTimeline'] | withShareTimeline: true | 均可 |
---
## 三、稳定版有、体验版无(勿回退)
### 3.1 gift-pay/detail
| 功能项 | 稳定版 | 体验版 | 说明 |
|--------|--------|--------|------|
| **UI 结构** | hero-card、requester-card、footer-bar、bg-effects | 简单 card | **勿回退** |
| **发起人信息** | 可点击头像跳 member-detail | 无 | **勿回退** |
| **跳转文章** | goToArticle 跳 read | 无 | **勿回退** |
| **doPay 登录** | 静默 getOpenId失败再弹 modal | 直接 toast | **勿回退** |
| **支付失败** | 识别 prepay 错误并重试 | 简单 toast | **勿回退** |
### 3.2 my 页面 - 编辑入口
| 功能项 | 稳定版 | 体验版 | 说明 |
|--------|--------|--------|------|
| **profile-name-actions** | ✅ 有编辑按钮 | ❌ 无 | **保留**稳定版 |
### 3.3 utils - ruleEngine
| 功能项 | 稳定版 | 体验版 | 说明 |
|--------|--------|--------|------|
| **ruleEngine** | ✅ app.js 引用 | ❌ 无 | **保留**稳定版 |
---
## 四、迁移优先级建议
| 优先级 | 迁移项 | 稳定版→体验版 |
|--------|--------|---------------|
| **P0** | app.js 运行时配置 | 稳定版缺,体验版有 |
| **P0** | app.js loadMpConfig 扩展 | 稳定版缺 auditMode体验版有 |
| **P0** | soul-api mp_config | 增加 auditMode、supportWechat |
| **P1** | index/my/wallet auditMode | 稳定版缺,体验版有 |
| **P2** | app.js request timeout | 稳定版缺,体验版有 |
| **P2** | read auditMode | 稳定版缺,体验版有 |
| **P3** | read personsConfig、showShareTip | 可选 |
---
## 五、不迁移项(维持现状)
| 项 | 说明 |
|----|------|
| gift-pay/detail 从新版迁 | 稳定版 UI 与逻辑更完善,无需回退 |
| 稳定版 ruleEngine | 已迁移,保留 |
| 稳定版 profile-name-actions | 保留,新版无 |
---
## 六、功能闭环 Checklist每功能必过
```
□ 界面:页面、交互、数据绑定
□ 接口API 存在、参数正确、响应格式规范
□ 数据DB/事务/幂等(若涉及)
□ 边界:未登录、余额不足、网络失败
□ 三端:小程序+后端+管理端(如需)是否都改到
□ 保护区域:未动 @/#、分销、支付 核心逻辑
```
---
## 七、文档产出
| 文档 | 路径 |
|------|------|
| **需求采纳清单** | `开发文档/新版迁移-需求采纳清单.md`(勾选是否采纳) |
| 功能差异清单 | `开发文档/新版迁移-功能差异清单.md`(本文档) |
| 迁移方案/清单 | `开发文档/新版迁移-开发方案与清单.md` |
---
**迁移前必做需求评审**:列出功能点 + 样式变更,逐一确认后再迁。先做功能对齐与取舍,评审通过后按最小功能迁移;界面修改先迁、大逻辑排后。

View File

@@ -0,0 +1,159 @@
# 新版迁移 - 管理端功能差异清单(稳定版 vs 体验版)
> 乘风分析稳定版soul-admin与体验版new-soul/soul-admin的差异稳定版未迁移完整。
> 更新日期2026-03-17
**目录说明**`soul-admin` = 稳定版(生产);`new-soul/soul-admin` = 体验版(新版参考)
---
## 一、差异摘要(按页面/模块)
| 页面/模块 | 稳定版 soul-admin | 体验版 new-soul/soul-admin | 迁移建议 |
|-----------|-------------------|---------------------------|----------|
| **Settings** | 无审核模式、无 aboutEnabled | 有审核模式、有 aboutEnabled、OSS 完整 | 迁审核模式、aboutEnabled |
| **Dashboard** | 无埋点统计、无代付金额、订单固定 5 条 | 有 trackStats、giftedTotal、订单可展开 4→10 | 迁代付金额、订单展开 |
| **Content** | chapters/ranking/search/link-person/link-tag/linkedmp | 同 | 一致 |
| **路由/布局** | 5 主菜单 + 系统设置 | 同 | 一致 |
---
## 二、Settings 页面对比
### 2.1 小程序审核模式
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **auditMode 开关** | ❌ 无 | ✅ 独立 Card + Switch单独保存 | **P0** 新增 |
| **UI 位置** | - | 小程序配置、OSS 之后,功能开关之前 | - |
| **API 传参** | - | `mp_config`(应为 `mpConfig` | 体验版有 bug迁时用 mpConfig |
---
### 2.2 功能开关
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **找伙伴** | ✅ | ✅ | 一致 |
| **推广功能** | ✅ | ✅ | 一致 |
| **搜索功能** | ✅ | ✅ | 一致 |
| **关于页面** | ❌ 无 | ✅ aboutEnabled | **P1** 新增 |
| **保存方式** | 与站点设置一起保存 | 独立 saveFeatureConfigOnly | 体验版更细粒度 |
---
### 2.3 OSS 配置
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **endpoint** | ✅ | ✅ | 一致 |
| **region** | ✅ | ✅ | 一致 |
| **accessKeyId** | ✅ | ✅ | 一致 |
| **accessKeySecret** | ✅ | ✅ | 一致 |
| **bucket** | ✅ | ✅ | 一致 |
| **配置状态提示** | ✅ | ✅ | 一致 |
---
### 2.4 其他 Settings 项
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **链接卡若存客宝密钥** | ✅ ckbLeadApiKey | ✅ | 一致 |
| **关于作者** | ✅ | ✅ | 一致 |
| **价格设置** | ✅ | ✅ | 一致 |
| **小程序配置** | ✅ appId/mchId 等 | ✅ | 一致 |
---
## 三、Dashboard 页面对比
### 3.1 统计卡片
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **总用户数** | ✅ | ✅ | 一致 |
| **总收入** | ✅ | ✅ 含 sub「含代付 ¥xx」 | **P1** 迁 giftedTotal |
| **订单数** | ✅ | ✅ | 一致 |
| **转化率** | ✅ 链接 distribution | ✅ 链接 users | 文案/链接略有不同 |
---
### 3.2 最近订单
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **显示条数** | 固定 5 条 | 默认 4 条,可展开至 10 条 | **P1** 迁 ordersExpanded |
| **订单类型展示** | 无 balance_recharge | ✅ 支持 balance_recharge、gift_pay | **P2** 迁 formatOrderProduct |
| **代付金额汇总** | ❌ 无 | ✅ giftedTotal | **P1** 迁 |
---
### 3.3 埋点统计
| 功能项 | 稳定版 | 体验版 | 迁移建议 |
|--------|--------|--------|----------|
| **track/stats** | ❌ 无 | ✅ 调用 /api/admin/track/stats | **P2** 依赖后端接口 |
| **period 切换** | - | week/day | - |
| **按模块展示** | - | BarChart3 卡片 | - |
---
## 四、稳定版独有(勿回退)
| 功能项 | 稳定版 | 体验版 | 说明 |
|--------|--------|--------|------|
| **搜索功能文案** | 「首页、目录页搜索栏」 | 「首页搜索栏」 | 稳定版更完整 |
| **ChaptersPage** | 有独立文件 | 无(或未用) | App 未挂路由,可能遗留 |
---
## 五、迁移优先级建议
| 优先级 | 迁移项 | 稳定版→体验版 |
|--------|--------|---------------|
| **P0** | Settings 小程序审核模式 | 稳定版缺,体验版有,需迁 |
| **P1** | Settings aboutEnabled | 稳定版缺,体验版有,需迁 |
| **P1** | Dashboard giftedTotal + ordersExpanded | 稳定版缺,体验版有,需迁 |
| **P2** | Dashboard 埋点统计 | 依赖 track/stats 接口 |
| **P2** | formatOrderProduct balance_recharge | 稳定版缺,体验版有,需迁 |
---
## 六、接口与数据层检查
| 接口 | 稳定版 | 体验版 | soul-api 支持 |
|------|--------|--------|---------------|
| GET /api/admin/settings | ✅ | ✅ | ✅ |
| POST /api/admin/settings | ✅ 传 mpConfig | ✅ 传 mp_configbug | ✅,需 camelCase |
| GET /api/admin/track/stats | ❌ 未用 | ✅ period=week/day | 需确认 |
| GET /api/admin/dashboard/stats | ✅ | ✅ | ✅ |
| GET /api/admin/dashboard/overview | ✅ | ✅ | ✅ |
---
## 七、功能闭环 Checklist每功能必过
```
□ 界面:页面、交互、数据绑定
□ 接口API 存在、参数正确、响应格式规范
□ 数据DB/配置存储system_config 表)
□ 边界:保存失败、加载失败
□ 三端:管理端 + soul-apimp_config 需供小程序 config 读取)
□ 保护区域:未动分销、支付核心逻辑
```
---
## 八、文档产出
| 文档 | 路径 |
|------|------|
| **需求采纳清单** | `开发文档/新版迁移-需求采纳清单.md`(勾选是否采纳) |
| 管理端功能差异清单 | `开发文档/新版迁移-管理端功能差异清单.md`(本文档) |
| 小程序功能差异清单 | `开发文档/新版迁移-功能差异清单.md` |
| 迁移完成度 | `开发文档/迁移完成度与待办清单.md` |
---
**迁移前必做需求评审**:列出功能点 + 样式变更,逐一确认后再迁。

View File

@@ -0,0 +1,61 @@
# 新版迁移 - 需求采纳清单
> 推荐迁移项汇总,供选择是否采纳。勾选 ☐ 表示采纳,☑ 表示已确认采纳。
> 更新日期2026-03-17
**标签说明**
- **🔴 必选**:提审/生产必备,建议优先
- **🟠 推荐**:有明显价值,建议采纳
- **🟡 可选**:按需采纳
- **⚪ 不迁**:明确不迁移
**乘风建议2026-03-17**X1/X2/X7 与审核模式配套必迁X8/X9 低成本建议采纳M6 因 soul-api 暂无 track/stats 接口待接口就绪后再迁X10 保持不迁。
---
## 一、管理端soul-admin
| 序号 | 功能项 | 标签 | 简要说明 | 采纳 |
|:----:|--------|------|----------|:----:|
| M1 | 小程序审核模式 | 🔴 必选 | 系统设置 → 审核模式开关,提审前隐藏支付入口 | ☑ |
| M2 | 关于页面开关 aboutEnabled | 🟠 推荐 | 功能开关第 4 项,控制关于页访问 | ☑ |
| M3 | Dashboard 代付金额 giftedTotal | 🟠 推荐 | 总收入下显示「含代付 ¥xx」 | ☑ |
| M4 | Dashboard 订单展开 | 🟠 推荐 | 默认 4 条,可展开至 10 条 | ☑ |
| M5 | 订单类型 balance_recharge | 🟡 可选 | 订单列表正确展示余额充值类型 | ☑ |
| M6 | 埋点统计 track/stats | 🟡 可选 | 依赖 /api/admin/track/statssoul-api 暂无,待接口就绪) | ☐ |
---
## 二、小程序miniprogram
| 序号 | 功能项 | 标签 | 简要说明 | 采纳 |
|:----:|--------|------|----------|:----:|
| X1 | app.js 运行时配置 | 🔴 必选 | getRuntimeBootstrapConfig避免生产用 localhost | ☑ |
| X2 | app.js loadMpConfig 扩展 | 🔴 必选 | 支持 apiDomain、auditMode、supportWechat | ☑ |
| X3 | soul-api mp_config | 🔴 必选 | 增加 auditMode、supportWechat 字段 | ☑ |
| X4 | index 超级个体按 auditMode 隐藏 | 🟠 推荐 | 审核时隐藏超级个体空态按钮 | ☑ |
| X5 | my 页支付相关按 auditMode 隐藏 | 🟠 推荐 | 会员、收益、余额、一键收款 | ☑ |
| X6 | wallet 页 auditMode | 🟠 推荐 | 审核时隐藏或提示 | ☑ |
| X7 | read 页 auditMode | 🟠 推荐 | 购买按钮等按审核隐藏 | ☑ |
| X8 | app.js request timeout | 🟡 可选 | 15s 超时,避免长时间挂起 | ☑ |
| X9 | read share-tip-inline | 🟡 可选 | 「分享后好友购买,你可获得 90% 收益」 | ☑ |
| X10 | read personsConfig、showShareTip | ⚪ 不迁 | 按需,优先级低 | ☐ |
---
## 三、不迁移项(维持现状)
| 项 | 说明 |
|----|------|
| gift-pay/detail 从体验版迁 | 稳定版 UI 与逻辑更完善 |
| 稳定版 ruleEngine | 保留 |
| 稳定版 profile-name-actions | 保留编辑入口 |
| 稳定版 referralEnabled | 保留,与 auditMode 并存 |
---
## 四、采纳后执行
1. 将 ☐ 改为 ☑ 表示确认采纳
2. 按标签优先级排期:🔴 → 🟠 → 🟡
3. 迁移完成后执行 `change-checklist` 三端关联检查

View File

@@ -83,6 +83,17 @@
原搁置项富文本/打包引导/存客宝均已确认稳定版已有,无新增搁置。
### 新版细节未迁移2026-03-17 乘风分析)
| 项 | 说明 | 优先级 |
|----|------|:------:|
| 运行时配置 | getRuntimeBootstrapConfig、extConfig/Storage、默认生产 baseUrl | P0 |
| loadMpConfig 扩展 | apiDomain、auditMode、supportWechat | P0 |
| auditMode 审核模式 | index/my/wallet/read 支付相关 UI 按审核隐藏 | P1 |
| request timeout | app.request 增加 timeout | P2 |
详见 `开发文档/新版迁移-功能差异清单.md`(小程序)、`开发文档/新版迁移-管理端功能差异清单.md`(管理端)。
### 规则引擎2026-03-17 已迁移)
| 项 | 状态 |
@@ -93,11 +104,11 @@
| after_match匹配后引导 | ✅ match.js reportMatch 后触发 |
| user_rules 表 + 默认规则 | ✅ AutoMigrate + add-user-rules-default.sql |
### 埋点(待补充
### 埋点(已补齐 2026-03-17
| 项 | 当前状态 | 待办 |
|----|----------|------|
| **埋点 trackClick** | 已接入:chapters、read、wallet | 遗漏:index、my、match、vip、search、referral 等页 |
| 项 | 当前状态 |
|----|----------|
| **埋点 trackClick** | 已接入index、my、match、vip、search、referral、chapters、read、wallet |
---