sync: Gitea 同步配置、miniprogram 页面逻辑、miniprogram 页面样式、脚本与配置、soul-admin 前端、soul-admin 页面、soul-api 接口逻辑、soul-api 路由等 | 原因: 多模块开发更新

This commit is contained in:
卡若
2026-03-08 08:00:39 +08:00
parent b7c35a89b0
commit 66cd90e511
43 changed files with 2559 additions and 809 deletions

54
Gitea同步说明.md Normal file
View File

@@ -0,0 +1,54 @@
# 一场soul的创业实验-永平 → Gitea 同步说明
**Gitea 仓库**`fnvtk/soul-yongping`
**地址**<http://open.quwanzhi.com:3000/fnvtk/soul-yongping>
本仓库已配置:**每次 `git commit` 后自动推送到 Gitea**(见 `.git/hooks/post-commit`),有更新即同步。
---
## 一、首次使用(完成一次推送后,之后都会自动同步)
本仓库的 **gitea 远程已使用与卡若AI 相同的 Gitea Token**,只需在 Gitea 上建仓后推送即可。
### 1. 在 Gitea 上创建仓库(若还没有)
1. 打开 <http://open.quwanzhi.com:3000>,登录 **fnvtk**
2. 点击「新建仓库」。
3. **仓库名称**填:`soul-yongping`
4. 描述可填:`一场soul的创业实验-永平 网站与小程序`
5. 不要勾选「使用自述文件初始化」,创建空仓库。
### 2. 执行首次推送
```bash
cd "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平"
git push -u gitea main
```
外网需代理时先设置再推送:
```bash
export GITEA_HTTP_PROXY=http://127.0.0.1:7897
git push -u gitea main
```
首次推送成功后,**之后每次在本项目里 `git commit`,都会自动执行 `git push gitea main`**,无需再手动上传。
---
## 二、自动同步机制
- **触发条件**:在本项目执行 `git commit`(任意分支的提交都会触发 hook但推送的是 `main`)。
- **执行动作**`post-commit` 钩子会执行 `git push gitea main`
- **关闭自动推送**:删除或改名 `.git/hooks/post-commit` 即可。
---
## 三、手动推送(可选)
若需要单独推送到 Gitea不依赖 commit
```bash
git push gitea main
```

View File

@@ -141,15 +141,22 @@ App({
}
},
// 绑定推荐码到用户
// 绑定推荐码到用户(自己的推荐码不请求接口,避免 400 与控制台报错)
async bindReferralCode(refCode) {
try {
const userId = this.globalData.userInfo?.id
if (!userId || !refCode) return
const myCode = this.getMyReferralCode()
if (myCode && this._normalizeReferralCode(refCode) === this._normalizeReferralCode(myCode)) {
console.log('[App] 跳过绑定:不能使用自己的推荐码')
this.globalData.pendingReferralCode = null
wx.removeStorageSync('pendingReferralCode')
return
}
console.log('[App] 绑定推荐码:', refCode, '到用户:', userId)
// 调用API绑定推荐关系
const res = await this.request('/api/miniprogram/referral/bind', {
method: 'POST',
data: {
@@ -158,19 +165,31 @@ App({
},
silent: true
})
if (res.success) {
console.log('[App] 推荐码绑定成功')
// 仅记录当前已绑定的推荐码,用于展示/调试是否允许更换由后端根据30天规则判断
wx.setStorageSync('boundReferralCode', refCode)
this.globalData.pendingReferralCode = null
wx.removeStorageSync('pendingReferralCode')
}
} catch (e) {
console.error('[App] 绑定推荐码失败:', e)
const msg = (e && e.message) ? String(e.message) : ''
if (msg.indexOf('不能使用自己的推荐码') !== -1) {
console.log('[App] 跳过绑定:不能使用自己的推荐码')
this.globalData.pendingReferralCode = null
wx.removeStorageSync('pendingReferralCode')
} else {
console.error('[App] 绑定推荐码失败:', e)
}
}
},
// 推荐码归一化后比较(忽略大小写、短横线等)
_normalizeReferralCode(code) {
if (!code || typeof code !== 'string') return ''
return code.replace(/[\s\-_]/g, '').toUpperCase().trim()
},
// 根据业务 id 从 bookData 查 mid用于跳转
getSectionMid(sectionId) {
const list = this.globalData.bookData || []

View File

@@ -18,178 +18,9 @@ Page({
hasFullBook: false,
purchasedSections: [],
// 书籍数据 - 完整真实标题
totalSections: 62,
bookData: [
{
id: 'part-1',
number: '一',
title: '真实的人',
subtitle: '人与人之间的底层逻辑',
chapters: [
{
id: 'chapter-1',
title: '第1章人与人之间的底层逻辑',
sections: [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', isFree: true, price: 1 },
{ id: '1.2', title: '老墨:资源整合高手的社交方法', isFree: false, price: 1 },
{ id: '1.3', title: '笑声背后的MBTI为什么ENTJ适合做资源INTP适合做系统', isFree: false, price: 1 },
{ id: '1.4', title: '人性的三角结构:利益、情感、价值观', isFree: false, price: 1 },
{ id: '1.5', title: '沟通差的问题:为什么你说的别人听不懂', isFree: false, price: 1 }
]
},
{
id: 'chapter-2',
title: '第2章人性困境案例',
sections: [
{ id: '2.1', title: '相亲故事:你以为找的是人,实际是在找模式', isFree: false, price: 1 },
{ id: '2.2', title: '找工作迷茫者:为什么简历解决不了人生', isFree: false, price: 1 },
{ id: '2.3', title: '撸运费险:小钱困住大脑的真实心理', isFree: false, price: 1 },
{ id: '2.4', title: '游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力', isFree: false, price: 1 },
{ id: '2.5', title: '健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒', isFree: false, price: 1 }
]
}
]
},
{
id: 'part-2',
number: '二',
title: '真实的行业',
subtitle: '电商、内容、传统行业解析',
chapters: [
{
id: 'chapter-3',
title: '第3章电商篇',
sections: [
{ id: '3.1', title: '3000万流水如何跑出来(退税模式解析)', isFree: false, price: 1 },
{ id: '3.2', title: '供应链之王 vs 打工人:利润不在前端', isFree: false, price: 1 },
{ id: '3.3', title: '社区团购的底层逻辑', isFree: false, price: 1 },
{ id: '3.4', title: '跨境电商与退税套利', isFree: false, price: 1 }
]
},
{
id: 'chapter-4',
title: '第4章内容商业篇',
sections: [
{ id: '4.1', title: '旅游号:30天10万粉的真实逻辑', isFree: false, price: 1 },
{ id: '4.2', title: '做号工厂:如何让一个号变成一个机器', isFree: false, price: 1 },
{ id: '4.3', title: '情绪内容为什么比专业内容更赚钱', isFree: false, price: 1 },
{ id: '4.4', title: '猫与宠物号:为什么宠物赛道永不过时', isFree: false, price: 1 },
{ id: '4.5', title: '直播间里的三种人:演员、技术工、系统流', isFree: false, price: 1 }
]
},
{
id: 'chapter-5',
title: '第5章传统行业篇',
sections: [
{ id: '5.1', title: '拍卖行抱朴一天240万的摇号生意', isFree: false, price: 1 },
{ id: '5.2', title: '土地拍卖:招拍挂背后的游戏规则', isFree: false, price: 1 },
{ id: '5.3', title: '地摊经济数字化一个月900块的餐车生意', isFree: false, price: 1 },
{ id: '5.4', title: '不良资产拍卖:我错过的一个亿佣金', isFree: false, price: 1 },
{ id: '5.5', title: '桶装水李总:跟物业合作的轻资产模式', isFree: false, price: 1 }
]
}
]
},
{
id: 'part-3',
number: '三',
title: '真实的错误',
subtitle: '我和别人犯过的错',
chapters: [
{
id: 'chapter-6',
title: '第6章我人生错过的4件大钱',
sections: [
{ id: '6.1', title: '电商财税窗口2016年的千万级机会', isFree: false, price: 1 },
{ id: '6.2', title: '供应链金融:我不懂的杠杆游戏', isFree: false, price: 1 },
{ id: '6.3', title: '内容红利2019年我为什么没做抖音', isFree: false, price: 1 },
{ id: '6.4', title: '数据资产化:我还在观望的未来机会', isFree: false, price: 1 }
]
},
{
id: 'chapter-7',
title: '第7章别人犯的错误',
sections: [
{ id: '7.1', title: '投资房年轻人的迷茫:资金 vs 能力', isFree: false, price: 1 },
{ id: '7.2', title: '信息差骗局:永远有人靠卖学习赚钱', isFree: false, price: 1 },
{ id: '7.3', title: '在Soul找恋爱但想赚钱的人', isFree: false, price: 1 },
{ id: '7.4', title: '创业者的三种死法:冲动、轻信、没结构', isFree: false, price: 1 },
{ id: '7.5', title: '人情生意的终点:关系越多亏得越多', isFree: false, price: 1 }
]
}
]
},
{
id: 'part-4',
number: '四',
title: '真实的赚钱',
subtitle: '底层结构与真实案例',
chapters: [
{
id: 'chapter-8',
title: '第8章底层结构',
sections: [
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', isFree: false, price: 1 },
{ id: '8.2', title: '价格杠杆:供应链与信息差', isFree: false, price: 1 },
{ id: '8.3', title: '时间杠杆:自动化 + AI', isFree: false, price: 1 },
{ id: '8.4', title: '情绪杠杆:咨询、婚恋、生意场', isFree: false, price: 1 },
{ id: '8.5', title: '社交杠杆:认识谁比你会什么更重要', isFree: false, price: 1 },
{ id: '8.6', title: '云阿米巴:分不属于自己的钱', isFree: false, price: 1 }
]
},
{
id: 'chapter-9',
title: '第9章我在Soul上亲访的赚钱案例',
sections: [
{ id: '9.1', title: '游戏账号私域:账号即资产', isFree: false, price: 1 },
{ id: '9.2', title: '健康包模式:高复购、高毛利', isFree: false, price: 1 },
{ id: '9.3', title: '药物私域:长期关系赛道', isFree: false, price: 1 },
{ id: '9.4', title: '残疾机构合作:退税 × AI × 人力成本', isFree: false, price: 1 },
{ id: '9.5', title: '私域银行:粉丝即小股东', isFree: false, price: 1 },
{ id: '9.6', title: 'Soul派对房:陌生人成交的最快场景', isFree: false, price: 1 },
{ id: '9.7', title: '飞书中台:从聊天到成交的流程化体系', isFree: false, price: 1 },
{ id: '9.8', title: '餐饮女孩6万营收、1万利润的死撑生意', isFree: false, price: 1 },
{ id: '9.9', title: '电竞生态:从陪玩到签约到酒店的完整链条', isFree: false, price: 1 },
{ id: '9.10', title: '淘客大佬损耗30%的白色通道', isFree: false, price: 1 },
{ id: '9.11', title: '蔬菜供应链:农户才是最赚钱的人', isFree: false, price: 1 },
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', isFree: false, price: 1 },
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', isFree: false, price: 1 },
{ id: '9.14', title: '大健康私域一个月150万的70后', isFree: false, price: 1 }
]
}
]
},
{
id: 'part-5',
number: '五',
title: '真实的社会',
subtitle: '未来职业与商业生态',
chapters: [
{
id: 'chapter-10',
title: '第10章未来职业的变化趋势',
sections: [
{ id: '10.1', title: 'AI时代哪些工作会消失哪些会崛起', isFree: false, price: 1 },
{ id: '10.2', title: '一人公司:为什么越来越多人选择单干', isFree: false, price: 1 },
{ id: '10.3', title: '为什么链接能力会成为第一价值', isFree: false, price: 1 },
{ id: '10.4', title: '新型公司:Soul-飞书-线下的三位一体', isFree: false, price: 1 }
]
},
{
id: 'chapter-11',
title: '第11章中国社会商业生态的未来',
sections: [
{ id: '11.1', title: '私域经济:为什么流量越来越贵', isFree: false, price: 1 },
{ id: '11.2', title: '银发经济与孤独经济:两个被忽视的万亿市场', isFree: false, price: 1 },
{ id: '11.3', title: '流量红利的终局', isFree: false, price: 1 },
{ id: '11.4', title: '大模型 + 供应链的组合拳', isFree: false, price: 1 },
{ id: '11.5', title: '社会分层的最终逻辑', isFree: false, price: 1 }
]
}
]
}
],
// 书籍数据:以后台内容管理为准,仅用接口 /api/miniprogram/book/all-chapters 返回的数据
totalSections: 0,
bookData: [],
// 展开状态:默认不展开任何篇章,直接显示目录
expandedPart: null,
@@ -222,23 +53,37 @@ Page({
return p.includes('序言') || p.includes('尾声') || p.includes('附录')
},
// 一次请求拉取全量目录,同时更新 totalSections / bookData / dailyChapters
// 一次请求拉取全量目录,以后台内容管理为准;同时更新 totalSections / bookData / dailyChapters
async loadChaptersOnce() {
try {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const rows = (res && res.data) || (res && res.chapters) || []
if (rows.length === 0) return
// 1. totalSections
// 无数据时清空目录,避免展示旧数据
if (rows.length === 0) {
app.globalData.bookData = []
wx.setStorageSync('bookData', [])
this.setData({
bookData: [],
totalSections: 0,
dailyChapters: [],
expandedPart: null
})
return
}
const totalSections = res.total ?? rows.length
app.globalData.bookData = rows
wx.setStorageSync('bookData', rows)
// 2. bookData过滤序言/尾声/附录,中间篇章按 part 聚合)
// bookData过滤序言/尾声/附录,按 part 聚合,篇章顺序按 sort_order 与后台一致含「2026每日派对干货」等
const filtered = rows.filter(r => !this._isFixedPart(r.partTitle || r.part_title))
const partMap = new Map()
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
filtered.forEach((r) => {
const pid = r.partId || r.part_id || 'part-1'
const cid = r.chapterId || r.chapter_id || 'chapter-1'
const sortOrder = r.sectionOrder ?? r.sort_order ?? 999999
if (!partMap.has(pid)) {
const partIdx = partMap.size
partMap.set(pid, {
@@ -246,10 +91,12 @@ Page({
number: numbers[partIdx] || String(partIdx + 1),
title: r.partTitle || r.part_title || '未分类',
subtitle: r.chapterTitle || r.chapter_title || '',
chapters: new Map()
chapters: new Map(),
minSortOrder: sortOrder
})
}
const part = partMap.get(pid)
if (sortOrder < part.minSortOrder) part.minSortOrder = sortOrder
if (!part.chapters.has(cid)) {
part.chapters.set(cid, {
id: cid,
@@ -267,13 +114,16 @@ Page({
isNew: r.isNew === true || r.is_new === true
})
})
const bookData = Array.from(partMap.values()).map(p => ({
...p,
const partList = Array.from(partMap.values())
partList.sort((a, b) => (a.minSortOrder ?? 999999) - (b.minSortOrder ?? 999999))
const bookData = partList.map((p, idx) => ({
id: p.id,
number: numbers[idx] || String(idx + 1),
title: p.title,
subtitle: p.subtitle,
chapters: Array.from(p.chapters.values())
}))
const firstPart = bookData[0] && bookData[0].id
// 3. dailyChapterssort_order > 62 的新增章节按更新时间取前20
const baseSort = 62
const daily = rows
.filter(r => (r.sectionOrder ?? r.sort_order ?? 0) > baseSort)
@@ -296,7 +146,14 @@ Page({
dailyChapters: daily,
expandedPart: this.data.expandedPart
})
} catch (e) { console.log('[Chapters] 加载目录失败:', e) }
} catch (e) {
console.log('[Chapters] 加载目录失败:', e)
this.setData({ bookData: [], totalSections: 0 })
}
},
onPullDownRefresh() {
this.loadChaptersOnce().then(() => wx.stopPullDownRefresh()).catch(() => wx.stopPullDownRefresh())
},
onShow() {

View File

@@ -1,6 +1,6 @@
{
"usingComponents": {},
"enablePullDownRefresh": false,
"enablePullDownRefresh": true,
"backgroundTextStyle": "light",
"backgroundColor": "#000000"
}

View File

@@ -23,12 +23,8 @@ Page({
totalSections: 62,
bookData: [],
// 推荐章节
featuredSections: [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
],
// 推荐章节:以服务端推荐算法为准,不再预置写死内容
featuredSections: [],
// 最新章节(动态计算)
latestSection: null,

View File

@@ -4,7 +4,7 @@
<!-- 自定义导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 顶部区域按设计稿S 图标 + 标题副标题 | 点击链接卡若 + 章数 -->
<!-- 顶部区域按设计稿S 图标 + 标题副标题 | 点击链接卡若) -->
<view class="header">
<view class="header-content">
<view class="logo-section">
@@ -21,7 +21,6 @@
<image class="contact-avatar" src="/assets/images/author-avatar.png" mode="aspectFill"/>
<text class="contact-text">点击链接卡若</text>
</view>
<view class="chapter-badge">{{totalSections}}章</view>
</view>
</view>
@@ -53,37 +52,6 @@
<view class="banner-action"><text class="banner-action-text">开始阅读</text><view class="banner-arrow">→</view></view>
</view>
<!-- 阅读进度卡 -->
<view class="progress-card card">
<view class="progress-header">
<text class="progress-title">我的阅读</text>
<text class="progress-count">{{readCount}}/{{totalSections}}章</text>
</view>
<view class="progress-bar-wrapper">
<view class="progress-bar-bg">
<view class="progress-bar-fill" style="width: {{totalSections > 0 ? (readCount / totalSections) * 100 : 0}}%;"></view>
</view>
</view>
<view class="progress-stats">
<view class="stat-item">
<text class="stat-value brand-color">{{readCount}}</text>
<text class="stat-label">已读</text>
</view>
<view class="stat-item">
<text class="stat-value">{{totalSections - readCount}}</text>
<text class="stat-label">待读</text>
</view>
<view class="stat-item">
<text class="stat-value">{{partCount}}</text>
<text class="stat-label">篇章</text>
</view>
<view class="stat-item">
<text class="stat-value">{{totalSections}}</text>
<text class="stat-label">章节</text>
</view>
</view>
</view>
<!-- 超级个体(横向滚动,已去掉「查看全部」) -->
<view class="section">
<view class="section-header">
@@ -170,7 +138,6 @@
</view>
<view class="timeline-right">
<text class="timeline-price">¥{{item.price}}</text>
<text class="timeline-date">{{item.dateStr}}</text>
</view>
</view>
</view>

View File

@@ -12,7 +12,7 @@ const app = getApp()
// 导师顾问:跳转到存客宝添加微信
// 团队招募:跳转到存客宝添加微信
let MATCH_TYPES = [
{ id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false },
{ id: 'partner', label: '超级个体', matchLabel: '超级个体', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false },
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: true, showJoinAfterMatch: true, requirePurchase: true },
{ id: 'mentor', label: '导师顾问', matchLabel: '导师顾问', icon: '❤️', matchFromDB: true, showJoinAfterMatch: true },
{ id: 'team', label: '团队招募', matchLabel: '团队招募', icon: '🎮', matchFromDB: true, showJoinAfterMatch: true }
@@ -27,7 +27,7 @@ Page({
// 匹配类型
matchTypes: MATCH_TYPES,
selectedType: 'partner',
currentTypeLabel: '找伙伴',
currentTypeLabel: '超级个体',
// 用户状态
isLoggedIn: false,

View File

@@ -111,31 +111,26 @@ Page({
const { isLoggedIn, userInfo } = app.globalData
if (isLoggedIn && userInfo) {
const readIds = app.globalData.readSectionIds || []
const recentList = readIds.slice(-5).reverse().map(id => ({
id,
mid: app.getSectionMid(id),
title: `章节 ${id}`
}))
const userId = userInfo.id || ''
const userIdShort = userId.length > 20 ? userId.slice(0, 10) + '...' + userId.slice(-6) : userId
const userWechat = wx.getStorageSync('user_wechat') || userInfo.wechat || ''
// 先设基础信息;收益由 loadMyEarnings 专用接口拉取,加载前用 - 占位
// 先设基础信息;阅读统计与收益再分别从后端刷新
this.setData({
isLoggedIn: true,
userInfo,
userIdShort,
userWechat,
readCount: Math.min(app.getReadCount(), this.data.totalSections || 62),
readCount: 0,
referralCount: userInfo.referralCount || 0,
earnings: '-',
pendingEarnings: '-',
earningsLoading: true,
recentChapters: recentList,
totalReadTime: Math.floor(Math.random() * 200) + 50
recentChapters: [],
totalReadTime: 0,
matchHistory: 0
})
this.loadDashboardStats()
this.loadMyEarnings()
this.loadPendingConfirm()
this.loadVipStatus()
@@ -149,11 +144,48 @@ Page({
earnings: '-',
pendingEarnings: '-',
earningsLoading: false,
recentChapters: []
recentChapters: [],
totalReadTime: 0,
matchHistory: 0
})
}
},
async loadDashboardStats() {
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const res = await app.request({
url: `/api/miniprogram/user/dashboard-stats?userId=${encodeURIComponent(userId)}`,
silent: true
})
if (!res?.success || !res.data) return
const readSectionIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds : []
app.globalData.readSectionIds = readSectionIds
wx.setStorageSync('readSectionIds', readSectionIds)
const recentChapters = Array.isArray(res.data.recentChapters)
? res.data.recentChapters.map((item) => ({
id: item.id,
mid: item.mid || app.getSectionMid(item.id),
title: item.title || `章节 ${item.id}`
}))
: []
this.setData({
readCount: Number(res.data.readCount || 0),
totalReadTime: Number(res.data.totalReadMinutes || 0),
matchHistory: Number(res.data.matchHistory || 0),
recentChapters
})
} catch (e) {
console.log('[My] 拉取阅读统计失败:', e && e.message)
}
},
// 拉取待确认收款列表(用于「确认收款」按钮)
async loadPendingConfirm() {
const userInfo = app.globalData.userInfo

View File

@@ -442,31 +442,54 @@ Page({
},
// 加载导航
loadNavigation(id) {
const sectionOrder = [
'preface', '1.1', '1.2', '1.3', '1.4', '1.5',
'2.1', '2.2', '2.3', '2.4', '2.5',
'3.1', '3.2', '3.3', '3.4',
'4.1', '4.2', '4.3', '4.4', '4.5',
'5.1', '5.2', '5.3', '5.4', '5.5',
'6.1', '6.2', '6.3', '6.4',
'7.1', '7.2', '7.3', '7.4', '7.5',
'8.1', '8.2', '8.3', '8.4', '8.5', '8.6',
'9.1', '9.2', '9.3', '9.4', '9.5', '9.6', '9.7', '9.8', '9.9', '9.10', '9.11', '9.12', '9.13', '9.14',
'10.1', '10.2', '10.3', '10.4',
'11.1', '11.2', '11.3', '11.4', '11.5',
'epilogue'
]
const currentIndex = sectionOrder.indexOf(id)
const prevId = currentIndex > 0 ? sectionOrder[currentIndex - 1] : null
const nextId = currentIndex < sectionOrder.length - 1 ? sectionOrder[currentIndex + 1] : null
this.setData({
prevSection: prevId ? { id: prevId, title: this.getSectionTitle(prevId) } : null,
nextSection: nextId ? { id: nextId, title: this.getSectionTitle(nextId) } : null
})
// 加载导航:以后台章节真实 sort_order 为准
async loadNavigation(id) {
try {
let rows = app.globalData.bookData || []
if (!Array.isArray(rows) || rows.length === 0) {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
rows = (res && (res.data || res.chapters)) || []
if (Array.isArray(rows) && rows.length > 0) {
app.globalData.bookData = rows
}
}
if (!Array.isArray(rows) || rows.length === 0) {
this.setData({ prevSection: null, nextSection: null })
return
}
const orderedSections = rows
.slice()
.sort((a, b) => {
const sortA = a.sortOrder ?? a.sort_order ?? 999999
const sortB = b.sortOrder ?? b.sort_order ?? 999999
if (sortA !== sortB) return sortA - sortB
const midA = a.mid ?? a.MID ?? 0
const midB = b.mid ?? b.MID ?? 0
if (midA !== midB) return midA - midB
return String(a.id || '').localeCompare(String(b.id || ''))
})
.map((item) => ({
id: item.id,
mid: item.mid ?? item.MID ?? 0,
title: item.sectionTitle || item.section_title || item.title || item.chapterTitle || this.getSectionTitle(item.id)
}))
const currentIndex = orderedSections.findIndex((item) => item.id === id)
if (currentIndex === -1) {
this.setData({ prevSection: null, nextSection: null })
return
}
const prevSection = currentIndex > 0 ? orderedSections[currentIndex - 1] : null
const nextSection = currentIndex < orderedSections.length - 1 ? orderedSections[currentIndex + 1] : null
this.setData({ prevSection, nextSection })
} catch (e) {
console.error('[Read] 加载上下篇导航失败:', e)
this.setData({ prevSection: null, nextSection: null })
}
},
// 返回(从分享进入无栈时回首页)
@@ -523,8 +546,9 @@ Page({
const { section, sectionId, sectionMid } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const shareTitle = section?.title
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
const safeSectionTitle = this.getSafeShareText(section?.title || '')
const shareTitle = safeSectionTitle
? `📚 ${safeSectionTitle.length > 20 ? safeSectionTitle.slice(0, 20) + '...' : safeSectionTitle}`
: '📚 Soul创业派对 - 真实商业故事'
return {
title: shareTitle,
@@ -533,17 +557,51 @@ Page({
}
},
// 分享到朋友圈:文案用本章节正文前文,便于不折叠时展示(朋友圈卡片标题约 64 字节)
// 分享到朋友圈:使用安全模板,避免正文敏感词触发平台风控提示
onShareTimeline() {
const { sectionId, sectionMid, shareTimelineTitle, section } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const title = (shareTimelineTitle && shareTimelineTitle.trim())
? (shareTimelineTitle.length > 32 ? shareTimelineTitle.slice(0, 32) + '...' : shareTimelineTitle)
: ((section?.title || '').trim().slice(0, 32) || 'Soul创业派对 - 真实商业故事')
const safeSectionTitle = this.getSafeShareText(section?.title || '')
const safePreviewTitle = this.getSafeShareText(shareTimelineTitle || '')
// 优先使用章节名,兜底使用预览文案;都为空时使用固定安全标题
const baseTitle = safeSectionTitle || safePreviewTitle || 'Soul创业派对真实商业案例'
const title = baseTitle.length > 32 ? `${baseTitle.slice(0, 32)}...` : baseTitle
return { title, query: ref ? `${q}&ref=${ref}` : q }
},
// 清洗分享文案,规避高风险收益承诺类词汇,降低平台风控误判
getSafeShareText(text = '') {
let safeText = String(text || '').trim()
if (!safeText) return ''
// 统一替换常见风险词
const riskyPatterns = [
/90%\s*收益/gi,
/百分之九十\s*收益/gi,
/收益/gi,
/锁定\s*\d+\s*天/gi,
/锁定期/gi,
/稳赚/gi,
/保本/gi,
/高回报/gi,
/返利/gi,
/理财/gi,
/投资/gi
]
riskyPatterns.forEach((pattern) => {
safeText = safeText.replace(pattern, '')
})
// 去掉多余空白和标点残留
safeText = safeText
.replace(/[,。;、,:\-\s]{2,}/g, ' ')
.replace(/^[,。;、,:\-\s]+|[,。;、,:\-\s]+$/g, '')
.trim()
return safeText
},
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
showLoginModal() {
try {
@@ -992,14 +1050,16 @@ Page({
// 跳转到上一篇
goToPrev() {
if (this.data.prevSection) {
wx.redirectTo({ url: `/pages/read/read?id=${this.data.prevSection.id}` })
const q = this.data.prevSection.mid ? `mid=${this.data.prevSection.mid}` : `id=${this.data.prevSection.id}`
wx.redirectTo({ url: `/pages/read/read?${q}` })
}
},
// 跳转到下一篇
goToNext() {
if (this.data.nextSection) {
wx.redirectTo({ url: `/pages/read/read?id=${this.data.nextSection.id}` })
const q = this.data.nextSection.mid ? `mid=${this.data.nextSection.mid}` : `id=${this.data.nextSection.id}`
wx.redirectTo({ url: `/pages/read/read?${q}` })
}
},

View File

@@ -81,8 +81,8 @@
<view class="action-section">
<view class="action-row-inline">
<button class="action-btn-inline btn-share-inline" open-type="share">
<text class="action-icon-small">📷</text>
<text class="action-text-small">分享到朋友圈</text>
<text class="action-icon-small">👥</text>
<text class="action-text-small">分享好友</text>
</button>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<text class="action-icon-small">🖼️</text>

View File

@@ -37,6 +37,9 @@ class ReadingTracker {
// 开始定期上报每30秒
this.startProgressReport()
// 立即上报一次「打开/点击」,确保内容管理后台的「点击」数据有记录(与 reading_progress 表直接捆绑)
setTimeout(() => this.reportProgressToServer(false), 0)
}
/**

7
scripts/gitea_push_once.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
# 首次推送 Gitea先设置 export GITEA_TOKEN=你的Token 再执行本脚本
[ -z "$GITEA_TOKEN" ] && echo "请先执行: export GITEA_TOKEN=你的Gitea的Token" && exit 1
cd "$(dirname "$0")/.."
git remote set-url gitea "http://fnvtk:${GITEA_TOKEN}@open.quwanzhi.com:3000/fnvtk/soul-yongping.git"
export HTTP_PROXY=http://127.0.0.1:7897 HTTPS_PROXY=http://127.0.0.1:7897
git push -u gitea main

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

495
soul-admin/dist/assets/index-V947pMyG.js vendored Normal file

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-DL1oFSEm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cw3R8GlJ.css">
<script type="module" crossorigin src="/assets/index-V947pMyG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cnf4LXuY.css">
</head>
<body>
<div id="root"></div>

View File

@@ -8,7 +8,6 @@ import { DistributionPage } from './pages/distribution/DistributionPage'
import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage'
import { ContentPage } from './pages/content/ContentPage'
import { ReferralSettingsPage } from './pages/referral-settings/ReferralSettingsPage'
import { AuthorSettingsPage } from './pages/author-settings/AuthorSettingsPage'
import { SettingsPage } from './pages/settings/SettingsPage'
import { PaymentPage } from './pages/payment/PaymentPage'
import { SitePage } from './pages/site/SitePage'
@@ -18,7 +17,7 @@ import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
import { VipRolesPage } from './pages/vip-roles/VipRolesPage'
import { MentorsPage } from './pages/mentors/MentorsPage'
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
import { AdminUsersPage } from './pages/admin-users/AdminUsersPage'
import { FindPartnerPage } from './pages/find-partner/FindPartnerPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
@@ -35,15 +34,16 @@ function App() {
<Route path="withdrawals" element={<WithdrawalsPage />} />
<Route path="content" element={<ContentPage />} />
<Route path="referral-settings" element={<ReferralSettingsPage />} />
<Route path="author-settings" element={<AuthorSettingsPage />} />
<Route path="author-settings" element={<Navigate to="/settings?tab=author" replace />} />
<Route path="admin-users" element={<Navigate to="/settings?tab=admin" replace />} />
<Route path="vip-roles" element={<VipRolesPage />} />
<Route path="mentors" element={<MentorsPage />} />
<Route path="mentor-consultations" element={<MentorConsultationsPage />} />
<Route path="admin-users" element={<AdminUsersPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="payment" element={<PaymentPage />} />
<Route path="site" element={<SitePage />} />
<Route path="qrcodes" element={<QRCodesPage />} />
<Route path="find-partner" element={<FindPartnerPage />} />
<Route path="match" element={<MatchPage />} />
<Route path="match-records" element={<MatchRecordsPage />} />
<Route path="api-doc" element={<ApiDocPage />} />

View File

@@ -11,9 +11,6 @@ import {
GitMerge,
Crown,
GraduationCap,
Calendar,
User,
ShieldCheck,
ChevronDown,
ChevronUp,
} from 'lucide-react'
@@ -28,13 +25,10 @@ const primaryMenuItems = [
]
// 折叠区「更多」(字典类 + 业务)
const moreMenuItems = [
{ icon: GitMerge, label: '找伙伴', href: '/find-partner' },
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
{ icon: User, label: '作者详情', href: '/author-settings' },
{ icon: ShieldCheck, label: '管理员', href: '/admin-users' },
{ icon: GraduationCap, label: '导师管理', href: '/mentors' },
{ icon: Calendar, label: '导师预约', href: '/mentor-consultations' },
{ icon: Wallet, label: '推广中心', href: '/distribution' },
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
]

View File

@@ -16,6 +16,8 @@ export interface SectionItem {
isNew?: boolean
clickCount?: number
payCount?: number
hotScore?: number
hotRank?: number
}
export interface ChapterItem {
@@ -55,6 +57,8 @@ interface ChapterTreeProps {
/** 批量移动:勾选章节 */
selectedSectionIds?: string[]
onToggleSectionSelect?: (sectionId: string) => void
/** 查看某节的付款记录 */
onShowSectionOrders?: (s: SectionItem) => void
}
export function ChapterTree({
@@ -72,6 +76,7 @@ export function ChapterTree({
onEditChapter,
selectedSectionIds = [],
onToggleSectionSelect,
onShowSectionOrders,
}: ChapterTreeProps) {
const [draggingItem, setDraggingItem] = useState<{ type: DragType; id: string } | null>(null)
const [dragOverTarget, setDragOverTarget] = useState<{ type: DragType; id: string } | null>(null)
@@ -318,6 +323,13 @@ export function ChapterTree({
) : (
<span className="text-xs text-gray-500">¥{sec.price}</span>
)}
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
@@ -382,6 +394,17 @@ export function ChapterTree({
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onEditPart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onEditPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑篇名">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onDeletePart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeletePart(part)} className="text-gray-500 hover:text-red-400 h-7 px-2" title="删除本篇">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<span className="text-xs text-gray-500">{chapterCount}</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
@@ -393,7 +416,26 @@ export function ChapterTree({
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
{part.chapters.map((chapter) => (
<div key={chapter.id} className="space-y-2">
<p className="text-xs text-gray-500 pb-1">{chapter.title}</p>
<div className="flex items-center gap-2 w-full">
<p className="text-xs text-gray-500 pb-1 flex-1">{chapter.title}</p>
<div className="flex gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{onEditChapter && (
<Button variant="ghost" size="sm" onClick={() => onEditChapter(part, chapter)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑章节名称">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onAddChapterInPart && (
<Button variant="ghost" size="sm" onClick={() => onAddChapterInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="新增第X章">
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onDeleteChapter && (
<Button variant="ghost" size="sm" onClick={() => onDeleteChapter(part, chapter)} className="text-gray-500 hover:text-red-400 h-7 px-1.5" title="删除本章">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
<div className="space-y-1 pl-2">
{chapter.sections.map((section) => {
const secDragOver = isDragOver('section', section.id)
@@ -432,6 +474,12 @@ export function ChapterTree({
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(section.clickCount ?? 0)} · {(section.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(section.hotScore ?? 0).toFixed(1)} · {(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(section)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
<Eye className="w-3.5 h-3.5" />
</Button>
@@ -499,6 +547,12 @@ export function ChapterTree({
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
<Eye className="w-3.5 h-3.5" />
@@ -576,6 +630,12 @@ export function ChapterTree({
<span className="text-xs text-gray-500">¥{sec.price}</span>
)}
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
@@ -636,6 +696,12 @@ export function ChapterTree({
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
@@ -722,7 +788,7 @@ export function ChapterTree({
{isExpanded && (
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
{part.chapters.map((chapter, chIndex) => {
{part.chapters.map((chapter) => {
const chDragOver = isDragOver('chapter', chapter.id)
return (
<div key={chapter.id} className="space-y-2">
@@ -835,6 +901,12 @@ export function ChapterTree({
<span className="text-xs text-gray-500">¥{section.price}</span>
)}
<span className="text-[10px] text-gray-500" title="点击次数 · 付款笔数"> {(section.clickCount ?? 0)} · {(section.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(section.hotScore ?? 0).toFixed(1)} · {(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(section)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5 shrink-0">
</Button>
)}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
<Eye className="w-3.5 h-3.5" />

View File

@@ -37,7 +37,7 @@ import {
Image as ImageIcon,
Search,
} from 'lucide-react'
import { get, put, del } from '@/api/client'
import { get, put, post, del } from '@/api/client'
import { ChapterTree } from './ChapterTree'
import { apiUrl } from '@/api/client'
@@ -54,6 +54,8 @@ interface SectionListItem {
filePath?: string
clickCount?: number
payCount?: number
hotScore?: number
hotRank?: number
}
interface Section {
@@ -65,6 +67,8 @@ interface Section {
isNew?: boolean
clickCount?: number
payCount?: number
hotScore?: number
hotRank?: number
}
interface Chapter {
@@ -126,12 +130,15 @@ function buildTree(sections: SectionListItem[]): Part[] {
isNew: s.isNew,
clickCount: s.clickCount ?? 0,
payCount: s.payCount ?? 0,
hotScore: s.hotScore ?? 0,
hotRank: s.hotRank ?? 0,
})
}
// 确保「2026每日派对干货」篇章存在不在第六篇编号体系内
const DAILY_PART_ID = 'part-2026-daily'
const DAILY_PART_TITLE = '2026每日派对干货'
if (!partMap.has(DAILY_PART_ID)) {
const hasDailyPart = Array.from(partMap.values()).some((p) => p.title === DAILY_PART_TITLE || p.title.includes(DAILY_PART_TITLE))
if (!hasDailyPart) {
partMap.set(DAILY_PART_ID, {
id: DAILY_PART_ID,
title: DAILY_PART_TITLE,
@@ -195,6 +202,10 @@ export function ContentPage() {
const [isSavingPart, setIsSavingPart] = useState(false)
const [sectionOrdersModal, setSectionOrdersModal] = useState<{ section: Section; orders: SectionOrder[] } | null>(null)
const [sectionOrdersLoading, setSectionOrdersLoading] = useState(false)
const [showRankingAlgorithmModal, setShowRankingAlgorithmModal] = useState(false)
const [rankingWeights, setRankingWeights] = useState({ readWeight: 0.5, recencyWeight: 0.3, payWeight: 0.2 })
const [rankingWeightsLoading, setRankingWeightsLoading] = useState(false)
const [rankingWeightsSaving, setRankingWeightsSaving] = useState(false)
const tree = buildTree(sectionsList)
const totalSections = sectionsList.length
@@ -202,8 +213,10 @@ export function ContentPage() {
const loadList = async () => {
setLoading(true)
try {
// 每次请求均从服务端拉取最新数据,确保点击量/付款数与 reading_progress、orders 表直接捆绑
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
'/api/db/book?action=list',
{ cache: 'no-store' as RequestCache },
)
setSectionsList(Array.isArray(data?.sections) ? data.sections : [])
} catch (e) {
@@ -269,6 +282,60 @@ export function ContentPage() {
}
}
const loadRankingWeights = useCallback(async () => {
setRankingWeightsLoading(true)
try {
const data = await get<{ success?: boolean; data?: { readWeight?: number; recencyWeight?: number; payWeight?: number } }>(
'/api/db/config/full?key=article_ranking_weights',
{ cache: 'no-store' as RequestCache },
)
const d = data && (data as { success?: boolean; data?: { readWeight?: number; recencyWeight?: number; payWeight?: number } }).data
if (d && typeof d.readWeight === 'number' && typeof d.recencyWeight === 'number' && typeof d.payWeight === 'number') {
setRankingWeights({
readWeight: Math.max(0, Math.min(1, d.readWeight)),
recencyWeight: Math.max(0, Math.min(1, d.recencyWeight)),
payWeight: Math.max(0, Math.min(1, d.payWeight)),
})
}
} catch {
// 使用默认值
} finally {
setRankingWeightsLoading(false)
}
}, [])
useEffect(() => {
if (showRankingAlgorithmModal) loadRankingWeights()
}, [showRankingAlgorithmModal, loadRankingWeights])
const handleSaveRankingWeights = async () => {
const { readWeight, recencyWeight, payWeight } = rankingWeights
const sum = readWeight + recencyWeight + payWeight
if (Math.abs(sum - 1) > 0.001) {
alert('三个权重之和必须等于 1')
return
}
setRankingWeightsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
key: 'article_ranking_weights',
value: { readWeight, recencyWeight, payWeight },
description: '文章排名算法权重',
})
if (res && (res as { success?: boolean }).success !== false) {
alert('已保存')
loadList()
} else {
alert('保存失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : ''))
}
} catch (e) {
console.error(e)
alert('保存失败')
} finally {
setRankingWeightsSaving(false)
}
}
const handleShowSectionOrders = async (section: Section & { filePath?: string }) => {
setSectionOrdersModal({ section, orders: [] })
setSectionOrdersLoading(true)
@@ -558,6 +625,56 @@ export function ContentPage() {
}
setIsMoving(true)
try {
const buildFallbackReorderItems = () => {
const selectedSet = new Set(selectedSectionIds)
const baseItems = sectionsList.map((s) => ({
id: s.id,
partId: s.partId || '',
partTitle: s.partTitle || '',
chapterId: s.chapterId || '',
chapterTitle: s.chapterTitle || '',
}))
const movingItems = baseItems
.filter((item) => selectedSet.has(item.id))
.map((item) => ({
...item,
partId: batchMoveTargetPartId,
partTitle: targetPart.title || batchMoveTargetPartId,
chapterId: batchMoveTargetChapterId,
chapterTitle: targetChapter.title || batchMoveTargetChapterId,
}))
const remainingItems = baseItems.filter((item) => !selectedSet.has(item.id))
let insertIndex = remainingItems.length
for (let i = remainingItems.length - 1; i >= 0; i -= 1) {
const item = remainingItems[i]
if (item.partId === batchMoveTargetPartId && item.chapterId === batchMoveTargetChapterId) {
insertIndex = i + 1
break
}
}
return [
...remainingItems.slice(0, insertIndex),
...movingItems,
...remainingItems.slice(insertIndex),
]
}
const tryFallbackReorder = async () => {
const reorderItems = buildFallbackReorderItems()
const reorderRes = await put<{ success?: boolean; error?: string }>(
'/api/db/book',
{ action: 'reorder', items: reorderItems },
)
if (reorderRes && (reorderRes as { success?: boolean }).success !== false) {
alert(`已移动 ${selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}`)
setShowBatchMoveModal(false)
setSelectedSectionIds([])
await loadList()
return true
}
return false
}
const payload = {
action: 'move-sections',
sectionIds: selectedSectionIds,
@@ -571,13 +688,18 @@ export function ContentPage() {
alert(`已移动 ${(res as { count?: number }).count ?? selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}`)
setShowBatchMoveModal(false)
setSelectedSectionIds([])
loadList()
await loadList()
} else {
alert('移动失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
const errorMessage = res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error || '' : '未知错误'
if (errorMessage.includes('缺少 id') || errorMessage.includes('无效的 action')) {
const fallbackOk = await tryFallbackReorder()
if (fallbackOk) return
}
alert('移动失败: ' + errorMessage)
}
} catch (e) {
console.error(e)
alert('移动失败')
alert('移动失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
} finally {
setIsMoving(false)
}
@@ -591,7 +713,10 @@ export function ContentPage() {
const handleDeletePart = async (part: Part) => {
const sectionIds = sectionsList.filter((s) => s.partId === part.id).map((s) => s.id)
if (sectionIds.length === 0) return
if (sectionIds.length === 0) {
alert('该篇下暂无小节可删除')
return
}
if (!confirm(`确定要删除「${part.title}」整篇吗?将删除共 ${sectionIds.length} 节内容,此操作不可恢复。`)) return
try {
for (const id of sectionIds) {
@@ -670,6 +795,14 @@ export function ContentPage() {
<p className="text-gray-400 mt-1"> {tree.length} · {totalSections} </p>
</div>
<div className="flex gap-2">
<Button
onClick={() => setShowRankingAlgorithmModal(true)}
variant="outline"
className="border-amber-500/50 text-amber-400 hover:bg-amber-500/10 bg-transparent"
>
<Settings2 className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={() => {
const url = import.meta.env.VITE_API_DOC_URL || (typeof window !== 'undefined' ? `${window.location.origin}/api-doc` : '')
@@ -986,6 +1119,122 @@ export function ContentPage() {
</DialogContent>
</Dialog>
{/* 付款记录弹窗 */}
<Dialog open={!!sectionOrdersModal} onOpenChange={(open) => !open && setSectionOrdersModal(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-3xl max-h-[85vh] overflow-hidden flex flex-col" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white">
{sectionOrdersModal?.section.title ?? ''}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-2">
{sectionOrdersLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : sectionOrdersModal && sectionOrdersModal.orders.length === 0 ? (
<p className="text-gray-500 text-center py-6"></p>
) : sectionOrdersModal ? (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-gray-700 text-left text-gray-400">
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2">ID</th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
</tr>
</thead>
<tbody>
{sectionOrdersModal.orders.map((o) => (
<tr key={o.id ?? o.orderSn ?? ''} className="border-b border-gray-700/50">
<td className="py-2 pr-2 text-gray-300">{o.orderSn ?? '-'}</td>
<td className="py-2 pr-2 text-gray-300">{o.userId ?? o.openId ?? '-'}</td>
<td className="py-2 pr-2 text-gray-300">¥{o.amount ?? 0}</td>
<td className="py-2 pr-2 text-gray-300">{o.status ?? '-'}</td>
<td className="py-2 pr-2 text-gray-500">{o.payTime ?? o.createdAt ?? '-'}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
</DialogContent>
</Dialog>
{/* 排名算法:权重可编辑 */}
<Dialog open={showRankingAlgorithmModal} onOpenChange={setShowRankingAlgorithmModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Settings2 className="w-5 h-5 text-amber-400" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-gray-400"> = × + × + × 1</p>
{rankingWeightsLoading ? (
<p className="text-gray-500">...</p>
) : (
<>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.readWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, readWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.recencyWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, recencyWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.payWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, payWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
/>
</div>
</div>
<p className="text-xs text-gray-500">: {(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight).toFixed(1)}</p>
<ul className="list-disc list-inside space-y-1 text-xs text-gray-400">
<li> 20 201</li>
<li> 30 301</li>
<li> 20 201</li>
</ul>
<Button
onClick={handleSaveRankingWeights}
disabled={rankingWeightsSaving || Math.abs(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight - 1) > 0.001}
className="w-full bg-amber-500 hover:bg-amber-600 text-white"
>
{rankingWeightsSaving ? '保存中...' : '保存权重'}
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
{/* 新建篇弹窗 */}
<Dialog open={showNewPartModal} onOpenChange={setShowNewPartModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
@@ -1269,6 +1518,7 @@ export function ContentPage() {
onEditChapter={handleEditChapter}
selectedSectionIds={selectedSectionIds}
onToggleSectionSelect={toggleSectionSelect}
onShowSectionOrders={handleShowSectionOrders}
/>
)}
</TabsContent>

View File

@@ -39,6 +39,18 @@ interface DashboardOverviewRes {
newUsers?: UserRow[]
}
interface UsersRes {
success?: boolean
users?: UserRow[]
total?: number
}
interface OrdersRes {
success?: boolean
orders?: OrderRow[]
total?: number
}
export function DashboardPage() {
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(true)
@@ -48,9 +60,11 @@ export function DashboardPage() {
const [paidOrderCount, setPaidOrderCount] = useState(0)
const [totalRevenue, setTotalRevenue] = useState(0)
const [conversionRate, setConversionRate] = useState(0)
const [loadError, setLoadError] = useState<string | null>(null)
async function loadData() {
setIsLoading(true)
setLoadError(null)
try {
const data = await get<DashboardOverviewRes>('/api/admin/dashboard/overview')
if (data?.success) {
@@ -60,9 +74,38 @@ export function DashboardPage() {
setConversionRate(data.conversionRate ?? 0)
setPurchases(data.recentOrders ?? [])
setUsers(data.newUsers ?? [])
return
}
} catch (e) {
console.error('加载数据失败', e)
console.error('数据概览接口失败,尝试降级拉取', e)
}
// 降级:新接口未部署或失败时,用原有接口拉取用户与订单
try {
const [usersData, ordersData] = await Promise.all([
get<UsersRes>('/api/db/users?page=1&pageSize=10'),
get<OrdersRes>('/api/orders?page=1&pageSize=20&status=paid'),
])
const totalUsers = typeof usersData?.total === 'number' ? usersData.total : (usersData?.users?.length ?? 0)
const orders = ordersData?.orders ?? []
const total = typeof ordersData?.total === 'number' ? ordersData.total : orders.length
const paidOrders = orders.filter((p) => p.status === 'paid' || p.status === 'completed' || p.status === 'success')
const revenue = paidOrders.reduce((sum, p) => sum + Number(p.amount || 0), 0)
const paidUserIds = new Set(paidOrders.map((p) => p.userId).filter(Boolean))
const rate = totalUsers > 0 && paidUserIds.size > 0 ? (paidUserIds.size / totalUsers) * 100 : 0
setTotalUsersCount(totalUsers)
setPaidOrderCount(total)
setTotalRevenue(revenue)
setConversionRate(rate)
setPurchases(orders.slice(0, 5))
setUsers(usersData?.users ?? [])
} catch (fallbackErr) {
console.error('降级拉取失败', fallbackErr)
const err = fallbackErr as Error & { status?: number }
if (err?.status === 401) {
setLoadError('登录已过期,请重新登录')
} else {
setLoadError('加载失败,请检查网络或联系管理员')
}
} finally {
setIsLoading(false)
}
@@ -70,6 +113,8 @@ export function DashboardPage() {
useEffect(() => {
loadData()
const timer = setInterval(loadData, 30000)
return () => clearInterval(timer)
}, [])
if (isLoading) {
@@ -157,7 +202,18 @@ export function DashboardPage() {
return (
<div className="p-8 w-full">
<h1 className="text-2xl font-bold mb-8 text-white"></h1>
{loadError && (
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-500/20 border border-amber-500/50 text-amber-200 text-sm flex items-center justify-between">
<span>{loadError}</span>
<button
type="button"
onClick={() => loadData()}
className="text-amber-400 hover:text-amber-300 underline"
>
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat, index) => (
<Card
@@ -183,14 +239,22 @@ export function DashboardPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white"></CardTitle>
<button
type="button"
onClick={() => loadData()}
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1"
title="刷新"
>
<RefreshCw className="w-3.5 h-3.5" />
30
</button>
</CardHeader>
<CardContent>
<div className="space-y-3">
{purchases
.slice(-5)
.reverse()
.slice(0, 5)
.map((p) => {
const referrer: UserRow | undefined = p.referrerId
? users.find((u) => u.id === p.referrerId)
@@ -285,8 +349,7 @@ export function DashboardPage() {
<CardContent>
<div className="space-y-3">
{users
.slice(-5)
.reverse()
.slice(0, 5)
.map((u) => (
<div
key={u.id}

View File

@@ -0,0 +1,65 @@
import { useState } from 'react'
import { Users, List, Handshake, GraduationCap, UserPlus, BarChart3 } from 'lucide-react'
import { MatchPoolTab } from './tabs/MatchPoolTab'
import { MatchRecordsTab } from './tabs/MatchRecordsTab'
import { ResourceDockingTab } from './tabs/ResourceDockingTab'
import { MentorBookingTab } from './tabs/MentorBookingTab'
import { TeamRecruitTab } from './tabs/TeamRecruitTab'
import { CKBStatsTab } from './tabs/CKBStatsTab'
const TABS = [
{ id: 'pool', label: '匹配池', icon: Users },
{ id: 'records', label: '匹配记录', icon: List },
{ id: 'resource', label: '资源对接', icon: Handshake },
{ id: 'mentor', label: '导师预约', icon: GraduationCap },
{ id: 'team', label: '团队招募', icon: UserPlus },
{ id: 'stats', label: '存客宝统计', icon: BarChart3 },
] as const
type TabId = (typeof TABS)[number]['id']
export function FindPartnerPage() {
const [activeTab, setActiveTab] = useState<TabId>('pool')
return (
<div className="p-8 w-full">
<div className="mb-6">
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Users className="w-6 h-6 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
</p>
</div>
<div className="flex gap-1 mb-6 bg-[#0f2137] rounded-lg p-1 border border-gray-700/50">
{TABS.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-md text-sm font-medium transition-all ${
isActive
? 'bg-[#38bdac] text-white shadow-lg'
: 'text-gray-400 hover:text-white hover:bg-gray-700/50'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
)
})}
</div>
{activeTab === 'pool' && <MatchPoolTab />}
{activeTab === 'records' && <MatchRecordsTab />}
{activeTab === 'resource' && <ResourceDockingTab />}
{activeTab === 'mentor' && <MentorBookingTab />}
{activeTab === 'team' && <TeamRecruitTab />}
{activeTab === 'stats' && <CKBStatsTab />}
</div>
)
}

View File

@@ -0,0 +1,242 @@
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { RefreshCw, Users, UserCheck, TrendingUp, Zap, Link2, CheckCircle2, XCircle } from 'lucide-react'
import { get, post } from '@/api/client'
interface MatchStats {
totalMatches: number
todayMatches: number
byType: { matchType: string; count: number }[]
uniqueUsers: number
}
interface CKBTestResult {
endpoint: string
label: string
status: 'idle' | 'testing' | 'success' | 'error'
message?: string
responseTime?: number
}
export function CKBStatsTab() {
const [stats, setStats] = useState<MatchStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [ckbTests, setCkbTests] = useState<CKBTestResult[]>([
{ endpoint: '/api/ckb/join', label: 'CKB 加入ckb/join', status: 'idle' },
{ endpoint: '/api/ckb/match', label: 'CKB 匹配上报ckb/match', status: 'idle' },
{ endpoint: '/api/miniprogram/ckb/lead', label: 'CKB 链接卡若ckb/lead', status: 'idle' },
{ endpoint: '/api/match/config', label: '匹配配置match/config', status: 'idle' },
])
const typeLabels: Record<string, string> = {
partner: '超级个体', investor: '资源对接', mentor: '导师顾问', team: '团队招募',
}
const loadStats = useCallback(async () => {
setIsLoading(true)
try {
const data = await get<{ success?: boolean; data?: MatchStats }>('/api/db/match-records?stats=true')
if (data?.success && data.data) {
setStats(data.data)
} else {
const fallback = await get<{ success?: boolean; records?: unknown[]; total?: number }>('/api/db/match-records?page=1&pageSize=1')
if (fallback?.success) {
setStats({
totalMatches: fallback.total ?? 0,
todayMatches: 0,
byType: [],
uniqueUsers: 0,
})
}
}
} catch (e) { console.error('加载统计失败:', e) }
finally { setIsLoading(false) }
}, [])
useEffect(() => { loadStats() }, [loadStats])
const testEndpoint = async (index: number) => {
const test = ckbTests[index]
const updated = [...ckbTests]
updated[index] = { ...test, status: 'testing', message: undefined, responseTime: undefined }
setCkbTests(updated)
const start = performance.now()
try {
let res: { success?: boolean; message?: string; code?: number }
if (test.endpoint.includes('match/config')) {
res = await get<{ success?: boolean }>(test.endpoint)
} else {
res = await post<{ success?: boolean; message?: string }>(test.endpoint, {
type: 'partner',
phone: '00000000000',
wechat: 'test_ping',
userId: 'test_admin_ping',
matchType: 'partner',
name: '接口测试',
})
}
const elapsed = Math.round(performance.now() - start)
const next = [...ckbTests]
const ok = res?.success !== undefined || res?.code === 200 || res?.code === 400
next[index] = {
...test,
status: ok ? 'success' : 'error',
message: res?.message || (ok ? '接口可用' : '响应异常'),
responseTime: elapsed,
}
setCkbTests(next)
} catch (e: unknown) {
const elapsed = Math.round(performance.now() - start)
const next = [...ckbTests]
next[index] = {
...test,
status: 'error',
message: e instanceof Error ? e.message : '请求失败',
responseTime: elapsed,
}
setCkbTests(next)
}
}
const testAll = async () => {
for (let i = 0; i < ckbTests.length; i++) {
await testEndpoint(i)
}
}
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-white mt-1">{isLoading ? '-' : (stats?.totalMatches ?? 0)}</p>
</div>
<Users className="w-10 h-10 text-[#38bdac]/50" />
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-white mt-1">{isLoading ? '-' : (stats?.todayMatches ?? 0)}</p>
</div>
<Zap className="w-10 h-10 text-yellow-400/50" />
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-white mt-1">{isLoading ? '-' : (stats?.uniqueUsers ?? 0)}</p>
</div>
<UserCheck className="w-10 h-10 text-blue-400/50" />
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-white mt-1">
{isLoading ? '-' : (stats?.uniqueUsers ? (stats.totalMatches / stats.uniqueUsers).toFixed(1) : '0')}
</p>
</div>
<TrendingUp className="w-10 h-10 text-green-400/50" />
</div>
</CardContent>
</Card>
</div>
{/* 按类型分布 */}
{stats?.byType && stats.byType.length > 0 && (
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.byType.map(item => (
<div key={item.matchType} className="bg-[#0a1628] rounded-lg p-4 text-center">
<p className="text-gray-400 text-sm">{typeLabels[item.matchType] || item.matchType}</p>
<p className="text-2xl font-bold text-white mt-2">{item.count}</p>
<p className="text-gray-500 text-xs mt-1">
{stats.totalMatches > 0 ? ((item.count / stats.totalMatches) * 100).toFixed(1) : 0}%
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* CKB 接口测试 */}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-white flex items-center gap-2">
<Link2 className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<p className="text-gray-400 text-sm mt-1">
CKB
</p>
</div>
<div className="flex gap-2">
<Button onClick={loadStats} disabled={isLoading} variant="outline" className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={testAll} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Zap className="w-4 h-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{ckbTests.map((test, idx) => (
<div key={test.endpoint} className="flex items-center justify-between bg-[#0a1628] rounded-lg px-4 py-3">
<div className="flex items-center gap-3">
{test.status === 'idle' && <div className="w-3 h-3 rounded-full bg-gray-500" />}
{test.status === 'testing' && <RefreshCw className="w-4 h-4 text-yellow-400 animate-spin" />}
{test.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-400" />}
{test.status === 'error' && <XCircle className="w-4 h-4 text-red-400" />}
<div>
<p className="text-white text-sm font-medium">{test.label}</p>
<p className="text-gray-500 text-xs font-mono">{test.endpoint}</p>
</div>
</div>
<div className="flex items-center gap-3">
{test.message && (
<span className={`text-xs ${test.status === 'success' ? 'text-green-400' : test.status === 'error' ? 'text-red-400' : 'text-gray-400'}`}>
{test.message}
</span>
)}
{test.responseTime !== undefined && (
<Badge className="bg-gray-700 text-gray-300 border-0">{test.responseTime}ms</Badge>
)}
<Button size="sm" variant="outline"
onClick={() => testEndpoint(idx)}
disabled={test.status === 'testing'}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent text-xs">
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,242 @@
import { useState, useEffect } from 'react'
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'
import { Save, RefreshCw, Edit3, Plus, Trash2, Users, Zap } from 'lucide-react'
import { get, post } from '@/api/client'
interface MatchType {
id: string; label: string; matchLabel: string; icon: string
matchFromDB: boolean; showJoinAfterMatch: boolean; price: number; enabled: boolean
}
interface MatchConfig {
matchTypes: MatchType[]; freeMatchLimit: number; matchPrice: number
settings: { enableFreeMatches: boolean; enablePaidMatches: boolean; maxMatchesPerDay: number }
}
const DEFAULT_CONFIG: MatchConfig = {
matchTypes: [
{ id: 'partner', label: '超级个体', matchLabel: '超级个体', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, price: 1, enabled: true },
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
{ id: 'mentor', label: '导师顾问', matchLabel: '导师顾问', icon: '❤️', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
{ id: 'team', label: '团队招募', matchLabel: '加入项目', icon: '🎮', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
],
freeMatchLimit: 3, matchPrice: 1,
settings: { enableFreeMatches: true, enablePaidMatches: true, maxMatchesPerDay: 10 },
}
const ICONS = ['⭐', '👥', '❤️', '🎮', '💼', '🚀', '💡', '🎯', '🔥', '✨']
export function MatchPoolTab() {
const [config, setConfig] = useState<MatchConfig>(DEFAULT_CONFIG)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [showTypeModal, setShowTypeModal] = useState(false)
const [editingType, setEditingType] = useState<MatchType | null>(null)
const [formData, setFormData] = useState({ id: '', label: '', matchLabel: '', icon: '⭐', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true })
const loadConfig = async () => {
setIsLoading(true)
try {
const data = await get<{ success?: boolean; data?: MatchConfig; config?: MatchConfig }>('/api/db/config/full?key=match_config')
const c = (data as { data?: MatchConfig })?.data ?? (data as { config?: MatchConfig })?.config
if (c) setConfig({ ...DEFAULT_CONFIG, ...c })
} catch (e) { console.error('加载匹配配置失败:', e) }
finally { setIsLoading(false) }
}
useEffect(() => { loadConfig() }, [])
const handleSave = async () => {
setIsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', { key: 'match_config', value: config, description: '匹配功能配置' })
alert(res?.success !== false ? '配置保存成功!' : '保存失败: ' + (res?.error || '未知错误'))
} catch (e) { console.error(e); alert('保存失败') }
finally { setIsSaving(false) }
}
const handleEditType = (type: MatchType) => {
setEditingType(type)
setFormData({ ...type })
setShowTypeModal(true)
}
const handleAddType = () => {
setEditingType(null)
setFormData({ id: '', label: '', matchLabel: '', icon: '⭐', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true })
setShowTypeModal(true)
}
const handleSaveType = () => {
if (!formData.id || !formData.label) { alert('请填写类型ID和名称'); return }
const newTypes = [...config.matchTypes]
if (editingType) {
const idx = newTypes.findIndex(t => t.id === editingType.id)
if (idx !== -1) newTypes[idx] = { ...formData }
} else {
if (newTypes.some(t => t.id === formData.id)) { alert('类型ID已存在'); return }
newTypes.push({ ...formData })
}
setConfig({ ...config, matchTypes: newTypes })
setShowTypeModal(false)
}
const handleDeleteType = (typeId: string) => {
if (!confirm('确定要删除这个匹配类型吗?')) return
setConfig({ ...config, matchTypes: config.matchTypes.filter(t => t.id !== typeId) })
}
const handleToggleType = (typeId: string) => {
setConfig({ ...config, matchTypes: config.matchTypes.map(t => t.id === typeId ? { ...t, enabled: !t.enabled } : t) })
}
return (
<div className="space-y-6">
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={loadConfig} disabled={isLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={handleSave} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-4 h-4 mr-2" /> {isSaving ? '保存中...' : '保存配置'}
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2"><Zap className="w-5 h-5 text-yellow-400" /> </CardTitle>
<CardDescription className="text-gray-400"></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input type="number" min={0} max={100} className="bg-[#0a1628] border-gray-700 text-white" value={config.freeMatchLimit} onChange={e => setConfig({ ...config, freeMatchLimit: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input type="number" min={0.01} step={0.01} className="bg-[#0a1628] border-gray-700 text-white" value={config.matchPrice} onChange={e => setConfig({ ...config, matchPrice: parseFloat(e.target.value) || 1 })} />
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input type="number" min={1} max={100} className="bg-[#0a1628] border-gray-700 text-white" value={config.settings.maxMatchesPerDay} onChange={e => setConfig({ ...config, settings: { ...config.settings, maxMatchesPerDay: parseInt(e.target.value, 10) || 10 } })} />
</div>
</div>
<div className="flex gap-8 pt-4 border-t border-gray-700/50">
<div className="flex items-center gap-3">
<Switch checked={config.settings.enableFreeMatches} onCheckedChange={checked => setConfig({ ...config, settings: { ...config.settings, enableFreeMatches: checked } })} />
<Label className="text-gray-300"></Label>
</div>
<div className="flex items-center gap-3">
<Switch checked={config.settings.enablePaidMatches} onCheckedChange={checked => setConfig({ ...config, settings: { ...config.settings, enablePaidMatches: checked } })} />
<Label className="text-gray-300"></Label>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-white flex items-center gap-2"><Users className="w-5 h-5 text-[#38bdac]" /> </CardTitle>
<CardDescription className="text-gray-400"></CardDescription>
</div>
<Button onClick={handleAddType} size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-1" />
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{config.matchTypes.map(type => (
<TableRow key={type.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell><span className="text-2xl">{type.icon}</span></TableCell>
<TableCell className="font-mono text-gray-300">{type.id}</TableCell>
<TableCell className="text-white font-medium">{type.label}</TableCell>
<TableCell className="text-gray-300">{type.matchLabel}</TableCell>
<TableCell><Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">¥{type.price}</Badge></TableCell>
<TableCell>{type.matchFromDB ? <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"></Badge> : <Badge variant="outline" className="text-gray-500 border-gray-600"></Badge>}</TableCell>
<TableCell><Switch checked={type.enabled} onCheckedChange={() => handleToggleType(type.id)} /></TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEditType(type)} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteType(type.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10"><Trash2 className="w-4 h-4" /></Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={showTypeModal} onOpenChange={setShowTypeModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
{editingType ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <Plus className="w-5 h-5 text-[#38bdac]" />}
{editingType ? '编辑匹配类型' : '添加匹配类型'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300">ID</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="如: partner" value={formData.id} onChange={e => setFormData({ ...formData, id: e.target.value })} disabled={!!editingType} />
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex gap-1 flex-wrap">
{ICONS.map(icon => (
<button key={icon} type="button" className={`w-8 h-8 text-lg rounded ${formData.icon === icon ? 'bg-[#38bdac]/30 ring-1 ring-[#38bdac]' : 'bg-[#0a1628]'}`} onClick={() => setFormData({ ...formData, icon })}>{icon}</button>
))}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="如: 超级个体" value={formData.label} onChange={e => setFormData({ ...formData, label: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="如: 超级个体" value={formData.matchLabel} onChange={e => setFormData({ ...formData, matchLabel: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input type="number" min={0.01} step={0.01} className="bg-[#0a1628] border-gray-700 text-white" value={formData.price} onChange={e => setFormData({ ...formData, price: parseFloat(e.target.value) || 1 })} />
</div>
<div className="flex gap-6 pt-2">
<div className="flex items-center gap-3"><Switch checked={formData.matchFromDB} onCheckedChange={checked => setFormData({ ...formData, matchFromDB: checked })} /><Label className="text-gray-300 text-sm"></Label></div>
<div className="flex items-center gap-3"><Switch checked={formData.showJoinAfterMatch} onCheckedChange={checked => setFormData({ ...formData, showJoinAfterMatch: checked })} /><Label className="text-gray-300 text-sm"></Label></div>
<div className="flex items-center gap-3"><Switch checked={formData.enabled} onCheckedChange={checked => setFormData({ ...formData, enabled: checked })} /><Label className="text-gray-300 text-sm"></Label></div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowTypeModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"></Button>
<Button onClick={handleSaveType} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Save className="w-4 h-4 mr-2" /> </Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { RefreshCw } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get } from '@/api/client'
interface MatchRecord {
id: string; userId: string; matchedUserId: string; matchType: string
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
userAvatar?: string; matchedUserAvatar?: string; matchScore?: number; createdAt: string
}
const matchTypeLabels: Record<string, string> = {
partner: '超级个体', investor: '资源对接', mentor: '导师顾问', team: '团队招募',
}
export function MatchRecordsTab() {
const [records, setRecords] = useState<MatchRecord[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [matchTypeFilter, setMatchTypeFilter] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function loadRecords() {
setIsLoading(true); setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (matchTypeFilter) params.set('matchType', matchTypeFilter)
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
else setError('加载匹配记录失败')
} catch { setError('加载失败,请检查网络后重试') }
finally { setIsLoading(false) }
}
useEffect(() => { loadRecords() }, [page, matchTypeFilter])
const totalPages = Math.ceil(total / pageSize) || 1
return (
<div>
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">×</button>
</div>
)}
<div className="flex justify-between items-center mb-4">
<p className="text-gray-400"> {total} </p>
<div className="flex items-center gap-4">
<select value={matchTypeFilter} onChange={e => { setMatchTypeFilter(e.target.value); setPage(1) }}
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm">
<option value=""></option>
{Object.entries(matchTypeLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<button type="button" onClick={loadRecords} disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">...</span></div>
) : (
<>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map(r => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 overflow-hidden">
{r.userAvatar ? <img src={r.userAvatar} alt="" className="w-full h-full object-cover" onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }} /> : null}
<span className={r.userAvatar ? 'hidden' : ''}>{(r.userNickname || r.userId || '?').charAt(0)}</span>
</div>
<div>
<div className="text-white">{r.userNickname || r.userId}</div>
<div className="text-xs text-gray-500 font-mono">{r.userId.slice(0, 16)}...</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 overflow-hidden">
{r.matchedUserAvatar ? <img src={r.matchedUserAvatar} alt="" className="w-full h-full object-cover" onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }} /> : null}
<span className={r.matchedUserAvatar ? 'hidden' : ''}>{(r.matchedNickname || r.matchedUserId || '?').charAt(0)}</span>
</div>
<div>
<div className="text-white">{r.matchedNickname || r.matchedUserId}</div>
<div className="text-xs text-gray-500 font-mono">{r.matchedUserId.slice(0, 16)}...</div>
</div>
</div>
</TableCell>
<TableCell><Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">{matchTypeLabels[r.matchType] || r.matchType}</Badge></TableCell>
<TableCell className="text-gray-400 text-sm">
{r.phone && <div>📱 {r.phone}</div>}
{r.wechatId && <div>💬 {r.wechatId}</div>}
{!r.phone && !r.wechatId && '-'}
</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{records.length === 0 && <TableRow><TableCell colSpan={5} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize}
onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
import { get } from '@/api/client'
interface Consultation {
id: number; userId: number; mentorId: number; consultationType: string
amount: number; status: string; createdAt: string
}
const statusMap: Record<string, string> = { created: '已创建', pending_pay: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消' }
const typeMap: Record<string, string> = { single: '单次', half_year: '半年', year: '年度' }
export function MentorBookingTab() {
const [list, setList] = useState<Consultation[]>([])
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState('')
async function load() {
setLoading(true)
try {
const url = statusFilter ? `/api/db/mentor-consultations?status=${statusFilter}` : '/api/db/mentor-consultations'
const data = await get<{ success?: boolean; data?: Consultation[] }>(url)
if (data?.success && data.data) setList(data.data)
} catch (e) { console.error(e) }
finally { setLoading(false) }
}
useEffect(() => { load() }, [statusFilter])
return (
<div>
<div className="flex justify-between items-center mb-4">
<p className="text-gray-400"></p>
<div className="flex items-center gap-2">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
className="bg-[#0f2137] border border-gray-700 rounded-lg px-3 py-2 text-gray-300 text-sm">
<option value=""></option>
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<Button onClick={load} disabled={loading} variant="outline" className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map(r => (
<TableRow key={r.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{r.id}</TableCell>
<TableCell className="text-gray-400">{r.userId}</TableCell>
<TableCell className="text-gray-400">{r.mentorId}</TableCell>
<TableCell className="text-gray-400">{typeMap[r.consultationType] || r.consultationType}</TableCell>
<TableCell className="text-white">¥{r.amount}</TableCell>
<TableCell className="text-gray-400">{statusMap[r.status] || r.status}</TableCell>
<TableCell className="text-gray-500 text-sm">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { RefreshCw } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get } from '@/api/client'
interface MatchRecord {
id: string; userId: string; matchedUserId: string; matchType: string
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
createdAt: string
}
export function ResourceDockingTab() {
const [records, setRecords] = useState<MatchRecord[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [isLoading, setIsLoading] = useState(true)
async function load() {
setIsLoading(true)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), matchType: 'investor' })
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
} catch (e) { console.error(e) }
finally { setIsLoading(false) }
}
useEffect(() => { load() }, [page])
const totalPages = Math.ceil(total / pageSize) || 1
return (
<div>
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-gray-400"> {total} </p>
<p className="text-gray-500 text-xs mt-1"></p>
</div>
<button type="button" onClick={load} disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">...</span></div>
) : (
<>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map(r => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell className="text-white">{r.userNickname || r.userId}</TableCell>
<TableCell className="text-white">{r.matchedNickname || r.matchedUserId}</TableCell>
<TableCell className="text-gray-400 text-sm">
{r.phone && <div>📱 {r.phone}</div>}
{r.wechatId && <div>💬 {r.wechatId}</div>}
{!r.phone && !r.wechatId && '-'}
</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{records.length === 0 && <TableRow><TableCell colSpan={4} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize} onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { RefreshCw } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get } from '@/api/client'
interface MatchRecord {
id: string; userId: string; matchedUserId: string; matchType: string
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
createdAt: string
}
export function TeamRecruitTab() {
const [records, setRecords] = useState<MatchRecord[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [isLoading, setIsLoading] = useState(true)
async function load() {
setIsLoading(true)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), matchType: 'team' })
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
} catch (e) { console.error(e) }
finally { setIsLoading(false) }
}
useEffect(() => { load() }, [page])
const totalPages = Math.ceil(total / pageSize) || 1
return (
<div>
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-gray-400"> {total} </p>
<p className="text-gray-500 text-xs mt-1"></p>
</div>
<button type="button" onClick={load} disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">...</span></div>
) : (
<>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map(r => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell className="text-white">{r.userNickname || r.userId}</TableCell>
<TableCell className="text-white">{r.matchedNickname || r.matchedUserId}</TableCell>
<TableCell className="text-gray-400 text-sm">
{r.phone && <div>📱 {r.phone}</div>}
{r.wechatId && <div>💬 {r.wechatId}</div>}
{!r.phone && !r.wechatId && '-'}
</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{records.length === 0 && <TableRow><TableCell colSpan={4} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize} onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Card,
CardContent,
@@ -30,8 +32,11 @@ import {
BookOpen,
Gift,
Smartphone,
ShieldCheck,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
import { AdminUsersPage } from '@/pages/admin-users/AdminUsersPage'
interface AuthorInfo {
name?: string
@@ -93,7 +98,14 @@ const defaultFeatures: FeatureConfig = {
aboutEnabled: true,
}
const TAB_KEYS = ['system', 'author', 'admin'] as const
type TabKey = (typeof TAB_KEYS)[number]
export function SettingsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const tabParam = searchParams.get('tab') ?? 'system'
const activeTab = TAB_KEYS.includes(tabParam as TabKey) ? (tabParam as TabKey) : 'system'
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
@@ -208,26 +220,58 @@ export function SettingsPage() {
}
}
const handleTabChange = (v: string) => {
setSearchParams(v === 'system' ? {} : { tab: v })
}
if (loading) return <div className="p-8 text-gray-500">...</div>
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<p className="text-gray-400 mt-1"></p>
</div>
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? '保存中...' : '保存设置'}
</Button>
{activeTab === 'system' && (
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? '保存中...' : '保存设置'}
</Button>
)}
</div>
<div className="space-y-6">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="mb-6 bg-[#0f2137] border border-gray-700/50 p-1">
<TabsTrigger
value="system"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<Settings className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="author"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<UserCircle className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="admin"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<ShieldCheck className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-0">
<div className="space-y-6">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -561,7 +605,17 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
<TabsContent value="author" className="mt-0">
<AuthorSettingsPage />
</TabsContent>
<TabsContent value="admin" className="mt-0">
<AdminUsersPage />
</TabsContent>
</Tabs>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent

View File

@@ -2,7 +2,6 @@ package handler
import (
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -82,6 +81,7 @@ func AdminDashboardOverview(c *gin.Context) {
}
recentOut = append(recentOut, gin.H{
"id": o.ID, "orderSn": o.OrderSN, "userId": o.UserID, "userNickname": nickname, "userAvatar": avatar,
"userPhone": phone,
"amount": o.Amount, "status": ptrStr(o.Status), "productType": o.ProductType, "productId": o.ProductID, "description": o.Description,
"referrerId": o.ReferrerID, "referralCode": referrerCode, "createdAt": o.CreatedAt, "paymentMethod": "微信",
})

View File

@@ -16,9 +16,11 @@ import (
var excludeParts = []string{"序言", "尾声", "附录"}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
// 小程序目录页以此接口为准与后台内容管理一致含「2026每日派对干货」等 part 须在 chapters 表中存在且 part_title 正确。
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
// 不过滤 status后台配置的篇章均返回由前端展示。
func BookAllChapters(c *gin.Context) {
q := database.DB().Model(&model.Chapter{})
if c.Query("excludeFixed") == "1" {
@@ -31,7 +33,7 @@ func BookAllChapters(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
}
// BookChapterByID GET /api/book/chapter/:id 按业务 id 查询(兼容旧链接)

View File

@@ -2,7 +2,9 @@ package handler
import (
"context"
"encoding/json"
"net/http"
"sort"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -11,13 +13,13 @@ import (
"gorm.io/gorm"
)
// listSelectCols 列表/导出不加载 content大幅加速
// listSelectCols 列表/导出不加载 content大幅加速;含 updated_at 用于热度算法
var listSelectCols = []string{
"id", "section_title", "price", "is_free", "is_new",
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order", "updated_at",
}
// sectionListItem 与前端 SectionListItem 一致(小写驼峰),含点击付款统计
// sectionListItem 与前端 SectionListItem 一致(小写驼峰),含点击付款与热度排名
type sectionListItem struct {
ID string `json:"id"`
Title string `json:"title"`
@@ -30,7 +32,9 @@ type sectionListItem struct {
ChapterTitle string `json:"chapterTitle"`
FilePath *string `json:"filePath,omitempty"`
ClickCount int `json:"clickCount,omitempty"` // 阅读/点击次数reading_progress 条数)
PayCount int `json:"payCount,omitempty"` // 付款笔数orders 已支付)
PayCount int `json:"payCount,omitempty"` // 付款笔数orders 已支付)
HotScore float64 `json:"hotScore,omitempty"` // 热度积分(文章排名算法算出)
HotRank int `json:"hotRank,omitempty"` // 热度排名1=最高)
}
// DBBookAction GET/POST/PUT /api/db/book
@@ -51,7 +55,7 @@ func DBBookAction(c *gin.Context) {
for _, r := range rows {
ids = append(ids, r.ID)
}
// 点击量reading_progress 按 section_id 计数
// 点击量:reading_progress 表直接捆绑,按 section_id 计数(小程序打开章节会立即上报一条)
type readCnt struct{ SectionID string `gorm:"column:section_id"`; Cnt int64 `gorm:"column:cnt"` }
var readCounts []readCnt
if len(ids) > 0 {
@@ -61,18 +65,93 @@ func DBBookAction(c *gin.Context) {
for _, x := range readCounts {
readMap[x.SectionID] = int(x.Cnt)
}
// 付款笔数orders 中 product_type=section 且 status=paid 按 product_id 计数
// 付款笔数:orders 表直接捆绑,兼容 paid/completed/success 等已支付状态
type payCnt struct{ ProductID string `gorm:"column:product_id"`; Cnt int64 `gorm:"column:cnt"` }
var payCounts []payCnt
if len(ids) > 0 {
db.Table("orders").Select("product_id, COUNT(*) as cnt").
Where("product_type = ? AND status = ? AND product_id IN ?", "section", "paid", ids).
Where("product_type = ? AND status IN ? AND product_id IN ?", "section", []string{"paid", "completed", "success"}, ids).
Group("product_id").Scan(&payCounts)
}
payMap := make(map[string]int)
for _, x := range payCounts {
payMap[x.ProductID] = int(x.Cnt)
}
// 文章排名算法:权重可从 config article_ranking_weights 读取,默认 阅读50% 新度30% 付款20%
readWeight, recencyWeight, payWeight := 0.5, 0.3, 0.2
var cfgRow model.SystemConfig
if err := db.Where("config_key = ?", "article_ranking_weights").First(&cfgRow).Error; err == nil && len(cfgRow.ConfigValue) > 0 {
var m map[string]interface{}
if json.Unmarshal(cfgRow.ConfigValue, &m) == nil {
if v, ok := m["readWeight"]; ok {
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
readWeight = f
}
}
if v, ok := m["recencyWeight"]; ok {
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
recencyWeight = f
}
}
if v, ok := m["payWeight"]; ok {
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
payWeight = f
}
}
}
}
// 热度 = readWeight*阅读排名分 + recencyWeight*新度排名分 + payWeight*付款排名分
readScore := make(map[string]float64)
idsByRead := make([]string, len(ids))
copy(idsByRead, ids)
sort.Slice(idsByRead, func(i, j int) bool { return readMap[idsByRead[i]] > readMap[idsByRead[j]] })
for i := 0; i < 20 && i < len(idsByRead); i++ {
readScore[idsByRead[i]] = float64(20 - i)
}
recencyScore := make(map[string]float64)
sort.Slice(rows, func(i, j int) bool { return rows[i].UpdatedAt.After(rows[j].UpdatedAt) })
for i := 0; i < 30 && i < len(rows); i++ {
recencyScore[rows[i].ID] = float64(30 - i)
}
payScore := make(map[string]float64)
idsByPay := make([]string, len(ids))
copy(idsByPay, ids)
sort.Slice(idsByPay, func(i, j int) bool { return payMap[idsByPay[i]] > payMap[idsByPay[j]] })
for i := 0; i < 20 && i < len(idsByPay); i++ {
payScore[idsByPay[i]] = float64(20 - i)
}
type idTotal struct {
id string
total float64
}
totals := make([]idTotal, 0, len(rows))
for _, r := range rows {
t := readWeight*readScore[r.ID] + recencyWeight*recencyScore[r.ID] + payWeight*payScore[r.ID]
totals = append(totals, idTotal{r.ID, t})
}
sort.Slice(totals, func(i, j int) bool { return totals[i].total > totals[j].total })
hotRankMap := make(map[string]int)
for i, t := range totals {
hotRankMap[t.id] = i + 1
}
hotScoreMap := make(map[string]float64)
for _, t := range totals {
hotScoreMap[t.id] = t.total
}
// 恢复 rows 的 sort_order 顺序(上面 recency 排序打乱了)
sort.Slice(rows, func(i, j int) bool {
soi, soj := 0, 0
if rows[i].SortOrder != nil {
soi = *rows[i].SortOrder
}
if rows[j].SortOrder != nil {
soj = *rows[j].SortOrder
}
if soi != soj {
return soi < soj
}
return rows[i].ID < rows[j].ID
})
sections := make([]sectionListItem, 0, len(rows))
for _, r := range rows {
price := 1.0
@@ -91,6 +170,8 @@ func DBBookAction(c *gin.Context) {
ChapterTitle: r.ChapterTitle,
ClickCount: readMap[r.ID],
PayCount: payMap[r.ID],
HotScore: hotScoreMap[r.ID],
HotRank: hotRankMap[r.ID],
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
@@ -135,7 +216,8 @@ func DBBookAction(c *gin.Context) {
return
}
var orders []model.Order
if err := db.Where("product_type = ? AND product_id = ?", "section", id).Order("created_at DESC").Limit(200).Find(&orders).Error; err != nil {
if err := db.Where("product_type = ? AND product_id = ? AND status IN ?", "section", id, []string{"paid", "completed", "success"}).
Order("pay_time DESC, created_at DESC").Limit(200).Find(&orders).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "orders": []model.Order{}})
return
}
@@ -297,7 +379,15 @@ func DBBookAction(c *gin.Context) {
return
}
}
if body.Action == "move-sections" && len(body.SectionIds) > 0 && body.TargetPartID != "" && body.TargetChapterID != "" {
if body.Action == "move-sections" {
if len(body.SectionIds) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "批量移动缺少 sectionIds请先勾选要移动的节"})
return
}
if body.TargetPartID == "" || body.TargetChapterID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "批量移动缺少目标篇或目标章targetPartId、targetChapterId"})
return
}
up := map[string]interface{}{
"part_id": body.TargetPartID,
"chapter_id": body.TargetChapterID,

View File

@@ -18,11 +18,11 @@ const defaultFreeMatchLimit = 3
// MatchQuota 匹配次数配额(纯计算:订单 + match_records
type MatchQuota struct {
PurchasedTotal int64 `json:"purchasedTotal"`
PurchasedUsed int64 `json:"purchasedUsed"`
PurchasedUsed int64 `json:"purchasedUsed"`
MatchesUsedToday int64 `json:"matchesUsedToday"`
FreeRemainToday int64 `json:"freeRemainToday"`
PurchasedRemain int64 `json:"purchasedRemain"`
RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数
FreeRemainToday int64 `json:"freeRemainToday"`
PurchasedRemain int64 `json:"purchasedRemain"`
RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数
}
func getFreeMatchLimit(db *gorm.DB) int {
@@ -73,7 +73,7 @@ func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
}
remainToday := freeRemain + purchasedRemain
return MatchQuota{
PurchasedTotal: purchasedTotal,
PurchasedTotal: purchasedTotal,
PurchasedUsed: purchasedUsed,
MatchesUsedToday: matchesToday,
FreeRemainToday: freeRemain,
@@ -83,7 +83,7 @@ func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
}
var defaultMatchTypes = []gin.H{
gin.H{"id": "partner", "label": "创业合伙", "matchLabel": "创业伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
gin.H{"id": "partner", "label": "超级个体", "matchLabel": "超级个体", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
gin.H{"id": "investor", "label": "资源对接", "matchLabel": "资源对接", "icon": "👥", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
gin.H{"id": "mentor", "label": "导师顾问", "matchLabel": "导师顾问", "icon": "❤️", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
gin.H{"id": "team", "label": "团队招募", "matchLabel": "加入项目", "icon": "🎮", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
@@ -100,7 +100,7 @@ func MatchConfigGet(c *gin.Context) {
"matchTypes": defaultMatchTypes,
"freeMatchLimit": 3,
"matchPrice": 1,
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
},
"source": "default",
})
@@ -181,10 +181,14 @@ func MatchUsers(c *gin.Context) {
return
}
}
// 只匹配已绑定微信或手机号的用户
// 找伙伴(partner)仅从超级个体池匹配is_vip=1 且 vip_expire_date>NOW其他类型已绑定微信或手机号的用户
var users []model.User
q := db.Where("id != ?", body.UserID).
Where("((wechat_id IS NOT NULL AND wechat_id != '') OR (phone IS NOT NULL AND phone != ''))")
if body.MatchType == "partner" {
// 超级个体VIP 会员池
q = q.Where("is_vip = 1 AND vip_expire_date > NOW()")
}
if err := q.Order("created_at DESC").Limit(20).Find(&users).Error; err != nil || len(users) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "暂无匹配用户", "data": nil, "code": "NO_USERS"})
return
@@ -212,7 +216,7 @@ func MatchUsers(c *gin.Context) {
phone = *r.Phone
}
intro := "来自Soul创业派对的伙伴"
matchLabels := map[string]string{"partner": "找伙伴", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"}
matchLabels := map[string]string{"partner": "超级个体", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"}
tag := matchLabels[body.MatchType]
if tag == "" {
tag = "找伙伴"

View File

@@ -11,8 +11,35 @@ import (
)
// DBMatchRecordsList GET /api/db/match-records 管理端-匹配记录列表(分页、按类型筛选)
// 当 ?stats=true 时返回汇总统计(总匹配数、今日匹配、按类型分布、独立用户数)
func DBMatchRecordsList(c *gin.Context) {
db := database.DB()
if c.Query("stats") == "true" {
var totalMatches int64
db.Model(&model.MatchRecord{}).Count(&totalMatches)
var todayMatches int64
db.Model(&model.MatchRecord{}).Where("created_at >= CURDATE()").Count(&todayMatches)
type TypeCount struct {
MatchType string `json:"matchType"`
Count int64 `json:"count"`
}
var byType []TypeCount
db.Model(&model.MatchRecord{}).Select("match_type as match_type, count(*) as count").Group("match_type").Scan(&byType)
var uniqueUsers int64
db.Model(&model.MatchRecord{}).Distinct("user_id").Count(&uniqueUsers)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"totalMatches": totalMatches,
"todayMatches": todayMatches,
"byType": byType,
"uniqueUsers": uniqueUsers,
},
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
matchType := c.Query("matchType")

View File

@@ -476,6 +476,87 @@ func UserReadingProgressPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "进度已保存"})
}
// UserDashboardStatsGet GET /api/user/dashboard-stats?userId=
// 返回我的页所需的真实统计:已读章节、阅读分钟、最近阅读、匹配次数
func UserDashboardStatsGet(c *gin.Context) {
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
return
}
db := database.DB()
var progressList []model.ReadingProgress
if err := db.Where("user_id = ?", userId).Order("last_open_at DESC").Find(&progressList).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取阅读统计失败"})
return
}
readCount := len(progressList)
totalReadSeconds := 0
recentIDs := make([]string, 0, 5)
seenRecent := make(map[string]bool)
readSectionIDs := make([]string, 0, len(progressList))
for _, item := range progressList {
totalReadSeconds += item.Duration
if item.SectionID != "" {
readSectionIDs = append(readSectionIDs, item.SectionID)
if !seenRecent[item.SectionID] && len(recentIDs) < 5 {
seenRecent[item.SectionID] = true
recentIDs = append(recentIDs, item.SectionID)
}
}
}
totalReadMinutes := totalReadSeconds / 60
if totalReadSeconds > 0 && totalReadMinutes == 0 {
totalReadMinutes = 1
}
chapterMap := make(map[string]model.Chapter)
if len(recentIDs) > 0 {
var chapters []model.Chapter
if err := db.Select("id", "mid", "section_title").Where("id IN ?", recentIDs).Find(&chapters).Error; err == nil {
for _, ch := range chapters {
chapterMap[ch.ID] = ch
}
}
}
recentChapters := make([]gin.H, 0, len(recentIDs))
for _, id := range recentIDs {
ch, ok := chapterMap[id]
title := id
mid := 0
if ok {
if ch.SectionTitle != "" {
title = ch.SectionTitle
}
mid = ch.MID
}
recentChapters = append(recentChapters, gin.H{
"id": id,
"mid": mid,
"title": title,
})
}
var matchHistory int64
db.Model(&model.MatchRecord{}).Where("user_id = ?", userId).Count(&matchHistory)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"readCount": readCount,
"totalReadMinutes": totalReadMinutes,
"recentChapters": recentChapters,
"matchHistory": matchHistory,
"readSectionIds": readSectionIDs,
},
})
}
// UserTrackGet GET /api/user/track?userId=&limit= 从 user_tracks 表查GORM
func UserTrackGet(c *gin.Context) {
userId := c.Query("userId")

View File

@@ -264,6 +264,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/user/profile", handler.UserProfileGet)
miniprogram.POST("/user/profile", handler.UserProfilePost)
miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus)
miniprogram.GET("/user/dashboard-stats", handler.UserDashboardStatsGet)
miniprogram.GET("/user/reading-progress", handler.UserReadingProgressGet)
miniprogram.POST("/user/reading-progress", handler.UserReadingProgressPost)
miniprogram.POST("/user/update", handler.UserUpdate)

View File

@@ -0,0 +1,39 @@
# 需求文档标题
> 创建日期YYYY-MM-DD
> 文档格式Markdown支持图片粘贴 + 预览)
---
## 一、背景与目标
(在此输入文字,可直接粘贴图片)
---
## 二、功能点
### 2.1 功能一
(文字 + 可粘贴的截图、原型图)
示例图片引用:`![描述](./images/截图1.png)`
### 2.2 功能二
---
## 三、补充说明
(可继续粘贴图片和文字)
---
## 使用提示
- **粘贴图片**:在 Cursor 中安装「Paste Image」扩展后直接 Ctrl+V / Cmd+V 即可将剪贴板图片保存到 `images/` 并自动插入引用
- **预览**`Cmd+Shift+V` 或右侧「Open Preview」查看排版效果

View File

@@ -0,0 +1,75 @@
# 需求文档格式说明
> 本目录支持 **Markdown.md** 格式,可在 Cursor 中直接粘贴图片并预览。
---
## 为什么用 Markdown
| 能力 | 说明 |
|:-----------|:-------------------------------------------------|
| 粘贴图片 | 配合扩展可 Ctrl+V / Cmd+V 直接粘贴剪贴板图片 |
| 预览 | Cursor 内置 Markdown 预览,`Cmd+Shift+V` 即可 |
| 版本管理 | 纯文本 + 图片文件,方便 Git 追踪 |
| 跨平台 | 任意编辑器可编辑,导出 PDF/HTML 也方便 |
---
## 使用方法
### 1. 新建文档
- 复制 `_模板_需求文档.md`,重命名为你的文档名(如 `20260308找伙伴功能.md`
- 或直接新建 `.md` 文件
### 2. 粘贴图片
**方式一:配合扩展(推荐)**
1. 在 Cursor 中安装扩展:`Paste Image``Markdown Paste`
2. 截屏或复制图片到剪贴板
3.`.md` 文件中按 `Ctrl+V`Mac`Cmd+V`
4. 扩展会自动将图片保存到 `images/` 并插入 `![描述](images/xxx.png)`
**方式二:手动**
1. 将图片放入本目录下的 `images/` 文件夹
2. 在文档中插入:`![图片描述](./images/文件名.png)`
### 3. 预览
- **快捷键**`Cmd+Shift+V`Mac`Ctrl+Shift+V`Windows
- **右键**:编辑器内右键 → 「Open Preview」
- 卡若AI 规则已配置 Markdown Preview Enhanced可直接查看排版
---
## 目录约定
```
修改/
├── README_需求文档格式说明.md ← 本说明
├── _模板_需求文档.md ← 可复制的模板
├── images/ ← 粘贴的图片存放于此
│ └── .gitkeep
├── 20260308找伙伴功能.md ← 你的需求文档
└── *.pdf / *.pages ← 仍可保留原格式作归档
```
---
## 扩展安装(如需粘贴图片)
在 Cursor 扩展市场搜索并安装其一:
- **Paste Image**`naumovs.paste-image`
- **Markdown Paste**:支持粘贴图片并生成 Markdown 引用
**Paste Image 路径配置**(可选):在 `settings.json` 中添加,使图片保存到当前目录的 `images/` 下:
```json
{
"pasteImage.path": "${currentFileDir}/images",
"pasteImage.basePath": "${currentFileDir}"
}
```

View File

@@ -0,0 +1,39 @@
# 需求文档标题
> 创建日期YYYY-MM-DD
> 文档格式Markdown支持图片粘贴 + 预览)
---
## 一、背景与目标
(在此输入文字,可直接粘贴图片)
---
## 二、功能点
### 2.1 功能一
(文字 + 可粘贴的截图、原型图)
示例图片引用:`![描述](./images/截图1.png)`
### 2.2 功能二
---
## 三、补充说明
(可继续粘贴图片和文字)
---
## 使用提示
- **粘贴图片**:在 Cursor 中安装「Paste Image」扩展后直接 Ctrl+V / Cmd+V 即可将剪贴板图片保存到 `images/` 并自动插入引用
- **预览**`Cmd+Shift+V` 或右侧「Open Preview」查看排版效果