更新小程序开发文档,新增2026-03-03的最佳实践记录,优化个人中心类页面的卡片区边距规范,确保一致性与可用性。调整相关页面以反映最新设计稿,提升用户体验与功能一致性。
27
.cursor/agent/小程序开发工程师/evolution/2026-03-03.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 2026-03-03 经验
|
||||||
|
|
||||||
|
## 我的页面卡片区边距优化
|
||||||
|
|
||||||
|
### 问题/场景
|
||||||
|
|
||||||
|
用户反馈「我的」页面的卡片区域左右边距过大,内容区在水平方向上显得较窄,未充分利用屏幕横向空间。
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
|
||||||
|
将卡片区域及上下关联区块的左右边距统一缩小:
|
||||||
|
|
||||||
|
| 区块 | 修改前 | 修改后 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| `.main-content` | 32rpx | 16rpx |
|
||||||
|
| `.header-block` | 40rpx | 16rpx |
|
||||||
|
| `.guest-block` | 48rpx | 16rpx |
|
||||||
|
| `.card` padding | 40rpx | 32rpx |
|
||||||
|
| `.card` margin-bottom | 32rpx | 24rpx |
|
||||||
|
|
||||||
|
### 提炼规则
|
||||||
|
|
||||||
|
个人中心、设置类页面(如「我的」)的卡片区左右边距宜紧凑,**推荐 16rpx**;卡片内边距 32rpx、卡片间距 24rpx,以充分利用横向空间。
|
||||||
|
|
||||||
|
### 适用
|
||||||
|
|
||||||
|
- `miniprogram/pages/my/` 及类似个人中心、设置页
|
||||||
@@ -3,3 +3,4 @@
|
|||||||
| 日期 | 摘要 | 文件 |
|
| 日期 | 摘要 | 文件 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 2026-02-28 | input 边距口诀、match 资源对接弹窗修正 | [2026-02-28.md](./2026-02-28.md) |
|
| 2026-02-28 | input 边距口诀、match 资源对接弹窗修正 | [2026-02-28.md](./2026-02-28.md) |
|
||||||
|
| 2026-03-03 | 我的页面卡片区边距优化,16rpx 推荐值 | [2026-03-03.md](./2026-03-03.md) |
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|------|------|------|------------|------|
|
|------|------|------|------------|------|
|
||||||
| 2026-02-27 | 小程序、团队 | 最佳实践 | SKILL-小程序开发 §6、SKILL-管理端开发 §4.1 | 输入框 padding 用 view/div 包裹 |
|
| 2026-02-27 | 小程序、团队 | 最佳实践 | SKILL-小程序开发 §6、SKILL-管理端开发 §4.1 | 输入框 padding 用 view/div 包裹 |
|
||||||
| 2026-02-28 | 小程序、管理端 | 最佳实践 | miniprogram §6、admin §4.1 | input 边距口诀「外边包 view、内部 width 100%」;match 弹窗已修正 |
|
| 2026-02-28 | 小程序、管理端 | 最佳实践 | miniprogram §6、admin §4.1 | input 边距口诀「外边包 view、内部 width 100%」;match 弹窗已修正 |
|
||||||
|
| 2026-03-03 | 小程序 | 最佳实践 | miniprogram §8 | 我的页面卡片区边距 16rpx,个人中心类页面布局规范 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,4 +35,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**最后更新**:2026-02-28
|
**最后更新**:2026-03-03
|
||||||
|
|||||||
@@ -20,9 +20,10 @@
|
|||||||
| 2026-02-27 | 吸收经验:输入框 padding 用 view 包裹,已升级 SKILL-小程序开发 §6 | 已完成 |
|
| 2026-02-27 | 吸收经验:输入框 padding 用 view 包裹,已升级 SKILL-小程序开发 §6 | 已完成 |
|
||||||
| 2026-02-28 | stitch_soul 需求评审:首页/目录/导师/会员/资料五类页面,待需求与接口确定后分阶段实现 | 待续 |
|
| 2026-02-28 | stitch_soul 需求评审:首页/目录/导师/会员/资料五类页面,待需求与接口确定后分阶段实现 | 待续 |
|
||||||
| 2026-02-28 | 吸收经验:input 边距口诀「外边包 view、内部 width 100%」写入 Skill §6;match 资源对接弹窗已按规范修正 | 已完成 |
|
| 2026-02-28 | 吸收经验:input 边距口诀「外边包 view、内部 width 100%」写入 Skill §6;match 资源对接弹窗已按规范修正 | 已完成 |
|
||||||
|
| 2026-03-03 | 吸收经验:我的页面卡片区边距优化,16rpx 为个人中心类页面推荐值,已升级 SKILL §8 | 已完成 |
|
||||||
|
|
||||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**最后更新**:2026-02-28
|
**最后更新**:2026-03-03
|
||||||
|
|||||||
@@ -68,11 +68,20 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 何时使用本 Skill
|
## 7. 布局与边距(个人中心/设置类页面)
|
||||||
|
|
||||||
|
- **卡片区左右边距**:宜紧凑,**推荐 16rpx**,避免内容区过窄。
|
||||||
|
- **卡片**:内边距 32rpx,卡片间距 24rpx。
|
||||||
|
- **适用**:`pages/my/`、设置页等个人中心类页面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 何时使用本 Skill
|
||||||
|
|
||||||
- 在 **miniprogram/** 下新增或修改页面、组件、utils 时。
|
- 在 **miniprogram/** 下新增或修改页面、组件、utils 时。
|
||||||
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
|
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
|
||||||
- 做阅读、支付、推荐、提现等与 soul-api 对接的功能时。
|
- 做阅读、支付、推荐、提现等与 soul-api 对接的功能时。
|
||||||
- 做表单、input/textarea 样式时(遵循 §6,用 view 包裹,padding 写在 view 上)。
|
- 做表单、input/textarea 样式时(遵循 §6,用 view 包裹,padding 写在 view 上)。
|
||||||
|
- 做个人中心、设置页布局时(遵循 §7,卡片区边距 16rpx)。
|
||||||
|
|
||||||
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。
|
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const { parseScene } = require('./utils/scene.js')
|
|||||||
App({
|
App({
|
||||||
globalData: {
|
globalData: {
|
||||||
// API基础地址 - 连接真实后端
|
// API基础地址 - 连接真实后端
|
||||||
baseUrl: 'https://soulapi.quwanzhi.com',
|
baseUrl: 'https://soulapi.quwanzhi.com',
|
||||||
// baseUrl: 'https://souldev.quwanzhi.com',
|
// baseUrl: 'https://souldev.quwanzhi.com',
|
||||||
// baseUrl: 'http://localhost:8080',
|
// baseUrl: 'http://localhost:8080',
|
||||||
|
|
||||||
@@ -56,7 +56,10 @@ App({
|
|||||||
navBarHeight: 88,
|
navBarHeight: 88,
|
||||||
|
|
||||||
// TabBar相关
|
// TabBar相关
|
||||||
currentTab: 0
|
currentTab: 0,
|
||||||
|
|
||||||
|
// 更新检测:上次检测时间戳,避免频繁请求
|
||||||
|
lastUpdateCheck: 0
|
||||||
},
|
},
|
||||||
|
|
||||||
onLaunch(options) {
|
onLaunch(options) {
|
||||||
@@ -77,9 +80,10 @@ App({
|
|||||||
this.handleReferralCode(options)
|
this.handleReferralCode(options)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 小程序显示时也检查分享参数
|
// 小程序显示时:处理分享参数、检测更新(从后台切回时)
|
||||||
onShow(options) {
|
onShow(options) {
|
||||||
this.handleReferralCode(options)
|
this.handleReferralCode(options)
|
||||||
|
this.checkUpdate()
|
||||||
},
|
},
|
||||||
|
|
||||||
// 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref),与 utils/scene 解析闭环
|
// 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref),与 utils/scene 解析闭环
|
||||||
@@ -238,21 +242,30 @@ App({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 检查更新
|
/**
|
||||||
|
* 小程序更新检测(基于 wx.getUpdateManager)
|
||||||
|
* - 启动时检测;从后台切回前台时也检测(间隔至少 5 分钟,避免频繁请求)
|
||||||
|
*/
|
||||||
checkUpdate() {
|
checkUpdate() {
|
||||||
if (wx.canIUse('getUpdateManager')) {
|
try {
|
||||||
|
if (!wx.canIUse('getUpdateManager')) return
|
||||||
|
const now = Date.now()
|
||||||
|
const lastCheck = this.globalData.lastUpdateCheck || 0
|
||||||
|
if (lastCheck && now - lastCheck < 5 * 60 * 1000) return // 5 分钟内不重复检测
|
||||||
|
this.globalData.lastUpdateCheck = now
|
||||||
|
|
||||||
const updateManager = wx.getUpdateManager()
|
const updateManager = wx.getUpdateManager()
|
||||||
|
|
||||||
updateManager.onCheckForUpdate((res) => {
|
updateManager.onCheckForUpdate((res) => {
|
||||||
if (res.hasUpdate) {
|
if (res.hasUpdate) {
|
||||||
console.log('发现新版本')
|
console.log('[App] 发现新版本,正在下载...')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
updateManager.onUpdateReady(() => {
|
updateManager.onUpdateReady(() => {
|
||||||
wx.showModal({
|
wx.showModal({
|
||||||
title: '更新提示',
|
title: '更新提示',
|
||||||
content: '新版本已准备好,是否重启应用?',
|
content: '新版本已准备好,重启后即可使用',
|
||||||
|
confirmText: '立即重启',
|
||||||
|
cancelText: '稍后',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
updateManager.applyUpdate()
|
updateManager.applyUpdate()
|
||||||
@@ -260,13 +273,15 @@ App({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
updateManager.onUpdateFailed(() => {
|
updateManager.onUpdateFailed(() => {
|
||||||
wx.showToast({
|
wx.showToast({
|
||||||
title: '更新失败,请稍后重试',
|
title: '更新失败,请稍后重试',
|
||||||
icon: 'none'
|
icon: 'none',
|
||||||
|
duration: 2500
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[App] checkUpdate failed:', e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
5
miniprogram/assets/icons/book-arrow-teal.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 8l4 4-4 4" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 463 B |
5
miniprogram/assets/icons/book-arrow.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 8v8M9 11l3-3 3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
4
miniprogram/assets/icons/book-open-teal.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 354 B |
4
miniprogram/assets/icons/clock-teal.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="12 6 12 12 16 14" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 317 B |
4
miniprogram/assets/icons/clock.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="12 6 12 12 16 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 327 B |
6
miniprogram/assets/icons/eye-off.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.576 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" stroke="#94A3B8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.8"/>
|
||||||
|
<path d="M14.084 14.158a3 3 0 1 1-4.242-4.242" stroke="#94A3B8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.8"/>
|
||||||
|
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-4.444" stroke="#94A3B8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.8"/>
|
||||||
|
<line x1="2" y1="2" x2="22" y2="22" stroke="#94A3B8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 767 B |
4
miniprogram/assets/icons/eye-teal.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 392 B |
4
miniprogram/assets/icons/eye.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 402 B |
3
miniprogram/assets/icons/folder-teal.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 251 B |
3
miniprogram/assets/icons/folder.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 256 B |
5
miniprogram/assets/icons/info-blue.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="#3B82F6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12" stroke="#3B82F6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" stroke="#3B82F6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 443 B |
5
miniprogram/assets/icons/info.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 458 B |
4
miniprogram/assets/icons/settings-gray.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 855 B |
6
miniprogram/assets/icons/users-teal.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="9" cy="7" r="4" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 573 B |
BIN
miniprogram/assets/images/author-avatar.png
Normal file
|
After Width: | Height: | Size: 503 KiB |
@@ -7,23 +7,15 @@ const app = getApp()
|
|||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
statusBarHeight: 44,
|
statusBarHeight: 44,
|
||||||
|
authorLoading: true,
|
||||||
author: {
|
author: {
|
||||||
name: '卡若',
|
name: '卡若',
|
||||||
avatar: 'K',
|
avatar: 'K',
|
||||||
title: 'Soul派对房主理人 · 私域运营专家',
|
avatarImg: '/assets/images/author-avatar.png',
|
||||||
bio: '每天早上6点到9点,在Soul派对房分享真实的创业故事。专注私域运营与项目变现,用"云阿米巴"模式帮助创业者构建可持续的商业体系。本书记录了62个真实商业案例,涵盖电商、内容、传统行业等多个领域。',
|
title: '',
|
||||||
stats: [
|
bio: '',
|
||||||
{ label: '商业案例', value: '62' },
|
stats: [],
|
||||||
{ label: '连续直播', value: '365天' },
|
highlights: []
|
||||||
{ label: '派对分享', value: '1000+' }
|
|
||||||
],
|
|
||||||
// 联系方式已移至后台配置
|
|
||||||
contact: null,
|
|
||||||
highlights: [
|
|
||||||
'5年私域运营经验',
|
|
||||||
'帮助100+品牌从0到1增长',
|
|
||||||
'连续创业者,擅长商业模式设计'
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
bookInfo: {
|
bookInfo: {
|
||||||
title: '一场Soul的创业实验',
|
title: '一场Soul的创业实验',
|
||||||
@@ -44,22 +36,68 @@ Page({
|
|||||||
this.setData({
|
this.setData({
|
||||||
statusBarHeight: app.globalData.statusBarHeight
|
statusBarHeight: app.globalData.statusBarHeight
|
||||||
})
|
})
|
||||||
|
this.loadAuthor()
|
||||||
this.loadBookStats()
|
this.loadBookStats()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadAuthor() {
|
||||||
|
this.setData({ authorLoading: true })
|
||||||
|
try {
|
||||||
|
const res = await app.request({ url: '/api/miniprogram/about/author', silent: true })
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
const d = res.data
|
||||||
|
let avatarImg = d.avatarImg || ''
|
||||||
|
if (avatarImg && !avatarImg.startsWith('http')) {
|
||||||
|
const base = (app.globalData.baseUrl || '').replace(/\/$/, '')
|
||||||
|
avatarImg = base ? base + (avatarImg.startsWith('/') ? avatarImg : '/' + avatarImg) : avatarImg
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
author: {
|
||||||
|
name: d.name || '卡若',
|
||||||
|
avatar: d.avatar || 'K',
|
||||||
|
avatarImg: avatarImg || '/assets/images/author-avatar.png',
|
||||||
|
title: d.title || '',
|
||||||
|
bio: d.bio || '',
|
||||||
|
stats: Array.isArray(d.stats) ? d.stats : [
|
||||||
|
{ label: '商业案例', value: '62' },
|
||||||
|
{ label: '连续直播', value: '365天' },
|
||||||
|
{ label: '派对分享', value: '1000+' }
|
||||||
|
],
|
||||||
|
highlights: Array.isArray(d.highlights) ? d.highlights : []
|
||||||
|
},
|
||||||
|
authorLoading: false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.setData({ authorLoading: false })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[About] 加载作者配置失败,使用默认')
|
||||||
|
this.setData({ authorLoading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 加载书籍统计
|
// 加载书籍统计(合并到作者统计第一项「商业案例」)
|
||||||
async loadBookStats() {
|
async loadBookStats() {
|
||||||
try {
|
try {
|
||||||
const res = await app.request('/api/miniprogram/book/stats')
|
const res = await app.request({ url: '/api/miniprogram/book/stats', silent: true })
|
||||||
if (res && res.success) {
|
if (res?.success && res.data) {
|
||||||
this.setData({
|
const total = res.data?.totalChapters || 62
|
||||||
'bookInfo.totalChapters': res.data?.totalChapters || 62,
|
this.setData({ 'bookInfo.totalChapters': total })
|
||||||
'author.stats': [
|
const stats = this.data.author?.stats || []
|
||||||
{ label: '商业案例', value: String(res.data?.totalChapters || 62) },
|
const idx = stats.findIndex((s) => s && (s.label === '商业案例' || s.label === '章节'))
|
||||||
{ label: '连续直播', value: '365天' },
|
if (idx >= 0 && stats[idx]) {
|
||||||
{ label: '派对分享', value: '1000+' }
|
const next = [...stats]
|
||||||
]
|
next[idx] = { ...stats[idx], value: String(total) }
|
||||||
})
|
this.setData({ 'author.stats': next })
|
||||||
|
} else if (stats.length === 0) {
|
||||||
|
this.setData({
|
||||||
|
'author.stats': [
|
||||||
|
{ label: '商业案例', value: String(total) },
|
||||||
|
{ label: '连续直播', value: '365天' },
|
||||||
|
{ label: '派对分享', value: '1000+' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('[About] 加载书籍统计失败,使用默认值')
|
console.log('[About] 加载书籍统计失败,使用默认值')
|
||||||
|
|||||||
@@ -8,9 +8,13 @@
|
|||||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||||
|
|
||||||
<view class="content">
|
<view class="content">
|
||||||
|
<view wx:if="{{authorLoading}}" class="loading-row">加载中...</view>
|
||||||
<!-- 作者信息卡片 -->
|
<!-- 作者信息卡片 -->
|
||||||
<view class="author-card">
|
<view class="author-card" wx:if="{{!authorLoading}}">
|
||||||
<view class="author-avatar">{{author.avatar}}</view>
|
<view class="author-avatar-wrap">
|
||||||
|
<image wx:if="{{author.avatarImg}}" class="author-avatar-img" src="{{author.avatarImg}}" mode="aspectFill"/>
|
||||||
|
<view wx:else class="author-avatar">{{author.avatar}}</view>
|
||||||
|
</view>
|
||||||
<text class="author-name">{{author.name}}</text>
|
<text class="author-name">{{author.name}}</text>
|
||||||
<text class="author-title">{{author.title}}</text>
|
<text class="author-title">{{author.title}}</text>
|
||||||
<text class="author-bio">{{author.bio}}</text>
|
<text class="author-bio">{{author.bio}}</text>
|
||||||
@@ -33,7 +37,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 书籍信息 -->
|
<!-- 书籍信息 -->
|
||||||
<view class="book-info-card" wx:if="{{bookInfo}}">
|
<view class="book-info-card" wx:if="{{bookInfo && !authorLoading}}">
|
||||||
<text class="card-title">📚 {{bookInfo.title}}</text>
|
<text class="card-title">📚 {{bookInfo.title}}</text>
|
||||||
<view class="book-stats">
|
<view class="book-stats">
|
||||||
<view class="book-stat">
|
<view class="book-stat">
|
||||||
@@ -58,7 +62,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 联系方式 - 引导到Soul派对房 -->
|
<!-- 联系方式 - 引导到Soul派对房 -->
|
||||||
<view class="contact-card">
|
<view class="contact-card" wx:if="{{!authorLoading}}">
|
||||||
<text class="card-title">联系作者</text>
|
<text class="card-title">联系作者</text>
|
||||||
<view class="contact-item">
|
<view class="contact-item">
|
||||||
<text class="contact-icon">🎉</text>
|
<text class="contact-icon">🎉</text>
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
.nav-title { font-size: 36rpx; font-weight: 600; color: #00CED1; }
|
.nav-title { font-size: 36rpx; font-weight: 600; color: #00CED1; }
|
||||||
.nav-placeholder { width: 72rpx; }
|
.nav-placeholder { width: 72rpx; }
|
||||||
.content { padding: 32rpx; }
|
.content { padding: 32rpx; }
|
||||||
|
.loading-row { text-align: center; color: rgba(255,255,255,0.6); font-size: 28rpx; padding: 48rpx 0; }
|
||||||
.author-card { background: linear-gradient(135deg, #1c1c1e 0%, #2c2c2e 100%); border-radius: 32rpx; padding: 48rpx; text-align: center; margin-bottom: 24rpx; border: 2rpx solid rgba(0,206,209,0.2); }
|
.author-card { background: linear-gradient(135deg, #1c1c1e 0%, #2c2c2e 100%); border-radius: 32rpx; padding: 48rpx; text-align: center; margin-bottom: 24rpx; border: 2rpx solid rgba(0,206,209,0.2); }
|
||||||
.author-avatar { width: 160rpx; height: 160rpx; border-radius: 50%; background: linear-gradient(135deg, #00CED1, #20B2AA); display: flex; align-items: center; justify-content: center; margin: 0 auto 24rpx; font-size: 64rpx; color: #fff; font-weight: 700; border: 4rpx solid rgba(0,206,209,0.3); }
|
.author-avatar-wrap { width: 160rpx; height: 160rpx; margin: 0 auto 24rpx; overflow: hidden; border-radius: 50%; border: 4rpx solid rgba(0,206,209,0.3); flex-shrink: 0; }
|
||||||
|
.author-avatar-img { width: 100%; height: 100%; display: block; }
|
||||||
|
.author-avatar { width: 100%; height: 100%; border-radius: 50%; background: linear-gradient(135deg, #00CED1, #20B2AA); display: flex; align-items: center; justify-content: center; font-size: 64rpx; color: #fff; font-weight: 700; }
|
||||||
.author-name { font-size: 40rpx; font-weight: 700; color: #fff; display: block; margin-bottom: 8rpx; }
|
.author-name { font-size: 40rpx; font-weight: 700; color: #fff; display: block; margin-bottom: 8rpx; }
|
||||||
.author-title { font-size: 26rpx; color: #00CED1; display: block; margin-bottom: 24rpx; }
|
.author-title { font-size: 26rpx; color: #00CED1; display: block; margin-bottom: 24rpx; }
|
||||||
.author-bio { font-size: 26rpx; color: rgba(255,255,255,0.7); line-height: 1.8; display: block; margin-bottom: 32rpx; }
|
.author-bio { font-size: 26rpx; color: rgba(255,255,255,0.7); line-height: 1.8; display: block; margin-bottom: 32rpx; }
|
||||||
|
|||||||
@@ -212,29 +212,31 @@ Page({
|
|||||||
navBarHeight: app.globalData.navBarHeight
|
navBarHeight: app.globalData.navBarHeight
|
||||||
})
|
})
|
||||||
this.updateUserStatus()
|
this.updateUserStatus()
|
||||||
this.loadBookDataFromServer()
|
this.loadChaptersOnce()
|
||||||
this.loadDailyChapters()
|
|
||||||
this.loadTotalFromServer()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadTotalFromServer() {
|
// 固定模块(序言、尾声、附录)不参与中间篇章
|
||||||
try {
|
_isFixedPart(pt) {
|
||||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
if (!pt) return false
|
||||||
if (res && (res.total || (res.data && res.data.length))) {
|
const p = String(pt).toLowerCase().replace(/[_\s||]/g, '')
|
||||||
this.setData({ totalSections: res.total || (res.data || []).length })
|
return p.includes('序言') || p.includes('尾声') || p.includes('附录')
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// stitch_soul P0-8:从服务端加载目录,按 part 聚合,带 isNew、免费/¥1
|
// 一次请求拉取全量目录,同时更新 totalSections / bookData / dailyChapters
|
||||||
async loadBookDataFromServer() {
|
async loadChaptersOnce() {
|
||||||
try {
|
try {
|
||||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||||
const rows = (res && res.data) || (res && res.chapters) || []
|
const rows = (res && res.data) || (res && res.chapters) || []
|
||||||
if (rows.length === 0) return
|
if (rows.length === 0) return
|
||||||
|
|
||||||
|
// 1. totalSections
|
||||||
|
const totalSections = res.total ?? rows.length
|
||||||
|
|
||||||
|
// 2. bookData(过滤序言/尾声/附录,中间篇章按 part 聚合)
|
||||||
|
const filtered = rows.filter(r => !this._isFixedPart(r.partTitle || r.part_title))
|
||||||
const partMap = new Map()
|
const partMap = new Map()
|
||||||
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
|
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
|
||||||
rows.forEach((r, idx) => {
|
filtered.forEach((r) => {
|
||||||
const pid = r.partId || r.part_id || 'part-1'
|
const pid = r.partId || r.part_id || 'part-1'
|
||||||
const cid = r.chapterId || r.chapter_id || 'chapter-1'
|
const cid = r.chapterId || r.chapter_id || 'chapter-1'
|
||||||
if (!partMap.has(pid)) {
|
if (!partMap.has(pid)) {
|
||||||
@@ -270,9 +272,28 @@ Page({
|
|||||||
chapters: Array.from(p.chapters.values())
|
chapters: Array.from(p.chapters.values())
|
||||||
}))
|
}))
|
||||||
const firstPart = bookData[0] && bookData[0].id
|
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)
|
||||||
|
.sort((a, b) => new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0))
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(c => {
|
||||||
|
const d = new Date(c.updatedAt || c.updated_at || Date.now())
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
mid: c.mid ?? c.MID ?? 0,
|
||||||
|
title: c.section_title || c.title || c.sectionTitle,
|
||||||
|
price: c.price ?? 1,
|
||||||
|
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
this.setData({
|
this.setData({
|
||||||
bookData,
|
bookData,
|
||||||
totalSections: rows.length,
|
totalSections,
|
||||||
|
dailyChapters: daily,
|
||||||
expandedPart: firstPart || this.data.expandedPart
|
expandedPart: firstPart || this.data.expandedPart
|
||||||
})
|
})
|
||||||
} catch (e) { console.log('[Chapters] 加载目录失败:', e) }
|
} catch (e) { console.log('[Chapters] 加载目录失败:', e) }
|
||||||
@@ -324,30 +345,6 @@ Page({
|
|||||||
wx.switchTab({ url: '/pages/index/index' })
|
wx.switchTab({ url: '/pages/index/index' })
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadDailyChapters() {
|
|
||||||
try {
|
|
||||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
|
||||||
const chapters = (res && res.data) || (res && res.chapters) || []
|
|
||||||
const daily = chapters
|
|
||||||
.filter(c => (c.sectionOrder || c.sort_order || 0) > 62)
|
|
||||||
.sort((a, b) => new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0))
|
|
||||||
.slice(0, 20)
|
|
||||||
.map(c => {
|
|
||||||
const d = new Date(c.updatedAt || c.updated_at || Date.now())
|
|
||||||
return {
|
|
||||||
id: c.id,
|
|
||||||
mid: c.mid ?? c.MID ?? 0,
|
|
||||||
title: c.section_title || c.title || c.sectionTitle,
|
|
||||||
price: c.price || 1,
|
|
||||||
dateStr: `${d.getMonth()+1}/${d.getDate()}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (daily.length > 0) {
|
|
||||||
this.setData({ dailyChapters: daily, totalSections: 62 + daily.length })
|
|
||||||
}
|
|
||||||
} catch (e) { console.log('[Chapters] 加载最新新增失败:', e) }
|
|
||||||
},
|
|
||||||
|
|
||||||
// 跳转到搜索页
|
// 跳转到搜索页
|
||||||
goToSearch() {
|
goToSearch() {
|
||||||
wx.navigateTo({ url: '/pages/search/search' })
|
wx.navigateTo({ url: '/pages/search/search' })
|
||||||
|
|||||||
@@ -104,19 +104,13 @@ Page({
|
|||||||
this.updateUserStatus()
|
this.updateUserStatus()
|
||||||
},
|
},
|
||||||
|
|
||||||
// 初始化数据
|
// 初始化数据:首次进页面并行异步加载,加快首屏展示
|
||||||
async initData() {
|
initData() {
|
||||||
this.setData({ loading: true })
|
this.setData({ loading: false })
|
||||||
try {
|
this.loadBookData()
|
||||||
await this.loadBookData()
|
this.loadFeaturedFromServer()
|
||||||
await this.loadFeaturedFromServer()
|
this.loadSuperMembers()
|
||||||
this.loadSuperMembers()
|
this.loadLatestChapters()
|
||||||
this.loadLatestChapters()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('初始化失败:', e)
|
|
||||||
} finally {
|
|
||||||
this.setData({ loading: false })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadSuperMembers() {
|
async loadSuperMembers() {
|
||||||
@@ -358,9 +352,14 @@ Page({
|
|||||||
wx.switchTab({ url: '/pages/my/my' })
|
wx.switchTab({ url: '/pages/my/my' })
|
||||||
},
|
},
|
||||||
|
|
||||||
// 下拉刷新
|
// 下拉刷新(等待各异步加载完成后再结束)
|
||||||
async onPullDownRefresh() {
|
async onPullDownRefresh() {
|
||||||
await this.initData()
|
await Promise.all([
|
||||||
|
this.loadBookData(),
|
||||||
|
this.loadFeaturedFromServer(),
|
||||||
|
this.loadSuperMembers(),
|
||||||
|
this.loadLatestChapters()
|
||||||
|
])
|
||||||
this.updateUserStatus()
|
this.updateUserStatus()
|
||||||
wx.stopPullDownRefresh()
|
wx.stopPullDownRefresh()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<!-- 自定义导航栏占位 -->
|
<!-- 自定义导航栏占位 -->
|
||||||
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
|
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
|
||||||
|
|
||||||
<!-- 顶部区域 -->
|
<!-- 顶部区域(按设计稿:S 图标 + 标题副标题 | 点击链接卡若 + 章数) -->
|
||||||
<view class="header">
|
<view class="header">
|
||||||
<view class="header-content">
|
<view class="header-content">
|
||||||
<view class="logo-section">
|
<view class="logo-section">
|
||||||
@@ -12,17 +12,15 @@
|
|||||||
<text class="logo-text">S</text>
|
<text class="logo-text">S</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="logo-info">
|
<view class="logo-info">
|
||||||
<view class="logo-title-row">
|
<text class="logo-title-text">Soul创业派对</text>
|
||||||
<text class="logo-title-text">Soul创业派对</text>
|
|
||||||
<view class="contact-btn" bindtap="goToAbout">
|
|
||||||
<text class="contact-icon">+</text>
|
|
||||||
<text class="contact-text">联系作者</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<text class="logo-subtitle">来自派对房的真实故事</text>
|
<text class="logo-subtitle">来自派对房的真实故事</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="header-right">
|
<view class="header-right">
|
||||||
|
<view class="contact-btn" bindtap="goToAbout">
|
||||||
|
<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 class="chapter-badge">{{totalSections}}章</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -38,8 +36,8 @@
|
|||||||
|
|
||||||
<!-- 主内容区 -->
|
<!-- 主内容区 -->
|
||||||
<view class="main-content">
|
<view class="main-content">
|
||||||
<!-- Banner卡片 - 最新章节 -->
|
<!-- Banner卡片 - 最新章节(异步加载) -->
|
||||||
<view class="banner-card" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
|
<view class="banner-card" wx:if="{{latestSection}}" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
|
||||||
<view class="banner-glow"></view>
|
<view class="banner-glow"></view>
|
||||||
<view class="banner-tag">最新更新</view>
|
<view class="banner-tag">最新更新</view>
|
||||||
<view class="banner-title">{{latestSection.title}}</view>
|
<view class="banner-title">{{latestSection.title}}</view>
|
||||||
@@ -49,6 +47,13 @@
|
|||||||
<view class="banner-arrow">→</view>
|
<view class="banner-arrow">→</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="banner-card banner-skeleton" wx:else bindtap="goToChapters">
|
||||||
|
<view class="banner-glow"></view>
|
||||||
|
<view class="banner-tag">最新更新</view>
|
||||||
|
<view class="banner-title">加载中...</view>
|
||||||
|
<view class="banner-part"></view>
|
||||||
|
<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-card card">
|
||||||
@@ -81,14 +86,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 超级个体(横向滚动) -->
|
<!-- 超级个体(横向滚动,已去掉「查看全部」) -->
|
||||||
<view class="section">
|
<view class="section">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">超级个体</text>
|
<text class="section-title">超级个体</text>
|
||||||
<view class="section-more" bindtap="goToSuperList">
|
|
||||||
<text class="more-text">查看全部</text>
|
|
||||||
<text class="more-arrow">›</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
<!-- 加载中:骨架动画 -->
|
<!-- 加载中:骨架动画 -->
|
||||||
<view wx:if="{{superMembersLoading}}" class="super-loading">
|
<view wx:if="{{superMembersLoading}}" class="super-loading">
|
||||||
@@ -124,14 +125,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 精选推荐(带 tag) -->
|
<!-- 精选推荐(带 tag,已去掉「查看全部」) -->
|
||||||
<view class="section">
|
<view class="section">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">精选推荐</text>
|
<text class="section-title">精选推荐</text>
|
||||||
<view class="section-more" bindtap="goToChapters">
|
|
||||||
<text class="more-text">查看全部</text>
|
|
||||||
<text class="more-arrow">›</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="featured-list">
|
<view class="featured-list">
|
||||||
<view
|
<view
|
||||||
|
|||||||
@@ -55,13 +55,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16rpx;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-title-text {
|
.logo-title-text {
|
||||||
font-size: 36rpx;
|
font-size: 36rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -71,22 +64,25 @@
|
|||||||
.contact-btn {
|
.contact-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4rpx;
|
gap: 12rpx;
|
||||||
padding: 8rpx 16rpx;
|
padding: 8rpx 20rpx 8rpx 12rpx;
|
||||||
background: rgba(0, 206, 209, 0.1);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border: 2rpx solid rgba(0, 206, 209, 0.2);
|
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 32rpx;
|
border-radius: 40rpx;
|
||||||
font-size: 20rpx;
|
font-size: 24rpx;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: #00CED1;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-icon {
|
.contact-avatar {
|
||||||
font-size: 20rpx;
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-text {
|
.contact-text {
|
||||||
font-size: 20rpx;
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-title {
|
.logo-title {
|
||||||
@@ -105,7 +101,8 @@
|
|||||||
.logo-subtitle {
|
.logo-subtitle {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
margin-top: 4rpx;
|
margin-top: 8rpx;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
@@ -176,6 +173,10 @@
|
|||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banner-skeleton .banner-title {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.banner-glow {
|
.banner-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Soul创业派对 - 超级个体/会员详情页
|
||||||
|
* 接口:优先 /api/miniprogram/vip/members?id=xx(VIP),回退 /api/miniprogram/users?id=xx(任意用户)
|
||||||
|
* 字段映射:name/vipName, avatar/vipAvatar, contact/vipContact/phone, wechatId, project/vipProject/projectIntro,
|
||||||
|
* mbti, region, industry, position, businessScale, skills,
|
||||||
|
* storyBestMonth→bestMonth, storyAchievement→achievement, storyTurning→turningPoint,
|
||||||
|
* helpOffer→canHelp, helpNeed→needHelp
|
||||||
|
*/
|
||||||
const app = getApp()
|
const app = getApp()
|
||||||
|
|
||||||
const MOCK_ENRICHMENT = [
|
|
||||||
{ mbti: 'ENTJ', region: '深圳', industry: '跨境电商', position: '创始人', businessScale: '年GMV 3000万+', skills: '电商运营、供应链管理、团队搭建', contactRaw: '13800138001', wechatRaw: 'wxid_entj001', bestMonth: '做跨境电商独立站,单月GMV突破200万,净利润35万', achievement: '从0到1搭建了30人的电商团队,年营收破3000万', turningPoint: '2019年从传统外贸转型跨境电商,放弃稳定薪资All in创业', canHelp: '电商选品、供应链对接、团队管理SOP', needHelp: '寻找品牌合作方和内容营销人才', project: '跨境电商独立站+亚马逊多店铺运营,主营家居类目' },
|
|
||||||
{ mbti: 'INFP', region: '杭州', industry: '新媒体/电商', position: '创始人', businessScale: '年GMV 5000万+', skills: '短视频制作、IP打造、私域运营', contactRaw: '13900139002', wechatRaw: 'wxid_****abc', bestMonth: '旅游账号30天涨粉10万,带货佣金收入12万', achievement: '帮助3个素人打造个人IP,每个月稳定变现5万+', turningPoint: '辞去互联网大厂工作开始做自媒体,第三个月就超过原薪资', canHelp: '短视频脚本、账号冷启动、私域转化设计', needHelp: '寻找供应链资源和线下活动合作', project: '旅游+生活方式自媒体矩阵,全网粉丝50万+' },
|
|
||||||
{ mbti: 'INTP', region: '厦门', industry: 'SaaS/技术', position: '技术合伙人', businessScale: '天使轮200万', skills: 'AI开发、小程序开发、系统架构', contactRaw: '13700137003', wechatRaw: 'wxid_intp003', bestMonth: 'AI客服系统外包项目,单月收入18万', achievement: '独立开发的SaaS产品获得天使轮200万融资', turningPoint: '从程序员转型技术创业者,学会用技术解决商业问题', canHelp: '技术方案设计、AI应用落地、小程序开发', needHelp: '需要商业化运营和市场推广合伙人', project: 'AI+私域运营工具SaaS平台' },
|
|
||||||
{ mbti: 'ESTP', region: '成都', industry: '资源对接', position: '联合创始人', businessScale: '50+城市', skills: '资源对接、商务BD、活动策划', contactRaw: '13600136004', wechatRaw: 'wxid_estp004', bestMonth: '撮合景区合作,居间费收入25万', achievement: '组建覆盖全国50+城市创业者社群,活跃成员3000+', turningPoint: '在Soul派对房认识第一个合伙人,打开了社交创业的大门', canHelp: '各行业资源对接、活动策划、社群引荐', needHelp: '寻找技术合伙人和内容创作者', project: '创业者资源对接平台+线下创业者沙龙' }
|
|
||||||
]
|
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: { statusBarHeight: 44, member: null, loading: true },
|
data: { statusBarHeight: 44, member: null, loading: true },
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ Page({
|
|||||||
if (u) {
|
if (u) {
|
||||||
this.setData({ member: this.enrichAndFormat({
|
this.setData({ member: this.enrichAndFormat({
|
||||||
id: u.id, name: u.vipName || u.vip_name || u.nickname || '创业者',
|
id: u.id, name: u.vipName || u.vip_name || u.nickname || '创业者',
|
||||||
avatar: u.vipAvatar || u.vip_avatar || u.avatar || '', isVip: u.is_vip === 1,
|
avatar: u.vipAvatar || u.vip_avatar || u.avatar || '', isVip: !!(u.is_vip),
|
||||||
contactRaw: u.vipContact || u.vip_contact || u.phone || '',
|
contactRaw: u.vipContact || u.vip_contact || u.phone || '',
|
||||||
wechatId: u.wechatId || u.wechat_id,
|
wechatId: u.wechatId || u.wechat_id,
|
||||||
project: u.vipProject || u.vip_project || u.projectIntro || u.project_intro || '',
|
project: u.vipProject || u.vip_project || u.projectIntro || u.project_intro || '',
|
||||||
@@ -51,29 +52,34 @@ Page({
|
|||||||
this.setData({ loading: false })
|
this.setData({ loading: false })
|
||||||
},
|
},
|
||||||
|
|
||||||
enrichAndFormat(raw) {
|
// 将空值、「未填写」、纯空格均视为未填写(用于隐藏对应项)
|
||||||
const hash = (raw.id || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0)
|
_emptyIfPlaceholder(v) {
|
||||||
const mock = MOCK_ENRICHMENT[hash % MOCK_ENRICHMENT.length]
|
if (v == null || v === undefined) return ''
|
||||||
|
const s = String(v).trim()
|
||||||
|
return (s === '' || s === '未填写') ? '' : s
|
||||||
|
},
|
||||||
|
|
||||||
|
enrichAndFormat(raw) {
|
||||||
|
const e = (v) => this._emptyIfPlaceholder(v)
|
||||||
const merged = {
|
const merged = {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
name: raw.name || raw.vipName || raw.vip_name || raw.nickname || '创业者',
|
name: raw.name || raw.vipName || raw.vip_name || raw.nickname || '创业者',
|
||||||
avatar: raw.avatar || raw.vipAvatar || raw.vip_avatar || '',
|
avatar: raw.avatar || raw.vipAvatar || raw.vip_avatar || '',
|
||||||
isVip: raw.isVip || raw.is_vip === 1,
|
isVip: !!(raw.isVip || raw.is_vip),
|
||||||
mbti: raw.mbti || mock.mbti,
|
mbti: e(raw.mbti),
|
||||||
region: raw.region || mock.region,
|
region: e(raw.region),
|
||||||
industry: raw.industry || mock.industry,
|
industry: e(raw.industry),
|
||||||
position: raw.position || mock.position,
|
position: e(raw.position),
|
||||||
businessScale: raw.businessScale || raw.business_scale || mock.businessScale,
|
businessScale: e(raw.businessScale || raw.business_scale),
|
||||||
skills: raw.skills || mock.skills,
|
skills: e(raw.skills),
|
||||||
contactRaw: raw.contactRaw || raw.vipContact || raw.vip_contact || raw.phone || mock.contactRaw,
|
contactRaw: raw.contactRaw || raw.vipContact || raw.vip_contact || raw.phone || '',
|
||||||
wechatRaw: raw.wechatRaw || raw.wechatId || raw.wechat_id || mock.wechatRaw,
|
wechatRaw: raw.wechatRaw || raw.wechatId || raw.wechat_id || '',
|
||||||
bestMonth: raw.bestMonth || raw.storyBestMonth || raw.story_best_month || mock.bestMonth,
|
bestMonth: e(raw.bestMonth || raw.storyBestMonth || raw.story_best_month),
|
||||||
achievement: raw.achievement || raw.storyAchievement || raw.story_achievement || mock.achievement,
|
achievement: e(raw.achievement || raw.storyAchievement || raw.story_achievement),
|
||||||
turningPoint: raw.turningPoint || raw.storyTurning || raw.story_turning || mock.turningPoint,
|
turningPoint: e(raw.turningPoint || raw.storyTurning || raw.story_turning),
|
||||||
canHelp: raw.canHelp || raw.helpOffer || raw.help_offer || mock.canHelp,
|
canHelp: e(raw.canHelp || raw.helpOffer || raw.help_offer),
|
||||||
needHelp: raw.needHelp || raw.helpNeed || raw.help_need || mock.needHelp,
|
needHelp: e(raw.needHelp || raw.helpNeed || raw.help_need),
|
||||||
project: raw.project || raw.vipProject || raw.vip_project || raw.projectIntro || raw.project_intro || mock.project
|
project: e(raw.project || raw.vipProject || raw.vip_project || raw.projectIntro || raw.project_intro)
|
||||||
}
|
}
|
||||||
|
|
||||||
const contact = merged.contactRaw || ''
|
const contact = merged.contactRaw || ''
|
||||||
|
|||||||
@@ -6,10 +6,7 @@
|
|||||||
<text class="nav-icon">‹</text>
|
<text class="nav-icon">‹</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="nav-title">个人资料</text>
|
<text class="nav-title">个人资料</text>
|
||||||
<view class="nav-right">
|
<view class="nav-placeholder"></view>
|
||||||
<view class="nav-icon-wrap"><text class="nav-icon">⋯</text></view>
|
|
||||||
<view class="nav-icon-wrap"><text class="nav-icon-dot">●</text></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||||
|
|
||||||
@@ -18,21 +15,23 @@
|
|||||||
<view class="card-profile">
|
<view class="card-profile">
|
||||||
<view class="profile-deco"></view>
|
<view class="profile-deco"></view>
|
||||||
<view class="profile-body">
|
<view class="profile-body">
|
||||||
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
|
<view class="avatar-outer">
|
||||||
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
|
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
|
||||||
<view class="avatar-ph" wx:else><text>{{member.name[0] || '创'}}</text></view>
|
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
|
||||||
|
<view class="avatar-ph" wx:else><text>{{member.name[0] || '创'}}</text></view>
|
||||||
|
</view>
|
||||||
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
|
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="profile-name">{{member.name}}</text>
|
<text class="profile-name">{{member.name}}</text>
|
||||||
<view class="profile-tags">
|
<view class="profile-tags" wx:if="{{member.mbti || member.region}}">
|
||||||
<text class="tag tag-mbti" wx:if="{{member.mbti}}">{{member.mbti}}</text>
|
<text class="tag tag-mbti" wx:if="{{member.mbti}}">{{member.mbti}}</text>
|
||||||
<text class="tag tag-region" wx:if="{{member.region}}"><text class="pin-icon">📍</text>{{member.region}}</text>
|
<text class="tag tag-region" wx:if="{{member.region}}"><text class="pin-icon">📍</text>{{member.region}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 基本信息 -->
|
<!-- 基本信息(未填写行已隐藏) -->
|
||||||
<view class="card">
|
<view class="card" wx:if="{{member.industry || member.position || member.businessScale || member.skills || member.contactRaw || member.contactDisplay || member.wechatRaw || member.wechatDisplay}}">
|
||||||
<view class="card-head">
|
<view class="card-head">
|
||||||
<text class="card-icon">👤</text>
|
<text class="card-icon">👤</text>
|
||||||
<text class="card-label">基本信息</text>
|
<text class="card-label">基本信息</text>
|
||||||
@@ -55,26 +54,30 @@
|
|||||||
<text class="f-key">我擅长</text>
|
<text class="f-key">我擅长</text>
|
||||||
<text class="f-val">{{member.skills}}</text>
|
<text class="f-val">{{member.skills}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="field" wx:if="{{member.contactDisplay || member.contactRaw}}">
|
<view class="field" wx:if="{{member.contactRaw || member.contactDisplay}}">
|
||||||
<text class="f-key">联系方式</text>
|
<text class="f-key">联系方式</text>
|
||||||
<view class="f-row">
|
<view class="f-row">
|
||||||
<text class="f-val mono">{{member.contactDisplay || '未填写'}}</text>
|
<text class="f-val mono">{{member.contactDisplay || member.contactRaw}}</text>
|
||||||
<view class="icon-copy" wx:if="{{!member.contactUnlocked}}" bindtap="unlockContact">🔒</view>
|
<view class="icon-copy icon-eye-off" wx:if="{{member.contactRaw && !member.contactUnlocked}}" bindtap="unlockContact">
|
||||||
<view class="icon-copy" wx:else bindtap="copyContact">📋</view>
|
<image class="icon-img" src="/assets/icons/eye-off.svg" mode="aspectFit"/>
|
||||||
|
</view>
|
||||||
|
<view class="icon-copy" wx:elif="{{member.contactRaw && member.contactUnlocked}}" bindtap="copyContact">📋</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="field" wx:if="{{member.wechatDisplay || member.wechatRaw}}">
|
<view class="field" wx:if="{{member.wechatRaw || member.wechatDisplay}}">
|
||||||
<text class="f-key">微信号</text>
|
<text class="f-key">微信号</text>
|
||||||
<view class="f-row">
|
<view class="f-row">
|
||||||
<text class="f-val mono">{{member.wechatDisplay || '未填写'}}</text>
|
<text class="f-val mono">{{member.wechatDisplay || member.wechatRaw}}</text>
|
||||||
<view class="icon-copy" wx:if="{{!member.wechatUnlocked}}" bindtap="unlockContact">🔒</view>
|
<view class="icon-copy icon-eye-off" wx:if="{{member.wechatRaw && !member.wechatUnlocked}}" bindtap="unlockContact">
|
||||||
<view class="icon-copy" wx:else bindtap="copyWechat">📋</view>
|
<image class="icon-img" src="/assets/icons/eye-off.svg" mode="aspectFit"/>
|
||||||
|
</view>
|
||||||
|
<view class="icon-copy" wx:elif="{{member.wechatRaw && member.wechatUnlocked}}" bindtap="copyWechat">📋</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 个人故事 -->
|
<!-- 个人故事(未填写行已隐藏) -->
|
||||||
<view class="card" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
|
<view class="card" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
|
||||||
<view class="card-head">
|
<view class="card-head">
|
||||||
<text class="card-icon bulb">💡</text>
|
<text class="card-icon bulb">💡</text>
|
||||||
@@ -85,12 +88,12 @@
|
|||||||
<view class="story-head"><text class="story-icon">🏆</text><text class="story-q">最赚钱的一个月做的是什么</text></view>
|
<view class="story-head"><text class="story-icon">🏆</text><text class="story-q">最赚钱的一个月做的是什么</text></view>
|
||||||
<text class="story-a">{{member.bestMonth}}</text>
|
<text class="story-a">{{member.bestMonth}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="divider" wx:if="{{member.bestMonth && (member.achievement || member.turningPoint)}}"></view>
|
<view class="divider" wx:if="{{member.bestMonth}}"></view>
|
||||||
<view class="story" wx:if="{{member.achievement}}">
|
<view class="story" wx:if="{{member.achievement}}">
|
||||||
<view class="story-head"><text class="story-icon">⭐</text><text class="story-q">最有成就感的一件事</text></view>
|
<view class="story-head"><text class="story-icon">⭐</text><text class="story-q">最有成就感的一件事</text></view>
|
||||||
<text class="story-a">{{member.achievement}}</text>
|
<text class="story-a">{{member.achievement}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="divider" wx:if="{{member.achievement && member.turningPoint}}"></view>
|
<view class="divider" wx:if="{{member.achievement}}"></view>
|
||||||
<view class="story" wx:if="{{member.turningPoint}}">
|
<view class="story" wx:if="{{member.turningPoint}}">
|
||||||
<view class="story-head"><text class="story-icon turn">🔄</text><text class="story-q">人生的转折点</text></view>
|
<view class="story-head"><text class="story-icon turn">🔄</text><text class="story-q">人生的转折点</text></view>
|
||||||
<text class="story-a">{{member.turningPoint}}</text>
|
<text class="story-a">{{member.turningPoint}}</text>
|
||||||
@@ -98,7 +101,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 互助需求 -->
|
<!-- 互助需求(未填写行已隐藏) -->
|
||||||
<view class="card" wx:if="{{member.canHelp || member.needHelp}}">
|
<view class="card" wx:if="{{member.canHelp || member.needHelp}}">
|
||||||
<view class="card-head">
|
<view class="card-head">
|
||||||
<text class="card-icon">🤝</text>
|
<text class="card-icon">🤝</text>
|
||||||
|
|||||||
@@ -35,12 +35,16 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.profile-body { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; }
|
.profile-body { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; }
|
||||||
.avatar-wrap {
|
.avatar-outer {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 176rpx; height: 176rpx;
|
width: 176rpx; height: 176rpx;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
.avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%; height: 100%;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 32rpx;
|
|
||||||
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||||
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.4);
|
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
@@ -62,10 +66,12 @@
|
|||||||
font-size: 56rpx; color: #5EEAD4; font-weight: 700;
|
font-size: 56rpx; color: #5EEAD4; font-weight: 700;
|
||||||
}
|
}
|
||||||
.vip-tag {
|
.vip-tag {
|
||||||
position: absolute; bottom: 4rpx; right: 4rpx;
|
position: absolute; bottom: -4rpx; right: -4rpx;
|
||||||
background: linear-gradient(135deg, #F59E0B, #e8920d);
|
background: linear-gradient(135deg, #F59E0B, #e8920d);
|
||||||
color: #000; font-size: 20rpx; font-weight: 800;
|
color: #000; font-size: 20rpx; font-weight: 800;
|
||||||
padding: 6rpx 16rpx; border-radius: 20rpx;
|
padding: 6rpx 14rpx; border-radius: 16rpx;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
.profile-name { font-size: 40rpx; font-weight: 700; color: #fff; margin-bottom: 24rpx; letter-spacing: 2rpx; }
|
.profile-name { font-size: 40rpx; font-weight: 700; color: #fff; margin-bottom: 24rpx; letter-spacing: 2rpx; }
|
||||||
.profile-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; flex-wrap: wrap; }
|
.profile-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; flex-wrap: wrap; }
|
||||||
@@ -96,6 +102,8 @@
|
|||||||
.f-val.mono { font-family: ui-monospace, monospace; letter-spacing: 2rpx; }
|
.f-val.mono { font-family: ui-monospace, monospace; letter-spacing: 2rpx; }
|
||||||
.f-row { display: flex; align-items: center; gap: 16rpx; }
|
.f-row { display: flex; align-items: center; gap: 16rpx; }
|
||||||
.icon-copy { font-size: 36rpx; color: #94A3B8; opacity: 0.6; padding: 8rpx; }
|
.icon-copy { font-size: 36rpx; color: #94A3B8; opacity: 0.6; padding: 8rpx; }
|
||||||
|
.icon-eye-off { display: flex; align-items: center; justify-content: center; }
|
||||||
|
.icon-eye-off .icon-img { width: 40rpx; height: 40rpx; }
|
||||||
|
|
||||||
.divider { height: 1rpx; background: rgba(255, 255, 255, 0.05); margin: 32rpx 0; }
|
.divider { height: 1rpx; background: rgba(255, 255, 255, 0.05); margin: 32rpx 0; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<!-- 我的页 - professional_profile_with_earnings_vip 1:1 还原 -->
|
<!-- 我的页 - 设计稿 1:1 还原 -->
|
||||||
<view class="page">
|
<view class="page">
|
||||||
<!-- 顶部导航:仅标题(设置已移至用户区右侧,避让胶囊) -->
|
<!-- 顶部导航:标题 + 右侧设置齿轮 -->
|
||||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||||
<text class="nav-title">我的</text>
|
<text class="nav-title">我的</text>
|
||||||
|
<view class="nav-settings" bindtap="handleMenuTap" data-id="settings">
|
||||||
|
<image class="nav-settings-icon" src="/assets/icons/settings-gray.svg" mode="aspectFit"/>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
|
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
|
||||||
|
|
||||||
@@ -16,77 +19,69 @@
|
|||||||
<view class="guest-login-btn" bindtap="showLogin">点击登录</view>
|
<view class="guest-login-btn" bindtap="showLogin">点击登录</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 已登录:用户区 -->
|
<!-- 已登录:用户卡片(设计稿布局) -->
|
||||||
<view class="header-block" wx:else>
|
<view class="profile-card" wx:else>
|
||||||
<view class="user-row">
|
<view class="profile-card-inner">
|
||||||
<view class="avatar-wrap" bindtap="onAvatarTap">
|
<view class="profile-top-row">
|
||||||
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
|
<view class="avatar-wrap" bindtap="onAvatarTap">
|
||||||
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
|
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
|
||||||
<text wx:else class="avatar-text">{{userInfo.nickname ? userInfo.nickname[0] : '?'}}</text>
|
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
|
||||||
|
<text wx:else class="avatar-text">{{userInfo.nickname ? userInfo.nickname[0] : '?'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
|
||||||
|
<view class="vip-badge vip-badge-gray" wx:else bindtap="goToVip">VIP</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
|
<view class="profile-meta">
|
||||||
<view class="vip-badge vip-badge-gray" wx:else bindtap="goToVip">VIP</view>
|
<view class="profile-name-row">
|
||||||
</view>
|
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
|
||||||
<view class="user-meta">
|
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
|
||||||
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
|
</view>
|
||||||
<view class="vip-tags">
|
<view class="vip-tags">
|
||||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text>
|
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text>
|
||||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">匹配</text>
|
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">匹配</text>
|
||||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
|
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
|
||||||
|
</view>
|
||||||
|
<text class="user-wechat" bindtap="copyUserId">微信号: {{userWechat || userIdShort || '--'}}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="user-id" bindtap="copyUserId">{{userWechat ? '微信: ' + userWechat : 'ID: ' + userIdShort}}</text>
|
|
||||||
<text class="vip-expire" wx:if="{{isVip && vipExpireDate}}">会员到期时间:{{vipExpireDate}}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="user-actions">
|
<view class="profile-stats-row">
|
||||||
<view class="action-btn" bindtap="handleMenuTap" data-id="settings"><text class="action-icon">⚙️</text></view>
|
<view class="profile-stat">
|
||||||
<view class="action-btn" catchtap="goToProfileEdit"><text class="action-icon">✎</text></view>
|
<text class="profile-stat-val">{{readCount}}</text>
|
||||||
|
<text class="profile-stat-label">已读章节</text>
|
||||||
|
</view>
|
||||||
|
<view class="profile-stat">
|
||||||
|
<text class="profile-stat-val">{{referralCount}}</text>
|
||||||
|
<text class="profile-stat-label">推荐好友</text>
|
||||||
|
</view>
|
||||||
|
<view class="profile-stat">
|
||||||
|
<text class="profile-stat-val">{{earnings === '-' ? '--' : earnings}}</text>
|
||||||
|
<text class="profile-stat-label">我的收益</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 已登录:内容区 -->
|
<!-- 已登录:内容区 -->
|
||||||
<view class="main-content" wx:if="{{isLoggedIn}}">
|
<view class="main-content" wx:if="{{isLoggedIn}}">
|
||||||
<!-- 分享收益 -->
|
|
||||||
<view class="card earnings-card">
|
|
||||||
<view class="card-header">
|
|
||||||
<text class="card-icon">💰</text>
|
|
||||||
<text class="card-title">分享收益</text>
|
|
||||||
</view>
|
|
||||||
<view class="earnings-grid">
|
|
||||||
<view class="earnings-col">
|
|
||||||
<text class="earnings-val primary">{{referralCount}}</text>
|
|
||||||
<text class="earnings-label">推荐好友</text>
|
|
||||||
</view>
|
|
||||||
<view class="earnings-col">
|
|
||||||
<text class="earnings-val primary">{{earnings === '-' ? '--' : earnings}}</text>
|
|
||||||
<text class="earnings-label">我的收益</text>
|
|
||||||
</view>
|
|
||||||
<view class="earnings-col" bindtap="handleWithdraw">
|
|
||||||
<text class="earnings-val primary">{{pendingEarnings === '-' ? '--' : pendingEarnings}}</text>
|
|
||||||
<text class="earnings-label">可提现金额</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 阅读统计 -->
|
<!-- 阅读统计 -->
|
||||||
<view class="card stats-card">
|
<view class="card stats-card">
|
||||||
<view class="card-header">
|
<view class="card-header">
|
||||||
<text class="card-icon">👁️</text>
|
<image class="card-icon-img" src="/assets/icons/eye-teal.svg" mode="aspectFit"/>
|
||||||
<text class="card-title">阅读统计</text>
|
<text class="card-title">阅读统计</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stats-grid">
|
<view class="stats-grid">
|
||||||
<view class="stat-box">
|
<view class="stat-box">
|
||||||
<text class="stat-icon">📖</text>
|
<image class="stat-icon-img" src="/assets/icons/book-open-teal.svg" mode="aspectFit"/>
|
||||||
<text class="stat-num">{{readCount}}</text>
|
<text class="stat-num">{{readCount}}</text>
|
||||||
<text class="stat-label">已读章节</text>
|
<text class="stat-label">已读章节</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-box">
|
<view class="stat-box">
|
||||||
<text class="stat-icon">⏱</text>
|
<image class="stat-icon-img" src="/assets/icons/clock-teal.svg" mode="aspectFit"/>
|
||||||
<text class="stat-num">{{totalReadTime}}</text>
|
<text class="stat-num">{{totalReadTime}}</text>
|
||||||
<text class="stat-label">阅读分钟</text>
|
<text class="stat-label">阅读分钟</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-box">
|
<view class="stat-box">
|
||||||
<text class="stat-icon">👥</text>
|
<image class="stat-icon-img" src="/assets/icons/users-teal.svg" mode="aspectFit"/>
|
||||||
<text class="stat-num">{{matchHistory}}</text>
|
<text class="stat-num">{{matchHistory}}</text>
|
||||||
<text class="stat-label">匹配伙伴</text>
|
<text class="stat-label">匹配伙伴</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -96,7 +91,7 @@
|
|||||||
<!-- 最近阅读 -->
|
<!-- 最近阅读 -->
|
||||||
<view class="card recent-card">
|
<view class="card recent-card">
|
||||||
<view class="card-header">
|
<view class="card-header">
|
||||||
<text class="card-icon">📖</text>
|
<image class="card-icon-img" src="/assets/icons/book-arrow-teal.svg" mode="aspectFit"/>
|
||||||
<text class="card-title">最近阅读</text>
|
<text class="card-title">最近阅读</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="recent-list" wx:if="{{recentChapters.length > 0}}">
|
<view class="recent-list" wx:if="{{recentChapters.length > 0}}">
|
||||||
@@ -121,22 +116,29 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 我的订单 + 关于作者 -->
|
<!-- 我的订单 + 关于作者 + 设置 -->
|
||||||
<view class="card menu-card">
|
<view class="card menu-card">
|
||||||
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
|
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
|
||||||
<view class="menu-left">
|
<view class="menu-left">
|
||||||
<view class="menu-icon-wrap icon-teal"><text class="menu-icon">📦</text></view>
|
<view class="menu-icon-wrap icon-teal"><image class="menu-icon-img" src="/assets/icons/folder-teal.svg" mode="aspectFit"/></view>
|
||||||
<text class="menu-text">我的订单</text>
|
<text class="menu-text">我的订单</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" bindtap="handleMenuTap" data-id="about">
|
<view class="menu-item" bindtap="handleMenuTap" data-id="about">
|
||||||
<view class="menu-left">
|
<view class="menu-left">
|
||||||
<view class="menu-icon-wrap icon-blue"><text class="menu-icon">ℹ</text></view>
|
<view class="menu-icon-wrap icon-blue"><image class="menu-icon-img" src="/assets/icons/info-blue.svg" mode="aspectFit"/></view>
|
||||||
<text class="menu-text">关于作者</text>
|
<text class="menu-text">关于作者</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="menu-item" bindtap="handleMenuTap" data-id="settings">
|
||||||
|
<view class="menu-left">
|
||||||
|
<view class="menu-icon-wrap icon-gray"><image class="menu-icon-img" src="/assets/icons/settings-gray.svg" mode="aspectFit"/></view>
|
||||||
|
<text class="menu-text">设置</text>
|
||||||
|
</view>
|
||||||
|
<text class="menu-arrow">›</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
/* 真机适配:底部留足 TabBar + 安全区,避免「我的订单」被遮挡 */
|
/* 真机适配:底部留足 TabBar + 安全区,避免「我的订单」被遮挡 */
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
|
||||||
background: #121212;
|
background: #121212;
|
||||||
padding-bottom: calc(220rpx + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(220rpx + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
@@ -16,16 +15,18 @@
|
|||||||
background: rgba(18,18,18,0.9); backdrop-filter: blur(8rpx);
|
background: rgba(18,18,18,0.9); backdrop-filter: blur(8rpx);
|
||||||
display: flex; align-items: center;
|
display: flex; align-items: center;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 0 200rpx 0 32rpx; /* 右侧 200rpx 避让真机右上角胶囊 */
|
padding: 0 120rpx 0 32rpx; /* 右侧避让胶囊 */
|
||||||
border-bottom: 1rpx solid rgba(255,255,255,0.05);
|
border-bottom: 1rpx solid rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
.nav-title { font-size: 40rpx; font-weight: bold; color: #4FD1C5; }
|
.nav-title { font-size: 40rpx; font-weight: bold; color: #4FD1C5; flex: 1; }
|
||||||
|
.nav-settings { width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; }
|
||||||
|
.nav-settings-icon { width: 44rpx; height: 44rpx; opacity: 0.7; }
|
||||||
.nav-placeholder { width: 100%; }
|
.nav-placeholder { width: 100%; }
|
||||||
|
|
||||||
/* ===== 未登录 ===== */
|
/* ===== 未登录 ===== */
|
||||||
.guest-block {
|
.guest-block {
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
padding: 64rpx 48rpx;
|
padding: 64rpx 16rpx;
|
||||||
}
|
}
|
||||||
.guest-avatar { width: 144rpx; height: 144rpx; border-radius: 50%; background: #1A1A1A; border: 4rpx solid #374151; overflow: hidden; margin-bottom: 24rpx; }
|
.guest-avatar { width: 144rpx; height: 144rpx; border-radius: 50%; background: #1A1A1A; border: 4rpx solid #374151; overflow: hidden; margin-bottom: 24rpx; }
|
||||||
.guest-avatar-img { width: 100%; height: 100%; display: block; }
|
.guest-avatar-img { width: 100%; height: 100%; display: block; }
|
||||||
@@ -33,9 +34,13 @@
|
|||||||
.guest-name { font-size: 36rpx; font-weight: bold; color: #E5E7EB; margin-bottom: 24rpx; }
|
.guest-name { font-size: 36rpx; font-weight: bold; color: #E5E7EB; margin-bottom: 24rpx; }
|
||||||
.guest-login-btn { padding: 20rpx 48rpx; background: #4FD1C5; color: #000; font-size: 28rpx; font-weight: 600; border-radius: 24rpx; }
|
.guest-login-btn { padding: 20rpx 48rpx; background: #4FD1C5; color: #000; font-size: 28rpx; font-weight: 600; border-radius: 24rpx; }
|
||||||
|
|
||||||
/* ===== 用户区(设计稿 header) ===== */
|
/* ===== 用户卡片(设计稿 1:1) ===== */
|
||||||
.header-block { padding: 32rpx 40rpx 48rpx; }
|
.profile-card { padding: 24rpx 16rpx 32rpx; }
|
||||||
.user-row { display: flex; align-items: center; gap: 32rpx; }
|
.profile-card-inner {
|
||||||
|
background: #1A1A1A; border-radius: 24rpx; padding: 32rpx;
|
||||||
|
border: 1rpx solid rgba(75,85,99,0.5);
|
||||||
|
}
|
||||||
|
.profile-top-row { display: flex; align-items: flex-start; gap: 32rpx; }
|
||||||
.avatar-wrap { position: relative; flex-shrink: 0; }
|
.avatar-wrap { position: relative; flex-shrink: 0; }
|
||||||
.avatar-inner {
|
.avatar-inner {
|
||||||
width: 130rpx; height: 130rpx; border-radius: 50%; overflow: hidden;
|
width: 130rpx; height: 130rpx; border-radius: 50%; overflow: hidden;
|
||||||
@@ -52,33 +57,43 @@
|
|||||||
padding: 4rpx 12rpx; border-radius: 8rpx;
|
padding: 4rpx 12rpx; border-radius: 8rpx;
|
||||||
}
|
}
|
||||||
.vip-badge-gray { background: rgba(255,255,255,0.2); color: rgba(255,255,255,0.5); }
|
.vip-badge-gray { background: rgba(255,255,255,0.2); color: rgba(255,255,255,0.5); }
|
||||||
.user-actions { display: flex; flex-direction: column; gap: 24rpx; flex-shrink: 0; margin-left: auto; align-items: flex-end; }
|
.profile-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
|
||||||
.action-btn { width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; }
|
.profile-name-row { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
|
||||||
.action-icon { font-size: 36rpx; color: #4FD1C5; opacity: 0.9; }
|
|
||||||
.user-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
|
||||||
.user-name {
|
.user-name {
|
||||||
font-size: 44rpx; font-weight: bold; color: #fff;
|
font-size: 44rpx; font-weight: bold; color: #fff;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;
|
||||||
}
|
}
|
||||||
|
.become-member-btn {
|
||||||
|
padding: 12rpx 28rpx; border: 2rpx solid #C8A146; color: #C8A146;
|
||||||
|
font-size: 24rpx; font-weight: 500; border-radius: 40rpx; white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.become-member-vip { border-color: rgba(200,161,70,0.5); color: rgba(200,161,70,0.8); }
|
||||||
.vip-tags { display: flex; gap: 12rpx; flex-shrink: 0; }
|
.vip-tags { display: flex; gap: 12rpx; flex-shrink: 0; }
|
||||||
.vip-tag {
|
.vip-tag {
|
||||||
font-size: 20rpx; padding: 6rpx 12rpx; border-radius: 8rpx;
|
font-size: 20rpx; padding: 6rpx 12rpx; border-radius: 8rpx;
|
||||||
border: 1rpx solid #374151; background: rgba(255,255,255,0.05); color: #9CA3AF;
|
border: 1rpx solid #374151; background: rgba(255,255,255,0.05); color: #9CA3AF;
|
||||||
}
|
}
|
||||||
.vip-tag-active { border-color: #C8A146; background: rgba(200,161,70,0.1); color: #C8A146; }
|
.vip-tag-active { border-color: #C8A146; background: rgba(200,161,70,0.1); color: #C8A146; }
|
||||||
.user-id { display: block; font-size: 28rpx; color: #6B7280; }
|
.user-wechat { font-size: 26rpx; color: #6B7280; }
|
||||||
.vip-expire { display: block; font-size: 22rpx; color: #6B7280; margin-top: 4rpx; }
|
.profile-stats-row {
|
||||||
|
display: flex; justify-content: space-around; margin-top: 32rpx;
|
||||||
|
padding-top: 24rpx; border-top: 1rpx solid #374151;
|
||||||
|
}
|
||||||
|
.profile-stat { text-align: center; }
|
||||||
|
.profile-stat-val { display: block; font-size: 36rpx; font-weight: bold; color: #4FD1C5; }
|
||||||
|
.profile-stat-label { display: block; font-size: 22rpx; color: #6B7280; margin-top: 8rpx; }
|
||||||
|
|
||||||
/* ===== 主内容区 ===== */
|
/* ===== 主内容区 ===== */
|
||||||
.main-content { padding: 0 32rpx 48rpx; }
|
.main-content { }
|
||||||
|
|
||||||
/* 卡片通用 */
|
/* 卡片通用 */
|
||||||
.card {
|
.card {
|
||||||
background: #1A1A1A; border-radius: 24rpx; padding: 40rpx;
|
background: #1A1A1A; border-radius: 24rpx; padding: 32rpx;
|
||||||
margin-bottom: 32rpx; border: 1rpx solid rgba(75,85,99,0.5);
|
margin-bottom: 24rpx; border: 1rpx solid rgba(75,85,99,0.5);
|
||||||
}
|
}
|
||||||
.card-header { display: flex; align-items: center; gap: 16rpx; margin-bottom: 32rpx; }
|
.card-header { display: flex; align-items: center; gap: 16rpx; margin-bottom: 32rpx; }
|
||||||
.card-icon { font-size: 40rpx; }
|
.card-icon { font-size: 40rpx; }
|
||||||
|
.card-icon-img { width: 40rpx; height: 40rpx; flex-shrink: 0; }
|
||||||
.card-title { font-size: 32rpx; font-weight: bold; color: #fff; }
|
.card-title { font-size: 32rpx; font-weight: bold; color: #fff; }
|
||||||
|
|
||||||
/* 分享收益 */
|
/* 分享收益 */
|
||||||
@@ -92,16 +107,20 @@
|
|||||||
.earnings-val.primary { color: #4FD1C5; }
|
.earnings-val.primary { color: #4FD1C5; }
|
||||||
.earnings-label { display: block; font-size: 24rpx; color: #6B7280; margin-top: 8rpx; }
|
.earnings-label { display: block; font-size: 24rpx; color: #6B7280; margin-top: 8rpx; }
|
||||||
|
|
||||||
/* 阅读统计 */
|
/* 阅读统计 - 统一高度避免真机错位 */
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24rpx; }
|
.stats-grid {
|
||||||
.stat-box {
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 24rpx;
|
||||||
background: #252525; border-radius: 20rpx; padding: 20rpx;
|
align-items: stretch;
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
||||||
aspect-ratio: 2/1;
|
|
||||||
}
|
}
|
||||||
.stat-icon { font-size: 40rpx; margin-bottom: 8rpx; color: #4FD1C5; }
|
.stat-box {
|
||||||
.stat-num { font-size: 36rpx; font-weight: bold; color: #fff; }
|
background: #252525; border-radius: 20rpx; padding: 24rpx;
|
||||||
.stat-label { font-size: 20rpx; color: #6B7280; margin-top: 4rpx; }
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
min-height: 140rpx;
|
||||||
|
}
|
||||||
|
.stat-icon { font-size: 40rpx; margin-bottom: 8rpx; color: #4FD1C5; flex-shrink: 0; }
|
||||||
|
.stat-icon-img { width: 44rpx; height: 44rpx; margin-bottom: 8rpx; flex-shrink: 0; display: block; }
|
||||||
|
.stat-num { font-size: 36rpx; font-weight: bold; color: #fff; line-height: 1.2; }
|
||||||
|
.stat-label { font-size: 20rpx; color: #6B7280; margin-top: 4rpx; line-height: 1.2; }
|
||||||
|
|
||||||
/* 最近阅读 */
|
/* 最近阅读 */
|
||||||
.recent-list { display: flex; flex-direction: column; gap: 24rpx; }
|
.recent-list { display: flex; flex-direction: column; gap: 24rpx; }
|
||||||
@@ -131,9 +150,15 @@
|
|||||||
}
|
}
|
||||||
.menu-icon-wrap .menu-icon { font-size: 32rpx; }
|
.menu-icon-wrap .menu-icon { font-size: 32rpx; }
|
||||||
.icon-teal { background: rgba(79,209,197,0.2); }
|
.icon-teal { background: rgba(79,209,197,0.2); }
|
||||||
.icon-teal .menu-icon { color: #4FD1C5; }
|
.icon-teal .menu-icon,
|
||||||
|
.icon-teal .menu-icon-img { color: #4FD1C5; }
|
||||||
|
.icon-teal .menu-icon-img { width: 32rpx; height: 32rpx; }
|
||||||
.icon-blue { background: rgba(59,130,246,0.2); }
|
.icon-blue { background: rgba(59,130,246,0.2); }
|
||||||
.icon-blue .menu-icon { color: #3B82F6; }
|
.icon-blue .menu-icon,
|
||||||
|
.icon-blue .menu-icon-img { color: #3B82F6; }
|
||||||
|
.icon-blue .menu-icon-img { width: 32rpx; height: 32rpx; }
|
||||||
|
.icon-gray { background: rgba(156,163,175,0.15); }
|
||||||
|
.icon-gray .menu-icon-img { width: 32rpx; height: 32rpx; }
|
||||||
.menu-text { font-size: 28rpx; color: #E5E7EB; font-weight: 500; }
|
.menu-text { font-size: 28rpx; color: #E5E7EB; font-weight: 500; }
|
||||||
.menu-arrow { font-size: 36rpx; color: #9CA3AF; }
|
.menu-arrow { font-size: 36rpx; color: #9CA3AF; }
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,22 @@ Page({
|
|||||||
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userInfo.id}`, silent: true })
|
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userInfo.id}`, silent: true })
|
||||||
if (res?.success && res.data) {
|
if (res?.success && res.data) {
|
||||||
const d = res.data
|
const d = res.data
|
||||||
|
const e = (v) => (v == null || v === undefined ? '' : (String(v).trim() === '' || String(v).trim() === '未填写' ? '' : String(v).trim()))
|
||||||
const phone = d.phone || ''
|
const phone = d.phone || ''
|
||||||
const wechat = d.wechatId || wx.getStorageSync('user_wechat') || ''
|
const wechat = d.wechatId || wx.getStorageSync('user_wechat') || ''
|
||||||
this.setData({
|
this.setData({
|
||||||
profile: {
|
profile: {
|
||||||
...d,
|
...d,
|
||||||
|
industry: e(d.industry),
|
||||||
|
position: e(d.position),
|
||||||
|
businessScale: e(d.businessScale || d.business_scale),
|
||||||
|
skills: e(d.skills),
|
||||||
|
storyBestMonth: e(d.storyBestMonth || d.story_best_month),
|
||||||
|
storyAchievement: e(d.storyAchievement || d.story_achievement),
|
||||||
|
storyTurning: e(d.storyTurning || d.story_turning),
|
||||||
|
helpOffer: e(d.helpOffer || d.help_offer),
|
||||||
|
helpNeed: e(d.helpNeed || d.help_need),
|
||||||
|
projectIntro: e(d.projectIntro || d.project_intro),
|
||||||
phoneMask: phone ? phone.slice(0, 3) + '****' + phone.slice(-2) : '',
|
phoneMask: phone ? phone.slice(0, 3) + '****' + phone.slice(-2) : '',
|
||||||
wechatMask: wechat ? (wechat.length > 8 ? wechat.slice(0, 4) + '****' + wechat.slice(-3) : wechat) : '',
|
wechatMask: wechat ? (wechat.length > 8 ? wechat.slice(0, 4) + '****' + wechat.slice(-3) : wechat) : '',
|
||||||
phone,
|
phone,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
<text class="field-hint" wx:if="{{profile.wechat}}">复制</text>
|
<text class="field-hint" wx:if="{{profile.wechat}}">复制</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="field-empty" wx:if="{{!profile.industry && !profile.position && !profile.phone && !profile.wechat && !profile.skills}}">
|
<view class="field-empty" wx:if="{{!profile.industry && !profile.position && !profile.businessScale && !profile.skills && !profile.phone && !profile.wechat}}">
|
||||||
点击右上角 ⋯ 编辑完善资料
|
点击右上角 ⋯ 编辑完善资料
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -65,9 +65,6 @@ Page({
|
|||||||
showPosterModal: false,
|
showPosterModal: false,
|
||||||
isPaying: false,
|
isPaying: false,
|
||||||
isGeneratingPoster: false,
|
isGeneratingPoster: false,
|
||||||
|
|
||||||
// 免费章节
|
|
||||||
freeIds: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
|
|
||||||
|
|
||||||
// 章节 mid(扫码/海报分享用,便于分享 path 带 mid)
|
// 章节 mid(扫码/海报分享用,便于分享 path 带 mid)
|
||||||
sectionMid: null
|
sectionMid: null
|
||||||
@@ -124,12 +121,13 @@ Page({
|
|||||||
try {
|
try {
|
||||||
const config = await accessManager.fetchLatestConfig()
|
const config = await accessManager.fetchLatestConfig()
|
||||||
this.setData({
|
this.setData({
|
||||||
freeIds: config.freeChapters,
|
|
||||||
sectionPrice: config.prices?.section ?? 1,
|
sectionPrice: config.prices?.section ?? 1,
|
||||||
fullBookPrice: config.prices?.fullbook ?? 9.9
|
fullBookPrice: config.prices?.fullbook ?? 9.9
|
||||||
})
|
})
|
||||||
|
|
||||||
const accessState = await accessManager.determineAccessState(id, config.freeChapters)
|
// 统一:先拉章节数据,用 isFree/price===0 判断免费
|
||||||
|
const chapterRes = await app.request({ url: `/api/miniprogram/book/chapter/${id}`, silent: true })
|
||||||
|
const accessState = await accessManager.determineAccessState(id, chapterRes)
|
||||||
const canAccess = accessManager.canAccessFullContent(accessState)
|
const canAccess = accessManager.canAccessFullContent(accessState)
|
||||||
|
|
||||||
this.setData({
|
this.setData({
|
||||||
@@ -139,8 +137,8 @@ Page({
|
|||||||
showPaywall: !canAccess
|
showPaywall: !canAccess
|
||||||
})
|
})
|
||||||
|
|
||||||
// 【标准流程】3. 加载内容
|
// 加载内容(复用已拉取的章节数据,避免二次请求)
|
||||||
await this.loadContent(id, accessState)
|
await this.loadContent(id, accessState, chapterRes)
|
||||||
|
|
||||||
// 【标准流程】4. 如果有权限,初始化阅读追踪
|
// 【标准流程】4. 如果有权限,初始化阅读追踪
|
||||||
if (canAccess) {
|
if (canAccess) {
|
||||||
@@ -192,18 +190,22 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成)
|
// 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成)
|
||||||
async loadContent(id, accessState) {
|
// prefetchedChapter:若已有章节数据(含 content)则复用,避免二次请求
|
||||||
|
async loadContent(id, accessState, prefetchedChapter) {
|
||||||
try {
|
try {
|
||||||
const section = this.getSectionInfo(id)
|
|
||||||
const sectionPrice = this.data.sectionPrice ?? 1
|
const sectionPrice = this.data.sectionPrice ?? 1
|
||||||
if (section.price === undefined || section.price === null) {
|
let res = prefetchedChapter
|
||||||
section.price = sectionPrice
|
if (!res || !res.content) {
|
||||||
|
res = await app.request({ url: `/api/miniprogram/book/chapter/${id}`, silent: true })
|
||||||
|
}
|
||||||
|
const section = {
|
||||||
|
id: res.id || id,
|
||||||
|
title: res.sectionTitle || res.title || this.getSectionTitle(id),
|
||||||
|
isFree: res.isFree === true || (res.price !== undefined && res.price === 0),
|
||||||
|
price: res.price ?? sectionPrice
|
||||||
}
|
}
|
||||||
this.setData({ section })
|
this.setData({ section })
|
||||||
|
|
||||||
// 从 API 获取内容
|
|
||||||
const res = await app.request({ url: `/api/miniprogram/book/chapter/${id}`, silent: true })
|
|
||||||
|
|
||||||
if (res && res.content) {
|
if (res && res.content) {
|
||||||
const lines = res.content.split('\n').filter(line => line.trim())
|
const lines = res.content.split('\n').filter(line => line.trim())
|
||||||
const previewCount = Math.ceil(lines.length * 0.2)
|
const previewCount = Math.ceil(lines.length * 0.2)
|
||||||
@@ -584,14 +586,14 @@ Page({
|
|||||||
// 1. 刷新用户购买状态(从 orders 表拉取最新)
|
// 1. 刷新用户购买状态(从 orders 表拉取最新)
|
||||||
await accessManager.refreshUserPurchaseStatus()
|
await accessManager.refreshUserPurchaseStatus()
|
||||||
|
|
||||||
// 2. 重新拉取免费列表(极端情况:刚登录时当前章节可能改免费了)
|
// 2. 重新拉取章节数据,用 isFree/price 判断免费
|
||||||
const config = await accessManager.fetchLatestConfig()
|
const chapterRes = await app.request({
|
||||||
this.setData({ freeIds: config.freeChapters })
|
url: `/api/miniprogram/book/chapter/${this.data.sectionId}`,
|
||||||
|
silent: true
|
||||||
// 3. 重新判断当前章节权限
|
})
|
||||||
const newAccessState = await accessManager.determineAccessState(
|
const newAccessState = await accessManager.determineAccessState(
|
||||||
this.data.sectionId,
|
this.data.sectionId,
|
||||||
config.freeChapters
|
chapterRes
|
||||||
)
|
)
|
||||||
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
||||||
|
|
||||||
@@ -602,9 +604,9 @@ Page({
|
|||||||
showPaywall: !canAccess
|
showPaywall: !canAccess
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. 如果已解锁,重新加载内容并初始化阅读追踪
|
// 3. 如果已解锁,重新加载内容并初始化阅读追踪
|
||||||
if (canAccess) {
|
if (canAccess) {
|
||||||
await this.loadContent(this.data.sectionId, newAccessState)
|
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
|
||||||
readingTracker.init(this.data.sectionId)
|
readingTracker.init(this.data.sectionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,19 +857,22 @@ Page({
|
|||||||
// 2. 刷新用户购买状态
|
// 2. 刷新用户购买状态
|
||||||
await accessManager.refreshUserPurchaseStatus()
|
await accessManager.refreshUserPurchaseStatus()
|
||||||
|
|
||||||
// 3. 重新判断当前章节权限(应为 unlocked_purchased)
|
// 3. 重新拉取章节并判断权限(应为 unlocked_purchased)
|
||||||
|
const chapterRes = await app.request({
|
||||||
|
url: `/api/miniprogram/book/chapter/${this.data.sectionId}`,
|
||||||
|
silent: true
|
||||||
|
})
|
||||||
let newAccessState = await accessManager.determineAccessState(
|
let newAccessState = await accessManager.determineAccessState(
|
||||||
this.data.sectionId,
|
this.data.sectionId,
|
||||||
this.data.freeIds
|
chapterRes
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果权限未生效,再重试一次(可能回调延迟)
|
|
||||||
if (newAccessState !== 'unlocked_purchased') {
|
if (newAccessState !== 'unlocked_purchased') {
|
||||||
console.log('[Pay] 权限未生效,1秒后重试...')
|
console.log('[Pay] 权限未生效,1秒后重试...')
|
||||||
await this.sleep(1000)
|
await this.sleep(1000)
|
||||||
newAccessState = await accessManager.determineAccessState(
|
newAccessState = await accessManager.determineAccessState(
|
||||||
this.data.sectionId,
|
this.data.sectionId,
|
||||||
this.data.freeIds
|
chapterRes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,7 +885,7 @@ Page({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 4. 重新加载全文
|
// 4. 重新加载全文
|
||||||
await this.loadContent(this.data.sectionId, newAccessState)
|
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
|
||||||
|
|
||||||
// 5. 初始化阅读追踪
|
// 5. 初始化阅读追踪
|
||||||
if (canAccess) {
|
if (canAccess) {
|
||||||
@@ -1188,14 +1193,20 @@ Page({
|
|||||||
wx.showLoading({ title: '重试中...', mask: true })
|
wx.showLoading({ title: '重试中...', mask: true })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 重新拉取配置
|
|
||||||
const config = await accessManager.fetchLatestConfig()
|
const config = await accessManager.fetchLatestConfig()
|
||||||
this.setData({ freeIds: config.freeChapters })
|
this.setData({
|
||||||
|
sectionPrice: config.prices?.section ?? 1,
|
||||||
|
fullBookPrice: config.prices?.fullbook ?? 9.9
|
||||||
|
})
|
||||||
|
|
||||||
// 重新判断权限
|
// 重新拉取章节,用 isFree/price 判断免费
|
||||||
|
const chapterRes = await app.request({
|
||||||
|
url: `/api/miniprogram/book/chapter/${this.data.sectionId}`,
|
||||||
|
silent: true
|
||||||
|
})
|
||||||
const newAccessState = await accessManager.determineAccessState(
|
const newAccessState = await accessManager.determineAccessState(
|
||||||
this.data.sectionId,
|
this.data.sectionId,
|
||||||
config.freeChapters
|
chapterRes
|
||||||
)
|
)
|
||||||
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
||||||
|
|
||||||
@@ -1205,8 +1216,7 @@ Page({
|
|||||||
showPaywall: !canAccess
|
showPaywall: !canAccess
|
||||||
})
|
})
|
||||||
|
|
||||||
// 重新加载内容
|
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
|
||||||
await this.loadContent(this.data.sectionId, newAccessState)
|
|
||||||
|
|
||||||
// 如果有权限,初始化阅读追踪
|
// 如果有权限,初始化阅读追踪
|
||||||
if (canAccess) {
|
if (canAccess) {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ Page({
|
|||||||
{ title: '链接资源', desc: '深度私域资源池', icon: '🔗' },
|
{ title: '链接资源', desc: '深度私域资源池', icon: '🔗' },
|
||||||
{ title: '专属VIP标识', desc: '金色尊享光圈', icon: '✓' }
|
{ title: '专属VIP标识', desc: '金色尊享光圈', icon: '✓' }
|
||||||
],
|
],
|
||||||
profile: { vipName: '', vipProject: '', vipContact: '', vipAvatar: '', vipBio: '' },
|
|
||||||
purchasing: false
|
purchasing: false
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -49,62 +48,10 @@ Page({
|
|||||||
expireDateStr: expStr,
|
expireDateStr: expStr,
|
||||||
price: d.price || 1980
|
price: d.price || 1980
|
||||||
})
|
})
|
||||||
if (d.isVip) this.loadProfile(userId)
|
|
||||||
}
|
}
|
||||||
} catch (e) { console.log('[VIP] 加载失败', e) }
|
} catch (e) { console.log('[VIP] 加载失败', e) }
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadProfile(userId) {
|
|
||||||
try {
|
|
||||||
const res = await app.request(`/api/miniprogram/vip/profile?userId=${userId}`)
|
|
||||||
if (res?.success) {
|
|
||||||
const p = res.data
|
|
||||||
// 头像若为相对路径则补全
|
|
||||||
if (p.vipAvatar && !p.vipAvatar.startsWith('http')) {
|
|
||||||
p.vipAvatar = app.globalData.baseUrl.replace(/\/$/, '') + (p.vipAvatar.startsWith('/') ? p.vipAvatar : '/' + p.vipAvatar)
|
|
||||||
}
|
|
||||||
this.setData({ profile: p })
|
|
||||||
}
|
|
||||||
} catch (e) { console.log('[VIP] 资料加载失败', e) }
|
|
||||||
},
|
|
||||||
|
|
||||||
async onChooseVipAvatar() {
|
|
||||||
wx.chooseMedia({
|
|
||||||
count: 1,
|
|
||||||
mediaType: ['image'],
|
|
||||||
sourceType: ['album', 'camera'],
|
|
||||||
success: async (res) => {
|
|
||||||
const tempPath = res.tempFiles[0].tempFilePath
|
|
||||||
wx.showLoading({ title: '上传中...', mask: true })
|
|
||||||
try {
|
|
||||||
const uploadRes = await new Promise((resolve, reject) => {
|
|
||||||
wx.uploadFile({
|
|
||||||
url: app.globalData.baseUrl + '/api/miniprogram/upload',
|
|
||||||
filePath: tempPath,
|
|
||||||
name: 'file',
|
|
||||||
formData: { folder: 'avatars' },
|
|
||||||
success: (r) => {
|
|
||||||
try {
|
|
||||||
const d = JSON.parse(r.data)
|
|
||||||
d.success ? resolve(d) : reject(new Error(d.error || '上传失败'))
|
|
||||||
} catch { reject(new Error('解析失败')) }
|
|
||||||
},
|
|
||||||
fail: reject
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const path = uploadRes.url || uploadRes.data?.url || ''
|
|
||||||
const avatarUrl = path.startsWith('http') ? path : (app.globalData.baseUrl.replace(/\/$/, '') + (path.startsWith('/') ? path : '/' + path))
|
|
||||||
this.setData({ 'profile.vipAvatar': avatarUrl })
|
|
||||||
wx.hideLoading()
|
|
||||||
wx.showToast({ title: '头像已更新', icon: 'success' })
|
|
||||||
} catch (e) {
|
|
||||||
wx.hideLoading()
|
|
||||||
wx.showToast({ title: e.message || '上传失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async handlePurchase() {
|
async handlePurchase() {
|
||||||
let userId = app.globalData.userInfo?.id
|
let userId = app.globalData.userInfo?.id
|
||||||
let openId = app.globalData.openId || app.globalData.userInfo?.open_id
|
let openId = app.globalData.openId || app.globalData.userInfo?.open_id
|
||||||
@@ -156,24 +103,6 @@ Page({
|
|||||||
} finally { this.setData({ purchasing: false }) }
|
} finally { this.setData({ purchasing: false }) }
|
||||||
},
|
},
|
||||||
|
|
||||||
onVipNameInput(e) { this.setData({ 'profile.vipName': e.detail.value }) },
|
|
||||||
onVipProjectInput(e) { this.setData({ 'profile.vipProject': e.detail.value }) },
|
|
||||||
onVipContactInput(e) { this.setData({ 'profile.vipContact': e.detail.value }) },
|
|
||||||
onVipBioInput(e) { this.setData({ 'profile.vipBio': e.detail.value }) },
|
|
||||||
|
|
||||||
async saveProfile() {
|
|
||||||
const userId = app.globalData.userInfo?.id
|
|
||||||
if (!userId) return
|
|
||||||
const p = this.data.profile
|
|
||||||
try {
|
|
||||||
const res = await app.request('/api/miniprogram/vip/profile', {
|
|
||||||
method: 'POST', data: { userId, vipName: p.vipName, vipProject: p.vipProject, vipContact: p.vipContact, vipAvatar: p.vipAvatar, vipBio: p.vipBio }
|
|
||||||
})
|
|
||||||
if (res?.success) wx.showToast({ title: '资料已保存', icon: 'success' })
|
|
||||||
else wx.showToast({ title: res?.error || '保存失败', icon: 'none' })
|
|
||||||
} catch (e) { wx.showToast({ title: '保存失败', icon: 'none' }) }
|
|
||||||
},
|
|
||||||
|
|
||||||
goBack() { wx.navigateBack() },
|
goBack() { wx.navigateBack() },
|
||||||
|
|
||||||
onShareAppMessage() {
|
onShareAppMessage() {
|
||||||
|
|||||||
@@ -8,14 +8,10 @@
|
|||||||
<view class="nav-placeholder-r"></view>
|
<view class="nav-placeholder-r"></view>
|
||||||
</view>
|
</view>
|
||||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||||
<!-- 会员宣传区 - 设计稿 premium 卡片 -->
|
<!-- 会员宣传区(已去掉 VIP PREMIUM 标签) -->
|
||||||
<view class="vip-hero {{isVip ? 'vip-hero-active' : ''}}">
|
<view class="vip-hero {{isVip ? 'vip-hero-active' : ''}}">
|
||||||
<text class="vip-hero-tag">VIP PREMIUM</text>
|
<view class="vip-hero-title">加入卡若的</view>
|
||||||
<text class="vip-hero-title">
|
<view class="vip-hero-title gold">创业派对 会员</view>
|
||||||
加入卡若的
|
|
||||||
<text class="gold">创业派对</text>
|
|
||||||
会员
|
|
||||||
</text>
|
|
||||||
<text class="vip-hero-sub" wx:if="{{isVip}}">有效期至 {{expireDateStr}}(剩余{{daysRemaining}}天)</text>
|
<text class="vip-hero-sub" wx:if="{{isVip}}">有效期至 {{expireDateStr}}(剩余{{daysRemaining}}天)</text>
|
||||||
<text class="vip-hero-sub" wx:else>一次加入 尊享终身陪伴与成长</text>
|
<text class="vip-hero-sub" wx:else>一次加入 尊享终身陪伴与成长</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -54,47 +50,6 @@
|
|||||||
{{purchasing ? "处理中..." : "¥" + price + "/年 加入创业派对"}}
|
{{purchasing ? "处理中..." : "¥" + price + "/年 加入创业派对"}}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="bottom-spacer" wx:if="{{!isVip}}"></view>
|
|
||||||
<!-- VIP资料填写(仅VIP可见) -->
|
|
||||||
<view class="profile-card" wx:if="{{isVip}}">
|
|
||||||
<text class="profile-title">会员资料(展示在创业老板排行)</text>
|
|
||||||
<view class="avatar-row">
|
|
||||||
<view class="avatar-label">头像</view>
|
|
||||||
<view class="avatar-slot" bindtap="onChooseVipAvatar">
|
|
||||||
<image wx:if="{{profile.vipAvatar}}" class="avatar-img" src="{{profile.vipAvatar}}" mode="aspectFill"/>
|
|
||||||
<view wx:else class="avatar-placeholder">
|
|
||||||
<text class="avatar-add">+</text>
|
|
||||||
<text class="avatar-hint">上传头像</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="form-group">
|
|
||||||
<text class="form-label">姓名</text>
|
|
||||||
<view class="form-input">
|
|
||||||
<input type="nickname" placeholder="您的真实姓名" placeholder-class="form-placeholder" value="{{profile.vipName}}" bindinput="onVipNameInput" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="form-group">
|
|
||||||
<text class="form-label">项目名称</text>
|
|
||||||
<view class="form-input">
|
|
||||||
<input placeholder="您的项目/公司名称" placeholder-class="form-placeholder" value="{{profile.vipProject}}" bindinput="onVipProjectInput" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="form-group">
|
|
||||||
<text class="form-label">联系方式</text>
|
|
||||||
<view class="form-input">
|
|
||||||
<input placeholder="微信号或手机号" placeholder-class="form-placeholder" value="{{profile.vipContact}}" bindinput="onVipContactInput" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="form-group">
|
|
||||||
<text class="form-label">一句话简介</text>
|
|
||||||
<view class="form-input">
|
|
||||||
<input placeholder="简要描述您的业务" placeholder-class="form-placeholder" value="{{profile.vipBio}}" bindinput="onVipBioInput" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="save-btn-wrap">
|
|
||||||
<button class="save-btn" bindtap="saveProfile">保存资料</button>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="bottom-space"></view>
|
<view class="bottom-space"></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
.vip-hero { margin: 24rpx; padding: 48rpx 32rpx; border-radius: 24rpx; background: linear-gradient(135deg, rgba(0,206,209,0.08), rgba(255,215,0,0.06)); border: 1rpx solid rgba(0,206,209,0.2); }
|
.vip-hero { margin: 24rpx; padding: 48rpx 32rpx; border-radius: 24rpx; background: linear-gradient(135deg, rgba(0,206,209,0.08), rgba(255,215,0,0.06)); border: 1rpx solid rgba(0,206,209,0.2); }
|
||||||
.vip-hero-active { border-color: rgba(255,215,0,0.4); background: linear-gradient(135deg, rgba(255,215,0,0.15), rgba(0,206,209,0.08)); }
|
.vip-hero-active { border-color: rgba(255,215,0,0.4); background: linear-gradient(135deg, rgba(255,215,0,0.15), rgba(0,206,209,0.08)); }
|
||||||
.vip-hero-tag { display: inline-block; background: rgba(0,206,209,0.15); color: #00CED1; font-size: 22rpx; padding: 6rpx 16rpx; border-radius: 16rpx; margin-bottom: 20rpx; }
|
.vip-hero-tag { display: inline-block; background: rgba(0,206,209,0.15); color: #00CED1; font-size: 22rpx; padding: 6rpx 16rpx; border-radius: 16rpx; margin-bottom: 20rpx; }
|
||||||
.vip-hero-title { display: block; font-size: 44rpx; font-weight: bold; color: #fff; margin-top: 12rpx; }
|
.vip-hero-title { display: block; font-size: 44rpx; font-weight: bold; color: #fff; }
|
||||||
.gold { color: #FFD700; }
|
.gold { color: #FFD700; }
|
||||||
.vip-hero-sub { display: block; font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 12rpx; }
|
.vip-hero-sub { display: block; font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 12rpx; }
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,47 @@
|
|||||||
"miniprogram": {
|
"miniprogram": {
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"name": "pages/about/about",
|
"name": "pages/vip/vip",
|
||||||
"pathName": "pages/about/about",
|
"pathName": "pages/vip/vip",
|
||||||
"query": "",
|
"query": "",
|
||||||
"scene": null,
|
"scene": null,
|
||||||
"launchMode": "default"
|
"launchMode": "default"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "pages/member-detail/member-detail",
|
||||||
|
"pathName": "pages/member-detail/member-detail",
|
||||||
|
"query": "id=ogpTW5QBRQNUOm4-zvg8it2XySrI",
|
||||||
|
"launchMode": "default",
|
||||||
|
"scene": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pages/vip/vip",
|
||||||
|
"pathName": "pages/vip/vip",
|
||||||
|
"query": "",
|
||||||
|
"launchMode": "default",
|
||||||
|
"scene": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pages/member-detail/member-detail",
|
||||||
|
"pathName": "pages/member-detail/member-detail",
|
||||||
|
"query": "id=ogpTW5cteGWqwOsRzh_CCIdKWGSE",
|
||||||
|
"launchMode": "default",
|
||||||
|
"scene": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pages/about/about",
|
||||||
|
"pathName": "pages/about/about",
|
||||||
|
"query": "",
|
||||||
|
"launchMode": "default",
|
||||||
|
"scene": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pages/about/about",
|
||||||
|
"pathName": "pages/about/about",
|
||||||
|
"query": "",
|
||||||
|
"launchMode": "default",
|
||||||
|
"scene": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "pages/vip/vip",
|
"name": "pages/vip/vip",
|
||||||
"pathName": "pages/vip/vip",
|
"pathName": "pages/vip/vip",
|
||||||
|
|||||||
@@ -18,45 +18,44 @@ class ChapterAccessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拉取最新配置(免费章节列表、价格等)
|
* 拉取最新配置(价格等,免费以章节数据为准)
|
||||||
*/
|
*/
|
||||||
async fetchLatestConfig() {
|
async fetchLatestConfig() {
|
||||||
try {
|
try {
|
||||||
const res = await app.request({ url: '/api/miniprogram/config', silent: true, timeout: 3000 })
|
const res = await app.request({ url: '/api/miniprogram/config', silent: true, timeout: 3000 })
|
||||||
if (res.success && res.freeChapters) {
|
if (res.success && res.prices) {
|
||||||
return {
|
return {
|
||||||
freeChapters: res.freeChapters,
|
|
||||||
prices: res.prices || { section: 1, fullbook: 9.9 }
|
prices: res.prices || { section: 1, fullbook: 9.9 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[AccessManager] 获取配置失败,使用默认配置:', e)
|
console.warn('[AccessManager] 获取配置失败,使用默认配置:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认配置
|
|
||||||
return {
|
return {
|
||||||
freeChapters: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
|
|
||||||
prices: { section: 1, fullbook: 9.9 }
|
prices: { section: 1, fullbook: 9.9 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断章节是否免费
|
* 判断章节是否免费(统一:章节 isFree 或 price===0)
|
||||||
*/
|
*/
|
||||||
isFreeChapter(sectionId, freeList) {
|
isFreeFromChapterData(chapterData) {
|
||||||
return freeList.includes(sectionId)
|
if (!chapterData) return false
|
||||||
|
if (chapterData.isFree === true) return true
|
||||||
|
if (chapterData.price !== undefined && chapterData.price === 0) return true
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 【核心方法】确定章节权限状态
|
* 【核心方法】确定章节权限状态(统一以章节数据 isFree/price 为准)
|
||||||
* @param {string} sectionId - 章节ID
|
* @param {string} sectionId - 章节ID
|
||||||
* @param {Array} freeList - 免费章节列表
|
* @param {object} chapterData - 章节接口返回 { isFree, price, ... },免费 = isFree 或 price===0
|
||||||
* @returns {Promise<string>} accessState
|
* @returns {Promise<string>} accessState
|
||||||
*/
|
*/
|
||||||
async determineAccessState(sectionId, freeList) {
|
async determineAccessState(sectionId, chapterData) {
|
||||||
try {
|
try {
|
||||||
// 1. 检查是否免费
|
// 1. 检查是否免费(统一:章节 isFree 或 price===0)
|
||||||
if (this.isFreeChapter(sectionId, freeList)) {
|
if (this.isFreeFromChapterData(chapterData)) {
|
||||||
console.log('[AccessManager] 免费章节:', sectionId)
|
console.log('[AccessManager] 免费章节:', sectionId)
|
||||||
return this.accessStates.FREE
|
return this.accessStates.FREE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { UsersPage } from './pages/users/UsersPage'
|
|||||||
import { DistributionPage } from './pages/distribution/DistributionPage'
|
import { DistributionPage } from './pages/distribution/DistributionPage'
|
||||||
import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage'
|
import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage'
|
||||||
import { ContentPage } from './pages/content/ContentPage'
|
import { ContentPage } from './pages/content/ContentPage'
|
||||||
import { ChaptersPage } from './pages/chapters/ChaptersPage'
|
|
||||||
import { ReferralSettingsPage } from './pages/referral-settings/ReferralSettingsPage'
|
import { ReferralSettingsPage } from './pages/referral-settings/ReferralSettingsPage'
|
||||||
|
import { AuthorSettingsPage } from './pages/author-settings/AuthorSettingsPage'
|
||||||
import { SettingsPage } from './pages/settings/SettingsPage'
|
import { SettingsPage } from './pages/settings/SettingsPage'
|
||||||
import { PaymentPage } from './pages/payment/PaymentPage'
|
import { PaymentPage } from './pages/payment/PaymentPage'
|
||||||
import { SitePage } from './pages/site/SitePage'
|
import { SitePage } from './pages/site/SitePage'
|
||||||
@@ -18,6 +18,7 @@ import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
|
|||||||
import { VipRolesPage } from './pages/vip-roles/VipRolesPage'
|
import { VipRolesPage } from './pages/vip-roles/VipRolesPage'
|
||||||
import { MentorsPage } from './pages/mentors/MentorsPage'
|
import { MentorsPage } from './pages/mentors/MentorsPage'
|
||||||
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
|
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
|
||||||
|
import { AdminUsersPage } from './pages/admin-users/AdminUsersPage'
|
||||||
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
|
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
|
||||||
import { NotFoundPage } from './pages/not-found/NotFoundPage'
|
import { NotFoundPage } from './pages/not-found/NotFoundPage'
|
||||||
|
|
||||||
@@ -33,11 +34,12 @@ function App() {
|
|||||||
<Route path="distribution" element={<DistributionPage />} />
|
<Route path="distribution" element={<DistributionPage />} />
|
||||||
<Route path="withdrawals" element={<WithdrawalsPage />} />
|
<Route path="withdrawals" element={<WithdrawalsPage />} />
|
||||||
<Route path="content" element={<ContentPage />} />
|
<Route path="content" element={<ContentPage />} />
|
||||||
<Route path="chapters" element={<ChaptersPage />} />
|
|
||||||
<Route path="referral-settings" element={<ReferralSettingsPage />} />
|
<Route path="referral-settings" element={<ReferralSettingsPage />} />
|
||||||
|
<Route path="author-settings" element={<AuthorSettingsPage />} />
|
||||||
<Route path="vip-roles" element={<VipRolesPage />} />
|
<Route path="vip-roles" element={<VipRolesPage />} />
|
||||||
<Route path="mentors" element={<MentorsPage />} />
|
<Route path="mentors" element={<MentorsPage />} />
|
||||||
<Route path="mentor-consultations" element={<MentorConsultationsPage />} />
|
<Route path="mentor-consultations" element={<MentorConsultationsPage />} />
|
||||||
|
<Route path="admin-users" element={<AdminUsersPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="payment" element={<PaymentPage />} />
|
<Route path="payment" element={<PaymentPage />} />
|
||||||
<Route path="site" element={<SitePage />} />
|
<Route path="site" element={<SitePage />} />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
aria-describedby={undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg',
|
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg',
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -12,21 +12,30 @@ import {
|
|||||||
Crown,
|
Crown,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
User,
|
||||||
|
ShieldCheck,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { get, post } from '@/api/client'
|
import { get, post } from '@/api/client'
|
||||||
import { clearAdminToken } from '@/api/auth'
|
import { clearAdminToken } from '@/api/auth'
|
||||||
|
|
||||||
const menuItems = [
|
// 主菜单(折叠后可见 4-5 项)
|
||||||
|
const primaryMenuItems = [
|
||||||
{ icon: LayoutDashboard, label: '数据概览', href: '/dashboard' },
|
{ icon: LayoutDashboard, label: '数据概览', href: '/dashboard' },
|
||||||
{ icon: BookOpen, label: '内容管理', href: '/content' },
|
{ icon: BookOpen, label: '内容管理', href: '/content' },
|
||||||
{ icon: Users, label: '用户管理', href: '/users' },
|
{ icon: Users, label: '用户管理', href: '/users' },
|
||||||
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
|
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
|
||||||
|
{ icon: User, label: '作者详情', href: '/author-settings' },
|
||||||
|
]
|
||||||
|
// 折叠区「更多」
|
||||||
|
const moreMenuItems = [
|
||||||
|
{ icon: ShieldCheck, label: '管理员', href: '/admin-users' },
|
||||||
{ icon: GraduationCap, label: '导师管理', href: '/mentors' },
|
{ icon: GraduationCap, label: '导师管理', href: '/mentors' },
|
||||||
{ icon: Calendar, label: '导师预约', href: '/mentor-consultations' },
|
{ icon: Calendar, label: '导师预约', href: '/mentor-consultations' },
|
||||||
{ icon: Wallet, label: '交易中心', href: '/distribution' },
|
{ icon: Wallet, label: '交易中心', href: '/distribution' },
|
||||||
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
|
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
|
||||||
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
|
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
|
||||||
{ icon: Settings, label: '系统设置', href: '/settings' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
@@ -34,10 +43,15 @@ export function AdminLayout() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
const [moreExpanded, setMoreExpanded] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
const inMore = moreMenuItems.some((item) => location.pathname === item.href)
|
||||||
|
if (inMore) setMoreExpanded(true)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
@@ -89,8 +103,8 @@ export function AdminLayout() {
|
|||||||
<p className="text-xs text-gray-400 mt-1">Soul创业派对</p>
|
<p className="text-xs text-gray-400 mt-1">Soul创业派对</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 p-4 space-y-1">
|
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||||
{menuItems.map((item) => {
|
{primaryMenuItems.map((item) => {
|
||||||
const isActive = location.pathname === item.href
|
const isActive = location.pathname === item.href
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -102,11 +116,55 @@ export function AdminLayout() {
|
|||||||
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
|
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon className="w-5 h-5" />
|
<item.icon className="w-5 h-5 shrink-0" />
|
||||||
<span className="text-sm">{item.label}</span>
|
<span className="text-sm">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMoreExpanded(!moreExpanded)}
|
||||||
|
className="w-full flex items-center justify-between gap-3 px-4 py-3 text-gray-400 hover:bg-gray-700/50 hover:text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
{moreExpanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||||
|
<span className="text-sm">更多</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{moreExpanded && (
|
||||||
|
<div className="space-y-1 pl-4">
|
||||||
|
{moreMenuItems.map((item) => {
|
||||||
|
const isActive = location.pathname === item.href
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#38bdac]/20 text-[#38bdac] font-medium'
|
||||||
|
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5 shrink-0" />
|
||||||
|
<span className="text-sm">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-4 mt-4 border-t border-gray-700/50">
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||||
|
location.pathname === '/settings'
|
||||||
|
? 'bg-[#38bdac]/20 text-[#38bdac] font-medium'
|
||||||
|
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5 shrink-0" />
|
||||||
|
<span className="text-sm">系统设置</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-gray-700/50 space-y-1">
|
<div className="p-4 border-t border-gray-700/50 space-y-1">
|
||||||
|
|||||||
413
soul-admin/src/pages/admin-users/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
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 { ShieldCheck, Plus, Edit3, Trash2, X, Save, RefreshCw } from 'lucide-react'
|
||||||
|
import { get, post, put, del } from '@/api/client'
|
||||||
|
import { Pagination } from '@/components/ui/Pagination'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListRes {
|
||||||
|
success?: boolean
|
||||||
|
records?: AdminUser[]
|
||||||
|
total?: number
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
totalPages?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminUsersPage() {
|
||||||
|
const [records, setRecords] = useState<AdminUser[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize] = useState(10)
|
||||||
|
const [totalPages, setTotalPages] = useState(0)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const debouncedSearch = useDebounce(searchTerm, 300)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
|
||||||
|
const [formUsername, setFormUsername] = useState('')
|
||||||
|
const [formPassword, setFormPassword] = useState('')
|
||||||
|
const [formName, setFormName] = useState('')
|
||||||
|
const [formRole, setFormRole] = useState<'super_admin' | 'admin'>('admin')
|
||||||
|
const [formStatus, setFormStatus] = useState<'active' | 'disabled'>('active')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
pageSize: String(pageSize),
|
||||||
|
})
|
||||||
|
if (debouncedSearch.trim()) params.set('search', debouncedSearch.trim())
|
||||||
|
const data = await get<ListRes>(`/api/admin/users?${params}`)
|
||||||
|
if (data?.success) {
|
||||||
|
setRecords((data as ListRes).records || [])
|
||||||
|
setTotal((data as ListRes).total ?? 0)
|
||||||
|
setTotalPages((data as ListRes).totalPages ?? 0)
|
||||||
|
} else {
|
||||||
|
setError((data as ListRes).error || '加载失败')
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { status?: number; data?: { error?: string } }
|
||||||
|
setError(err.status === 403 ? '无权限访问' : err?.data?.error || '加载失败')
|
||||||
|
setRecords([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadList()
|
||||||
|
}, [page, pageSize, debouncedSearch])
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingUser(null)
|
||||||
|
setFormUsername('')
|
||||||
|
setFormPassword('')
|
||||||
|
setFormName('')
|
||||||
|
setFormRole('admin')
|
||||||
|
setFormStatus('active')
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (u: AdminUser) => {
|
||||||
|
setEditingUser(u)
|
||||||
|
setFormUsername(u.username)
|
||||||
|
setFormPassword('')
|
||||||
|
setFormName(u.name || '')
|
||||||
|
setFormRole((u.role === 'super_admin' ? 'super_admin' : 'admin') as 'super_admin' | 'admin')
|
||||||
|
setFormStatus((u.status === 'disabled' ? 'disabled' : 'active') as 'active' | 'disabled')
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formUsername.trim()) {
|
||||||
|
setError('用户名不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editingUser && !formPassword) {
|
||||||
|
setError('新建时密码必填,至少 6 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (formPassword && formPassword.length < 6) {
|
||||||
|
setError('密码至少 6 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (editingUser) {
|
||||||
|
const data = await put<{ success?: boolean; error?: string }>('/api/admin/users', {
|
||||||
|
id: editingUser.id,
|
||||||
|
password: formPassword || undefined,
|
||||||
|
name: formName.trim(),
|
||||||
|
role: formRole,
|
||||||
|
status: formStatus,
|
||||||
|
})
|
||||||
|
if (data?.success) {
|
||||||
|
setShowModal(false)
|
||||||
|
loadList()
|
||||||
|
} else {
|
||||||
|
setError(data?.error || '保存失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await post<{ success?: boolean; error?: string }>('/api/admin/users', {
|
||||||
|
username: formUsername.trim(),
|
||||||
|
password: formPassword,
|
||||||
|
name: formName.trim(),
|
||||||
|
role: formRole,
|
||||||
|
})
|
||||||
|
if (data?.success) {
|
||||||
|
setShowModal(false)
|
||||||
|
loadList()
|
||||||
|
} else {
|
||||||
|
setError(data?.error || '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { data?: { error?: string } }
|
||||||
|
setError(err?.data?.error || '保存失败')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('确定删除该管理员?')) return
|
||||||
|
try {
|
||||||
|
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/users?id=${id}`)
|
||||||
|
if (data?.success) loadList()
|
||||||
|
else setError(data?.error || '删除失败')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { data?: { error?: string } }
|
||||||
|
setError(err?.data?.error || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (s: string) => {
|
||||||
|
if (!s) return '-'
|
||||||
|
try {
|
||||||
|
const d = new Date(s)
|
||||||
|
return isNaN(d.getTime()) ? s : d.toLocaleString('zh-CN')
|
||||||
|
} catch {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 w-full">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
管理员用户
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 mt-1">后台登录账号管理,仅超级管理员可操作</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户名/昵称"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-48 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadList}
|
||||||
|
disabled={loading}
|
||||||
|
className="border-gray-600 text-gray-300"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
新增管理员
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm flex justify-between items-center">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</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">用户名</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>
|
||||||
|
{records.map((u) => (
|
||||||
|
<TableRow key={u.id} className="border-gray-700/50">
|
||||||
|
<TableCell className="text-gray-300">{u.id}</TableCell>
|
||||||
|
<TableCell className="text-white font-medium">{u.username}</TableCell>
|
||||||
|
<TableCell className="text-gray-400">{u.name || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
u.role === 'super_admin'
|
||||||
|
? 'border-amber-500/50 text-amber-400'
|
||||||
|
: 'border-gray-600 text-gray-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{u.role === 'super_admin' ? '超级管理员' : '管理员'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
u.status === 'active'
|
||||||
|
? 'border-[#38bdac]/50 text-[#38bdac]'
|
||||||
|
: 'border-gray-500 text-gray-500'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{u.status === 'active' ? '正常' : '已禁用'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500 text-sm">{formatDate(u.createdAt)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(u)}
|
||||||
|
className="text-gray-400 hover:text-[#38bdac]"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(u.id)}
|
||||||
|
className="text-gray-400 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{records.length === 0 && !loading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
|
||||||
|
{error === '无权限访问' ? '仅超级管理员可查看' : '暂无管理员'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="p-4 border-t border-gray-700/50">
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||||
|
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">
|
||||||
|
{editingUser ? '编辑管理员' : '新增管理员'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">用户名</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="登录用户名"
|
||||||
|
value={formUsername}
|
||||||
|
onChange={(e) => setFormUsername(e.target.value)}
|
||||||
|
disabled={!!editingUser}
|
||||||
|
/>
|
||||||
|
{editingUser && (
|
||||||
|
<p className="text-xs text-gray-500">用户名不可修改</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">{editingUser ? '新密码(留空不改)' : '密码'}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder={editingUser ? '留空表示不修改' : '至少 6 位'}
|
||||||
|
value={formPassword}
|
||||||
|
onChange={(e) => setFormPassword(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={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">角色</Label>
|
||||||
|
<select
|
||||||
|
value={formRole}
|
||||||
|
onChange={(e) => setFormRole(e.target.value as 'super_admin' | 'admin')}
|
||||||
|
className="w-full h-10 px-3 rounded-md bg-[#0a1628] border border-gray-700 text-white"
|
||||||
|
>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
<option value="super_admin">超级管理员</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{editingUser && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">状态</Label>
|
||||||
|
<select
|
||||||
|
value={formStatus}
|
||||||
|
onChange={(e) => setFormStatus(e.target.value as 'active' | 'disabled')}
|
||||||
|
className="w-full h-10 px-3 rounded-md bg-[#0a1628] border border-gray-700 text-white"
|
||||||
|
>
|
||||||
|
<option value="active">正常</option>
|
||||||
|
<option value="disabled">禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="border-gray-600 text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
360
soul-admin/src/pages/author-settings/AuthorSettingsPage.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Save, User, Image, Plus, X, Upload } from 'lucide-react'
|
||||||
|
import { get, post, apiUrl } from '@/api/client'
|
||||||
|
import { getAdminToken } from '@/api/auth'
|
||||||
|
|
||||||
|
interface StatItem {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthorConfig {
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
avatarImg: string
|
||||||
|
title: string
|
||||||
|
bio: string
|
||||||
|
stats: StatItem[]
|
||||||
|
highlights: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT: AuthorConfig = {
|
||||||
|
name: '卡若',
|
||||||
|
avatar: 'K',
|
||||||
|
avatarImg: '',
|
||||||
|
title: 'Soul派对房主理人 · 私域运营专家',
|
||||||
|
bio: '每天早上6点到9点,在Soul派对房分享真实的创业故事。专注私域运营与项目变现,用"云阿米巴"模式帮助创业者构建可持续的商业体系。',
|
||||||
|
stats: [
|
||||||
|
{ label: '商业案例', value: '62' },
|
||||||
|
{ label: '连续直播', value: '365天' },
|
||||||
|
{ label: '派对分享', value: '1000+' },
|
||||||
|
],
|
||||||
|
highlights: [
|
||||||
|
'5年私域运营经验',
|
||||||
|
'帮助100+品牌从0到1增长',
|
||||||
|
'连续创业者,擅长商业模式设计',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStats(v: unknown): StatItem[] {
|
||||||
|
if (!Array.isArray(v)) return DEFAULT.stats
|
||||||
|
return v.map((x) => {
|
||||||
|
if (x && typeof x === 'object' && 'label' in x && 'value' in x) {
|
||||||
|
return { label: String(x.label), value: String(x.value) }
|
||||||
|
}
|
||||||
|
return { label: '', value: '' }
|
||||||
|
}).filter((s) => s.label || s.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHighlights(v: unknown): string[] {
|
||||||
|
if (!Array.isArray(v)) return DEFAULT.highlights
|
||||||
|
return v.map((x) => (typeof x === 'string' ? x : String(x ?? ''))).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthorSettingsPage() {
|
||||||
|
const [config, setConfig] = useState<AuthorConfig>(DEFAULT)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
get<{ success?: boolean; data?: Record<string, unknown> }>('/api/admin/author-settings')
|
||||||
|
.then((res) => {
|
||||||
|
const d = (res as { data?: Record<string, unknown> })?.data
|
||||||
|
if (d && typeof d === 'object') {
|
||||||
|
setConfig({
|
||||||
|
name: String(d.name ?? DEFAULT.name),
|
||||||
|
avatar: String(d.avatar ?? DEFAULT.avatar),
|
||||||
|
avatarImg: String(d.avatarImg ?? ''),
|
||||||
|
title: String(d.title ?? DEFAULT.title),
|
||||||
|
bio: String(d.bio ?? DEFAULT.bio),
|
||||||
|
stats: parseStats(d.stats).length ? parseStats(d.stats) : DEFAULT.stats,
|
||||||
|
highlights: parseHighlights(d.highlights).length ? parseHighlights(d.highlights) : DEFAULT.highlights,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: config.name,
|
||||||
|
avatar: config.avatar || 'K',
|
||||||
|
avatarImg: config.avatarImg,
|
||||||
|
title: config.title,
|
||||||
|
bio: config.bio,
|
||||||
|
stats: config.stats.filter((s) => s.label || s.value),
|
||||||
|
highlights: config.highlights.filter(Boolean),
|
||||||
|
}
|
||||||
|
const res = await post<{ success?: boolean; error?: string }>('/api/admin/author-settings', body)
|
||||||
|
if (!res || (res as { success?: boolean }).success === false) {
|
||||||
|
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
// 轻量反馈,不阻塞
|
||||||
|
const msg = document.createElement('div')
|
||||||
|
msg.className = 'fixed top-4 right-4 z-50 px-4 py-2 rounded-lg bg-[#38bdac] text-white text-sm shadow-lg'
|
||||||
|
msg.textContent = '作者设置已保存'
|
||||||
|
document.body.appendChild(msg)
|
||||||
|
setTimeout(() => msg.remove(), 2000)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploadingAvatar(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('folder', 'avatars')
|
||||||
|
const token = getAdminToken()
|
||||||
|
const headers: HeadersInit = {}
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
const res = await fetch(apiUrl('/api/upload'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.success && data?.url) {
|
||||||
|
setConfig((prev) => ({ ...prev, avatarImg: data.url }))
|
||||||
|
} else {
|
||||||
|
alert('上传失败: ' + (data?.error || '未知错误'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert('上传失败')
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false)
|
||||||
|
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addStat = () => setConfig((prev) => ({ ...prev, stats: [...prev.stats, { label: '', value: '' }] }))
|
||||||
|
const removeStat = (i: number) =>
|
||||||
|
setConfig((prev) => ({ ...prev, stats: prev.stats.filter((_, idx) => idx !== i) }))
|
||||||
|
const updateStat = (i: number, field: 'label' | 'value', val: string) =>
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
stats: prev.stats.map((s, idx) => (idx === i ? { ...s, [field]: val } : s)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const addHighlight = () => setConfig((prev) => ({ ...prev, highlights: [...prev.highlights, ''] }))
|
||||||
|
const removeHighlight = (i: number) =>
|
||||||
|
setConfig((prev) => ({ ...prev, highlights: prev.highlights.filter((_, idx) => idx !== i) }))
|
||||||
|
const updateHighlight = (i: number, val: string) =>
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
highlights: prev.highlights.map((h, idx) => (idx === i ? val : h)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
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>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||||
|
<User className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
作者详情
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
配置小程序「关于作者」页展示的作者信息,包括头像、简介、统计数据与亮点标签。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || loading}
|
||||||
|
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-white">
|
||||||
|
<User className="w-4 h-4 text-[#38bdac]" />
|
||||||
|
基本信息
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">
|
||||||
|
作者姓名、头像、头衔与个人简介,将展示在「关于作者」页顶部。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<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"
|
||||||
|
value={config.name}
|
||||||
|
onChange={(e) => setConfig((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="卡若"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">首字母占位(无头像时显示)</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white w-20"
|
||||||
|
value={config.avatar}
|
||||||
|
onChange={(e) => setConfig((prev) => ({ ...prev, avatar: e.target.value.slice(0, 1) || 'K' }))}
|
||||||
|
placeholder="K"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300 flex items-center gap-2">
|
||||||
|
<Image className="w-3 h-3 text-[#38bdac]" />
|
||||||
|
头像图片
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={config.avatarImg}
|
||||||
|
onChange={(e) => setConfig((prev) => ({ ...prev, avatarImg: e.target.value }))}
|
||||||
|
placeholder="上传或粘贴 URL,如 /uploads/avatars/xxx.png"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-gray-600 text-gray-400 shrink-0"
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{uploadingAvatar ? '上传中...' : '上传'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{config.avatarImg && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<img
|
||||||
|
src={config.avatarImg.startsWith('http') ? config.avatarImg : apiUrl(config.avatarImg)}
|
||||||
|
alt="头像预览"
|
||||||
|
className="w-20 h-20 rounded-full object-cover border border-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">头衔</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={config.title}
|
||||||
|
onChange={(e) => setConfig((prev) => ({ ...prev, title: e.target.value }))}
|
||||||
|
placeholder="Soul派对房主理人 · 私域运营专家"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">个人简介</Label>
|
||||||
|
<Textarea
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white min-h-[120px]"
|
||||||
|
value={config.bio}
|
||||||
|
onChange={(e) => setConfig((prev) => ({ ...prev, bio: e.target.value }))}
|
||||||
|
placeholder="每天早上6点到9点..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">统计数据</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">
|
||||||
|
展示在作者卡片中的数字指标,如「商业案例 62」「连续直播 365天」。第一个「商业案例」的值可由书籍统计自动更新。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{config.stats.map((s, i) => (
|
||||||
|
<div key={i} className="flex gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={s.label}
|
||||||
|
onChange={(e) => updateStat(i, 'label', e.target.value)}
|
||||||
|
placeholder="标签"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={s.value}
|
||||||
|
onChange={(e) => updateStat(i, 'value', e.target.value)}
|
||||||
|
placeholder="数值"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-gray-400 hover:text-red-400"
|
||||||
|
onClick={() => removeStat(i)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" onClick={addStat} className="border-gray-600 text-gray-400">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
添加统计项
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">亮点标签</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">
|
||||||
|
作者优势或成就的简短描述,以标签形式展示。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{config.highlights.map((h, i) => (
|
||||||
|
<div key={i} className="flex gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={h}
|
||||||
|
onChange={(e) => updateHighlight(i, e.target.value)}
|
||||||
|
placeholder="5年私域运营经验"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-gray-400 hover:text-red-400"
|
||||||
|
onClick={() => removeHighlight(i)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" onClick={addHighlight} className="border-gray-600 text-gray-400">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
添加亮点
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { get, post } from '@/api/client'
|
|
||||||
|
|
||||||
interface Section {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
price: number
|
|
||||||
isFree: boolean
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Chapter {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
sections?: Section[]
|
|
||||||
price?: number
|
|
||||||
isFree?: boolean
|
|
||||||
status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Part {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
type: string
|
|
||||||
chapters: Chapter[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Stats {
|
|
||||||
totalSections: number
|
|
||||||
freeSections: number
|
|
||||||
paidSections: number
|
|
||||||
totalParts: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChaptersPage() {
|
|
||||||
const [structure, setStructure] = useState<Part[]>([])
|
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [expandedParts, setExpandedParts] = useState<string[]>([])
|
|
||||||
const [editingSection, setEditingSection] = useState<string | null>(null)
|
|
||||||
const [editPrice, setEditPrice] = useState<number>(1)
|
|
||||||
|
|
||||||
async function loadChapters() {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const data = await get<{ success?: boolean; data?: { structure?: Part[]; stats?: Stats } }>(
|
|
||||||
'/api/admin/chapters',
|
|
||||||
)
|
|
||||||
if (data?.success && data.data) {
|
|
||||||
setStructure(data.data.structure ?? [])
|
|
||||||
setStats(data.data.stats ?? null)
|
|
||||||
} else {
|
|
||||||
setError('加载章节失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载章节失败:', e)
|
|
||||||
setError('加载失败,请检查网络后重试')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadChapters()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const togglePart = (partId: string) => {
|
|
||||||
setExpandedParts((prev) =>
|
|
||||||
prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdatePrice = async (sectionId: string) => {
|
|
||||||
try {
|
|
||||||
const result = await post<{ success?: boolean }>('/api/admin/chapters', {
|
|
||||||
action: 'updatePrice',
|
|
||||||
chapterId: sectionId,
|
|
||||||
data: { price: editPrice },
|
|
||||||
})
|
|
||||||
if (result?.success) {
|
|
||||||
alert('价格更新成功')
|
|
||||||
setEditingSection(null)
|
|
||||||
loadChapters()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('更新价格失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleFree = async (sectionId: string, currentFree: boolean) => {
|
|
||||||
try {
|
|
||||||
const result = await post<{ success?: boolean }>('/api/admin/chapters', {
|
|
||||||
action: 'toggleFree',
|
|
||||||
chapterId: sectionId,
|
|
||||||
data: { isFree: !currentFree },
|
|
||||||
})
|
|
||||||
if (result?.success) {
|
|
||||||
alert('状态更新成功')
|
|
||||||
loadChapters()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('更新状态失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-[60vh] flex items-center justify-center">
|
|
||||||
<div className="text-xl text-gray-400">加载中...</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-black text-white">
|
|
||||||
{/* 导航栏 */}
|
|
||||||
<div className="sticky top-0 bg-black/90 backdrop-blur border-b border-white/10 z-50">
|
|
||||||
<div className="w-full min-w-[1024px] px-4 py-4 flex items-center justify-between">
|
|
||||||
<h1 className="text-xl font-bold">章节管理</h1>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={loadChapters}
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white disabled:opacity-50"
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpandedParts(structure.map((p) => p.id))}
|
|
||||||
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white"
|
|
||||||
>
|
|
||||||
展开全部
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpandedParts([])}
|
|
||||||
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white"
|
|
||||||
>
|
|
||||||
收起全部
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full min-w-[1024px] px-4 py-8">
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
{/* 统计卡片 */}
|
|
||||||
{stats && (
|
|
||||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
|
||||||
<div className="bg-gradient-to-br from-cyan-500/20 to-cyan-500/5 border border-cyan-500/30 rounded-xl p-4">
|
|
||||||
<div className="text-3xl font-bold text-cyan-400">{stats.totalSections}</div>
|
|
||||||
<div className="text-white/60 text-sm mt-1">总章节数</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gradient-to-br from-green-500/20 to-green-500/5 border border-green-500/30 rounded-xl p-4">
|
|
||||||
<div className="text-3xl font-bold text-green-400">{stats.freeSections}</div>
|
|
||||||
<div className="text-white/60 text-sm mt-1">免费章节</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gradient-to-br from-yellow-500/20 to-yellow-500/5 border border-yellow-500/30 rounded-xl p-4">
|
|
||||||
<div className="text-3xl font-bold text-yellow-400">{stats.paidSections}</div>
|
|
||||||
<div className="text-white/60 text-sm mt-1">付费章节</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/30 rounded-xl p-4">
|
|
||||||
<div className="text-3xl font-bold text-purple-400">{stats.totalParts}</div>
|
|
||||||
<div className="text-white/60 text-sm mt-1">篇章数</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 章节列表 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{structure.map((part) => (
|
|
||||||
<div
|
|
||||||
key={part.id}
|
|
||||||
className="bg-white/5 border border-white/10 rounded-xl overflow-hidden"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5"
|
|
||||||
onClick={() => togglePart(part.id)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && togglePart(part.id)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">
|
|
||||||
{part.type === 'preface'
|
|
||||||
? '📖'
|
|
||||||
: part.type === 'epilogue'
|
|
||||||
? '🎬'
|
|
||||||
: part.type === 'appendix'
|
|
||||||
? '📎'
|
|
||||||
: '📚'}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-white">{part.title}</span>
|
|
||||||
<span className="text-white/40 text-sm">
|
|
||||||
({part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} 节)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-white/40">{expandedParts.includes(part.id) ? '▲' : '▼'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expandedParts.includes(part.id) && (
|
|
||||||
<div className="border-t border-white/10">
|
|
||||||
{part.chapters.map((chapter) => (
|
|
||||||
<div
|
|
||||||
key={chapter.id}
|
|
||||||
className="border-b border-white/5 last:border-b-0"
|
|
||||||
>
|
|
||||||
{chapter.sections ? (
|
|
||||||
<>
|
|
||||||
<div className="px-6 py-3 bg-white/5 text-white/70 font-medium">
|
|
||||||
{chapter.title}
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-white/5">
|
|
||||||
{chapter.sections.map((section) => (
|
|
||||||
<div
|
|
||||||
key={section.id}
|
|
||||||
className="flex items-center justify-between px-6 py-3 hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
section.isFree ? 'text-green-400' : 'text-yellow-400'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{section.isFree ? '🔓' : '🔒'}
|
|
||||||
</span>
|
|
||||||
<span className="text-white/80">{section.id}</span>
|
|
||||||
<span className="text-white/60">{section.title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{editingSection === section.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editPrice}
|
|
||||||
onChange={(e) => setEditPrice(Number(e.target.value))}
|
|
||||||
className="w-20 px-2 py-1 bg-white/10 border border-white/20 rounded text-white"
|
|
||||||
min={0}
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleUpdatePrice(section.id)}
|
|
||||||
className="px-3 py-1 bg-cyan-500 text-black rounded text-sm"
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingSection(null)}
|
|
||||||
className="px-3 py-1 bg-white/20 rounded text-sm text-white"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs ${
|
|
||||||
section.isFree
|
|
||||||
? 'bg-green-500/20 text-green-400'
|
|
||||||
: 'bg-yellow-500/20 text-yellow-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{section.isFree ? '免费' : `¥${section.price}`}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingSection(section.id)
|
|
||||||
setEditPrice(section.price)
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20 text-white"
|
|
||||||
>
|
|
||||||
编辑价格
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
handleToggleFree(section.id, section.isFree)
|
|
||||||
}
|
|
||||||
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20 text-white"
|
|
||||||
>
|
|
||||||
{section.isFree ? '设为付费' : '设为免费'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-between px-6 py-3 hover:bg-white/5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
chapter.isFree ? 'text-green-400' : 'text-yellow-400'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{chapter.isFree ? '🔓' : '🔒'}
|
|
||||||
</span>
|
|
||||||
<span className="text-white/80">{chapter.title}</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs ${
|
|
||||||
chapter.isFree
|
|
||||||
? 'bg-green-500/20 text-green-400'
|
|
||||||
: 'bg-yellow-500/20 text-yellow-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{chapter.isFree ? '免费' : `¥${chapter.price ?? 1}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
534
soul-admin/src/pages/content/ChapterTree.tsx
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
/**
|
||||||
|
* 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序
|
||||||
|
* 整行可拖拽;节和章可跨篇
|
||||||
|
*/
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { ChevronRight, ChevronDown, BookOpen, Eye, Edit3, Trash2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const PART_LABELS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||||
|
|
||||||
|
// 固定模块:序言首位、附录/尾声末位,不可拖拽
|
||||||
|
const FIXED_PART_KEYS = ['序言', '尾声', '附录']
|
||||||
|
function isFixedPart(title: string): boolean {
|
||||||
|
if (!title) return false
|
||||||
|
return FIXED_PART_KEYS.some((k) => title.includes(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
price: number
|
||||||
|
isFree?: boolean
|
||||||
|
isNew?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
sections: SectionItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
chapters: ChapterItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type DragType = 'part' | 'chapter' | 'section'
|
||||||
|
|
||||||
|
function parseDragData(data: string): { type: DragType; id: string } | null {
|
||||||
|
if (data.startsWith('part:')) return { type: 'part', id: data.slice(5) }
|
||||||
|
if (data.startsWith('chapter:')) return { type: 'chapter', id: data.slice(8) }
|
||||||
|
if (data.startsWith('section:')) return { type: 'section', id: data.slice(8) }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChapterTreeProps {
|
||||||
|
parts: PartItem[]
|
||||||
|
expandedParts: string[]
|
||||||
|
onTogglePart: (partId: string) => void
|
||||||
|
onReorder: (items: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[]) => Promise<void>
|
||||||
|
onReadSection: (s: SectionItem) => void
|
||||||
|
onDeleteSection: (s: SectionItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChapterTree({
|
||||||
|
parts,
|
||||||
|
expandedParts,
|
||||||
|
onTogglePart,
|
||||||
|
onReorder,
|
||||||
|
onReadSection,
|
||||||
|
onDeleteSection,
|
||||||
|
}: ChapterTreeProps) {
|
||||||
|
const buildSectionsList = useCallback(
|
||||||
|
(): { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[] => {
|
||||||
|
const list: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[] = []
|
||||||
|
for (const part of parts) {
|
||||||
|
for (const ch of part.chapters) {
|
||||||
|
for (const s of ch.sections) {
|
||||||
|
list.push({
|
||||||
|
id: s.id,
|
||||||
|
partId: part.id,
|
||||||
|
partTitle: part.title,
|
||||||
|
chapterId: ch.id,
|
||||||
|
chapterTitle: ch.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
},
|
||||||
|
[parts],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
async (e: React.DragEvent, toType: DragType, toId: string, toContext?: { partId: string; partTitle: string; chapterId: string; chapterTitle: string }) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const data = e.dataTransfer.getData('text/plain')
|
||||||
|
const from = parseDragData(data)
|
||||||
|
if (!from) return
|
||||||
|
if (from.type === toType && from.id === toId) return
|
||||||
|
|
||||||
|
const sections = buildSectionsList()
|
||||||
|
const sectionMap = new Map(sections.map((x) => [x.id, x]))
|
||||||
|
|
||||||
|
// 固定模块(序言、附录、尾声)不可拖拽,也不可作为落点
|
||||||
|
if (from.type === 'section') {
|
||||||
|
const sec = sectionMap.get(from.id)
|
||||||
|
if (sec && isFixedPart(sec.partTitle)) return
|
||||||
|
} else {
|
||||||
|
const part = from.type === 'part' ? parts.find((p) => p.id === from.id) : parts.find((p) => p.chapters.some((c) => c.id === from.id))
|
||||||
|
if (part && isFixedPart(part.title)) return
|
||||||
|
}
|
||||||
|
if (toContext && isFixedPart(toContext.partTitle)) return
|
||||||
|
if (toType === 'part') {
|
||||||
|
const toPart = parts.find((p) => p.id === toId)
|
||||||
|
if (toPart && isFixedPart(toPart.title)) return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.type === 'part' && toType === 'part') {
|
||||||
|
const partOrder = parts.map((p) => p.id)
|
||||||
|
const fromIdx = partOrder.indexOf(from.id)
|
||||||
|
const toIdx = partOrder.indexOf(toId)
|
||||||
|
if (fromIdx === -1 || toIdx === -1) return
|
||||||
|
const next = [...partOrder]
|
||||||
|
next.splice(fromIdx, 1)
|
||||||
|
next.splice(fromIdx < toIdx ? toIdx - 1 : toIdx, 0, from.id)
|
||||||
|
const newList: typeof sections = []
|
||||||
|
for (const pid of next) {
|
||||||
|
const p = parts.find((x) => x.id === pid)
|
||||||
|
if (!p) continue
|
||||||
|
for (const ch of p.chapters) {
|
||||||
|
for (const s of ch.sections) {
|
||||||
|
const ctx = sectionMap.get(s.id)
|
||||||
|
if (ctx) newList.push(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await onReorder(newList)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.type === 'chapter' && (toType === 'chapter' || toType === 'section' || toType === 'part')) {
|
||||||
|
const srcPart = parts.find((p) => p.chapters.some((c) => c.id === from.id))
|
||||||
|
const srcCh = srcPart?.chapters.find((c) => c.id === from.id)
|
||||||
|
if (!srcPart || !srcCh) return
|
||||||
|
|
||||||
|
let targetPartId: string
|
||||||
|
let targetPartTitle: string
|
||||||
|
let insertAfterId: string | null = null
|
||||||
|
|
||||||
|
if (toType === 'section') {
|
||||||
|
const ctx = sectionMap.get(toId)
|
||||||
|
if (!ctx) return
|
||||||
|
targetPartId = ctx.partId
|
||||||
|
targetPartTitle = ctx.partTitle
|
||||||
|
insertAfterId = toId
|
||||||
|
} else if (toType === 'chapter') {
|
||||||
|
const part = parts.find((p) => p.chapters.some((c) => c.id === toId))
|
||||||
|
const ch = part?.chapters.find((c) => c.id === toId)
|
||||||
|
if (!part || !ch) return
|
||||||
|
targetPartId = part.id
|
||||||
|
targetPartTitle = part.title
|
||||||
|
const lastInCh = sections.filter((s) => s.chapterId === toId).pop()
|
||||||
|
insertAfterId = lastInCh?.id ?? null
|
||||||
|
} else {
|
||||||
|
const part = parts.find((p) => p.id === toId)
|
||||||
|
if (!part || !part.chapters[0]) return
|
||||||
|
targetPartId = part.id
|
||||||
|
targetPartTitle = part.title
|
||||||
|
const firstChSections = sections.filter((s) => s.partId === part.id && s.chapterId === part.chapters[0].id)
|
||||||
|
insertAfterId = firstChSections[firstChSections.length - 1]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const movingIds = srcCh.sections.map((s) => s.id)
|
||||||
|
const rest = sections.filter((s) => !movingIds.includes(s.id))
|
||||||
|
|
||||||
|
let targetIdx = rest.length
|
||||||
|
if (insertAfterId) {
|
||||||
|
const idx = rest.findIndex((s) => s.id === insertAfterId)
|
||||||
|
if (idx >= 0) targetIdx = idx + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const moving = movingIds.map((id) => {
|
||||||
|
const s = sectionMap.get(id)!
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
partId: targetPartId,
|
||||||
|
partTitle: targetPartTitle,
|
||||||
|
chapterId: srcCh.id,
|
||||||
|
chapterTitle: srcCh.title,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await onReorder([...rest.slice(0, targetIdx), ...moving, ...rest.slice(targetIdx)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.type === 'section' && (toType === 'section' || toType === 'chapter' || toType === 'part')) {
|
||||||
|
if (!toContext) return
|
||||||
|
const { partId: targetPartId, partTitle: targetPartTitle, chapterId: targetChapterId, chapterTitle: targetChapterTitle } = toContext
|
||||||
|
|
||||||
|
let toIdx: number
|
||||||
|
if (toType === 'section') {
|
||||||
|
toIdx = sections.findIndex((s) => s.id === toId)
|
||||||
|
} else if (toType === 'chapter') {
|
||||||
|
const lastInCh = sections.filter((s) => s.chapterId === toId).pop()
|
||||||
|
toIdx = lastInCh ? sections.findIndex((s) => s.id === lastInCh.id) + 1 : sections.length
|
||||||
|
} else {
|
||||||
|
const part = parts.find((p) => p.id === toId)
|
||||||
|
if (!part?.chapters[0]) return
|
||||||
|
const firstChSections = sections.filter((s) => s.partId === part.id && s.chapterId === part.chapters[0].id)
|
||||||
|
const last = firstChSections[firstChSections.length - 1]
|
||||||
|
toIdx = last ? sections.findIndex((s) => s.id === last.id) + 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromIdx = sections.findIndex((s) => s.id === from.id)
|
||||||
|
if (fromIdx === -1) return
|
||||||
|
|
||||||
|
const next = sections.filter((s) => s.id !== from.id)
|
||||||
|
const insertIdx = fromIdx < toIdx ? toIdx - 1 : toIdx
|
||||||
|
const moved = sections[fromIdx]
|
||||||
|
const newItem = { ...moved, partId: targetPartId, partTitle: targetPartTitle, chapterId: targetChapterId, chapterTitle: targetChapterTitle }
|
||||||
|
next.splice(insertIdx, 0, newItem)
|
||||||
|
await onReorder(next)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[parts, buildSectionsList, onReorder],
|
||||||
|
)
|
||||||
|
|
||||||
|
const droppableHandlers = (type: DragType, id: string, ctx?: { partId: string; partTitle: string; chapterId: string; chapterTitle: string }) => ({
|
||||||
|
onDragEnter: (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
},
|
||||||
|
onDragOver: (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
},
|
||||||
|
onDrop: (e: React.DragEvent) => {
|
||||||
|
const from = parseDragData(e.dataTransfer.getData('text/plain'))
|
||||||
|
if (!from) return
|
||||||
|
if (type === 'section' && from.type === 'section' && from.id === id) return
|
||||||
|
if (type === 'part') {
|
||||||
|
if (from.type === 'part') handleDrop(e, 'part', id)
|
||||||
|
else {
|
||||||
|
const part = parts.find((p) => p.id === id)
|
||||||
|
const firstCh = part?.chapters[0]
|
||||||
|
if (firstCh && ctx) handleDrop(e, 'part', id, ctx)
|
||||||
|
}
|
||||||
|
} else if (type === 'chapter' && ctx) {
|
||||||
|
if (from.type === 'section' || from.type === 'chapter') handleDrop(e, 'chapter', id, ctx)
|
||||||
|
} else if (type === 'section' && ctx) {
|
||||||
|
handleDrop(e, 'section', id, ctx)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const partLabel = (i: number) => PART_LABELS[i] ?? String(i + 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{parts.map((part, partIndex) => {
|
||||||
|
const isXuYan = part.title === '序言' || part.title.includes('序言')
|
||||||
|
const isWeiSheng = part.title === '尾声' || part.title.includes('尾声')
|
||||||
|
const isFuLu = part.title === '附录' || part.title.includes('附录')
|
||||||
|
const isExpanded = expandedParts.includes(part.id)
|
||||||
|
const chapterCount = part.chapters.length
|
||||||
|
const sectionCount = part.chapters.reduce((s, ch) => s + ch.sections.length, 0)
|
||||||
|
|
||||||
|
// 序言:单行卡片样式,固定首位不可拖拽
|
||||||
|
if (isXuYan && part.chapters.length === 1 && part.chapters[0].sections.length === 1) {
|
||||||
|
const sec = part.chapters[0].sections[0]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={part.id}
|
||||||
|
className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0 select-none">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gray-600/50 flex items-center justify-center shrink-0">
|
||||||
|
<BookOpen className="w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-gray-200 truncate">
|
||||||
|
{part.chapters[0].title} | {sec.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 shrink-0"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{sec.price === 0 || sec.isFree ? (
|
||||||
|
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded">免费</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-500">¥{sec.price}</span>
|
||||||
|
)}
|
||||||
|
<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" />
|
||||||
|
</Button>
|
||||||
|
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附录:平铺章节列表,固定末位不可拖拽
|
||||||
|
if (isFuLu) {
|
||||||
|
return (
|
||||||
|
<div key={part.id} className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-5">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-4">附录</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{part.chapters.map((ch, chIdx) => (
|
||||||
|
<div
|
||||||
|
key={ch.id}
|
||||||
|
className="flex justify-between items-center py-2 select-none hover:bg-[#162840]/50 rounded px-2 -mx-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-300">附录{chIdx + 1} | {ch.title}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尾声:固定末位不可拖拽
|
||||||
|
if (isWeiSheng && part.chapters.length === 1 && part.chapters[0].sections.length === 1) {
|
||||||
|
const sec = part.chapters[0].sections[0]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={part.id}
|
||||||
|
className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0 select-none">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gray-600/50 flex items-center justify-center shrink-0">
|
||||||
|
<BookOpen className="w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-gray-200 truncate">
|
||||||
|
{part.chapters[0].title} | {sec.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 shrink-0"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{sec.price === 0 || sec.isFree ? (
|
||||||
|
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded">免费</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-500">¥{sec.price}</span>
|
||||||
|
)}
|
||||||
|
<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" />
|
||||||
|
</Button>
|
||||||
|
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isWeiSheng) {
|
||||||
|
// 尾声多章节:平铺展示,不可拖拽
|
||||||
|
return (
|
||||||
|
<div key={part.id} className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-5">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-4">尾声</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{part.chapters.map((ch) =>
|
||||||
|
ch.sections.map((sec) => (
|
||||||
|
<div key={sec.id} className="flex justify-between items-center py-2 select-none hover:bg-[#162840]/50 rounded px-2 -mx-2">
|
||||||
|
<span className="text-sm text-gray-300">{ch.title} | {sec.title}</span>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<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" />
|
||||||
|
</Button>
|
||||||
|
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通篇:卡片 + 章/节
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={part.id}
|
||||||
|
className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] overflow-hidden"
|
||||||
|
{...droppableHandlers('part', part.id, {
|
||||||
|
partId: part.id,
|
||||||
|
partTitle: part.title,
|
||||||
|
chapterId: part.chapters[0]?.id ?? '',
|
||||||
|
chapterTitle: part.chapters[0]?.title ?? '',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.setData('text/plain', 'part:' + part.id)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between p-4 cursor-grab active:cursor-grabbing select-none hover:bg-[#162840]/50 transition-colors"
|
||||||
|
onClick={() => onTogglePart(part.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[#38bdac] flex items-center justify-center text-white font-bold shadow-lg shadow-[#38bdac]/30 shrink-0">
|
||||||
|
{partLabel(partIndex)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white text-base">{part.title}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">共 {sectionCount} 节</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-xs text-gray-500">{chapterCount}章</span>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-5 h-5 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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) => (
|
||||||
|
<div key={chapter.id} className="space-y-2">
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.setData('text/plain', 'chapter:' + chapter.id)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}}
|
||||||
|
className="py-2 px-2 rounded cursor-grab active:cursor-grabbing select-none hover:bg-[#162840]/30 -mx-2"
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
const from = parseDragData(e.dataTransfer.getData('text/plain'))
|
||||||
|
if (!from) return
|
||||||
|
const ctx = { partId: part.id, partTitle: part.title, chapterId: chapter.id, chapterTitle: chapter.title }
|
||||||
|
if (from.type === 'section') handleDrop(e, 'chapter', chapter.id, ctx)
|
||||||
|
else if (from.type === 'chapter') handleDrop(e, 'chapter', chapter.id, ctx)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-gray-500 pb-1">第{chIndex + 1}章 | {chapter.title}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 pl-2">
|
||||||
|
{chapter.sections.map((section) => (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.setData('text/plain', 'section:' + section.id)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[#162840]/50 group cursor-grab active:cursor-grabbing select-none min-h-[40px]"
|
||||||
|
{...droppableHandlers('section', section.id, {
|
||||||
|
partId: part.id,
|
||||||
|
partTitle: part.title,
|
||||||
|
chapterId: chapter.id,
|
||||||
|
chapterTitle: chapter.title,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full shrink-0 ${section.price === 0 || section.isFree ? 'border-2 border-[#38bdac] bg-transparent' : 'bg-gray-500'}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-200 truncate">
|
||||||
|
{section.id} {section.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 shrink-0"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{section.isNew && (
|
||||||
|
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded">NEW</span>
|
||||||
|
)}
|
||||||
|
{section.price === 0 || section.isFree ? (
|
||||||
|
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded">免费</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-500">¥{section.price}</span>
|
||||||
|
)}
|
||||||
|
<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" />
|
||||||
|
</Button>
|
||||||
|
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(section)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -28,8 +28,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Settings2,
|
Settings2,
|
||||||
ChevronRight,
|
|
||||||
CheckCircle,
|
|
||||||
Edit3,
|
Edit3,
|
||||||
Save,
|
Save,
|
||||||
X,
|
X,
|
||||||
@@ -39,9 +37,9 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { get, put, del } from '@/api/client'
|
import { get, put, del } from '@/api/client'
|
||||||
|
import { ChapterTree } from './ChapterTree'
|
||||||
import { apiUrl } from '@/api/client'
|
import { apiUrl } from '@/api/client'
|
||||||
|
|
||||||
interface SectionListItem {
|
interface SectionListItem {
|
||||||
@@ -114,10 +112,23 @@ function buildTree(sections: SectionListItem[]): Part[] {
|
|||||||
isNew: s.isNew,
|
isNew: s.isNew,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Array.from(partMap.values()).map((p) => ({
|
const parts = Array.from(partMap.values()).map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
chapters: Array.from(p.chapters.values()),
|
chapters: Array.from(p.chapters.values()),
|
||||||
}))
|
}))
|
||||||
|
// 固定顺序:序言首位,附录/尾声末位
|
||||||
|
const orderKey = (t: string) => {
|
||||||
|
if (t.includes('序言')) return 0
|
||||||
|
if (t.includes('附录')) return 2
|
||||||
|
if (t.includes('尾声')) return 3
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return parts.sort((a, b) => {
|
||||||
|
const ka = orderKey(a.title)
|
||||||
|
const kb = orderKey(b.title)
|
||||||
|
if (ka !== kb) return ka - kb
|
||||||
|
return 0
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContentPage() {
|
export function ContentPage() {
|
||||||
@@ -153,9 +164,6 @@ export function ContentPage() {
|
|||||||
'/api/db/book?action=list',
|
'/api/db/book?action=list',
|
||||||
)
|
)
|
||||||
setSectionsList(Array.isArray(data?.sections) ? data.sections : [])
|
setSectionsList(Array.isArray(data?.sections) ? data.sections : [])
|
||||||
if (expandedParts.length === 0 && tree.length > 0) {
|
|
||||||
setExpandedParts([tree[0].id])
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
setSectionsList([])
|
setSectionsList([])
|
||||||
@@ -168,11 +176,6 @@ export function ContentPage() {
|
|||||||
loadList()
|
loadList()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading && tree.length > 0 && expandedParts.length === 0) {
|
|
||||||
setExpandedParts([tree[0].id])
|
|
||||||
}
|
|
||||||
}, [loading, tree.length, expandedParts.length])
|
|
||||||
|
|
||||||
const togglePart = (partId: string) => {
|
const togglePart = (partId: string) => {
|
||||||
setExpandedParts((prev) =>
|
setExpandedParts((prev) =>
|
||||||
@@ -180,6 +183,32 @@ export function ContentPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReorderTree = useCallback(
|
||||||
|
(items: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[]): Promise<void> => {
|
||||||
|
const prev = sectionsList
|
||||||
|
const newList: SectionListItem[] = items.flatMap((it) => {
|
||||||
|
const s = prev.find((x) => x.id === it.id)
|
||||||
|
if (!s) return []
|
||||||
|
return [{ ...s, partId: it.partId, partTitle: it.partTitle, chapterId: it.chapterId, chapterTitle: it.chapterTitle }]
|
||||||
|
})
|
||||||
|
setSectionsList(newList)
|
||||||
|
put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
|
||||||
|
.then((res) => {
|
||||||
|
if (res && (res as { success?: boolean }).success === false) {
|
||||||
|
setSectionsList(prev)
|
||||||
|
alert('排序失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : '未知错误'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setSectionsList(prev)
|
||||||
|
console.error('排序失败:', e)
|
||||||
|
alert('排序失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
|
||||||
|
})
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
[sectionsList],
|
||||||
|
)
|
||||||
|
|
||||||
const handleDeleteSection = async (section: Section & { filePath?: string }) => {
|
const handleDeleteSection = async (section: Section & { filePath?: string }) => {
|
||||||
if (!confirm(`确定要删除章节「${section.title}」吗?此操作不可恢复。`)) return
|
if (!confirm(`确定要删除章节「${section.title}」吗?此操作不可恢复。`)) return
|
||||||
try {
|
try {
|
||||||
@@ -546,7 +575,7 @@ export function ContentPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-gray-300">免费章节</Label>
|
<Label className="text-gray-300">免费章节(唯一入口,小程序以 is_free 或 price=0 为准)</Label>
|
||||||
<div className="flex items-center h-10">
|
<div className="flex items-center h-10">
|
||||||
<label className="flex items-center cursor-pointer">
|
<label className="flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -685,6 +714,23 @@ export function ContentPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="chapters" className="space-y-4">
|
<TabsContent value="chapters" className="space-y-4">
|
||||||
|
{/* 书籍信息卡片 */}
|
||||||
|
<div className="rounded-2xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[#38bdac] flex items-center justify-center text-white shadow-lg shadow-[#38bdac]/20 shrink-0">
|
||||||
|
<BookOpen className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-bold text-base text-white leading-tight mb-1">一场SOUL的创业实验场</h2>
|
||||||
|
<p className="text-xs text-gray-500">来自Soul派对房的真实商业故事</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center shrink-0">
|
||||||
|
<span className="block text-2xl font-bold text-[#38bdac]">{totalSections}</span>
|
||||||
|
<span className="text-xs text-gray-500">章节</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowNewSectionModal(true)}
|
onClick={() => setShowNewSectionModal(true)}
|
||||||
className="w-full bg-[#38bdac]/10 hover:bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30"
|
className="w-full bg-[#38bdac]/10 hover:bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30"
|
||||||
@@ -699,85 +745,14 @@ export function ContentPage() {
|
|||||||
<span className="ml-2 text-gray-400">加载中...</span>
|
<span className="ml-2 text-gray-400">加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
tree.map((part, partIndex) => (
|
<ChapterTree
|
||||||
<Card key={part.id} className="bg-[#0f2137] border-gray-700/50 shadow-xl overflow-hidden">
|
parts={tree}
|
||||||
<CardHeader
|
expandedParts={expandedParts}
|
||||||
className="cursor-pointer hover:bg-[#162840] transition-colors"
|
onTogglePart={togglePart}
|
||||||
onClick={() => togglePart(part.id)}
|
onReorder={handleReorderTree}
|
||||||
>
|
onReadSection={handleReadSection}
|
||||||
<div className="flex items-center justify-between">
|
onDeleteSection={handleDeleteSection}
|
||||||
<div className="flex items-center gap-3">
|
/>
|
||||||
<span className="text-[#38bdac] font-mono text-sm">
|
|
||||||
0{partIndex + 1}
|
|
||||||
</span>
|
|
||||||
<CardTitle className="text-white">{part.title}</CardTitle>
|
|
||||||
<Badge variant="outline" className="text-gray-400 border-gray-600">
|
|
||||||
{part.chapters.reduce((sum, ch) => sum + ch.sections.length, 0)} 节
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<ChevronRight
|
|
||||||
className={`w-5 h-5 text-gray-400 transition-transform ${expandedParts.includes(part.id) ? 'rotate-90' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
{expandedParts.includes(part.id) && (
|
|
||||||
<CardContent className="pt-0 pb-4">
|
|
||||||
<div className="space-y-3 pl-8 border-l-2 border-gray-700">
|
|
||||||
{part.chapters.map((chapter) => (
|
|
||||||
<div key={chapter.id} className="space-y-2">
|
|
||||||
<h4 className="font-medium text-gray-300">{chapter.title}</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{chapter.sections.map((section) => (
|
|
||||||
<div
|
|
||||||
key={section.id}
|
|
||||||
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[#162840] text-sm group transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-[#38bdac]" />
|
|
||||||
<span className="text-gray-400">{section.title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[#38bdac] font-medium">
|
|
||||||
{section.price === 0 ? '免费' : `¥${section.price}`}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleReadSection(section)}
|
|
||||||
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
|
||||||
读取
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleReadSection(section)}
|
|
||||||
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4 mr-1" />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeleteSection(section)}
|
|
||||||
className="text-gray-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const DEFAULT_CONFIG: MatchConfig = {
|
|||||||
{
|
{
|
||||||
id: 'mentor',
|
id: 'mentor',
|
||||||
label: '导师顾问',
|
label: '导师顾问',
|
||||||
matchLabel: '商业顾问',
|
matchLabel: '导师顾问',
|
||||||
icon: '❤️',
|
icon: '❤️',
|
||||||
matchFromDB: false,
|
matchFromDB: false,
|
||||||
showJoinAfterMatch: true,
|
showJoinAfterMatch: true,
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Gift,
|
Gift,
|
||||||
X,
|
|
||||||
Plus,
|
|
||||||
Smartphone,
|
Smartphone,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { get, post } from '@/api/client'
|
import { get, post } from '@/api/client'
|
||||||
@@ -97,15 +95,6 @@ const defaultFeatures: FeatureConfig = {
|
|||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
|
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
|
||||||
const [freeChapters, setFreeChapters] = useState<string[]>([
|
|
||||||
'preface',
|
|
||||||
'epilogue',
|
|
||||||
'1.1',
|
|
||||||
'appendix-1',
|
|
||||||
'appendix-2',
|
|
||||||
'appendix-3',
|
|
||||||
])
|
|
||||||
const [newFreeChapter, setNewFreeChapter] = useState('')
|
|
||||||
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
|
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
|
||||||
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
|
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
@@ -128,13 +117,11 @@ export function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await get<{
|
const res = await get<{
|
||||||
success?: boolean
|
success?: boolean
|
||||||
freeChapters?: string[]
|
|
||||||
featureConfig?: Partial<FeatureConfig>
|
featureConfig?: Partial<FeatureConfig>
|
||||||
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo }
|
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo }
|
||||||
mpConfig?: Partial<MpConfig>
|
mpConfig?: Partial<MpConfig>
|
||||||
}>('/api/admin/settings')
|
}>('/api/admin/settings')
|
||||||
if (!res || (res as { success?: boolean }).success === false) return
|
if (!res || (res as { success?: boolean }).success === false) return
|
||||||
if (Array.isArray(res.freeChapters) && res.freeChapters.length) setFreeChapters(res.freeChapters)
|
|
||||||
if (res.featureConfig && Object.keys(res.featureConfig).length)
|
if (res.featureConfig && Object.keys(res.featureConfig).length)
|
||||||
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
|
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
|
||||||
if (res.mpConfig && typeof res.mpConfig === 'object')
|
if (res.mpConfig && typeof res.mpConfig === 'object')
|
||||||
@@ -193,7 +180,6 @@ export function SettingsPage() {
|
|||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
|
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
|
||||||
freeChapters,
|
|
||||||
featureConfig,
|
featureConfig,
|
||||||
siteSettings: {
|
siteSettings: {
|
||||||
sectionPrice: localSettings.sectionPrice,
|
sectionPrice: localSettings.sectionPrice,
|
||||||
@@ -222,17 +208,6 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addFreeChapter = () => {
|
|
||||||
if (newFreeChapter && !freeChapters.includes(newFreeChapter)) {
|
|
||||||
setFreeChapters([...freeChapters, newFreeChapter])
|
|
||||||
setNewFreeChapter('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFreeChapter = (chapter: string) => {
|
|
||||||
setFreeChapters(freeChapters.filter((c) => c !== chapter))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <div className="p-8 text-gray-500">加载中...</div>
|
if (loading) return <div className="p-8 text-gray-500">加载中...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -498,53 +473,6 @@ export function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white flex items-center gap-2">
|
|
||||||
<Gift className="w-5 h-5 text-[#38bdac]" />
|
|
||||||
免费章节
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">
|
|
||||||
设置哪些章节对所有用户免费开放
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{freeChapters.map((chapter) => (
|
|
||||||
<span
|
|
||||||
key={chapter}
|
|
||||||
className="inline-flex items-center gap-1 bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30 px-3 py-1 rounded-md text-sm"
|
|
||||||
>
|
|
||||||
{chapter}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeFreeChapter(chapter)}
|
|
||||||
className="ml-1 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white flex-1"
|
|
||||||
placeholder="输入章节ID,如 1.2、2.1、preface"
|
|
||||||
value={newFreeChapter}
|
|
||||||
onChange={(e) => setNewFreeChapter(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && addFreeChapter()}
|
|
||||||
/>
|
|
||||||
<Button onClick={addFreeChapter} className="bg-[#38bdac] hover:bg-[#2da396]">
|
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
添加
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
常用ID: preface(序言), epilogue(尾声), appendix-1/2/3(附录), 1.1/1.2等(章节)
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-white flex items-center gap-2">
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/api-doc/apidocpage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"errors":true,"version":"5.6.3"}
|
||||||
@@ -18,8 +18,11 @@ type AdminClaims struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssueAdminJWT 签发管理端 JWT,使用 ADMIN_SESSION_SECRET 签名
|
// IssueAdminJWT 签发管理端 JWT,使用 ADMIN_SESSION_SECRET 签名(role 为空时默认 admin)
|
||||||
func IssueAdminJWT(secret, username string) (string, error) {
|
func IssueAdminJWT(secret, username, role string) (string, error) {
|
||||||
|
if role == "" {
|
||||||
|
role = "admin"
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
claims := AdminClaims{
|
claims := AdminClaims{
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
@@ -28,7 +31,7 @@ func IssueAdminJWT(secret, username string) (string, error) {
|
|||||||
Subject: "admin",
|
Subject: "admin",
|
||||||
},
|
},
|
||||||
Username: username,
|
Username: username,
|
||||||
Role: "admin",
|
Role: role,
|
||||||
}
|
}
|
||||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
return tok.SignedString([]byte(secret))
|
return tok.SignedString([]byte(secret))
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ func Init(dsn string) error {
|
|||||||
if err := db.AutoMigrate(&model.MentorConsultation{}); err != nil {
|
if err := db.AutoMigrate(&model.MentorConsultation{}); err != nil {
|
||||||
log.Printf("database: mentor_consultations migrate warning: %v", err)
|
log.Printf("database: mentor_consultations migrate warning: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := db.AutoMigrate(&model.AuthorConfig{}); err != nil {
|
||||||
|
log.Printf("database: author_config migrate warning: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.AdminUser{}); err != nil {
|
||||||
|
log.Printf("database: admin_users migrate warning: %v", err)
|
||||||
|
}
|
||||||
log.Println("database: connected")
|
log.Println("database: connected")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import (
|
|||||||
|
|
||||||
"soul-api/internal/auth"
|
"soul-api/internal/auth"
|
||||||
"soul-api/internal/config"
|
"soul-api/internal/config"
|
||||||
|
"soul-api/internal/database"
|
||||||
|
"soul-api/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminCheck GET /api/admin 鉴权检查(JWT:Authorization Bearer 或 Cookie),已登录返回 success 或概览占位
|
// AdminCheck GET /api/admin 鉴权检查(JWT:Authorization Bearer 或 Cookie),已登录返回 success 或概览占位
|
||||||
@@ -39,7 +43,7 @@ func AdminCheck(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminLogin POST /api/admin 登录(校验 ADMIN_USERNAME/PASSWORD,返回 JWT,前端存 token 并带 Authorization: Bearer)
|
// AdminLogin POST /api/admin 登录(优先校验 admin_users 表,表空时回退 ADMIN_USERNAME/PASSWORD 并自动初始化)
|
||||||
func AdminLogin(c *gin.Context) {
|
func AdminLogin(c *gin.Context) {
|
||||||
cfg := config.Get()
|
cfg := config.Get()
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
@@ -56,11 +60,69 @@ func AdminLogin(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
username := trimSpace(body.Username)
|
username := trimSpace(body.Username)
|
||||||
password := body.Password
|
password := body.Password
|
||||||
|
db := database.DB()
|
||||||
|
|
||||||
|
// 1. 尝试从 admin_users 表校验
|
||||||
|
var u model.AdminUser
|
||||||
|
err := db.Where("username = ?", username).First(&u).Error
|
||||||
|
if err == nil {
|
||||||
|
if u.Status != "active" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "账号已禁用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bcryptErr := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); bcryptErr != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, u.Username, u.Role)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "签发失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"token": token,
|
||||||
|
"user": gin.H{"id": u.ID, "username": u.Username, "role": u.Role, "name": u.Name},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 表内无匹配:若表为空且 env 账号正确,则创建初始 super_admin 并登录
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "系统错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if username != cfg.AdminUsername || password != cfg.AdminPassword {
|
if username != cfg.AdminUsername || password != cfg.AdminPassword {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, username)
|
// 表为空时初始化超级管理员
|
||||||
|
var cnt int64
|
||||||
|
if db.Model(&model.AdminUser{}).Count(&cnt).Error != nil || cnt > 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "初始化失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
initial := model.AdminUser{
|
||||||
|
Username: cfg.AdminUsername,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Role: "super_admin",
|
||||||
|
Name: "卡若",
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
if err := db.Create(&initial).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "初始化失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, initial.Username, initial.Role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "签发失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "签发失败"})
|
||||||
return
|
return
|
||||||
@@ -68,9 +130,7 @@ func AdminLogin(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"token": token,
|
"token": token,
|
||||||
"user": gin.H{
|
"user": gin.H{"id": initial.ID, "username": initial.Username, "role": initial.Role, "name": initial.Name},
|
||||||
"id": "admin", "username": cfg.AdminUsername, "role": "admin", "name": "卡若",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ func AdminChaptersAction(c *gin.Context) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
ChapterID string `json:"chapterId"` // 前端兼容:section id
|
||||||
|
SectionTitle string `json:"sectionTitle"`
|
||||||
|
Ids []string `json:"ids"` // reorder:新顺序的 section id 列表
|
||||||
Price *float64 `json:"price"`
|
Price *float64 `json:"price"`
|
||||||
IsFree *bool `json:"isFree"`
|
IsFree *bool `json:"isFree"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
@@ -94,26 +97,63 @@ func AdminChaptersAction(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resolveID := func() string {
|
||||||
|
if body.ID != "" {
|
||||||
|
return body.ID
|
||||||
|
}
|
||||||
|
return body.ChapterID
|
||||||
|
}
|
||||||
db := database.DB()
|
db := database.DB()
|
||||||
if body.Action == "updatePrice" && body.ID != "" && body.Price != nil {
|
if body.Action == "updatePrice" {
|
||||||
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("price", *body.Price)
|
id := resolveID()
|
||||||
}
|
if id != "" && body.Price != nil {
|
||||||
if body.Action == "toggleFree" && body.ID != "" && body.IsFree != nil {
|
db.Model(&model.Chapter{}).Where("id = ?", id).Update("price", *body.Price)
|
||||||
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("is_free", *body.IsFree)
|
|
||||||
}
|
|
||||||
if body.Action == "updateStatus" && body.ID != "" && body.Status != nil {
|
|
||||||
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("status", *body.Status)
|
|
||||||
}
|
|
||||||
if body.Action == "updateEdition" && body.ID != "" {
|
|
||||||
updates := make(map[string]interface{})
|
|
||||||
if body.EditionStandard != nil {
|
|
||||||
updates["edition_standard"] = *body.EditionStandard
|
|
||||||
}
|
}
|
||||||
if body.EditionPremium != nil {
|
}
|
||||||
updates["edition_premium"] = *body.EditionPremium
|
if body.Action == "toggleFree" {
|
||||||
|
id := resolveID()
|
||||||
|
if id != "" && body.IsFree != nil {
|
||||||
|
db.Model(&model.Chapter{}).Where("id = ?", id).Update("is_free", *body.IsFree)
|
||||||
}
|
}
|
||||||
if len(updates) > 0 {
|
}
|
||||||
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates)
|
if body.Action == "updateStatus" {
|
||||||
|
id := resolveID()
|
||||||
|
if id != "" && body.Status != nil {
|
||||||
|
db.Model(&model.Chapter{}).Where("id = ?", id).Update("status", *body.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Action == "rename" {
|
||||||
|
id := resolveID()
|
||||||
|
if id != "" && body.SectionTitle != "" {
|
||||||
|
db.Model(&model.Chapter{}).Where("id = ?", id).Update("section_title", body.SectionTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Action == "delete" {
|
||||||
|
id := resolveID()
|
||||||
|
if id != "" {
|
||||||
|
db.Where("id = ?", id).Delete(&model.Chapter{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Action == "reorder" && len(body.Ids) > 0 {
|
||||||
|
for i, id := range body.Ids {
|
||||||
|
if id != "" {
|
||||||
|
db.Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Action == "updateEdition" {
|
||||||
|
id := resolveID()
|
||||||
|
if id != "" {
|
||||||
|
updates := make(map[string]interface{})
|
||||||
|
if body.EditionStandard != nil {
|
||||||
|
updates["edition_standard"] = *body.EditionStandard
|
||||||
|
}
|
||||||
|
if body.EditionPremium != nil {
|
||||||
|
updates["edition_premium"] = *body.EditionPremium
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
db.Model(&model.Chapter{}).Where("id = ?", id).Updates(updates)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
|||||||
@@ -16,9 +16,18 @@ import (
|
|||||||
var excludeParts = []string{"序言", "尾声", "附录"}
|
var excludeParts = []string{"序言", "尾声", "附录"}
|
||||||
|
|
||||||
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
||||||
|
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
||||||
|
// COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位
|
||||||
|
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
||||||
func BookAllChapters(c *gin.Context) {
|
func BookAllChapters(c *gin.Context) {
|
||||||
|
q := database.DB().Model(&model.Chapter{})
|
||||||
|
if c.Query("excludeFixed") == "1" {
|
||||||
|
for _, p := range excludeParts {
|
||||||
|
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||||
|
}
|
||||||
|
}
|
||||||
var list []model.Chapter
|
var list []model.Chapter
|
||||||
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
|
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
||||||
// 从 system_config 读取 free_chapters、mp_config、feature_config、chapter_config,合并后返回
|
// 从 system_config 读取 chapter_config、feature_config、mp_config,合并后返回(免费以章节 is_free/price 为准)
|
||||||
func GetPublicDBConfig(c *gin.Context) {
|
func GetPublicDBConfig(c *gin.Context) {
|
||||||
defaultFree := []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"}
|
|
||||||
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
||||||
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}
|
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}
|
||||||
apiDomain := "https://soulapi.quwanzhi.com"
|
apiDomain := "https://soulapi.quwanzhi.com"
|
||||||
@@ -36,16 +35,15 @@ func GetPublicDBConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := gin.H{
|
out := gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"freeChapters": defaultFree,
|
"prices": defaultPrices,
|
||||||
"prices": defaultPrices,
|
"features": defaultFeatures,
|
||||||
"features": defaultFeatures,
|
"mpConfig": defaultMp,
|
||||||
"mpConfig": defaultMp,
|
"configs": gin.H{},
|
||||||
"configs": gin.H{}, // 兼容 miniprogram 备用格式 res.configs.feature_config
|
|
||||||
}
|
}
|
||||||
db := database.DB()
|
db := database.DB()
|
||||||
|
|
||||||
keys := []string{"chapter_config", "free_chapters", "feature_config", "mp_config"}
|
keys := []string{"chapter_config", "feature_config", "mp_config"}
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
var row model.SystemConfig
|
var row model.SystemConfig
|
||||||
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
||||||
@@ -58,17 +56,6 @@ func GetPublicDBConfig(c *gin.Context) {
|
|||||||
switch k {
|
switch k {
|
||||||
case "chapter_config":
|
case "chapter_config":
|
||||||
if m, ok := val.(map[string]interface{}); ok {
|
if m, ok := val.(map[string]interface{}); ok {
|
||||||
if v, ok := m["freeChapters"].([]interface{}); ok && len(v) > 0 {
|
|
||||||
arr := make([]string, 0, len(v))
|
|
||||||
for _, x := range v {
|
|
||||||
if s, ok := x.(string); ok {
|
|
||||||
arr = append(arr, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(arr) > 0 {
|
|
||||||
out["freeChapters"] = arr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := m["prices"].(map[string]interface{}); ok {
|
if v, ok := m["prices"].(map[string]interface{}); ok {
|
||||||
out["prices"] = v
|
out["prices"] = v
|
||||||
}
|
}
|
||||||
@@ -77,19 +64,6 @@ func GetPublicDBConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
out["configs"].(gin.H)["chapter_config"] = m
|
out["configs"].(gin.H)["chapter_config"] = m
|
||||||
}
|
}
|
||||||
case "free_chapters":
|
|
||||||
if arr, ok := val.([]interface{}); ok && len(arr) > 0 {
|
|
||||||
ss := make([]string, 0, len(arr))
|
|
||||||
for _, x := range arr {
|
|
||||||
if s, ok := x.(string); ok {
|
|
||||||
ss = append(ss, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(ss) > 0 {
|
|
||||||
out["freeChapters"] = ss
|
|
||||||
}
|
|
||||||
out["configs"].(gin.H)["free_chapters"] = arr
|
|
||||||
}
|
|
||||||
case "feature_config":
|
case "feature_config":
|
||||||
if m, ok := val.(map[string]interface{}); ok {
|
if m, ok := val.(map[string]interface{}); ok {
|
||||||
// 合并到 features,不整体覆盖以保留 chapter_config 里的
|
// 合并到 features,不整体覆盖以保留 chapter_config 里的
|
||||||
@@ -158,7 +132,7 @@ func DBConfigGet(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminSettingsGet GET /api/admin/settings 系统设置页专用:仅返回免费章节、功能开关、站点/作者与价格、小程序配置
|
// AdminSettingsGet GET /api/admin/settings 系统设置页专用:仅返回功能开关、站点/作者与价格、小程序配置
|
||||||
func AdminSettingsGet(c *gin.Context) {
|
func AdminSettingsGet(c *gin.Context) {
|
||||||
db := database.DB()
|
db := database.DB()
|
||||||
apiDomain := "https://soulapi.quwanzhi.com"
|
apiDomain := "https://soulapi.quwanzhi.com"
|
||||||
@@ -174,12 +148,11 @@ func AdminSettingsGet(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
out := gin.H{
|
out := gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"freeChapters": []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"},
|
|
||||||
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
|
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
|
||||||
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
|
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
|
||||||
"mpConfig": defaultMp,
|
"mpConfig": defaultMp,
|
||||||
}
|
}
|
||||||
keys := []string{"free_chapters", "feature_config", "site_settings", "mp_config"}
|
keys := []string{"feature_config", "site_settings", "mp_config"}
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
var row model.SystemConfig
|
var row model.SystemConfig
|
||||||
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
||||||
@@ -190,18 +163,6 @@ func AdminSettingsGet(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch k {
|
switch k {
|
||||||
case "free_chapters":
|
|
||||||
if arr, ok := val.([]interface{}); ok && len(arr) > 0 {
|
|
||||||
ss := make([]string, 0, len(arr))
|
|
||||||
for _, x := range arr {
|
|
||||||
if s, ok := x.(string); ok {
|
|
||||||
ss = append(ss, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(ss) > 0 {
|
|
||||||
out["freeChapters"] = ss
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "feature_config":
|
case "feature_config":
|
||||||
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
|
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
|
||||||
out["featureConfig"] = m
|
out["featureConfig"] = m
|
||||||
@@ -226,10 +187,9 @@ func AdminSettingsGet(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存免费章节、功能开关、站点/作者与价格、小程序配置
|
// AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存功能开关、站点/作者与价格、小程序配置
|
||||||
func AdminSettingsPost(c *gin.Context) {
|
func AdminSettingsPost(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
FreeChapters []string `json:"freeChapters"`
|
|
||||||
FeatureConfig map[string]interface{} `json:"featureConfig"`
|
FeatureConfig map[string]interface{} `json:"featureConfig"`
|
||||||
SiteSettings map[string]interface{} `json:"siteSettings"`
|
SiteSettings map[string]interface{} `json:"siteSettings"`
|
||||||
MpConfig map[string]interface{} `json:"mpConfig"`
|
MpConfig map[string]interface{} `json:"mpConfig"`
|
||||||
@@ -256,12 +216,6 @@ func AdminSettingsPost(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return db.Save(&row).Error
|
return db.Save(&row).Error
|
||||||
}
|
}
|
||||||
if body.FreeChapters != nil {
|
|
||||||
if err := saveKey("free_chapters", "免费章节ID列表", body.FreeChapters); err != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存免费章节失败: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if body.FeatureConfig != nil {
|
if body.FeatureConfig != nil {
|
||||||
if err := saveKey("feature_config", "功能开关配置", body.FeatureConfig); err != nil {
|
if err := saveKey("feature_config", "功能开关配置", body.FeatureConfig); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存功能开关失败: " + err.Error()})
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存功能开关失败: " + err.Error()})
|
||||||
@@ -366,6 +320,140 @@ func AdminReferralSettingsPost(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"})
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authorConfigToResponse(row *model.AuthorConfig) gin.H {
|
||||||
|
defaultStats := []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}}
|
||||||
|
defaultHighlights := []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"}
|
||||||
|
var stats []gin.H
|
||||||
|
if row.Stats != "" {
|
||||||
|
_ = json.Unmarshal([]byte(row.Stats), &stats)
|
||||||
|
}
|
||||||
|
if len(stats) == 0 {
|
||||||
|
stats = defaultStats
|
||||||
|
}
|
||||||
|
var highlights []string
|
||||||
|
if row.Highlights != "" {
|
||||||
|
_ = json.Unmarshal([]byte(row.Highlights), &highlights)
|
||||||
|
}
|
||||||
|
if len(highlights) == 0 {
|
||||||
|
highlights = defaultHighlights
|
||||||
|
}
|
||||||
|
return gin.H{
|
||||||
|
"name": row.Name,
|
||||||
|
"avatar": row.Avatar,
|
||||||
|
"avatarImg": row.AvatarImg,
|
||||||
|
"title": row.Title,
|
||||||
|
"bio": row.Bio,
|
||||||
|
"stats": stats,
|
||||||
|
"highlights": highlights,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAuthorSettingsGet GET /api/admin/author-settings 作者详情配置(管理端专用)
|
||||||
|
func AdminAuthorSettingsGet(c *gin.Context) {
|
||||||
|
defaultAuthor := gin.H{
|
||||||
|
"name": "卡若",
|
||||||
|
"avatar": "K",
|
||||||
|
"avatarImg": "",
|
||||||
|
"title": "Soul派对房主理人 · 私域运营专家",
|
||||||
|
"bio": "每天早上6点到9点,在Soul派对房分享真实的创业故事。专注私域运营与项目变现,用云阿米巴模式帮助创业者构建可持续的商业体系。",
|
||||||
|
"stats": []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}},
|
||||||
|
"highlights": []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"},
|
||||||
|
}
|
||||||
|
db := database.DB()
|
||||||
|
var row model.AuthorConfig
|
||||||
|
if err := db.First(&row).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultAuthor})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": authorConfigToResponse(&row)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAuthorSettingsPost POST /api/admin/author-settings 保存作者详情配置
|
||||||
|
func AdminAuthorSettingsPost(c *gin.Context) {
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
str := func(k string) string {
|
||||||
|
if v, ok := body[k]; ok && v != nil {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name := str("name")
|
||||||
|
if name == "" {
|
||||||
|
name = "卡若"
|
||||||
|
}
|
||||||
|
avatar := str("avatar")
|
||||||
|
if avatar == "" {
|
||||||
|
avatar = "K"
|
||||||
|
}
|
||||||
|
statsVal := body["stats"]
|
||||||
|
if statsVal == nil {
|
||||||
|
statsVal = []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}}
|
||||||
|
}
|
||||||
|
highlightsVal := body["highlights"]
|
||||||
|
if highlightsVal == nil {
|
||||||
|
highlightsVal = []string{}
|
||||||
|
}
|
||||||
|
statsBytes, _ := json.Marshal(statsVal)
|
||||||
|
highlightsBytes, _ := json.Marshal(highlightsVal)
|
||||||
|
|
||||||
|
db := database.DB()
|
||||||
|
var row model.AuthorConfig
|
||||||
|
err := db.First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
row = model.AuthorConfig{
|
||||||
|
Name: name,
|
||||||
|
Avatar: avatar,
|
||||||
|
AvatarImg: str("avatarImg"),
|
||||||
|
Title: str("title"),
|
||||||
|
Bio: str("bio"),
|
||||||
|
Stats: string(statsBytes),
|
||||||
|
Highlights: string(highlightsBytes),
|
||||||
|
}
|
||||||
|
err = db.Create(&row).Error
|
||||||
|
} else {
|
||||||
|
row.Name = name
|
||||||
|
row.Avatar = avatar
|
||||||
|
row.AvatarImg = str("avatarImg")
|
||||||
|
row.Title = str("title")
|
||||||
|
row.Bio = str("bio")
|
||||||
|
row.Stats = string(statsBytes)
|
||||||
|
row.Highlights = string(highlightsBytes)
|
||||||
|
err = db.Save(&row).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "作者设置已保存"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniprogramAboutAuthor GET /api/miniprogram/about/author 小程序-关于作者页拉取作者配置(公开,无需鉴权)
|
||||||
|
func MiniprogramAboutAuthor(c *gin.Context) {
|
||||||
|
defaultAuthor := gin.H{
|
||||||
|
"name": "卡若",
|
||||||
|
"avatar": "K",
|
||||||
|
"avatarImg": "",
|
||||||
|
"title": "Soul派对房主理人 · 私域运营专家",
|
||||||
|
"bio": "每天早上6点到9点,在Soul派对房分享真实的创业故事。",
|
||||||
|
"stats": []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}},
|
||||||
|
"highlights": []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"},
|
||||||
|
}
|
||||||
|
db := database.DB()
|
||||||
|
var row model.AuthorConfig
|
||||||
|
if err := db.First(&row).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultAuthor})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": authorConfigToResponse(&row)})
|
||||||
|
}
|
||||||
|
|
||||||
// DBConfigPost POST /api/db/config
|
// DBConfigPost POST /api/db/config
|
||||||
func DBConfigPost(c *gin.Context) {
|
func DBConfigPost(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"soul-api/internal/database"
|
"soul-api/internal/database"
|
||||||
@@ -10,6 +11,12 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// listSelectCols 列表/导出不加载 content,大幅加速
|
||||||
|
var listSelectCols = []string{
|
||||||
|
"id", "section_title", "price", "is_free", "is_new",
|
||||||
|
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
|
||||||
|
}
|
||||||
|
|
||||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
|
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
|
||||||
type sectionListItem struct {
|
type sectionListItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -34,7 +41,7 @@ func DBBookAction(c *gin.Context) {
|
|||||||
switch action {
|
switch action {
|
||||||
case "list":
|
case "list":
|
||||||
var rows []model.Chapter
|
var rows []model.Chapter
|
||||||
if err := db.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,7 +100,7 @@ func DBBookAction(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
case "export":
|
case "export":
|
||||||
var rows []model.Chapter
|
var rows []model.Chapter
|
||||||
if err := db.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -179,7 +186,11 @@ func DBBookAction(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
var body struct {
|
var body struct {
|
||||||
ID string `json:"id"`
|
Action string `json:"action"`
|
||||||
|
// reorder:新顺序,支持跨篇跨章时附带 partId/chapterId
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
Items []reorderItem `json:"items"`
|
||||||
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Price *float64 `json:"price"`
|
Price *float64 `json:"price"`
|
||||||
@@ -188,8 +199,57 @@ func DBBookAction(c *gin.Context) {
|
|||||||
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
|
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
|
||||||
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
|
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Action == "reorder" {
|
||||||
|
// 立即返回成功,后台异步执行排序更新
|
||||||
|
if len(body.Items) > 0 {
|
||||||
|
items := make([]reorderItem, len(body.Items))
|
||||||
|
copy(items, body.Items)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
go func() {
|
||||||
|
db := database.DB()
|
||||||
|
for i, it := range items {
|
||||||
|
if it.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
up := map[string]interface{}{"sort_order": i}
|
||||||
|
if it.PartID != "" {
|
||||||
|
up["part_id"] = it.PartID
|
||||||
|
}
|
||||||
|
if it.PartTitle != "" {
|
||||||
|
up["part_title"] = it.PartTitle
|
||||||
|
}
|
||||||
|
if it.ChapterID != "" {
|
||||||
|
up["chapter_id"] = it.ChapterID
|
||||||
|
}
|
||||||
|
if it.ChapterTitle != "" {
|
||||||
|
up["chapter_title"] = it.ChapterTitle
|
||||||
|
}
|
||||||
|
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.IDs) > 0 {
|
||||||
|
ids := make([]string, len(body.IDs))
|
||||||
|
copy(ids, body.IDs)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
go func() {
|
||||||
|
db := database.DB()
|
||||||
|
for i, id := range ids {
|
||||||
|
if id != "" {
|
||||||
|
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.ID == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
price := 1.0
|
price := 1.0
|
||||||
@@ -228,6 +288,14 @@ func DBBookAction(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reorderItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PartID string `json:"partId"`
|
||||||
|
PartTitle string `json:"partTitle"`
|
||||||
|
ChapterID string `json:"chapterId"`
|
||||||
|
ChapterTitle string `json:"chapterTitle"`
|
||||||
|
}
|
||||||
|
|
||||||
type importItem struct {
|
type importItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
|
|||||||
var defaultMatchTypes = []gin.H{
|
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": "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": "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},
|
gin.H{"id": "team", "label": "团队招募", "matchLabel": "加入项目", "icon": "🎮", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"soul-api/internal/database"
|
"soul-api/internal/database"
|
||||||
@@ -17,6 +21,27 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
orderPollLogger *log.Logger
|
||||||
|
orderPollLoggerOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// orderPollLogf 将订单轮询检测日志写入 log/order-poll.log,不输出到控制台
|
||||||
|
func orderPollLogf(format string, args ...interface{}) {
|
||||||
|
orderPollLoggerOnce.Do(func() {
|
||||||
|
_ = os.MkdirAll("log", 0755)
|
||||||
|
f, err := os.OpenFile(filepath.Join("log", "order-poll.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
orderPollLogger = log.New(io.Discard, "", 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderPollLogger = log.New(f, "[OrderPoll] ", log.Ldate|log.Ltime)
|
||||||
|
})
|
||||||
|
if orderPollLogger != nil {
|
||||||
|
orderPollLogger.Printf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MiniprogramLogin POST /api/miniprogram/login
|
// MiniprogramLogin POST /api/miniprogram/login
|
||||||
func MiniprogramLogin(c *gin.Context) {
|
func MiniprogramLogin(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -355,7 +380,7 @@ func miniprogramPayGet(c *gin.Context) {
|
|||||||
"transaction_id": transactionID,
|
"transaction_id": transactionID,
|
||||||
"pay_time": now,
|
"pay_time": now,
|
||||||
})
|
})
|
||||||
fmt.Printf("[PayGet] 主动同步订单已支付: %s\n", orderSn)
|
orderPollLogf("主动同步订单已支付: %s", orderSn)
|
||||||
}
|
}
|
||||||
case "CLOSED", "REVOKED", "PAYERROR":
|
case "CLOSED", "REVOKED", "PAYERROR":
|
||||||
status = "failed"
|
status = "failed"
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ func VipMembers(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// formatVipMember 构建会员展示数据;优先 vip_*,无则回退到用户 nickname/avatar
|
// formatVipMember 构建会员展示数据;优先 vip_*,无则回退到用户 nickname/avatar
|
||||||
// 用于首页超级个体、创业老板排行等场景,展示真实用户头像和昵称
|
// 用于首页超级个体、创业老板排行、会员详情页等场景;含 P3 资料扩展以对接 member-detail
|
||||||
func formatVipMember(u *model.User, isVip bool) gin.H {
|
func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||||
name := ""
|
name := ""
|
||||||
if u.VipName != nil && *u.VipName != "" {
|
if u.VipName != nil && *u.VipName != "" {
|
||||||
@@ -291,9 +291,9 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
|||||||
if avatar == "" && u.Avatar != nil && *u.Avatar != "" {
|
if avatar == "" && u.Avatar != nil && *u.Avatar != "" {
|
||||||
avatar = *u.Avatar
|
avatar = *u.Avatar
|
||||||
}
|
}
|
||||||
project := ""
|
project := getStringValue(u.VipProject)
|
||||||
if u.VipProject != nil {
|
if project == "" {
|
||||||
project = *u.VipProject
|
project = getStringValue(u.ProjectIntro)
|
||||||
}
|
}
|
||||||
bio := ""
|
bio := ""
|
||||||
if u.VipBio != nil {
|
if u.VipBio != nil {
|
||||||
@@ -303,24 +303,51 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
|||||||
if u.VipContact != nil {
|
if u.VipContact != nil {
|
||||||
contact = *u.VipContact
|
contact = *u.VipContact
|
||||||
}
|
}
|
||||||
|
if contact == "" {
|
||||||
|
contact = getStringValue(u.Phone)
|
||||||
|
}
|
||||||
vipRole := ""
|
vipRole := ""
|
||||||
if u.VipRole != nil {
|
if u.VipRole != nil {
|
||||||
vipRole = *u.VipRole
|
vipRole = *u.VipRole
|
||||||
}
|
}
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"id": u.ID,
|
"id": u.ID,
|
||||||
"name": name,
|
"name": name,
|
||||||
"nickname": name,
|
"nickname": name,
|
||||||
"avatar": avatar,
|
"avatar": avatar,
|
||||||
"vip_name": name,
|
"vip_name": name,
|
||||||
"vipName": name,
|
"vipName": name,
|
||||||
"vipRole": vipRole,
|
"vipRole": vipRole,
|
||||||
"vip_avatar": avatar,
|
"vip_avatar": avatar,
|
||||||
"vipAvatar": avatar,
|
"vipAvatar": avatar,
|
||||||
"vipProject": project,
|
"vipProject": project,
|
||||||
"vipContact": contact,
|
"vip_project": project,
|
||||||
"vipBio": bio,
|
"vipContact": contact,
|
||||||
"is_vip": isVip,
|
"vip_contact": contact,
|
||||||
|
"vipBio": bio,
|
||||||
|
"wechatId": getStringValue(u.WechatID),
|
||||||
|
"wechat_id": getStringValue(u.WechatID),
|
||||||
|
"phone": getStringValue(u.Phone),
|
||||||
|
"mbti": getStringValue(u.Mbti),
|
||||||
|
"region": getStringValue(u.Region),
|
||||||
|
"industry": getStringValue(u.Industry),
|
||||||
|
"position": getStringValue(u.Position),
|
||||||
|
"businessScale": getStringValue(u.BusinessScale),
|
||||||
|
"business_scale": getStringValue(u.BusinessScale),
|
||||||
|
"skills": getStringValue(u.Skills),
|
||||||
|
"storyBestMonth": getStringValue(u.StoryBestMonth),
|
||||||
|
"story_best_month": getStringValue(u.StoryBestMonth),
|
||||||
|
"storyAchievement": getStringValue(u.StoryAchievement),
|
||||||
|
"story_achievement": getStringValue(u.StoryAchievement),
|
||||||
|
"storyTurning": getStringValue(u.StoryTurning),
|
||||||
|
"story_turning": getStringValue(u.StoryTurning),
|
||||||
|
"helpOffer": getStringValue(u.HelpOffer),
|
||||||
|
"help_offer": getStringValue(u.HelpOffer),
|
||||||
|
"helpNeed": getStringValue(u.HelpNeed),
|
||||||
|
"help_need": getStringValue(u.HelpNeed),
|
||||||
|
"projectIntro": getStringValue(u.ProjectIntro),
|
||||||
|
"project_intro": getStringValue(u.ProjectIntro),
|
||||||
|
"is_vip": isVip,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminAuth 管理端鉴权:校验 JWT(Authorization: Bearer 或 Cookie admin_session),未登录返回 401
|
const adminClaimsKey = "admin_claims"
|
||||||
|
|
||||||
|
// AdminAuth 管理端鉴权:校验 JWT(Authorization: Bearer 或 Cookie admin_session),未登录返回 401;通过则设置 admin_claims 到 context
|
||||||
func AdminAuth() gin.HandlerFunc {
|
func AdminAuth() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
cfg := config.Get()
|
cfg := config.Get()
|
||||||
@@ -18,10 +20,25 @@ func AdminAuth() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := auth.GetAdminJWTFromRequest(c.Request)
|
token := auth.GetAdminJWTFromRequest(c.Request)
|
||||||
if _, ok := auth.ParseAdminJWT(token, cfg.AdminSessionSecret); !ok {
|
claims, ok := auth.ParseAdminJWT(token, cfg.AdminSessionSecret)
|
||||||
|
if !ok {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未授权访问,请先登录"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未授权访问,请先登录"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.Set(adminClaimsKey, claims)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAdminClaims 从 context 获取 admin claims(需在 AdminAuth 之后调用)
|
||||||
|
func GetAdminClaims(c *gin.Context) *auth.AdminClaims {
|
||||||
|
v, ok := c.Get(adminClaimsKey)
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
claims, ok := v.(*auth.AdminClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,7 +66,13 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
admin.POST("/settings", handler.AdminSettingsPost)
|
admin.POST("/settings", handler.AdminSettingsPost)
|
||||||
admin.GET("/referral-settings", handler.AdminReferralSettingsGet)
|
admin.GET("/referral-settings", handler.AdminReferralSettingsGet)
|
||||||
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
|
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
|
||||||
|
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
|
||||||
|
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
|
||||||
admin.PUT("/orders/refund", handler.AdminOrderRefund)
|
admin.PUT("/orders/refund", handler.AdminOrderRefund)
|
||||||
|
admin.GET("/users", handler.AdminUsersList)
|
||||||
|
admin.POST("/users", handler.AdminUsersAction)
|
||||||
|
admin.PUT("/users", handler.AdminUsersAction)
|
||||||
|
admin.DELETE("/users", handler.AdminUsersAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 鉴权 -----
|
// ----- 鉴权 -----
|
||||||
@@ -276,6 +282,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
miniprogram.GET("/mentors", handler.MiniprogramMentorsList)
|
miniprogram.GET("/mentors", handler.MiniprogramMentorsList)
|
||||||
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
|
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
|
||||||
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
|
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
|
||||||
|
miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 提现 -----
|
// ----- 提现 -----
|
||||||
|
|||||||
74
开发文档/章节拖拽排序-后台说明.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 章节拖拽排序 - 后台处理说明
|
||||||
|
|
||||||
|
## 一、后台已实现
|
||||||
|
|
||||||
|
### 1. 接口
|
||||||
|
|
||||||
|
- **路径**:`PUT /api/db/book`
|
||||||
|
- **鉴权**:需登录管理端(AdminAuth)
|
||||||
|
|
||||||
|
**请求体**(拖拽排序时):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "reorder",
|
||||||
|
"ids": ["1.1", "2.3", "3.1", ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ids`:所有 section 的 id,按新的排序顺序排列
|
||||||
|
- 后端会依次将每条记录的 `sort_order` 更新为 0、1、2、…
|
||||||
|
|
||||||
|
### 2. 代码位置
|
||||||
|
|
||||||
|
- `soul-api/internal/handler/db_book.go`:`DBBookAction` 中 `http.MethodPut` 分支,约第 196–206 行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、数据库必须支持
|
||||||
|
|
||||||
|
### 1. `sort_order` 列
|
||||||
|
|
||||||
|
`chapters` 表必须有 `sort_order` 列,否则 `UPDATE sort_order` 会报错。
|
||||||
|
|
||||||
|
**检查**:
|
||||||
|
```sql
|
||||||
|
SHOW COLUMNS FROM chapters LIKE 'sort_order';
|
||||||
|
```
|
||||||
|
|
||||||
|
**若不存在,执行迁移**:
|
||||||
|
```bash
|
||||||
|
cd e:\Gongsi\Mycontent
|
||||||
|
node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-sort-order-to-chapters.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
或手动执行:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE chapters ADD COLUMN sort_order INT DEFAULT 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 现有数据处理
|
||||||
|
|
||||||
|
若已有数据且 `sort_order` 为 NULL 或未设置,可先按当前顺序初始化:
|
||||||
|
```sql
|
||||||
|
SET @i = 0;
|
||||||
|
UPDATE chapters SET sort_order = (@i := @i + 1) ORDER BY id;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、排查“无法拖拽”
|
||||||
|
|
||||||
|
| 现象 | 可能原因 | 处理方式 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 拖不起来 | 拖拽手柄太小、浏览器兼容性 | 点击左侧 ⋮⋮ 后按住再拖动 |
|
||||||
|
| 拖到目标没反应 | 未触发 drop | 确认拖到其他 section 行再松手 |
|
||||||
|
| 松手后弹“排序失败” | 1. `sort_order` 列不存在<br>2. 鉴权失败 401<br>3. 接口异常 | 1. 执行迁移脚本<br>2. 确认已登录管理端<br>3. 查看 soul-api 日志和浏览器控制台 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、快速验证
|
||||||
|
|
||||||
|
1. 执行迁移(若 `sort_order` 不存在)
|
||||||
|
2. 重启 soul-api
|
||||||
|
3. 在管理端登录后打开「内容管理 → 章节管理」
|
||||||
|
4. 用 ⋮⋮ 拖拽某一节到另一位置,松手后应刷新并显示新顺序
|
||||||