更新小程序开发文档,新增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-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-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-28 | stitch_soul 需求评审:首页/目录/导师/会员/资料五类页面,待需求与接口确定后分阶段实现 | 待续 |
|
||||
| 2026-02-28 | 吸收经验:input 边距口诀「外边包 view、内部 width 100%」写入 Skill §6;match 资源对接弹窗已按规范修正 | 已完成 |
|
||||
| 2026-03-03 | 吸收经验:我的页面卡片区边距优化,16rpx 为个人中心类页面推荐值,已升级 SKILL §8 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 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 时。
|
||||
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
|
||||
- 做阅读、支付、推荐、提现等与 soul-api 对接的功能时。
|
||||
- 做表单、input/textarea 样式时(遵循 §6,用 view 包裹,padding 写在 view 上)。
|
||||
- 做个人中心、设置页布局时(遵循 §7,卡片区边距 16rpx)。
|
||||
|
||||
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。
|
||||
|
||||
@@ -8,7 +8,7 @@ const { parseScene } = require('./utils/scene.js')
|
||||
App({
|
||||
globalData: {
|
||||
// API基础地址 - 连接真实后端
|
||||
baseUrl: 'https://soulapi.quwanzhi.com',
|
||||
baseUrl: 'https://soulapi.quwanzhi.com',
|
||||
// baseUrl: 'https://souldev.quwanzhi.com',
|
||||
// baseUrl: 'http://localhost:8080',
|
||||
|
||||
@@ -56,7 +56,10 @@ App({
|
||||
navBarHeight: 88,
|
||||
|
||||
// TabBar相关
|
||||
currentTab: 0
|
||||
currentTab: 0,
|
||||
|
||||
// 更新检测:上次检测时间戳,避免频繁请求
|
||||
lastUpdateCheck: 0
|
||||
},
|
||||
|
||||
onLaunch(options) {
|
||||
@@ -77,9 +80,10 @@ App({
|
||||
this.handleReferralCode(options)
|
||||
},
|
||||
|
||||
// 小程序显示时也检查分享参数
|
||||
// 小程序显示时:处理分享参数、检测更新(从后台切回时)
|
||||
onShow(options) {
|
||||
this.handleReferralCode(options)
|
||||
this.checkUpdate()
|
||||
},
|
||||
|
||||
// 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref),与 utils/scene 解析闭环
|
||||
@@ -238,21 +242,30 @@ App({
|
||||
}
|
||||
},
|
||||
|
||||
// 检查更新
|
||||
/**
|
||||
* 小程序更新检测(基于 wx.getUpdateManager)
|
||||
* - 启动时检测;从后台切回前台时也检测(间隔至少 5 分钟,避免频繁请求)
|
||||
*/
|
||||
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()
|
||||
|
||||
updateManager.onCheckForUpdate((res) => {
|
||||
if (res.hasUpdate) {
|
||||
console.log('发现新版本')
|
||||
console.log('[App] 发现新版本,正在下载...')
|
||||
}
|
||||
})
|
||||
|
||||
updateManager.onUpdateReady(() => {
|
||||
wx.showModal({
|
||||
title: '更新提示',
|
||||
content: '新版本已准备好,是否重启应用?',
|
||||
content: '新版本已准备好,重启后即可使用',
|
||||
confirmText: '立即重启',
|
||||
cancelText: '稍后',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
updateManager.applyUpdate()
|
||||
@@ -260,13 +273,15 @@ App({
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
updateManager.onUpdateFailed(() => {
|
||||
wx.showToast({
|
||||
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({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
authorLoading: true,
|
||||
author: {
|
||||
name: '卡若',
|
||||
avatar: 'K',
|
||||
title: 'Soul派对房主理人 · 私域运营专家',
|
||||
bio: '每天早上6点到9点,在Soul派对房分享真实的创业故事。专注私域运营与项目变现,用"云阿米巴"模式帮助创业者构建可持续的商业体系。本书记录了62个真实商业案例,涵盖电商、内容、传统行业等多个领域。',
|
||||
stats: [
|
||||
{ label: '商业案例', value: '62' },
|
||||
{ label: '连续直播', value: '365天' },
|
||||
{ label: '派对分享', value: '1000+' }
|
||||
],
|
||||
// 联系方式已移至后台配置
|
||||
contact: null,
|
||||
highlights: [
|
||||
'5年私域运营经验',
|
||||
'帮助100+品牌从0到1增长',
|
||||
'连续创业者,擅长商业模式设计'
|
||||
]
|
||||
avatarImg: '/assets/images/author-avatar.png',
|
||||
title: '',
|
||||
bio: '',
|
||||
stats: [],
|
||||
highlights: []
|
||||
},
|
||||
bookInfo: {
|
||||
title: '一场Soul的创业实验',
|
||||
@@ -44,22 +36,68 @@ Page({
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight
|
||||
})
|
||||
this.loadAuthor()
|
||||
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() {
|
||||
try {
|
||||
const res = await app.request('/api/miniprogram/book/stats')
|
||||
if (res && res.success) {
|
||||
this.setData({
|
||||
'bookInfo.totalChapters': res.data?.totalChapters || 62,
|
||||
'author.stats': [
|
||||
{ label: '商业案例', value: String(res.data?.totalChapters || 62) },
|
||||
{ label: '连续直播', value: '365天' },
|
||||
{ label: '派对分享', value: '1000+' }
|
||||
]
|
||||
})
|
||||
const res = await app.request({ url: '/api/miniprogram/book/stats', silent: true })
|
||||
if (res?.success && res.data) {
|
||||
const total = res.data?.totalChapters || 62
|
||||
this.setData({ 'bookInfo.totalChapters': total })
|
||||
const stats = this.data.author?.stats || []
|
||||
const idx = stats.findIndex((s) => s && (s.label === '商业案例' || s.label === '章节'))
|
||||
if (idx >= 0 && stats[idx]) {
|
||||
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) {
|
||||
console.log('[About] 加载书籍统计失败,使用默认值')
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
<view class="content">
|
||||
<view wx:if="{{authorLoading}}" class="loading-row">加载中...</view>
|
||||
<!-- 作者信息卡片 -->
|
||||
<view class="author-card">
|
||||
<view class="author-avatar">{{author.avatar}}</view>
|
||||
<view class="author-card" wx:if="{{!authorLoading}}">
|
||||
<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-title">{{author.title}}</text>
|
||||
<text class="author-bio">{{author.bio}}</text>
|
||||
@@ -33,7 +37,7 @@
|
||||
</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>
|
||||
<view class="book-stats">
|
||||
<view class="book-stat">
|
||||
@@ -58,7 +62,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 联系方式 - 引导到Soul派对房 -->
|
||||
<view class="contact-card">
|
||||
<view class="contact-card" wx:if="{{!authorLoading}}">
|
||||
<text class="card-title">联系作者</text>
|
||||
<view class="contact-item">
|
||||
<text class="contact-icon">🎉</text>
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
.nav-title { font-size: 36rpx; font-weight: 600; color: #00CED1; }
|
||||
.nav-placeholder { width: 72rpx; }
|
||||
.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-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-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; }
|
||||
|
||||
@@ -212,29 +212,31 @@ Page({
|
||||
navBarHeight: app.globalData.navBarHeight
|
||||
})
|
||||
this.updateUserStatus()
|
||||
this.loadBookDataFromServer()
|
||||
this.loadDailyChapters()
|
||||
this.loadTotalFromServer()
|
||||
this.loadChaptersOnce()
|
||||
},
|
||||
|
||||
async loadTotalFromServer() {
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
if (res && (res.total || (res.data && res.data.length))) {
|
||||
this.setData({ totalSections: res.total || (res.data || []).length })
|
||||
}
|
||||
} catch (e) {}
|
||||
// 固定模块(序言、尾声、附录)不参与中间篇章
|
||||
_isFixedPart(pt) {
|
||||
if (!pt) return false
|
||||
const p = String(pt).toLowerCase().replace(/[_\s||]/g, '')
|
||||
return p.includes('序言') || p.includes('尾声') || p.includes('附录')
|
||||
},
|
||||
|
||||
// stitch_soul P0-8:从服务端加载目录,按 part 聚合,带 isNew、免费/¥1
|
||||
async loadBookDataFromServer() {
|
||||
// 一次请求拉取全量目录,同时更新 totalSections / bookData / dailyChapters
|
||||
async loadChaptersOnce() {
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
const rows = (res && res.data) || (res && res.chapters) || []
|
||||
if (rows.length === 0) return
|
||||
|
||||
// 1. totalSections
|
||||
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 numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
|
||||
rows.forEach((r, idx) => {
|
||||
filtered.forEach((r) => {
|
||||
const pid = r.partId || r.part_id || 'part-1'
|
||||
const cid = r.chapterId || r.chapter_id || 'chapter-1'
|
||||
if (!partMap.has(pid)) {
|
||||
@@ -270,9 +272,28 @@ Page({
|
||||
chapters: Array.from(p.chapters.values())
|
||||
}))
|
||||
const firstPart = bookData[0] && bookData[0].id
|
||||
|
||||
// 3. dailyChapters(sort_order > 62 的新增章节,按更新时间取前20)
|
||||
const baseSort = 62
|
||||
const daily = rows
|
||||
.filter(r => (r.sectionOrder ?? r.sort_order ?? 0) > baseSort)
|
||||
.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({
|
||||
bookData,
|
||||
totalSections: rows.length,
|
||||
totalSections,
|
||||
dailyChapters: daily,
|
||||
expandedPart: firstPart || this.data.expandedPart
|
||||
})
|
||||
} catch (e) { console.log('[Chapters] 加载目录失败:', e) }
|
||||
@@ -324,30 +345,6 @@ Page({
|
||||
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() {
|
||||
wx.navigateTo({ url: '/pages/search/search' })
|
||||
|
||||
@@ -104,19 +104,13 @@ Page({
|
||||
this.updateUserStatus()
|
||||
},
|
||||
|
||||
// 初始化数据
|
||||
async initData() {
|
||||
this.setData({ loading: true })
|
||||
try {
|
||||
await this.loadBookData()
|
||||
await this.loadFeaturedFromServer()
|
||||
this.loadSuperMembers()
|
||||
this.loadLatestChapters()
|
||||
} catch (e) {
|
||||
console.error('初始化失败:', e)
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
// 初始化数据:首次进页面并行异步加载,加快首屏展示
|
||||
initData() {
|
||||
this.setData({ loading: false })
|
||||
this.loadBookData()
|
||||
this.loadFeaturedFromServer()
|
||||
this.loadSuperMembers()
|
||||
this.loadLatestChapters()
|
||||
},
|
||||
|
||||
async loadSuperMembers() {
|
||||
@@ -358,9 +352,14 @@ Page({
|
||||
wx.switchTab({ url: '/pages/my/my' })
|
||||
},
|
||||
|
||||
// 下拉刷新
|
||||
// 下拉刷新(等待各异步加载完成后再结束)
|
||||
async onPullDownRefresh() {
|
||||
await this.initData()
|
||||
await Promise.all([
|
||||
this.loadBookData(),
|
||||
this.loadFeaturedFromServer(),
|
||||
this.loadSuperMembers(),
|
||||
this.loadLatestChapters()
|
||||
])
|
||||
this.updateUserStatus()
|
||||
wx.stopPullDownRefresh()
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<!-- 自定义导航栏占位 -->
|
||||
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
<!-- 顶部区域 -->
|
||||
<!-- 顶部区域(按设计稿:S 图标 + 标题副标题 | 点击链接卡若 + 章数) -->
|
||||
<view class="header">
|
||||
<view class="header-content">
|
||||
<view class="logo-section">
|
||||
@@ -12,17 +12,15 @@
|
||||
<text class="logo-text">S</text>
|
||||
</view>
|
||||
<view class="logo-info">
|
||||
<view class="logo-title-row">
|
||||
<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-title-text">Soul创业派对</text>
|
||||
<text class="logo-subtitle">来自派对房的真实故事</text>
|
||||
</view>
|
||||
</view>
|
||||
<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>
|
||||
</view>
|
||||
@@ -38,8 +36,8 @@
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<view class="main-content">
|
||||
<!-- Banner卡片 - 最新章节 -->
|
||||
<view class="banner-card" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
|
||||
<!-- Banner卡片 - 最新章节(异步加载) -->
|
||||
<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-tag">最新更新</view>
|
||||
<view class="banner-title">{{latestSection.title}}</view>
|
||||
@@ -49,6 +47,13 @@
|
||||
<view class="banner-arrow">→</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">
|
||||
@@ -81,14 +86,10 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 超级个体(横向滚动) -->
|
||||
<!-- 超级个体(横向滚动,已去掉「查看全部」) -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">超级个体</text>
|
||||
<view class="section-more" bindtap="goToSuperList">
|
||||
<text class="more-text">查看全部</text>
|
||||
<text class="more-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 加载中:骨架动画 -->
|
||||
<view wx:if="{{superMembersLoading}}" class="super-loading">
|
||||
@@ -124,14 +125,10 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 精选推荐(带 tag) -->
|
||||
<!-- 精选推荐(带 tag,已去掉「查看全部」) -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">精选推荐</text>
|
||||
<view class="section-more" bindtap="goToChapters">
|
||||
<text class="more-text">查看全部</text>
|
||||
<text class="more-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="featured-list">
|
||||
<view
|
||||
|
||||
@@ -55,13 +55,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo-title-text {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
@@ -71,22 +64,25 @@
|
||||
.contact-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
background: rgba(0, 206, 209, 0.1);
|
||||
border: 2rpx solid rgba(0, 206, 209, 0.2);
|
||||
border-radius: 32rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: #00CED1;
|
||||
gap: 12rpx;
|
||||
padding: 8rpx 20rpx 8rpx 12rpx;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 40rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.contact-icon {
|
||||
font-size: 20rpx;
|
||||
.contact-avatar {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.contact-text {
|
||||
font-size: 20rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
@@ -105,7 +101,8 @@
|
||||
.logo-subtitle {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-top: 4rpx;
|
||||
margin-top: 8rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -176,6 +173,10 @@
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.banner-skeleton .banner-title {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.banner-glow {
|
||||
position: absolute;
|
||||
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 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({
|
||||
data: { statusBarHeight: 44, member: null, loading: true },
|
||||
|
||||
@@ -32,7 +33,7 @@ Page({
|
||||
if (u) {
|
||||
this.setData({ member: this.enrichAndFormat({
|
||||
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 || '',
|
||||
wechatId: u.wechatId || u.wechat_id,
|
||||
project: u.vipProject || u.vip_project || u.projectIntro || u.project_intro || '',
|
||||
@@ -51,29 +52,34 @@ Page({
|
||||
this.setData({ loading: false })
|
||||
},
|
||||
|
||||
enrichAndFormat(raw) {
|
||||
const hash = (raw.id || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0)
|
||||
const mock = MOCK_ENRICHMENT[hash % MOCK_ENRICHMENT.length]
|
||||
// 将空值、「未填写」、纯空格均视为未填写(用于隐藏对应项)
|
||||
_emptyIfPlaceholder(v) {
|
||||
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 = {
|
||||
id: raw.id,
|
||||
name: raw.name || raw.vipName || raw.vip_name || raw.nickname || '创业者',
|
||||
avatar: raw.avatar || raw.vipAvatar || raw.vip_avatar || '',
|
||||
isVip: raw.isVip || raw.is_vip === 1,
|
||||
mbti: raw.mbti || mock.mbti,
|
||||
region: raw.region || mock.region,
|
||||
industry: raw.industry || mock.industry,
|
||||
position: raw.position || mock.position,
|
||||
businessScale: raw.businessScale || raw.business_scale || mock.businessScale,
|
||||
skills: raw.skills || mock.skills,
|
||||
contactRaw: raw.contactRaw || raw.vipContact || raw.vip_contact || raw.phone || mock.contactRaw,
|
||||
wechatRaw: raw.wechatRaw || raw.wechatId || raw.wechat_id || mock.wechatRaw,
|
||||
bestMonth: raw.bestMonth || raw.storyBestMonth || raw.story_best_month || mock.bestMonth,
|
||||
achievement: raw.achievement || raw.storyAchievement || raw.story_achievement || mock.achievement,
|
||||
turningPoint: raw.turningPoint || raw.storyTurning || raw.story_turning || mock.turningPoint,
|
||||
canHelp: raw.canHelp || raw.helpOffer || raw.help_offer || mock.canHelp,
|
||||
needHelp: raw.needHelp || raw.helpNeed || raw.help_need || mock.needHelp,
|
||||
project: raw.project || raw.vipProject || raw.vip_project || raw.projectIntro || raw.project_intro || mock.project
|
||||
isVip: !!(raw.isVip || raw.is_vip),
|
||||
mbti: e(raw.mbti),
|
||||
region: e(raw.region),
|
||||
industry: e(raw.industry),
|
||||
position: e(raw.position),
|
||||
businessScale: e(raw.businessScale || raw.business_scale),
|
||||
skills: e(raw.skills),
|
||||
contactRaw: raw.contactRaw || raw.vipContact || raw.vip_contact || raw.phone || '',
|
||||
wechatRaw: raw.wechatRaw || raw.wechatId || raw.wechat_id || '',
|
||||
bestMonth: e(raw.bestMonth || raw.storyBestMonth || raw.story_best_month),
|
||||
achievement: e(raw.achievement || raw.storyAchievement || raw.story_achievement),
|
||||
turningPoint: e(raw.turningPoint || raw.storyTurning || raw.story_turning),
|
||||
canHelp: e(raw.canHelp || raw.helpOffer || raw.help_offer),
|
||||
needHelp: e(raw.needHelp || raw.helpNeed || raw.help_need),
|
||||
project: e(raw.project || raw.vipProject || raw.vip_project || raw.projectIntro || raw.project_intro)
|
||||
}
|
||||
|
||||
const contact = merged.contactRaw || ''
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
<text class="nav-icon">‹</text>
|
||||
</view>
|
||||
<text class="nav-title">个人资料</text>
|
||||
<view class="nav-right">
|
||||
<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 class="nav-placeholder"></view>
|
||||
</view>
|
||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
@@ -18,21 +15,23 @@
|
||||
<view class="card-profile">
|
||||
<view class="profile-deco"></view>
|
||||
<view class="profile-body">
|
||||
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
|
||||
<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 class="avatar-outer">
|
||||
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
|
||||
<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>
|
||||
<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-region" wx:if="{{member.region}}"><text class="pin-icon">📍</text>{{member.region}}</text>
|
||||
</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">
|
||||
<text class="card-icon">👤</text>
|
||||
<text class="card-label">基本信息</text>
|
||||
@@ -55,26 +54,30 @@
|
||||
<text class="f-key">我擅长</text>
|
||||
<text class="f-val">{{member.skills}}</text>
|
||||
</view>
|
||||
<view class="field" wx:if="{{member.contactDisplay || member.contactRaw}}">
|
||||
<view class="field" wx:if="{{member.contactRaw || member.contactDisplay}}">
|
||||
<text class="f-key">联系方式</text>
|
||||
<view class="f-row">
|
||||
<text class="f-val mono">{{member.contactDisplay || '未填写'}}</text>
|
||||
<view class="icon-copy" wx:if="{{!member.contactUnlocked}}" bindtap="unlockContact">🔒</view>
|
||||
<view class="icon-copy" wx:else bindtap="copyContact">📋</view>
|
||||
<text class="f-val mono">{{member.contactDisplay || member.contactRaw}}</text>
|
||||
<view class="icon-copy icon-eye-off" wx:if="{{member.contactRaw && !member.contactUnlocked}}" bindtap="unlockContact">
|
||||
<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 class="field" wx:if="{{member.wechatDisplay || member.wechatRaw}}">
|
||||
<view class="field" wx:if="{{member.wechatRaw || member.wechatDisplay}}">
|
||||
<text class="f-key">微信号</text>
|
||||
<view class="f-row">
|
||||
<text class="f-val mono">{{member.wechatDisplay || '未填写'}}</text>
|
||||
<view class="icon-copy" wx:if="{{!member.wechatUnlocked}}" bindtap="unlockContact">🔒</view>
|
||||
<view class="icon-copy" wx:else bindtap="copyWechat">📋</view>
|
||||
<text class="f-val mono">{{member.wechatDisplay || member.wechatRaw}}</text>
|
||||
<view class="icon-copy icon-eye-off" wx:if="{{member.wechatRaw && !member.wechatUnlocked}}" bindtap="unlockContact">
|
||||
<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 class="card" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
|
||||
<view class="card-head">
|
||||
<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>
|
||||
<text class="story-a">{{member.bestMonth}}</text>
|
||||
</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-head"><text class="story-icon">⭐</text><text class="story-q">最有成就感的一件事</text></view>
|
||||
<text class="story-a">{{member.achievement}}</text>
|
||||
</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-head"><text class="story-icon turn">🔄</text><text class="story-q">人生的转折点</text></view>
|
||||
<text class="story-a">{{member.turningPoint}}</text>
|
||||
@@ -98,7 +101,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 互助需求 -->
|
||||
<!-- 互助需求(未填写行已隐藏) -->
|
||||
<view class="card" wx:if="{{member.canHelp || member.needHelp}}">
|
||||
<view class="card-head">
|
||||
<text class="card-icon">🤝</text>
|
||||
|
||||
@@ -35,12 +35,16 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
.profile-body { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; }
|
||||
.avatar-wrap {
|
||||
.avatar-outer {
|
||||
position: relative;
|
||||
width: 176rpx; height: 176rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
width: 100%; height: 100%;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 32rpx;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
@@ -62,10 +66,12 @@
|
||||
font-size: 56rpx; color: #5EEAD4; font-weight: 700;
|
||||
}
|
||||
.vip-tag {
|
||||
position: absolute; bottom: 4rpx; right: 4rpx;
|
||||
position: absolute; bottom: -4rpx; right: -4rpx;
|
||||
background: linear-gradient(135deg, #F59E0B, #e8920d);
|
||||
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-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-row { display: flex; align-items: center; gap: 16rpx; }
|
||||
.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; }
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<!-- 我的页 - professional_profile_with_earnings_vip 1:1 还原 -->
|
||||
<!-- 我的页 - 设计稿 1:1 还原 -->
|
||||
<view class="page">
|
||||
<!-- 顶部导航:仅标题(设置已移至用户区右侧,避让胶囊) -->
|
||||
<!-- 顶部导航:标题 + 右侧设置齿轮 -->
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<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 class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
@@ -16,77 +19,69 @@
|
||||
<view class="guest-login-btn" bindtap="showLogin">点击登录</view>
|
||||
</view>
|
||||
|
||||
<!-- 已登录:用户区 -->
|
||||
<view class="header-block" wx:else>
|
||||
<view class="user-row">
|
||||
<view class="avatar-wrap" bindtap="onAvatarTap">
|
||||
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
|
||||
<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 class="profile-card" wx:else>
|
||||
<view class="profile-card-inner">
|
||||
<view class="profile-top-row">
|
||||
<view class="avatar-wrap" bindtap="onAvatarTap">
|
||||
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
|
||||
<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 class="vip-badge" wx:if="{{isVip}}">VIP</view>
|
||||
<view class="vip-badge vip-badge-gray" wx:else bindtap="goToVip">VIP</view>
|
||||
</view>
|
||||
<view class="user-meta">
|
||||
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
|
||||
<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>
|
||||
<view class="profile-meta">
|
||||
<view class="profile-name-row">
|
||||
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
|
||||
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
|
||||
</view>
|
||||
<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>
|
||||
</view>
|
||||
<text class="user-wechat" bindtap="copyUserId">微信号: {{userWechat || userIdShort || '--'}}</text>
|
||||
</view>
|
||||
<text class="user-id" bindtap="copyUserId">{{userWechat ? '微信: ' + userWechat : 'ID: ' + userIdShort}}</text>
|
||||
<text class="vip-expire" wx:if="{{isVip && vipExpireDate}}">会员到期时间:{{vipExpireDate}}</text>
|
||||
</view>
|
||||
<view class="user-actions">
|
||||
<view class="action-btn" bindtap="handleMenuTap" data-id="settings"><text class="action-icon">⚙️</text></view>
|
||||
<view class="action-btn" catchtap="goToProfileEdit"><text class="action-icon">✎</text></view>
|
||||
<view class="profile-stats-row">
|
||||
<view class="profile-stat">
|
||||
<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 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-header">
|
||||
<text class="card-icon">👁️</text>
|
||||
<image class="card-icon-img" src="/assets/icons/eye-teal.svg" mode="aspectFit"/>
|
||||
<text class="card-title">阅读统计</text>
|
||||
</view>
|
||||
<view class="stats-grid">
|
||||
<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-label">已读章节</text>
|
||||
</view>
|
||||
<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-label">阅读分钟</text>
|
||||
</view>
|
||||
<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-label">匹配伙伴</text>
|
||||
</view>
|
||||
@@ -96,7 +91,7 @@
|
||||
<!-- 最近阅读 -->
|
||||
<view class="card recent-card">
|
||||
<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>
|
||||
</view>
|
||||
<view class="recent-list" wx:if="{{recentChapters.length > 0}}">
|
||||
@@ -121,22 +116,29 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的订单 + 关于作者 -->
|
||||
<!-- 我的订单 + 关于作者 + 设置 -->
|
||||
<view class="card menu-card">
|
||||
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
|
||||
<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>
|
||||
</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="handleMenuTap" data-id="about">
|
||||
<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>
|
||||
</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
/* 真机适配:底部留足 TabBar + 安全区,避免「我的订单」被遮挡 */
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #121212;
|
||||
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);
|
||||
display: flex; align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 200rpx 0 32rpx; /* 右侧 200rpx 避让真机右上角胶囊 */
|
||||
padding: 0 120rpx 0 32rpx; /* 右侧避让胶囊 */
|
||||
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%; }
|
||||
|
||||
/* ===== 未登录 ===== */
|
||||
.guest-block {
|
||||
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-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-login-btn { padding: 20rpx 48rpx; background: #4FD1C5; color: #000; font-size: 28rpx; font-weight: 600; border-radius: 24rpx; }
|
||||
|
||||
/* ===== 用户区(设计稿 header) ===== */
|
||||
.header-block { padding: 32rpx 40rpx 48rpx; }
|
||||
.user-row { display: flex; align-items: center; gap: 32rpx; }
|
||||
/* ===== 用户卡片(设计稿 1:1) ===== */
|
||||
.profile-card { padding: 24rpx 16rpx 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-inner {
|
||||
width: 130rpx; height: 130rpx; border-radius: 50%; overflow: hidden;
|
||||
@@ -52,33 +57,43 @@
|
||||
padding: 4rpx 12rpx; border-radius: 8rpx;
|
||||
}
|
||||
.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; }
|
||||
.action-btn { width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.action-icon { font-size: 36rpx; color: #4FD1C5; opacity: 0.9; }
|
||||
.user-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.profile-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.profile-name-row { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
|
||||
.user-name {
|
||||
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-tag {
|
||||
font-size: 20rpx; padding: 6rpx 12rpx; border-radius: 8rpx;
|
||||
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; }
|
||||
.user-id { display: block; font-size: 28rpx; color: #6B7280; }
|
||||
.vip-expire { display: block; font-size: 22rpx; color: #6B7280; margin-top: 4rpx; }
|
||||
.user-wechat { font-size: 26rpx; color: #6B7280; }
|
||||
.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 {
|
||||
background: #1A1A1A; border-radius: 24rpx; padding: 40rpx;
|
||||
margin-bottom: 32rpx; border: 1rpx solid rgba(75,85,99,0.5);
|
||||
background: #1A1A1A; border-radius: 24rpx; padding: 32rpx;
|
||||
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-icon { font-size: 40rpx; }
|
||||
.card-icon-img { width: 40rpx; height: 40rpx; flex-shrink: 0; }
|
||||
.card-title { font-size: 32rpx; font-weight: bold; color: #fff; }
|
||||
|
||||
/* 分享收益 */
|
||||
@@ -92,16 +107,20 @@
|
||||
.earnings-val.primary { color: #4FD1C5; }
|
||||
.earnings-label { display: block; font-size: 24rpx; color: #6B7280; margin-top: 8rpx; }
|
||||
|
||||
/* 阅读统计 */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24rpx; }
|
||||
.stat-box {
|
||||
background: #252525; border-radius: 20rpx; padding: 20rpx;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
aspect-ratio: 2/1;
|
||||
/* 阅读统计 - 统一高度避免真机错位 */
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 24rpx;
|
||||
align-items: stretch;
|
||||
}
|
||||
.stat-icon { font-size: 40rpx; margin-bottom: 8rpx; color: #4FD1C5; }
|
||||
.stat-num { font-size: 36rpx; font-weight: bold; color: #fff; }
|
||||
.stat-label { font-size: 20rpx; color: #6B7280; margin-top: 4rpx; }
|
||||
.stat-box {
|
||||
background: #252525; border-radius: 20rpx; padding: 24rpx;
|
||||
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; }
|
||||
@@ -131,9 +150,15 @@
|
||||
}
|
||||
.menu-icon-wrap .menu-icon { font-size: 32rpx; }
|
||||
.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 .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-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 })
|
||||
if (res?.success && 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 wechat = d.wechatId || wx.getStorageSync('user_wechat') || ''
|
||||
this.setData({
|
||||
profile: {
|
||||
...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) : '',
|
||||
wechatMask: wechat ? (wechat.length > 8 ? wechat.slice(0, 4) + '****' + wechat.slice(-3) : wechat) : '',
|
||||
phone,
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<text class="field-hint" wx:if="{{profile.wechat}}">复制</text>
|
||||
</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>
|
||||
|
||||
@@ -65,9 +65,6 @@ Page({
|
||||
showPosterModal: false,
|
||||
isPaying: false,
|
||||
isGeneratingPoster: false,
|
||||
|
||||
// 免费章节
|
||||
freeIds: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
|
||||
|
||||
// 章节 mid(扫码/海报分享用,便于分享 path 带 mid)
|
||||
sectionMid: null
|
||||
@@ -124,12 +121,13 @@ Page({
|
||||
try {
|
||||
const config = await accessManager.fetchLatestConfig()
|
||||
this.setData({
|
||||
freeIds: config.freeChapters,
|
||||
sectionPrice: config.prices?.section ?? 1,
|
||||
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)
|
||||
|
||||
this.setData({
|
||||
@@ -139,8 +137,8 @@ Page({
|
||||
showPaywall: !canAccess
|
||||
})
|
||||
|
||||
// 【标准流程】3. 加载内容
|
||||
await this.loadContent(id, accessState)
|
||||
// 加载内容(复用已拉取的章节数据,避免二次请求)
|
||||
await this.loadContent(id, accessState, chapterRes)
|
||||
|
||||
// 【标准流程】4. 如果有权限,初始化阅读追踪
|
||||
if (canAccess) {
|
||||
@@ -192,18 +190,22 @@ Page({
|
||||
},
|
||||
|
||||
// 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成)
|
||||
async loadContent(id, accessState) {
|
||||
// prefetchedChapter:若已有章节数据(含 content)则复用,避免二次请求
|
||||
async loadContent(id, accessState, prefetchedChapter) {
|
||||
try {
|
||||
const section = this.getSectionInfo(id)
|
||||
const sectionPrice = this.data.sectionPrice ?? 1
|
||||
if (section.price === undefined || section.price === null) {
|
||||
section.price = sectionPrice
|
||||
let res = prefetchedChapter
|
||||
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 })
|
||||
|
||||
// 从 API 获取内容
|
||||
const res = await app.request({ url: `/api/miniprogram/book/chapter/${id}`, silent: true })
|
||||
|
||||
if (res && res.content) {
|
||||
const lines = res.content.split('\n').filter(line => line.trim())
|
||||
const previewCount = Math.ceil(lines.length * 0.2)
|
||||
@@ -584,14 +586,14 @@ Page({
|
||||
// 1. 刷新用户购买状态(从 orders 表拉取最新)
|
||||
await accessManager.refreshUserPurchaseStatus()
|
||||
|
||||
// 2. 重新拉取免费列表(极端情况:刚登录时当前章节可能改免费了)
|
||||
const config = await accessManager.fetchLatestConfig()
|
||||
this.setData({ freeIds: config.freeChapters })
|
||||
|
||||
// 3. 重新判断当前章节权限
|
||||
// 2. 重新拉取章节数据,用 isFree/price 判断免费
|
||||
const chapterRes = await app.request({
|
||||
url: `/api/miniprogram/book/chapter/${this.data.sectionId}`,
|
||||
silent: true
|
||||
})
|
||||
const newAccessState = await accessManager.determineAccessState(
|
||||
this.data.sectionId,
|
||||
config.freeChapters
|
||||
chapterRes
|
||||
)
|
||||
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
||||
|
||||
@@ -602,9 +604,9 @@ Page({
|
||||
showPaywall: !canAccess
|
||||
})
|
||||
|
||||
// 4. 如果已解锁,重新加载内容并初始化阅读追踪
|
||||
// 3. 如果已解锁,重新加载内容并初始化阅读追踪
|
||||
if (canAccess) {
|
||||
await this.loadContent(this.data.sectionId, newAccessState)
|
||||
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
|
||||
readingTracker.init(this.data.sectionId)
|
||||
}
|
||||
|
||||
@@ -855,19 +857,22 @@ Page({
|
||||
// 2. 刷新用户购买状态
|
||||
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(
|
||||
this.data.sectionId,
|
||||
this.data.freeIds
|
||||
chapterRes
|
||||
)
|
||||
|
||||
// 如果权限未生效,再重试一次(可能回调延迟)
|
||||
if (newAccessState !== 'unlocked_purchased') {
|
||||
console.log('[Pay] 权限未生效,1秒后重试...')
|
||||
await this.sleep(1000)
|
||||
newAccessState = await accessManager.determineAccessState(
|
||||
this.data.sectionId,
|
||||
this.data.freeIds
|
||||
chapterRes
|
||||
)
|
||||
}
|
||||
|
||||
@@ -880,7 +885,7 @@ Page({
|
||||
})
|
||||
|
||||
// 4. 重新加载全文
|
||||
await this.loadContent(this.data.sectionId, newAccessState)
|
||||
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
|
||||
|
||||
// 5. 初始化阅读追踪
|
||||
if (canAccess) {
|
||||
@@ -1188,14 +1193,20 @@ Page({
|
||||
wx.showLoading({ title: '重试中...', mask: true })
|
||||
|
||||
try {
|
||||
// 重新拉取配置
|
||||
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(
|
||||
this.data.sectionId,
|
||||
config.freeChapters
|
||||
chapterRes
|
||||
)
|
||||
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
||||
|
||||
@@ -1205,8 +1216,7 @@ Page({
|
||||
showPaywall: !canAccess
|
||||
})
|
||||
|
||||
// 重新加载内容
|
||||
await this.loadContent(this.data.sectionId, newAccessState)
|
||||
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
|
||||
|
||||
// 如果有权限,初始化阅读追踪
|
||||
if (canAccess) {
|
||||
|
||||
@@ -21,7 +21,6 @@ Page({
|
||||
{ title: '链接资源', desc: '深度私域资源池', icon: '🔗' },
|
||||
{ title: '专属VIP标识', desc: '金色尊享光圈', icon: '✓' }
|
||||
],
|
||||
profile: { vipName: '', vipProject: '', vipContact: '', vipAvatar: '', vipBio: '' },
|
||||
purchasing: false
|
||||
},
|
||||
|
||||
@@ -49,62 +48,10 @@ Page({
|
||||
expireDateStr: expStr,
|
||||
price: d.price || 1980
|
||||
})
|
||||
if (d.isVip) this.loadProfile(userId)
|
||||
}
|
||||
} 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() {
|
||||
let userId = app.globalData.userInfo?.id
|
||||
let openId = app.globalData.openId || app.globalData.userInfo?.open_id
|
||||
@@ -156,24 +103,6 @@ Page({
|
||||
} 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() },
|
||||
|
||||
onShareAppMessage() {
|
||||
|
||||
@@ -8,14 +8,10 @@
|
||||
<view class="nav-placeholder-r"></view>
|
||||
</view>
|
||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
<!-- 会员宣传区 - 设计稿 premium 卡片 -->
|
||||
<!-- 会员宣传区(已去掉 VIP PREMIUM 标签) -->
|
||||
<view class="vip-hero {{isVip ? 'vip-hero-active' : ''}}">
|
||||
<text class="vip-hero-tag">VIP PREMIUM</text>
|
||||
<text class="vip-hero-title">
|
||||
加入卡若的
|
||||
<text class="gold">创业派对</text>
|
||||
会员
|
||||
</text>
|
||||
<view class="vip-hero-title">加入卡若的</view>
|
||||
<view class="vip-hero-title gold">创业派对 会员</view>
|
||||
<text class="vip-hero-sub" wx:if="{{isVip}}">有效期至 {{expireDateStr}}(剩余{{daysRemaining}}天)</text>
|
||||
<text class="vip-hero-sub" wx:else>一次加入 尊享终身陪伴与成长</text>
|
||||
</view>
|
||||
@@ -54,47 +50,6 @@
|
||||
{{purchasing ? "处理中..." : "¥" + price + "/年 加入创业派对"}}
|
||||
</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>
|
||||
@@ -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-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-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; }
|
||||
.vip-hero-sub { display: block; font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 12rpx; }
|
||||
|
||||
|
||||
@@ -24,12 +24,47 @@
|
||||
"miniprogram": {
|
||||
"list": [
|
||||
{
|
||||
"name": "pages/about/about",
|
||||
"pathName": "pages/about/about",
|
||||
"name": "pages/vip/vip",
|
||||
"pathName": "pages/vip/vip",
|
||||
"query": "",
|
||||
"scene": null,
|
||||
"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",
|
||||
"pathName": "pages/vip/vip",
|
||||
|
||||
@@ -18,45 +18,44 @@ class ChapterAccessManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取最新配置(免费章节列表、价格等)
|
||||
* 拉取最新配置(价格等,免费以章节数据为准)
|
||||
*/
|
||||
async fetchLatestConfig() {
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/config', silent: true, timeout: 3000 })
|
||||
if (res.success && res.freeChapters) {
|
||||
if (res.success && res.prices) {
|
||||
return {
|
||||
freeChapters: res.freeChapters,
|
||||
prices: res.prices || { section: 1, fullbook: 9.9 }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[AccessManager] 获取配置失败,使用默认配置:', e)
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
return {
|
||||
freeChapters: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
|
||||
prices: { section: 1, fullbook: 9.9 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断章节是否免费
|
||||
* 判断章节是否免费(统一:章节 isFree 或 price===0)
|
||||
*/
|
||||
isFreeChapter(sectionId, freeList) {
|
||||
return freeList.includes(sectionId)
|
||||
isFreeFromChapterData(chapterData) {
|
||||
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 {Array} freeList - 免费章节列表
|
||||
* @param {object} chapterData - 章节接口返回 { isFree, price, ... },免费 = isFree 或 price===0
|
||||
* @returns {Promise<string>} accessState
|
||||
*/
|
||||
async determineAccessState(sectionId, freeList) {
|
||||
async determineAccessState(sectionId, chapterData) {
|
||||
try {
|
||||
// 1. 检查是否免费
|
||||
if (this.isFreeChapter(sectionId, freeList)) {
|
||||
// 1. 检查是否免费(统一:章节 isFree 或 price===0)
|
||||
if (this.isFreeFromChapterData(chapterData)) {
|
||||
console.log('[AccessManager] 免费章节:', sectionId)
|
||||
return this.accessStates.FREE
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { UsersPage } from './pages/users/UsersPage'
|
||||
import { DistributionPage } from './pages/distribution/DistributionPage'
|
||||
import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage'
|
||||
import { ContentPage } from './pages/content/ContentPage'
|
||||
import { ChaptersPage } from './pages/chapters/ChaptersPage'
|
||||
import { ReferralSettingsPage } from './pages/referral-settings/ReferralSettingsPage'
|
||||
import { AuthorSettingsPage } from './pages/author-settings/AuthorSettingsPage'
|
||||
import { SettingsPage } from './pages/settings/SettingsPage'
|
||||
import { PaymentPage } from './pages/payment/PaymentPage'
|
||||
import { SitePage } from './pages/site/SitePage'
|
||||
@@ -18,6 +18,7 @@ import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
|
||||
import { VipRolesPage } from './pages/vip-roles/VipRolesPage'
|
||||
import { MentorsPage } from './pages/mentors/MentorsPage'
|
||||
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
|
||||
import { AdminUsersPage } from './pages/admin-users/AdminUsersPage'
|
||||
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
|
||||
import { NotFoundPage } from './pages/not-found/NotFoundPage'
|
||||
|
||||
@@ -33,11 +34,12 @@ function App() {
|
||||
<Route path="distribution" element={<DistributionPage />} />
|
||||
<Route path="withdrawals" element={<WithdrawalsPage />} />
|
||||
<Route path="content" element={<ContentPage />} />
|
||||
<Route path="chapters" element={<ChaptersPage />} />
|
||||
<Route path="referral-settings" element={<ReferralSettingsPage />} />
|
||||
<Route path="author-settings" element={<AuthorSettingsPage />} />
|
||||
<Route path="vip-roles" element={<VipRolesPage />} />
|
||||
<Route path="mentors" element={<MentorsPage />} />
|
||||
<Route path="mentor-consultations" element={<MentorConsultationsPage />} />
|
||||
<Route path="admin-users" element={<AdminUsersPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="payment" element={<PaymentPage />} />
|
||||
<Route path="site" element={<SitePage />} />
|
||||
|
||||
@@ -31,6 +31,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
aria-describedby={undefined}
|
||||
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',
|
||||
className,
|
||||
|
||||
@@ -12,21 +12,30 @@ import {
|
||||
Crown,
|
||||
GraduationCap,
|
||||
Calendar,
|
||||
User,
|
||||
ShieldCheck,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
import { clearAdminToken } from '@/api/auth'
|
||||
|
||||
const menuItems = [
|
||||
// 主菜单(折叠后可见 4-5 项)
|
||||
const primaryMenuItems = [
|
||||
{ icon: LayoutDashboard, label: '数据概览', href: '/dashboard' },
|
||||
{ icon: BookOpen, label: '内容管理', href: '/content' },
|
||||
{ icon: Users, label: '用户管理', href: '/users' },
|
||||
{ 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: Calendar, label: '导师预约', href: '/mentor-consultations' },
|
||||
{ icon: Wallet, label: '交易中心', href: '/distribution' },
|
||||
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
|
||||
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
|
||||
{ icon: Settings, label: '系统设置', href: '/settings' },
|
||||
]
|
||||
|
||||
export function AdminLayout() {
|
||||
@@ -34,10 +43,15 @@ export function AdminLayout() {
|
||||
const navigate = useNavigate()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [authChecked, setAuthChecked] = useState(false)
|
||||
const [moreExpanded, setMoreExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const inMore = moreMenuItems.some((item) => location.pathname === item.href)
|
||||
if (inMore) setMoreExpanded(true)
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
@@ -89,8 +103,8 @@ export function AdminLayout() {
|
||||
<p className="text-xs text-gray-400 mt-1">Soul创业派对</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{primaryMenuItems.map((item) => {
|
||||
const isActive = location.pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
@@ -102,11 +116,55 @@ export function AdminLayout() {
|
||||
: '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>
|
||||
</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>
|
||||
|
||||
<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 {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -28,8 +28,6 @@ import {
|
||||
import {
|
||||
BookOpen,
|
||||
Settings2,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
Edit3,
|
||||
Save,
|
||||
X,
|
||||
@@ -39,9 +37,9 @@ import {
|
||||
Plus,
|
||||
Image as ImageIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { get, put, del } from '@/api/client'
|
||||
import { ChapterTree } from './ChapterTree'
|
||||
import { apiUrl } from '@/api/client'
|
||||
|
||||
interface SectionListItem {
|
||||
@@ -114,10 +112,23 @@ function buildTree(sections: SectionListItem[]): Part[] {
|
||||
isNew: s.isNew,
|
||||
})
|
||||
}
|
||||
return Array.from(partMap.values()).map((p) => ({
|
||||
const parts = Array.from(partMap.values()).map((p) => ({
|
||||
...p,
|
||||
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() {
|
||||
@@ -153,9 +164,6 @@ export function ContentPage() {
|
||||
'/api/db/book?action=list',
|
||||
)
|
||||
setSectionsList(Array.isArray(data?.sections) ? data.sections : [])
|
||||
if (expandedParts.length === 0 && tree.length > 0) {
|
||||
setExpandedParts([tree[0].id])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setSectionsList([])
|
||||
@@ -168,11 +176,6 @@ export function ContentPage() {
|
||||
loadList()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && tree.length > 0 && expandedParts.length === 0) {
|
||||
setExpandedParts([tree[0].id])
|
||||
}
|
||||
}, [loading, tree.length, expandedParts.length])
|
||||
|
||||
const togglePart = (partId: string) => {
|
||||
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 }) => {
|
||||
if (!confirm(`确定要删除章节「${section.title}」吗?此操作不可恢复。`)) return
|
||||
try {
|
||||
@@ -546,7 +575,7 @@ export function ContentPage() {
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
@@ -685,6 +714,23 @@ export function ContentPage() {
|
||||
</TabsList>
|
||||
|
||||
<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
|
||||
onClick={() => setShowNewSectionModal(true)}
|
||||
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>
|
||||
</div>
|
||||
) : (
|
||||
tree.map((part, partIndex) => (
|
||||
<Card key={part.id} className="bg-[#0f2137] border-gray-700/50 shadow-xl overflow-hidden">
|
||||
<CardHeader
|
||||
className="cursor-pointer hover:bg-[#162840] transition-colors"
|
||||
onClick={() => togglePart(part.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
))
|
||||
<ChapterTree
|
||||
parts={tree}
|
||||
expandedParts={expandedParts}
|
||||
onTogglePart={togglePart}
|
||||
onReorder={handleReorderTree}
|
||||
onReadSection={handleReadSection}
|
||||
onDeleteSection={handleDeleteSection}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ const DEFAULT_CONFIG: MatchConfig = {
|
||||
{
|
||||
id: 'mentor',
|
||||
label: '导师顾问',
|
||||
matchLabel: '商业顾问',
|
||||
matchLabel: '导师顾问',
|
||||
icon: '❤️',
|
||||
matchFromDB: false,
|
||||
showJoinAfterMatch: true,
|
||||
|
||||
@@ -29,8 +29,6 @@ import {
|
||||
MapPin,
|
||||
BookOpen,
|
||||
Gift,
|
||||
X,
|
||||
Plus,
|
||||
Smartphone,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
@@ -97,15 +95,6 @@ const defaultFeatures: FeatureConfig = {
|
||||
|
||||
export function SettingsPage() {
|
||||
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 [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -128,13 +117,11 @@ export function SettingsPage() {
|
||||
try {
|
||||
const res = await get<{
|
||||
success?: boolean
|
||||
freeChapters?: string[]
|
||||
featureConfig?: Partial<FeatureConfig>
|
||||
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo }
|
||||
mpConfig?: Partial<MpConfig>
|
||||
}>('/api/admin/settings')
|
||||
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)
|
||||
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
|
||||
if (res.mpConfig && typeof res.mpConfig === 'object')
|
||||
@@ -193,7 +180,6 @@ export function SettingsPage() {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
|
||||
freeChapters,
|
||||
featureConfig,
|
||||
siteSettings: {
|
||||
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>
|
||||
|
||||
return (
|
||||
@@ -498,53 +473,6 @@ export function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<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">
|
||||
<CardHeader>
|
||||
<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"`
|
||||
}
|
||||
|
||||
// IssueAdminJWT 签发管理端 JWT,使用 ADMIN_SESSION_SECRET 签名
|
||||
func IssueAdminJWT(secret, username string) (string, error) {
|
||||
// IssueAdminJWT 签发管理端 JWT,使用 ADMIN_SESSION_SECRET 签名(role 为空时默认 admin)
|
||||
func IssueAdminJWT(secret, username, role string) (string, error) {
|
||||
if role == "" {
|
||||
role = "admin"
|
||||
}
|
||||
now := time.Now()
|
||||
claims := AdminClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
@@ -28,7 +31,7 @@ func IssueAdminJWT(secret, username string) (string, error) {
|
||||
Subject: "admin",
|
||||
},
|
||||
Username: username,
|
||||
Role: "admin",
|
||||
Role: role,
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return tok.SignedString([]byte(secret))
|
||||
|
||||
@@ -42,6 +42,12 @@ func Init(dsn string) error {
|
||||
if err := db.AutoMigrate(&model.MentorConsultation{}); err != nil {
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ import (
|
||||
|
||||
"soul-api/internal/auth"
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
@@ -56,11 +60,69 @@ func AdminLogin(c *gin.Context) {
|
||||
}
|
||||
username := trimSpace(body.Username)
|
||||
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 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "签发失败"})
|
||||
return
|
||||
@@ -68,9 +130,7 @@ func AdminLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"token": token,
|
||||
"user": gin.H{
|
||||
"id": "admin", "username": cfg.AdminUsername, "role": "admin", "name": "卡若",
|
||||
},
|
||||
"user": gin.H{"id": initial.ID, "username": initial.Username, "role": initial.Role, "name": initial.Name},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ func AdminChaptersAction(c *gin.Context) {
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
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"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
Status *string `json:"status"`
|
||||
@@ -94,26 +97,63 @@ func AdminChaptersAction(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
resolveID := func() string {
|
||||
if body.ID != "" {
|
||||
return body.ID
|
||||
}
|
||||
return body.ChapterID
|
||||
}
|
||||
db := database.DB()
|
||||
if body.Action == "updatePrice" && body.ID != "" && body.Price != nil {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("price", *body.Price)
|
||||
}
|
||||
if body.Action == "toggleFree" && body.ID != "" && body.IsFree != nil {
|
||||
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.Action == "updatePrice" {
|
||||
id := resolveID()
|
||||
if id != "" && body.Price != nil {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", id).Update("price", *body.Price)
|
||||
}
|
||||
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})
|
||||
|
||||
@@ -16,9 +16,18 @@ import (
|
||||
var excludeParts = []string{"序言", "尾声", "附录"}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
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{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ import (
|
||||
)
|
||||
|
||||
// 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) {
|
||||
defaultFree := []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"}
|
||||
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}
|
||||
apiDomain := "https://soulapi.quwanzhi.com"
|
||||
@@ -36,16 +35,15 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"freeChapters": defaultFree,
|
||||
"prices": defaultPrices,
|
||||
"features": defaultFeatures,
|
||||
"mpConfig": defaultMp,
|
||||
"configs": gin.H{}, // 兼容 miniprogram 备用格式 res.configs.feature_config
|
||||
"success": true,
|
||||
"prices": defaultPrices,
|
||||
"features": defaultFeatures,
|
||||
"mpConfig": defaultMp,
|
||||
"configs": gin.H{},
|
||||
}
|
||||
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 {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
||||
@@ -58,17 +56,6 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
switch k {
|
||||
case "chapter_config":
|
||||
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 {
|
||||
out["prices"] = v
|
||||
}
|
||||
@@ -77,19 +64,6 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
}
|
||||
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":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
// 合并到 features,不整体覆盖以保留 chapter_config 里的
|
||||
@@ -158,7 +132,7 @@ func DBConfigGet(c *gin.Context) {
|
||||
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) {
|
||||
db := database.DB()
|
||||
apiDomain := "https://soulapi.quwanzhi.com"
|
||||
@@ -174,12 +148,11 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
}
|
||||
out := gin.H{
|
||||
"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},
|
||||
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
|
||||
"mpConfig": defaultMp,
|
||||
}
|
||||
keys := []string{"free_chapters", "feature_config", "site_settings", "mp_config"}
|
||||
keys := []string{"feature_config", "site_settings", "mp_config"}
|
||||
for _, k := range keys {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
||||
@@ -190,18 +163,6 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
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":
|
||||
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
|
||||
out["featureConfig"] = m
|
||||
@@ -226,10 +187,9 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存免费章节、功能开关、站点/作者与价格、小程序配置
|
||||
// AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存功能开关、站点/作者与价格、小程序配置
|
||||
func AdminSettingsPost(c *gin.Context) {
|
||||
var body struct {
|
||||
FreeChapters []string `json:"freeChapters"`
|
||||
FeatureConfig map[string]interface{} `json:"featureConfig"`
|
||||
SiteSettings map[string]interface{} `json:"siteSettings"`
|
||||
MpConfig map[string]interface{} `json:"mpConfig"`
|
||||
@@ -256,12 +216,6 @@ func AdminSettingsPost(c *gin.Context) {
|
||||
}
|
||||
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 err := saveKey("feature_config", "功能开关配置", body.FeatureConfig); err != nil {
|
||||
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": "推广设置已保存"})
|
||||
}
|
||||
|
||||
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
|
||||
func DBConfigPost(c *gin.Context) {
|
||||
var body struct {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
@@ -10,6 +11,12 @@ import (
|
||||
"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 一致(小写驼峰)
|
||||
type sectionListItem struct {
|
||||
ID string `json:"id"`
|
||||
@@ -34,7 +41,7 @@ func DBBookAction(c *gin.Context) {
|
||||
switch action {
|
||||
case "list":
|
||||
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{}})
|
||||
return
|
||||
}
|
||||
@@ -93,7 +100,7 @@ func DBBookAction(c *gin.Context) {
|
||||
return
|
||||
case "export":
|
||||
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()})
|
||||
return
|
||||
}
|
||||
@@ -179,7 +186,11 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
case http.MethodPut:
|
||||
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"`
|
||||
Content string `json:"content"`
|
||||
Price *float64 `json:"price"`
|
||||
@@ -188,8 +199,57 @@ func DBBookAction(c *gin.Context) {
|
||||
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
|
||||
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
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
|
||||
}
|
||||
price := 1.0
|
||||
@@ -228,6 +288,14 @@ func DBBookAction(c *gin.Context) {
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
|
||||
@@ -85,7 +85,7 @@ func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
|
||||
var defaultMatchTypes = []gin.H{
|
||||
gin.H{"id": "partner", "label": "创业合伙", "matchLabel": "创业伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
|
||||
gin.H{"id": "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},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
@@ -17,6 +21,27 @@ import (
|
||||
"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
|
||||
func MiniprogramLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -355,7 +380,7 @@ func miniprogramPayGet(c *gin.Context) {
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
})
|
||||
fmt.Printf("[PayGet] 主动同步订单已支付: %s\n", orderSn)
|
||||
orderPollLogf("主动同步订单已支付: %s", orderSn)
|
||||
}
|
||||
case "CLOSED", "REVOKED", "PAYERROR":
|
||||
status = "failed"
|
||||
|
||||
@@ -272,7 +272,7 @@ func VipMembers(c *gin.Context) {
|
||||
}
|
||||
|
||||
// formatVipMember 构建会员展示数据;优先 vip_*,无则回退到用户 nickname/avatar
|
||||
// 用于首页超级个体、创业老板排行等场景,展示真实用户头像和昵称
|
||||
// 用于首页超级个体、创业老板排行、会员详情页等场景;含 P3 资料扩展以对接 member-detail
|
||||
func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
name := ""
|
||||
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 != "" {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
project := ""
|
||||
if u.VipProject != nil {
|
||||
project = *u.VipProject
|
||||
project := getStringValue(u.VipProject)
|
||||
if project == "" {
|
||||
project = getStringValue(u.ProjectIntro)
|
||||
}
|
||||
bio := ""
|
||||
if u.VipBio != nil {
|
||||
@@ -303,24 +303,51 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
if u.VipContact != nil {
|
||||
contact = *u.VipContact
|
||||
}
|
||||
if contact == "" {
|
||||
contact = getStringValue(u.Phone)
|
||||
}
|
||||
vipRole := ""
|
||||
if u.VipRole != nil {
|
||||
vipRole = *u.VipRole
|
||||
}
|
||||
return gin.H{
|
||||
"id": u.ID,
|
||||
"name": name,
|
||||
"nickname": name,
|
||||
"avatar": avatar,
|
||||
"vip_name": name,
|
||||
"vipName": name,
|
||||
"vipRole": vipRole,
|
||||
"vip_avatar": avatar,
|
||||
"vipAvatar": avatar,
|
||||
"vipProject": project,
|
||||
"vipContact": contact,
|
||||
"vipBio": bio,
|
||||
"is_vip": isVip,
|
||||
"id": u.ID,
|
||||
"name": name,
|
||||
"nickname": name,
|
||||
"avatar": avatar,
|
||||
"vip_name": name,
|
||||
"vipName": name,
|
||||
"vipRole": vipRole,
|
||||
"vip_avatar": avatar,
|
||||
"vipAvatar": avatar,
|
||||
"vipProject": project,
|
||||
"vip_project": project,
|
||||
"vipContact": contact,
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return func(c *gin.Context) {
|
||||
cfg := config.Get()
|
||||
@@ -18,10 +20,25 @@ func AdminAuth() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
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": "未授权访问,请先登录"})
|
||||
return
|
||||
}
|
||||
c.Set(adminClaimsKey, claims)
|
||||
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.GET("/referral-settings", handler.AdminReferralSettingsGet)
|
||||
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.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/:id", handler.MiniprogramMentorsDetail)
|
||||
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. 用 ⋮⋮ 拖拽某一节到另一位置,松手后应刷新并显示新顺序
|
||||