sync: Gitea 同步配置、miniprogram 页面逻辑、miniprogram 页面样式、脚本与配置、soul-admin 前端、soul-admin 页面、soul-api 接口逻辑、soul-api 路由等 | 原因: 多模块开发更新
This commit is contained in:
54
Gitea同步说明.md
Normal file
54
Gitea同步说明.md
Normal 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
|
||||
```
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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. dailyChapters(sort_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() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"enablePullDownRefresh": false,
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "light",
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,6 +37,9 @@ class ReadingTracker {
|
||||
|
||||
// 开始定期上报(每30秒)
|
||||
this.startProgressReport()
|
||||
|
||||
// 立即上报一次「打开/点击」,确保内容管理后台的「点击」数据有记录(与 reading_progress 表直接捆绑)
|
||||
setTimeout(() => this.reportProgressToServer(false), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
7
scripts/gitea_push_once.sh
Executable file
7
scripts/gitea_push_once.sh
Executable 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
|
||||
1
soul-admin/dist/assets/index-Cnf4LXuY.css
vendored
Normal file
1
soul-admin/dist/assets/index-Cnf4LXuY.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-Cw3R8GlJ.css
vendored
1
soul-admin/dist/assets/index-Cw3R8GlJ.css
vendored
File diff suppressed because one or more lines are too long
470
soul-admin/dist/assets/index-DL1oFSEm.js
vendored
470
soul-admin/dist/assets/index-DL1oFSEm.js
vendored
File diff suppressed because one or more lines are too long
495
soul-admin/dist/assets/index-V947pMyG.js
vendored
Normal file
495
soul-admin/dist/assets/index-V947pMyG.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 名:得分 20~1</li>
|
||||
<li>最近更新前 30 篇:得分 30~1</li>
|
||||
<li>付款数前 20 名:得分 20~1</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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
65
soul-admin/src/pages/find-partner/FindPartnerPage.tsx
Normal file
65
soul-admin/src/pages/find-partner/FindPartnerPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx
Normal file
242
soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx
Normal file
242
soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
soul-admin/src/pages/find-partner/tabs/MatchRecordsTab.tsx
Normal file
132
soul-admin/src/pages/find-partner/tabs/MatchRecordsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx
Normal file
86
soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
86
soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx
Normal file
86
soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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": "微信",
|
||||
})
|
||||
|
||||
@@ -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 查询(兼容旧链接)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "找伙伴"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
开发文档/1、需求/修改/20260308找伙伴功能.pages
Normal file
BIN
开发文档/1、需求/修改/20260308找伙伴功能.pages
Normal file
Binary file not shown.
BIN
开发文档/1、需求/修改/20260308找伙伴功能.pdf
Normal file
BIN
开发文档/1、需求/修改/20260308找伙伴功能.pdf
Normal file
Binary file not shown.
39
开发文档/1、需求/修改/20260308找伙伴功能2.md
Normal file
39
开发文档/1、需求/修改/20260308找伙伴功能2.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 需求文档标题
|
||||
|
||||
> 创建日期:YYYY-MM-DD
|
||||
> 文档格式:Markdown(支持图片粘贴 + 预览)
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
(在此输入文字,可直接粘贴图片)
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 二、功能点
|
||||
|
||||
### 2.1 功能一
|
||||
|
||||
(文字 + 可粘贴的截图、原型图)
|
||||
|
||||
示例图片引用:``
|
||||
|
||||
### 2.2 功能二
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 三、补充说明
|
||||
|
||||
(可继续粘贴图片和文字)
|
||||
|
||||
---
|
||||
|
||||
## 使用提示
|
||||
|
||||
- **粘贴图片**:在 Cursor 中安装「Paste Image」扩展后,直接 Ctrl+V / Cmd+V 即可将剪贴板图片保存到 `images/` 并自动插入引用
|
||||
- **预览**:`Cmd+Shift+V` 或右侧「Open Preview」查看排版效果
|
||||
75
开发文档/1、需求/修改/README_需求文档格式说明.md
Normal file
75
开发文档/1、需求/修改/README_需求文档格式说明.md
Normal 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/` 并插入 ``
|
||||
|
||||
**方式二:手动**
|
||||
|
||||
1. 将图片放入本目录下的 `images/` 文件夹
|
||||
2. 在文档中插入:``
|
||||
|
||||
### 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}"
|
||||
}
|
||||
```
|
||||
39
开发文档/1、需求/修改/_模板_需求文档.md
Normal file
39
开发文档/1、需求/修改/_模板_需求文档.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 需求文档标题
|
||||
|
||||
> 创建日期:YYYY-MM-DD
|
||||
> 文档格式:Markdown(支持图片粘贴 + 预览)
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
(在此输入文字,可直接粘贴图片)
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 二、功能点
|
||||
|
||||
### 2.1 功能一
|
||||
|
||||
(文字 + 可粘贴的截图、原型图)
|
||||
|
||||
示例图片引用:``
|
||||
|
||||
### 2.2 功能二
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 三、补充说明
|
||||
|
||||
(可继续粘贴图片和文字)
|
||||
|
||||
---
|
||||
|
||||
## 使用提示
|
||||
|
||||
- **粘贴图片**:在 Cursor 中安装「Paste Image」扩展后,直接 Ctrl+V / Cmd+V 即可将剪贴板图片保存到 `images/` 并自动插入引用
|
||||
- **预览**:`Cmd+Shift+V` 或右侧「Open Preview」查看排版效果
|
||||
Reference in New Issue
Block a user