更新小程序开发文档,新增2026-03-03的最佳实践记录,优化个人中心类页面的卡片区边距规范,确保一致性与可用性。调整相关页面以反映最新设计稿,提升用户体验与功能一致性。

This commit is contained in:
Alex-larget
2026-03-04 19:06:06 +08:00
parent 7064f82126
commit 5a5f0087d2
66 changed files with 2555 additions and 1059 deletions

View 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/` 及类似个人中心、设置页

View File

@@ -3,3 +3,4 @@
| 日期 | 摘要 | 文件 | | 日期 | 摘要 | 文件 |
|------|------|------| |------|------|------|
| 2026-02-28 | input 边距口诀、match 资源对接弹窗修正 | [2026-02-28.md](./2026-02-28.md) | | 2026-02-28 | input 边距口诀、match 资源对接弹窗修正 | [2026-02-28.md](./2026-02-28.md) |
| 2026-03-03 | 我的页面卡片区边距优化16rpx 推荐值 | [2026-03-03.md](./2026-03-03.md) |

View File

@@ -24,6 +24,7 @@
|------|------|------|------------|------| |------|------|------|------------|------|
| 2026-02-27 | 小程序、团队 | 最佳实践 | SKILL-小程序开发 §6、SKILL-管理端开发 §4.1 | 输入框 padding 用 view/div 包裹 | | 2026-02-27 | 小程序、团队 | 最佳实践 | SKILL-小程序开发 §6、SKILL-管理端开发 §4.1 | 输入框 padding 用 view/div 包裹 |
| 2026-02-28 | 小程序、管理端 | 最佳实践 | miniprogram §6、admin §4.1 | input 边距口诀「外边包 view、内部 width 100%」match 弹窗已修正 | | 2026-02-28 | 小程序、管理端 | 最佳实践 | miniprogram §6、admin §4.1 | input 边距口诀「外边包 view、内部 width 100%」match 弹窗已修正 |
| 2026-03-03 | 小程序 | 最佳实践 | miniprogram §8 | 我的页面卡片区边距 16rpx个人中心类页面布局规范 |
--- ---
@@ -34,4 +35,4 @@
--- ---
**最后更新**2026-02-28 **最后更新**2026-03-03

View File

@@ -20,9 +20,10 @@
| 2026-02-27 | 吸收经验:输入框 padding 用 view 包裹,已升级 SKILL-小程序开发 §6 | 已完成 | | 2026-02-27 | 吸收经验:输入框 padding 用 view 包裹,已升级 SKILL-小程序开发 §6 | 已完成 |
| 2026-02-28 | stitch_soul 需求评审:首页/目录/导师/会员/资料五类页面,待需求与接口确定后分阶段实现 | 待续 | | 2026-02-28 | stitch_soul 需求评审:首页/目录/导师/会员/资料五类页面,待需求与接口确定后分阶段实现 | 待续 |
| 2026-02-28 | 吸收经验input 边距口诀「外边包 view、内部 width 100%」写入 Skill §6match 资源对接弹窗已按规范修正 | 已完成 | | 2026-02-28 | 吸收经验input 边距口诀「外边包 view、内部 width 100%」写入 Skill §6match 资源对接弹窗已按规范修正 | 已完成 |
| 2026-03-03 | 吸收经验我的页面卡片区边距优化16rpx 为个人中心类页面推荐值,已升级 SKILL §8 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置 > **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
--- ---
**最后更新**2026-02-28 **最后更新**2026-03-03

View File

@@ -68,11 +68,20 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
--- ---
## 7. 何时使用本 Skill ## 7. 布局与边距(个人中心/设置类页面)
- **卡片区左右边距**:宜紧凑,**推荐 16rpx**,避免内容区过窄。
- **卡片**:内边距 32rpx卡片间距 24rpx。
- **适用**`pages/my/`、设置页等个人中心类页面。
---
## 8. 何时使用本 Skill
-**miniprogram/** 下新增或修改页面、组件、utils 时。 -**miniprogram/** 下新增或修改页面、组件、utils 时。
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。 - 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
- 做阅读、支付、推荐、提现等与 soul-api 对接的功能时。 - 做阅读、支付、推荐、提现等与 soul-api 对接的功能时。
- 做表单、input/textarea 样式时(遵循 §6用 view 包裹padding 写在 view 上)。 - 做表单、input/textarea 样式时(遵循 §6用 view 包裹padding 写在 view 上)。
- 做个人中心、设置页布局时(遵循 §7卡片区边距 16rpx
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。 遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。

View File

@@ -8,7 +8,7 @@ const { parseScene } = require('./utils/scene.js')
App({ App({
globalData: { globalData: {
// API基础地址 - 连接真实后端 // API基础地址 - 连接真实后端
baseUrl: 'https://soulapi.quwanzhi.com', baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'https://souldev.quwanzhi.com', // baseUrl: 'https://souldev.quwanzhi.com',
// baseUrl: 'http://localhost:8080', // baseUrl: 'http://localhost:8080',
@@ -56,7 +56,10 @@ App({
navBarHeight: 88, navBarHeight: 88,
// TabBar相关 // TabBar相关
currentTab: 0 currentTab: 0,
// 更新检测:上次检测时间戳,避免频繁请求
lastUpdateCheck: 0
}, },
onLaunch(options) { onLaunch(options) {
@@ -77,9 +80,10 @@ App({
this.handleReferralCode(options) this.handleReferralCode(options)
}, },
// 小程序显示时也检查分享参数 // 小程序显示时:处理分享参数、检测更新(从后台切回时)
onShow(options) { onShow(options) {
this.handleReferralCode(options) this.handleReferralCode(options)
this.checkUpdate()
}, },
// 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref与 utils/scene 解析闭环 // 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref与 utils/scene 解析闭环
@@ -238,21 +242,30 @@ App({
} }
}, },
// 检查更新 /**
* 小程序更新检测(基于 wx.getUpdateManager
* - 启动时检测;从后台切回前台时也检测(间隔至少 5 分钟,避免频繁请求)
*/
checkUpdate() { checkUpdate() {
if (wx.canIUse('getUpdateManager')) { try {
if (!wx.canIUse('getUpdateManager')) return
const now = Date.now()
const lastCheck = this.globalData.lastUpdateCheck || 0
if (lastCheck && now - lastCheck < 5 * 60 * 1000) return // 5 分钟内不重复检测
this.globalData.lastUpdateCheck = now
const updateManager = wx.getUpdateManager() const updateManager = wx.getUpdateManager()
updateManager.onCheckForUpdate((res) => { updateManager.onCheckForUpdate((res) => {
if (res.hasUpdate) { if (res.hasUpdate) {
console.log('发现新版本') console.log('[App] 发现新版本,正在下载...')
} }
}) })
updateManager.onUpdateReady(() => { updateManager.onUpdateReady(() => {
wx.showModal({ wx.showModal({
title: '更新提示', title: '更新提示',
content: '新版本已准备好,是否重启应用?', content: '新版本已准备好,重启后即可使用',
confirmText: '立即重启',
cancelText: '稍后',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
updateManager.applyUpdate() updateManager.applyUpdate()
@@ -260,13 +273,15 @@ App({
} }
}) })
}) })
updateManager.onUpdateFailed(() => { updateManager.onUpdateFailed(() => {
wx.showToast({ wx.showToast({
title: '更新失败,请稍后重试', title: '更新失败,请稍后重试',
icon: 'none' icon: 'none',
duration: 2500
}) })
}) })
} catch (e) {
console.warn('[App] checkUpdate failed:', e)
} }
}, },

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

View File

@@ -7,23 +7,15 @@ const app = getApp()
Page({ Page({
data: { data: {
statusBarHeight: 44, statusBarHeight: 44,
authorLoading: true,
author: { author: {
name: '卡若', name: '卡若',
avatar: 'K', avatar: 'K',
title: 'Soul派对房主理人 · 私域运营专家', avatarImg: '/assets/images/author-avatar.png',
bio: '每天早上6点到9点在Soul派对房分享真实的创业故事。专注私域运营与项目变现用"云阿米巴"模式帮助创业者构建可持续的商业体系。本书记录了62个真实商业案例涵盖电商、内容、传统行业等多个领域。', title: '',
stats: [ bio: '',
{ label: '商业案例', value: '62' }, stats: [],
{ label: '连续直播', value: '365天' }, highlights: []
{ label: '派对分享', value: '1000+' }
],
// 联系方式已移至后台配置
contact: null,
highlights: [
'5年私域运营经验',
'帮助100+品牌从0到1增长',
'连续创业者,擅长商业模式设计'
]
}, },
bookInfo: { bookInfo: {
title: '一场Soul的创业实验', title: '一场Soul的创业实验',
@@ -44,22 +36,68 @@ Page({
this.setData({ this.setData({
statusBarHeight: app.globalData.statusBarHeight statusBarHeight: app.globalData.statusBarHeight
}) })
this.loadAuthor()
this.loadBookStats() this.loadBookStats()
}, },
async loadAuthor() {
this.setData({ authorLoading: true })
try {
const res = await app.request({ url: '/api/miniprogram/about/author', silent: true })
if (res?.success && res.data) {
const d = res.data
let avatarImg = d.avatarImg || ''
if (avatarImg && !avatarImg.startsWith('http')) {
const base = (app.globalData.baseUrl || '').replace(/\/$/, '')
avatarImg = base ? base + (avatarImg.startsWith('/') ? avatarImg : '/' + avatarImg) : avatarImg
}
this.setData({
author: {
name: d.name || '卡若',
avatar: d.avatar || 'K',
avatarImg: avatarImg || '/assets/images/author-avatar.png',
title: d.title || '',
bio: d.bio || '',
stats: Array.isArray(d.stats) ? d.stats : [
{ label: '商业案例', value: '62' },
{ label: '连续直播', value: '365天' },
{ label: '派对分享', value: '1000+' }
],
highlights: Array.isArray(d.highlights) ? d.highlights : []
},
authorLoading: false
})
} else {
this.setData({ authorLoading: false })
}
} catch (e) {
console.log('[About] 加载作者配置失败,使用默认')
this.setData({ authorLoading: false })
}
},
// 加载书籍统计 // 加载书籍统计(合并到作者统计第一项「商业案例」)
async loadBookStats() { async loadBookStats() {
try { try {
const res = await app.request('/api/miniprogram/book/stats') const res = await app.request({ url: '/api/miniprogram/book/stats', silent: true })
if (res && res.success) { if (res?.success && res.data) {
this.setData({ const total = res.data?.totalChapters || 62
'bookInfo.totalChapters': res.data?.totalChapters || 62, this.setData({ 'bookInfo.totalChapters': total })
'author.stats': [ const stats = this.data.author?.stats || []
{ label: '商业案例', value: String(res.data?.totalChapters || 62) }, const idx = stats.findIndex((s) => s && (s.label === '商业案例' || s.label === '章节'))
{ label: '连续直播', value: '365天' }, if (idx >= 0 && stats[idx]) {
{ label: '派对分享', value: '1000+' } const next = [...stats]
] next[idx] = { ...stats[idx], value: String(total) }
}) this.setData({ 'author.stats': next })
} else if (stats.length === 0) {
this.setData({
'author.stats': [
{ label: '商业案例', value: String(total) },
{ label: '连续直播', value: '365天' },
{ label: '派对分享', value: '1000+' }
]
})
}
} }
} catch (e) { } catch (e) {
console.log('[About] 加载书籍统计失败,使用默认值') console.log('[About] 加载书籍统计失败,使用默认值')

View File

@@ -8,9 +8,13 @@
<view style="height: {{statusBarHeight + 44}}px;"></view> <view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="content"> <view class="content">
<view wx:if="{{authorLoading}}" class="loading-row">加载中...</view>
<!-- 作者信息卡片 --> <!-- 作者信息卡片 -->
<view class="author-card"> <view class="author-card" wx:if="{{!authorLoading}}">
<view class="author-avatar">{{author.avatar}}</view> <view class="author-avatar-wrap">
<image wx:if="{{author.avatarImg}}" class="author-avatar-img" src="{{author.avatarImg}}" mode="aspectFill"/>
<view wx:else class="author-avatar">{{author.avatar}}</view>
</view>
<text class="author-name">{{author.name}}</text> <text class="author-name">{{author.name}}</text>
<text class="author-title">{{author.title}}</text> <text class="author-title">{{author.title}}</text>
<text class="author-bio">{{author.bio}}</text> <text class="author-bio">{{author.bio}}</text>
@@ -33,7 +37,7 @@
</view> </view>
<!-- 书籍信息 --> <!-- 书籍信息 -->
<view class="book-info-card" wx:if="{{bookInfo}}"> <view class="book-info-card" wx:if="{{bookInfo && !authorLoading}}">
<text class="card-title">📚 {{bookInfo.title}}</text> <text class="card-title">📚 {{bookInfo.title}}</text>
<view class="book-stats"> <view class="book-stats">
<view class="book-stat"> <view class="book-stat">
@@ -58,7 +62,7 @@
</view> </view>
<!-- 联系方式 - 引导到Soul派对房 --> <!-- 联系方式 - 引导到Soul派对房 -->
<view class="contact-card"> <view class="contact-card" wx:if="{{!authorLoading}}">
<text class="card-title">联系作者</text> <text class="card-title">联系作者</text>
<view class="contact-item"> <view class="contact-item">
<text class="contact-icon">🎉</text> <text class="contact-icon">🎉</text>

View File

@@ -4,8 +4,11 @@
.nav-title { font-size: 36rpx; font-weight: 600; color: #00CED1; } .nav-title { font-size: 36rpx; font-weight: 600; color: #00CED1; }
.nav-placeholder { width: 72rpx; } .nav-placeholder { width: 72rpx; }
.content { padding: 32rpx; } .content { padding: 32rpx; }
.loading-row { text-align: center; color: rgba(255,255,255,0.6); font-size: 28rpx; padding: 48rpx 0; }
.author-card { background: linear-gradient(135deg, #1c1c1e 0%, #2c2c2e 100%); border-radius: 32rpx; padding: 48rpx; text-align: center; margin-bottom: 24rpx; border: 2rpx solid rgba(0,206,209,0.2); } .author-card { background: linear-gradient(135deg, #1c1c1e 0%, #2c2c2e 100%); border-radius: 32rpx; padding: 48rpx; text-align: center; margin-bottom: 24rpx; border: 2rpx solid rgba(0,206,209,0.2); }
.author-avatar { width: 160rpx; height: 160rpx; border-radius: 50%; background: linear-gradient(135deg, #00CED1, #20B2AA); display: flex; align-items: center; justify-content: center; margin: 0 auto 24rpx; font-size: 64rpx; color: #fff; font-weight: 700; border: 4rpx solid rgba(0,206,209,0.3); } .author-avatar-wrap { width: 160rpx; height: 160rpx; margin: 0 auto 24rpx; overflow: hidden; border-radius: 50%; border: 4rpx solid rgba(0,206,209,0.3); flex-shrink: 0; }
.author-avatar-img { width: 100%; height: 100%; display: block; }
.author-avatar { width: 100%; height: 100%; border-radius: 50%; background: linear-gradient(135deg, #00CED1, #20B2AA); display: flex; align-items: center; justify-content: center; font-size: 64rpx; color: #fff; font-weight: 700; }
.author-name { font-size: 40rpx; font-weight: 700; color: #fff; display: block; margin-bottom: 8rpx; } .author-name { font-size: 40rpx; font-weight: 700; color: #fff; display: block; margin-bottom: 8rpx; }
.author-title { font-size: 26rpx; color: #00CED1; display: block; margin-bottom: 24rpx; } .author-title { font-size: 26rpx; color: #00CED1; display: block; margin-bottom: 24rpx; }
.author-bio { font-size: 26rpx; color: rgba(255,255,255,0.7); line-height: 1.8; display: block; margin-bottom: 32rpx; } .author-bio { font-size: 26rpx; color: rgba(255,255,255,0.7); line-height: 1.8; display: block; margin-bottom: 32rpx; }

View File

@@ -212,29 +212,31 @@ Page({
navBarHeight: app.globalData.navBarHeight navBarHeight: app.globalData.navBarHeight
}) })
this.updateUserStatus() this.updateUserStatus()
this.loadBookDataFromServer() this.loadChaptersOnce()
this.loadDailyChapters()
this.loadTotalFromServer()
}, },
async loadTotalFromServer() { // 固定模块(序言、尾声、附录)不参与中间篇章
try { _isFixedPart(pt) {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true }) if (!pt) return false
if (res && (res.total || (res.data && res.data.length))) { const p = String(pt).toLowerCase().replace(/[_\s|]/g, '')
this.setData({ totalSections: res.total || (res.data || []).length }) return p.includes('序言') || p.includes('尾声') || p.includes('附录')
}
} catch (e) {}
}, },
// stitch_soul P0-8从服务端加载目录按 part 聚合,带 isNew、免费/¥1 // 一次请求拉取全量目录,同时更新 totalSections / bookData / dailyChapters
async loadBookDataFromServer() { async loadChaptersOnce() {
try { try {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true }) const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const rows = (res && res.data) || (res && res.chapters) || [] const rows = (res && res.data) || (res && res.chapters) || []
if (rows.length === 0) return if (rows.length === 0) return
// 1. totalSections
const totalSections = res.total ?? rows.length
// 2. bookData过滤序言/尾声/附录,中间篇章按 part 聚合)
const filtered = rows.filter(r => !this._isFixedPart(r.partTitle || r.part_title))
const partMap = new Map() const partMap = new Map()
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二'] const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
rows.forEach((r, idx) => { filtered.forEach((r) => {
const pid = r.partId || r.part_id || 'part-1' const pid = r.partId || r.part_id || 'part-1'
const cid = r.chapterId || r.chapter_id || 'chapter-1' const cid = r.chapterId || r.chapter_id || 'chapter-1'
if (!partMap.has(pid)) { if (!partMap.has(pid)) {
@@ -270,9 +272,28 @@ Page({
chapters: Array.from(p.chapters.values()) chapters: Array.from(p.chapters.values())
})) }))
const firstPart = bookData[0] && bookData[0].id const firstPart = bookData[0] && bookData[0].id
// 3. dailyChapterssort_order > 62 的新增章节按更新时间取前20
const baseSort = 62
const daily = rows
.filter(r => (r.sectionOrder ?? r.sort_order ?? 0) > baseSort)
.sort((a, b) => new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0))
.slice(0, 20)
.map(c => {
const d = new Date(c.updatedAt || c.updated_at || Date.now())
return {
id: c.id,
mid: c.mid ?? c.MID ?? 0,
title: c.section_title || c.title || c.sectionTitle,
price: c.price ?? 1,
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
}
})
this.setData({ this.setData({
bookData, bookData,
totalSections: rows.length, totalSections,
dailyChapters: daily,
expandedPart: firstPart || this.data.expandedPart expandedPart: firstPart || this.data.expandedPart
}) })
} catch (e) { console.log('[Chapters] 加载目录失败:', e) } } catch (e) { console.log('[Chapters] 加载目录失败:', e) }
@@ -324,30 +345,6 @@ Page({
wx.switchTab({ url: '/pages/index/index' }) wx.switchTab({ url: '/pages/index/index' })
}, },
async loadDailyChapters() {
try {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const chapters = (res && res.data) || (res && res.chapters) || []
const daily = chapters
.filter(c => (c.sectionOrder || c.sort_order || 0) > 62)
.sort((a, b) => new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0))
.slice(0, 20)
.map(c => {
const d = new Date(c.updatedAt || c.updated_at || Date.now())
return {
id: c.id,
mid: c.mid ?? c.MID ?? 0,
title: c.section_title || c.title || c.sectionTitle,
price: c.price || 1,
dateStr: `${d.getMonth()+1}/${d.getDate()}`
}
})
if (daily.length > 0) {
this.setData({ dailyChapters: daily, totalSections: 62 + daily.length })
}
} catch (e) { console.log('[Chapters] 加载最新新增失败:', e) }
},
// 跳转到搜索页 // 跳转到搜索页
goToSearch() { goToSearch() {
wx.navigateTo({ url: '/pages/search/search' }) wx.navigateTo({ url: '/pages/search/search' })

View File

@@ -104,19 +104,13 @@ Page({
this.updateUserStatus() this.updateUserStatus()
}, },
// 初始化数据 // 初始化数据:首次进页面并行异步加载,加快首屏展示
async initData() { initData() {
this.setData({ loading: true }) this.setData({ loading: false })
try { this.loadBookData()
await this.loadBookData() this.loadFeaturedFromServer()
await this.loadFeaturedFromServer() this.loadSuperMembers()
this.loadSuperMembers() this.loadLatestChapters()
this.loadLatestChapters()
} catch (e) {
console.error('初始化失败:', e)
} finally {
this.setData({ loading: false })
}
}, },
async loadSuperMembers() { async loadSuperMembers() {
@@ -358,9 +352,14 @@ Page({
wx.switchTab({ url: '/pages/my/my' }) wx.switchTab({ url: '/pages/my/my' })
}, },
// 下拉刷新 // 下拉刷新(等待各异步加载完成后再结束)
async onPullDownRefresh() { async onPullDownRefresh() {
await this.initData() await Promise.all([
this.loadBookData(),
this.loadFeaturedFromServer(),
this.loadSuperMembers(),
this.loadLatestChapters()
])
this.updateUserStatus() this.updateUserStatus()
wx.stopPullDownRefresh() wx.stopPullDownRefresh()
}, },

View File

@@ -4,7 +4,7 @@
<!-- 自定义导航栏占位 --> <!-- 自定义导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view> <view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 顶部区域 --> <!-- 顶部区域按设计稿S 图标 + 标题副标题 | 点击链接卡若 + 章数) -->
<view class="header"> <view class="header">
<view class="header-content"> <view class="header-content">
<view class="logo-section"> <view class="logo-section">
@@ -12,17 +12,15 @@
<text class="logo-text">S</text> <text class="logo-text">S</text>
</view> </view>
<view class="logo-info"> <view class="logo-info">
<view class="logo-title-row"> <text class="logo-title-text">Soul创业派对</text>
<text class="logo-title-text">Soul创业派对</text>
<view class="contact-btn" bindtap="goToAbout">
<text class="contact-icon">+</text>
<text class="contact-text">联系作者</text>
</view>
</view>
<text class="logo-subtitle">来自派对房的真实故事</text> <text class="logo-subtitle">来自派对房的真实故事</text>
</view> </view>
</view> </view>
<view class="header-right"> <view class="header-right">
<view class="contact-btn" bindtap="goToAbout">
<image class="contact-avatar" src="/assets/images/author-avatar.png" mode="aspectFill"/>
<text class="contact-text">点击链接卡若</text>
</view>
<view class="chapter-badge">{{totalSections}}章</view> <view class="chapter-badge">{{totalSections}}章</view>
</view> </view>
</view> </view>
@@ -38,8 +36,8 @@
<!-- 主内容区 --> <!-- 主内容区 -->
<view class="main-content"> <view class="main-content">
<!-- Banner卡片 - 最新章节 --> <!-- Banner卡片 - 最新章节(异步加载) -->
<view class="banner-card" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}"> <view class="banner-card" wx:if="{{latestSection}}" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
<view class="banner-glow"></view> <view class="banner-glow"></view>
<view class="banner-tag">最新更新</view> <view class="banner-tag">最新更新</view>
<view class="banner-title">{{latestSection.title}}</view> <view class="banner-title">{{latestSection.title}}</view>
@@ -49,6 +47,13 @@
<view class="banner-arrow">→</view> <view class="banner-arrow">→</view>
</view> </view>
</view> </view>
<view class="banner-card banner-skeleton" wx:else bindtap="goToChapters">
<view class="banner-glow"></view>
<view class="banner-tag">最新更新</view>
<view class="banner-title">加载中...</view>
<view class="banner-part"></view>
<view class="banner-action"><text class="banner-action-text">开始阅读</text><view class="banner-arrow">→</view></view>
</view>
<!-- 阅读进度卡 --> <!-- 阅读进度卡 -->
<view class="progress-card card"> <view class="progress-card card">
@@ -81,14 +86,10 @@
</view> </view>
</view> </view>
<!-- 超级个体(横向滚动) --> <!-- 超级个体(横向滚动,已去掉「查看全部」 -->
<view class="section"> <view class="section">
<view class="section-header"> <view class="section-header">
<text class="section-title">超级个体</text> <text class="section-title">超级个体</text>
<view class="section-more" bindtap="goToSuperList">
<text class="more-text">查看全部</text>
<text class="more-arrow"></text>
</view>
</view> </view>
<!-- 加载中:骨架动画 --> <!-- 加载中:骨架动画 -->
<view wx:if="{{superMembersLoading}}" class="super-loading"> <view wx:if="{{superMembersLoading}}" class="super-loading">
@@ -124,14 +125,10 @@
</view> </view>
</view> </view>
<!-- 精选推荐(带 tag --> <!-- 精选推荐(带 tag,已去掉「查看全部」 -->
<view class="section"> <view class="section">
<view class="section-header"> <view class="section-header">
<text class="section-title">精选推荐</text> <text class="section-title">精选推荐</text>
<view class="section-more" bindtap="goToChapters">
<text class="more-text">查看全部</text>
<text class="more-arrow"></text>
</view>
</view> </view>
<view class="featured-list"> <view class="featured-list">
<view <view

View File

@@ -55,13 +55,6 @@
flex-direction: column; flex-direction: column;
} }
.logo-title-row {
display: flex;
align-items: center;
gap: 16rpx;
flex-wrap: wrap;
}
.logo-title-text { .logo-title-text {
font-size: 36rpx; font-size: 36rpx;
font-weight: 700; font-weight: 700;
@@ -71,22 +64,25 @@
.contact-btn { .contact-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4rpx; gap: 12rpx;
padding: 8rpx 16rpx; padding: 8rpx 20rpx 8rpx 12rpx;
background: rgba(0, 206, 209, 0.1); background: rgba(255, 255, 255, 0.08);
border: 2rpx solid rgba(0, 206, 209, 0.2); border: 2rpx solid rgba(255, 255, 255, 0.1);
border-radius: 32rpx; border-radius: 40rpx;
font-size: 20rpx; font-size: 24rpx;
font-weight: 600; font-weight: 500;
color: #00CED1; color: #ffffff;
} }
.contact-icon { .contact-avatar {
font-size: 20rpx; width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
} }
.contact-text { .contact-text {
font-size: 20rpx; font-size: 24rpx;
} }
.logo-title { .logo-title {
@@ -105,7 +101,8 @@
.logo-subtitle { .logo-subtitle {
font-size: 22rpx; font-size: 22rpx;
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
margin-top: 4rpx; margin-top: 8rpx;
display: block;
} }
.header-right { .header-right {
@@ -176,6 +173,10 @@
margin-bottom: 24rpx; margin-bottom: 24rpx;
} }
.banner-skeleton .banner-title {
color: rgba(255, 255, 255, 0.5);
}
.banner-glow { .banner-glow {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@@ -1,12 +1,13 @@
/**
* Soul创业派对 - 超级个体/会员详情页
* 接口:优先 /api/miniprogram/vip/members?id=xxVIP回退 /api/miniprogram/users?id=xx任意用户
* 字段映射name/vipName, avatar/vipAvatar, contact/vipContact/phone, wechatId, project/vipProject/projectIntro,
* mbti, region, industry, position, businessScale, skills,
* storyBestMonth→bestMonth, storyAchievement→achievement, storyTurning→turningPoint,
* helpOffer→canHelp, helpNeed→needHelp
*/
const app = getApp() const app = getApp()
const MOCK_ENRICHMENT = [
{ mbti: 'ENTJ', region: '深圳', industry: '跨境电商', position: '创始人', businessScale: '年GMV 3000万+', skills: '电商运营、供应链管理、团队搭建', contactRaw: '13800138001', wechatRaw: 'wxid_entj001', bestMonth: '做跨境电商独立站单月GMV突破200万净利润35万', achievement: '从0到1搭建了30人的电商团队年营收破3000万', turningPoint: '2019年从传统外贸转型跨境电商放弃稳定薪资All in创业', canHelp: '电商选品、供应链对接、团队管理SOP', needHelp: '寻找品牌合作方和内容营销人才', project: '跨境电商独立站+亚马逊多店铺运营,主营家居类目' },
{ mbti: 'INFP', region: '杭州', industry: '新媒体/电商', position: '创始人', businessScale: '年GMV 5000万+', skills: '短视频制作、IP打造、私域运营', contactRaw: '13900139002', wechatRaw: 'wxid_****abc', bestMonth: '旅游账号30天涨粉10万带货佣金收入12万', achievement: '帮助3个素人打造个人IP每个月稳定变现5万+', turningPoint: '辞去互联网大厂工作开始做自媒体,第三个月就超过原薪资', canHelp: '短视频脚本、账号冷启动、私域转化设计', needHelp: '寻找供应链资源和线下活动合作', project: '旅游+生活方式自媒体矩阵全网粉丝50万+' },
{ mbti: 'INTP', region: '厦门', industry: 'SaaS/技术', position: '技术合伙人', businessScale: '天使轮200万', skills: 'AI开发、小程序开发、系统架构', contactRaw: '13700137003', wechatRaw: 'wxid_intp003', bestMonth: 'AI客服系统外包项目单月收入18万', achievement: '独立开发的SaaS产品获得天使轮200万融资', turningPoint: '从程序员转型技术创业者,学会用技术解决商业问题', canHelp: '技术方案设计、AI应用落地、小程序开发', needHelp: '需要商业化运营和市场推广合伙人', project: 'AI+私域运营工具SaaS平台' },
{ mbti: 'ESTP', region: '成都', industry: '资源对接', position: '联合创始人', businessScale: '50+城市', skills: '资源对接、商务BD、活动策划', contactRaw: '13600136004', wechatRaw: 'wxid_estp004', bestMonth: '撮合景区合作居间费收入25万', achievement: '组建覆盖全国50+城市创业者社群活跃成员3000+', turningPoint: '在Soul派对房认识第一个合伙人打开了社交创业的大门', canHelp: '各行业资源对接、活动策划、社群引荐', needHelp: '寻找技术合伙人和内容创作者', project: '创业者资源对接平台+线下创业者沙龙' }
]
Page({ Page({
data: { statusBarHeight: 44, member: null, loading: true }, data: { statusBarHeight: 44, member: null, loading: true },
@@ -32,7 +33,7 @@ Page({
if (u) { if (u) {
this.setData({ member: this.enrichAndFormat({ this.setData({ member: this.enrichAndFormat({
id: u.id, name: u.vipName || u.vip_name || u.nickname || '创业者', id: u.id, name: u.vipName || u.vip_name || u.nickname || '创业者',
avatar: u.vipAvatar || u.vip_avatar || u.avatar || '', isVip: u.is_vip === 1, avatar: u.vipAvatar || u.vip_avatar || u.avatar || '', isVip: !!(u.is_vip),
contactRaw: u.vipContact || u.vip_contact || u.phone || '', contactRaw: u.vipContact || u.vip_contact || u.phone || '',
wechatId: u.wechatId || u.wechat_id, wechatId: u.wechatId || u.wechat_id,
project: u.vipProject || u.vip_project || u.projectIntro || u.project_intro || '', project: u.vipProject || u.vip_project || u.projectIntro || u.project_intro || '',
@@ -51,29 +52,34 @@ Page({
this.setData({ loading: false }) this.setData({ loading: false })
}, },
enrichAndFormat(raw) { // 将空值、「未填写」、纯空格均视为未填写(用于隐藏对应项)
const hash = (raw.id || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0) _emptyIfPlaceholder(v) {
const mock = MOCK_ENRICHMENT[hash % MOCK_ENRICHMENT.length] if (v == null || v === undefined) return ''
const s = String(v).trim()
return (s === '' || s === '未填写') ? '' : s
},
enrichAndFormat(raw) {
const e = (v) => this._emptyIfPlaceholder(v)
const merged = { const merged = {
id: raw.id, id: raw.id,
name: raw.name || raw.vipName || raw.vip_name || raw.nickname || '创业者', name: raw.name || raw.vipName || raw.vip_name || raw.nickname || '创业者',
avatar: raw.avatar || raw.vipAvatar || raw.vip_avatar || '', avatar: raw.avatar || raw.vipAvatar || raw.vip_avatar || '',
isVip: raw.isVip || raw.is_vip === 1, isVip: !!(raw.isVip || raw.is_vip),
mbti: raw.mbti || mock.mbti, mbti: e(raw.mbti),
region: raw.region || mock.region, region: e(raw.region),
industry: raw.industry || mock.industry, industry: e(raw.industry),
position: raw.position || mock.position, position: e(raw.position),
businessScale: raw.businessScale || raw.business_scale || mock.businessScale, businessScale: e(raw.businessScale || raw.business_scale),
skills: raw.skills || mock.skills, skills: e(raw.skills),
contactRaw: raw.contactRaw || raw.vipContact || raw.vip_contact || raw.phone || mock.contactRaw, contactRaw: raw.contactRaw || raw.vipContact || raw.vip_contact || raw.phone || '',
wechatRaw: raw.wechatRaw || raw.wechatId || raw.wechat_id || mock.wechatRaw, wechatRaw: raw.wechatRaw || raw.wechatId || raw.wechat_id || '',
bestMonth: raw.bestMonth || raw.storyBestMonth || raw.story_best_month || mock.bestMonth, bestMonth: e(raw.bestMonth || raw.storyBestMonth || raw.story_best_month),
achievement: raw.achievement || raw.storyAchievement || raw.story_achievement || mock.achievement, achievement: e(raw.achievement || raw.storyAchievement || raw.story_achievement),
turningPoint: raw.turningPoint || raw.storyTurning || raw.story_turning || mock.turningPoint, turningPoint: e(raw.turningPoint || raw.storyTurning || raw.story_turning),
canHelp: raw.canHelp || raw.helpOffer || raw.help_offer || mock.canHelp, canHelp: e(raw.canHelp || raw.helpOffer || raw.help_offer),
needHelp: raw.needHelp || raw.helpNeed || raw.help_need || mock.needHelp, needHelp: e(raw.needHelp || raw.helpNeed || raw.help_need),
project: raw.project || raw.vipProject || raw.vip_project || raw.projectIntro || raw.project_intro || mock.project project: e(raw.project || raw.vipProject || raw.vip_project || raw.projectIntro || raw.project_intro)
} }
const contact = merged.contactRaw || '' const contact = merged.contactRaw || ''

View File

@@ -6,10 +6,7 @@
<text class="nav-icon"></text> <text class="nav-icon"></text>
</view> </view>
<text class="nav-title">个人资料</text> <text class="nav-title">个人资料</text>
<view class="nav-right"> <view class="nav-placeholder"></view>
<view class="nav-icon-wrap"><text class="nav-icon">⋯</text></view>
<view class="nav-icon-wrap"><text class="nav-icon-dot">●</text></view>
</view>
</view> </view>
<view style="height: {{statusBarHeight + 44}}px;"></view> <view style="height: {{statusBarHeight + 44}}px;"></view>
@@ -18,21 +15,23 @@
<view class="card-profile"> <view class="card-profile">
<view class="profile-deco"></view> <view class="profile-deco"></view>
<view class="profile-body"> <view class="profile-body">
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}"> <view class="avatar-outer">
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/> <view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
<view class="avatar-ph" wx:else><text>{{member.name[0] || '创'}}</text></view> <image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
<view class="avatar-ph" wx:else><text>{{member.name[0] || '创'}}</text></view>
</view>
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view> <view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
</view> </view>
<text class="profile-name">{{member.name}}</text> <text class="profile-name">{{member.name}}</text>
<view class="profile-tags"> <view class="profile-tags" wx:if="{{member.mbti || member.region}}">
<text class="tag tag-mbti" wx:if="{{member.mbti}}">{{member.mbti}}</text> <text class="tag tag-mbti" wx:if="{{member.mbti}}">{{member.mbti}}</text>
<text class="tag tag-region" wx:if="{{member.region}}"><text class="pin-icon">📍</text>{{member.region}}</text> <text class="tag tag-region" wx:if="{{member.region}}"><text class="pin-icon">📍</text>{{member.region}}</text>
</view> </view>
</view> </view>
</view> </view>
<!-- 基本信息 --> <!-- 基本信息(未填写行已隐藏) -->
<view class="card"> <view class="card" wx:if="{{member.industry || member.position || member.businessScale || member.skills || member.contactRaw || member.contactDisplay || member.wechatRaw || member.wechatDisplay}}">
<view class="card-head"> <view class="card-head">
<text class="card-icon">👤</text> <text class="card-icon">👤</text>
<text class="card-label">基本信息</text> <text class="card-label">基本信息</text>
@@ -55,26 +54,30 @@
<text class="f-key">我擅长</text> <text class="f-key">我擅长</text>
<text class="f-val">{{member.skills}}</text> <text class="f-val">{{member.skills}}</text>
</view> </view>
<view class="field" wx:if="{{member.contactDisplay || member.contactRaw}}"> <view class="field" wx:if="{{member.contactRaw || member.contactDisplay}}">
<text class="f-key">联系方式</text> <text class="f-key">联系方式</text>
<view class="f-row"> <view class="f-row">
<text class="f-val mono">{{member.contactDisplay || '未填写'}}</text> <text class="f-val mono">{{member.contactDisplay || member.contactRaw}}</text>
<view class="icon-copy" wx:if="{{!member.contactUnlocked}}" bindtap="unlockContact">🔒</view> <view class="icon-copy icon-eye-off" wx:if="{{member.contactRaw && !member.contactUnlocked}}" bindtap="unlockContact">
<view class="icon-copy" wx:else bindtap="copyContact">📋</view> <image class="icon-img" src="/assets/icons/eye-off.svg" mode="aspectFit"/>
</view>
<view class="icon-copy" wx:elif="{{member.contactRaw && member.contactUnlocked}}" bindtap="copyContact">📋</view>
</view> </view>
</view> </view>
<view class="field" wx:if="{{member.wechatDisplay || member.wechatRaw}}"> <view class="field" wx:if="{{member.wechatRaw || member.wechatDisplay}}">
<text class="f-key">微信号</text> <text class="f-key">微信号</text>
<view class="f-row"> <view class="f-row">
<text class="f-val mono">{{member.wechatDisplay || '未填写'}}</text> <text class="f-val mono">{{member.wechatDisplay || member.wechatRaw}}</text>
<view class="icon-copy" wx:if="{{!member.wechatUnlocked}}" bindtap="unlockContact">🔒</view> <view class="icon-copy icon-eye-off" wx:if="{{member.wechatRaw && !member.wechatUnlocked}}" bindtap="unlockContact">
<view class="icon-copy" wx:else bindtap="copyWechat">📋</view> <image class="icon-img" src="/assets/icons/eye-off.svg" mode="aspectFit"/>
</view>
<view class="icon-copy" wx:elif="{{member.wechatRaw && member.wechatUnlocked}}" bindtap="copyWechat">📋</view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<!-- 个人故事 --> <!-- 个人故事(未填写行已隐藏) -->
<view class="card" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}"> <view class="card" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
<view class="card-head"> <view class="card-head">
<text class="card-icon bulb">💡</text> <text class="card-icon bulb">💡</text>
@@ -85,12 +88,12 @@
<view class="story-head"><text class="story-icon">🏆</text><text class="story-q">最赚钱的一个月做的是什么</text></view> <view class="story-head"><text class="story-icon">🏆</text><text class="story-q">最赚钱的一个月做的是什么</text></view>
<text class="story-a">{{member.bestMonth}}</text> <text class="story-a">{{member.bestMonth}}</text>
</view> </view>
<view class="divider" wx:if="{{member.bestMonth && (member.achievement || member.turningPoint)}}"></view> <view class="divider" wx:if="{{member.bestMonth}}"></view>
<view class="story" wx:if="{{member.achievement}}"> <view class="story" wx:if="{{member.achievement}}">
<view class="story-head"><text class="story-icon">⭐</text><text class="story-q">最有成就感的一件事</text></view> <view class="story-head"><text class="story-icon">⭐</text><text class="story-q">最有成就感的一件事</text></view>
<text class="story-a">{{member.achievement}}</text> <text class="story-a">{{member.achievement}}</text>
</view> </view>
<view class="divider" wx:if="{{member.achievement && member.turningPoint}}"></view> <view class="divider" wx:if="{{member.achievement}}"></view>
<view class="story" wx:if="{{member.turningPoint}}"> <view class="story" wx:if="{{member.turningPoint}}">
<view class="story-head"><text class="story-icon turn">🔄</text><text class="story-q">人生的转折点</text></view> <view class="story-head"><text class="story-icon turn">🔄</text><text class="story-q">人生的转折点</text></view>
<text class="story-a">{{member.turningPoint}}</text> <text class="story-a">{{member.turningPoint}}</text>
@@ -98,7 +101,7 @@
</view> </view>
</view> </view>
<!-- 互助需求 --> <!-- 互助需求(未填写行已隐藏) -->
<view class="card" wx:if="{{member.canHelp || member.needHelp}}"> <view class="card" wx:if="{{member.canHelp || member.needHelp}}">
<view class="card-head"> <view class="card-head">
<text class="card-icon">🤝</text> <text class="card-icon">🤝</text>

View File

@@ -35,12 +35,16 @@
pointer-events: none; pointer-events: none;
} }
.profile-body { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; } .profile-body { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; }
.avatar-wrap { .avatar-outer {
position: relative; position: relative;
width: 176rpx; height: 176rpx; width: 176rpx; height: 176rpx;
margin-bottom: 32rpx;
}
.avatar-wrap {
position: relative;
width: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
margin-bottom: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.1); border: 2rpx solid rgba(255, 255, 255, 0.1);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.4); box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.4);
} }
@@ -62,10 +66,12 @@
font-size: 56rpx; color: #5EEAD4; font-weight: 700; font-size: 56rpx; color: #5EEAD4; font-weight: 700;
} }
.vip-tag { .vip-tag {
position: absolute; bottom: 4rpx; right: 4rpx; position: absolute; bottom: -4rpx; right: -4rpx;
background: linear-gradient(135deg, #F59E0B, #e8920d); background: linear-gradient(135deg, #F59E0B, #e8920d);
color: #000; font-size: 20rpx; font-weight: 800; color: #000; font-size: 20rpx; font-weight: 800;
padding: 6rpx 16rpx; border-radius: 20rpx; padding: 6rpx 14rpx; border-radius: 16rpx;
z-index: 2;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
} }
.profile-name { font-size: 40rpx; font-weight: 700; color: #fff; margin-bottom: 24rpx; letter-spacing: 2rpx; } .profile-name { font-size: 40rpx; font-weight: 700; color: #fff; margin-bottom: 24rpx; letter-spacing: 2rpx; }
.profile-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; flex-wrap: wrap; } .profile-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; flex-wrap: wrap; }
@@ -96,6 +102,8 @@
.f-val.mono { font-family: ui-monospace, monospace; letter-spacing: 2rpx; } .f-val.mono { font-family: ui-monospace, monospace; letter-spacing: 2rpx; }
.f-row { display: flex; align-items: center; gap: 16rpx; } .f-row { display: flex; align-items: center; gap: 16rpx; }
.icon-copy { font-size: 36rpx; color: #94A3B8; opacity: 0.6; padding: 8rpx; } .icon-copy { font-size: 36rpx; color: #94A3B8; opacity: 0.6; padding: 8rpx; }
.icon-eye-off { display: flex; align-items: center; justify-content: center; }
.icon-eye-off .icon-img { width: 40rpx; height: 40rpx; }
.divider { height: 1rpx; background: rgba(255, 255, 255, 0.05); margin: 32rpx 0; } .divider { height: 1rpx; background: rgba(255, 255, 255, 0.05); margin: 32rpx 0; }

View File

@@ -1,8 +1,11 @@
<!-- 我的页 - professional_profile_with_earnings_vip 1:1 还原 --> <!-- 我的页 - 设计稿 1:1 还原 -->
<view class="page"> <view class="page">
<!-- 顶部导航:标题(设置已移至用户区右侧,避让胶囊) --> <!-- 顶部导航:标题 + 右侧设置齿轮 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;"> <view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<text class="nav-title">我的</text> <text class="nav-title">我的</text>
<view class="nav-settings" bindtap="handleMenuTap" data-id="settings">
<image class="nav-settings-icon" src="/assets/icons/settings-gray.svg" mode="aspectFit"/>
</view>
</view> </view>
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view> <view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
@@ -16,77 +19,69 @@
<view class="guest-login-btn" bindtap="showLogin">点击登录</view> <view class="guest-login-btn" bindtap="showLogin">点击登录</view>
</view> </view>
<!-- 已登录:用户 --> <!-- 已登录:用户卡片(设计稿布局) -->
<view class="header-block" wx:else> <view class="profile-card" wx:else>
<view class="user-row"> <view class="profile-card-inner">
<view class="avatar-wrap" bindtap="onAvatarTap"> <view class="profile-top-row">
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}"> <view class="avatar-wrap" bindtap="onAvatarTap">
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/> <view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
<text wx:else class="avatar-text">{{userInfo.nickname ? userInfo.nickname[0] : '?'}}</text> <image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
<text wx:else class="avatar-text">{{userInfo.nickname ? userInfo.nickname[0] : '?'}}</text>
</view>
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
<view class="vip-badge vip-badge-gray" wx:else bindtap="goToVip">VIP</view>
</view> </view>
<view class="vip-badge" wx:if="{{isVip}}">VIP</view> <view class="profile-meta">
<view class="vip-badge vip-badge-gray" wx:else bindtap="goToVip">VIP</view> <view class="profile-name-row">
</view> <text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
<view class="user-meta"> <view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text> </view>
<view class="vip-tags"> <view class="vip-tags">
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text> <text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">匹配</text> <text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">匹配</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text> <text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
</view>
<text class="user-wechat" bindtap="copyUserId">微信号: {{userWechat || userIdShort || '--'}}</text>
</view> </view>
<text class="user-id" bindtap="copyUserId">{{userWechat ? '微信: ' + userWechat : 'ID: ' + userIdShort}}</text>
<text class="vip-expire" wx:if="{{isVip && vipExpireDate}}">会员到期时间:{{vipExpireDate}}</text>
</view> </view>
<view class="user-actions"> <view class="profile-stats-row">
<view class="action-btn" bindtap="handleMenuTap" data-id="settings"><text class="action-icon">⚙️</text></view> <view class="profile-stat">
<view class="action-btn" catchtap="goToProfileEdit"><text class="action-icon">✎</text></view> <text class="profile-stat-val">{{readCount}}</text>
<text class="profile-stat-label">已读章节</text>
</view>
<view class="profile-stat">
<text class="profile-stat-val">{{referralCount}}</text>
<text class="profile-stat-label">推荐好友</text>
</view>
<view class="profile-stat">
<text class="profile-stat-val">{{earnings === '-' ? '--' : earnings}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
</view> </view>
</view> </view>
</view> </view>
<!-- 已登录:内容区 --> <!-- 已登录:内容区 -->
<view class="main-content" wx:if="{{isLoggedIn}}"> <view class="main-content" wx:if="{{isLoggedIn}}">
<!-- 分享收益 -->
<view class="card earnings-card">
<view class="card-header">
<text class="card-icon">💰</text>
<text class="card-title">分享收益</text>
</view>
<view class="earnings-grid">
<view class="earnings-col">
<text class="earnings-val primary">{{referralCount}}</text>
<text class="earnings-label">推荐好友</text>
</view>
<view class="earnings-col">
<text class="earnings-val primary">{{earnings === '-' ? '--' : earnings}}</text>
<text class="earnings-label">我的收益</text>
</view>
<view class="earnings-col" bindtap="handleWithdraw">
<text class="earnings-val primary">{{pendingEarnings === '-' ? '--' : pendingEarnings}}</text>
<text class="earnings-label">可提现金额</text>
</view>
</view>
</view>
<!-- 阅读统计 --> <!-- 阅读统计 -->
<view class="card stats-card"> <view class="card stats-card">
<view class="card-header"> <view class="card-header">
<text class="card-icon">👁️</text> <image class="card-icon-img" src="/assets/icons/eye-teal.svg" mode="aspectFit"/>
<text class="card-title">阅读统计</text> <text class="card-title">阅读统计</text>
</view> </view>
<view class="stats-grid"> <view class="stats-grid">
<view class="stat-box"> <view class="stat-box">
<text class="stat-icon">📖</text> <image class="stat-icon-img" src="/assets/icons/book-open-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{readCount}}</text> <text class="stat-num">{{readCount}}</text>
<text class="stat-label">已读章节</text> <text class="stat-label">已读章节</text>
</view> </view>
<view class="stat-box"> <view class="stat-box">
<text class="stat-icon">⏱</text> <image class="stat-icon-img" src="/assets/icons/clock-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{totalReadTime}}</text> <text class="stat-num">{{totalReadTime}}</text>
<text class="stat-label">阅读分钟</text> <text class="stat-label">阅读分钟</text>
</view> </view>
<view class="stat-box"> <view class="stat-box">
<text class="stat-icon">👥</text> <image class="stat-icon-img" src="/assets/icons/users-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{matchHistory}}</text> <text class="stat-num">{{matchHistory}}</text>
<text class="stat-label">匹配伙伴</text> <text class="stat-label">匹配伙伴</text>
</view> </view>
@@ -96,7 +91,7 @@
<!-- 最近阅读 --> <!-- 最近阅读 -->
<view class="card recent-card"> <view class="card recent-card">
<view class="card-header"> <view class="card-header">
<text class="card-icon">📖</text> <image class="card-icon-img" src="/assets/icons/book-arrow-teal.svg" mode="aspectFit"/>
<text class="card-title">最近阅读</text> <text class="card-title">最近阅读</text>
</view> </view>
<view class="recent-list" wx:if="{{recentChapters.length > 0}}"> <view class="recent-list" wx:if="{{recentChapters.length > 0}}">
@@ -121,22 +116,29 @@
</view> </view>
</view> </view>
<!-- 我的订单 + 关于作者 --> <!-- 我的订单 + 关于作者 + 设置 -->
<view class="card menu-card"> <view class="card menu-card">
<view class="menu-item" bindtap="handleMenuTap" data-id="orders"> <view class="menu-item" bindtap="handleMenuTap" data-id="orders">
<view class="menu-left"> <view class="menu-left">
<view class="menu-icon-wrap icon-teal"><text class="menu-icon">📦</text></view> <view class="menu-icon-wrap icon-teal"><image class="menu-icon-img" src="/assets/icons/folder-teal.svg" mode="aspectFit"/></view>
<text class="menu-text">我的订单</text> <text class="menu-text">我的订单</text>
</view> </view>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
<view class="menu-item" bindtap="handleMenuTap" data-id="about"> <view class="menu-item" bindtap="handleMenuTap" data-id="about">
<view class="menu-left"> <view class="menu-left">
<view class="menu-icon-wrap icon-blue"><text class="menu-icon"></text></view> <view class="menu-icon-wrap icon-blue"><image class="menu-icon-img" src="/assets/icons/info-blue.svg" mode="aspectFit"/></view>
<text class="menu-text">关于作者</text> <text class="menu-text">关于作者</text>
</view> </view>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
<view class="menu-item" bindtap="handleMenuTap" data-id="settings">
<view class="menu-left">
<view class="menu-icon-wrap icon-gray"><image class="menu-icon-img" src="/assets/icons/settings-gray.svg" mode="aspectFit"/></view>
<text class="menu-text">设置</text>
</view>
<text class="menu-arrow"></text>
</view>
</view> </view>
</view> </view>

View File

@@ -5,7 +5,6 @@
/* 真机适配:底部留足 TabBar + 安全区,避免「我的订单」被遮挡 */ /* 真机适配:底部留足 TabBar + 安全区,避免「我的订单」被遮挡 */
.page { .page {
min-height: 100vh;
background: #121212; background: #121212;
padding-bottom: calc(220rpx + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(220rpx + env(safe-area-inset-bottom, 0px));
} }
@@ -16,16 +15,18 @@
background: rgba(18,18,18,0.9); backdrop-filter: blur(8rpx); background: rgba(18,18,18,0.9); backdrop-filter: blur(8rpx);
display: flex; align-items: center; display: flex; align-items: center;
min-height: 44px; min-height: 44px;
padding: 0 200rpx 0 32rpx; /* 右侧 200rpx 避让真机右上角胶囊 */ padding: 0 120rpx 0 32rpx; /* 右侧避让胶囊 */
border-bottom: 1rpx solid rgba(255,255,255,0.05); border-bottom: 1rpx solid rgba(255,255,255,0.05);
} }
.nav-title { font-size: 40rpx; font-weight: bold; color: #4FD1C5; } .nav-title { font-size: 40rpx; font-weight: bold; color: #4FD1C5; flex: 1; }
.nav-settings { width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; }
.nav-settings-icon { width: 44rpx; height: 44rpx; opacity: 0.7; }
.nav-placeholder { width: 100%; } .nav-placeholder { width: 100%; }
/* ===== 未登录 ===== */ /* ===== 未登录 ===== */
.guest-block { .guest-block {
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
padding: 64rpx 48rpx; padding: 64rpx 16rpx;
} }
.guest-avatar { width: 144rpx; height: 144rpx; border-radius: 50%; background: #1A1A1A; border: 4rpx solid #374151; overflow: hidden; margin-bottom: 24rpx; } .guest-avatar { width: 144rpx; height: 144rpx; border-radius: 50%; background: #1A1A1A; border: 4rpx solid #374151; overflow: hidden; margin-bottom: 24rpx; }
.guest-avatar-img { width: 100%; height: 100%; display: block; } .guest-avatar-img { width: 100%; height: 100%; display: block; }
@@ -33,9 +34,13 @@
.guest-name { font-size: 36rpx; font-weight: bold; color: #E5E7EB; margin-bottom: 24rpx; } .guest-name { font-size: 36rpx; font-weight: bold; color: #E5E7EB; margin-bottom: 24rpx; }
.guest-login-btn { padding: 20rpx 48rpx; background: #4FD1C5; color: #000; font-size: 28rpx; font-weight: 600; border-radius: 24rpx; } .guest-login-btn { padding: 20rpx 48rpx; background: #4FD1C5; color: #000; font-size: 28rpx; font-weight: 600; border-radius: 24rpx; }
/* ===== 用户(设计稿 header ===== */ /* ===== 用户卡片(设计稿 1:1 ===== */
.header-block { padding: 32rpx 40rpx 48rpx; } .profile-card { padding: 24rpx 16rpx 32rpx; }
.user-row { display: flex; align-items: center; gap: 32rpx; } .profile-card-inner {
background: #1A1A1A; border-radius: 24rpx; padding: 32rpx;
border: 1rpx solid rgba(75,85,99,0.5);
}
.profile-top-row { display: flex; align-items: flex-start; gap: 32rpx; }
.avatar-wrap { position: relative; flex-shrink: 0; } .avatar-wrap { position: relative; flex-shrink: 0; }
.avatar-inner { .avatar-inner {
width: 130rpx; height: 130rpx; border-radius: 50%; overflow: hidden; width: 130rpx; height: 130rpx; border-radius: 50%; overflow: hidden;
@@ -52,33 +57,43 @@
padding: 4rpx 12rpx; border-radius: 8rpx; padding: 4rpx 12rpx; border-radius: 8rpx;
} }
.vip-badge-gray { background: rgba(255,255,255,0.2); color: rgba(255,255,255,0.5); } .vip-badge-gray { background: rgba(255,255,255,0.2); color: rgba(255,255,255,0.5); }
.user-actions { display: flex; flex-direction: column; gap: 24rpx; flex-shrink: 0; margin-left: auto; align-items: flex-end; } .profile-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
.action-btn { width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; } .profile-name-row { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
.action-icon { font-size: 36rpx; color: #4FD1C5; opacity: 0.9; }
.user-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.user-name { .user-name {
font-size: 44rpx; font-weight: bold; color: #fff; font-size: 44rpx; font-weight: bold; color: #fff;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;
} }
.become-member-btn {
padding: 12rpx 28rpx; border: 2rpx solid #C8A146; color: #C8A146;
font-size: 24rpx; font-weight: 500; border-radius: 40rpx; white-space: nowrap; flex-shrink: 0;
}
.become-member-vip { border-color: rgba(200,161,70,0.5); color: rgba(200,161,70,0.8); }
.vip-tags { display: flex; gap: 12rpx; flex-shrink: 0; } .vip-tags { display: flex; gap: 12rpx; flex-shrink: 0; }
.vip-tag { .vip-tag {
font-size: 20rpx; padding: 6rpx 12rpx; border-radius: 8rpx; font-size: 20rpx; padding: 6rpx 12rpx; border-radius: 8rpx;
border: 1rpx solid #374151; background: rgba(255,255,255,0.05); color: #9CA3AF; border: 1rpx solid #374151; background: rgba(255,255,255,0.05); color: #9CA3AF;
} }
.vip-tag-active { border-color: #C8A146; background: rgba(200,161,70,0.1); color: #C8A146; } .vip-tag-active { border-color: #C8A146; background: rgba(200,161,70,0.1); color: #C8A146; }
.user-id { display: block; font-size: 28rpx; color: #6B7280; } .user-wechat { font-size: 26rpx; color: #6B7280; }
.vip-expire { display: block; font-size: 22rpx; color: #6B7280; margin-top: 4rpx; } .profile-stats-row {
display: flex; justify-content: space-around; margin-top: 32rpx;
padding-top: 24rpx; border-top: 1rpx solid #374151;
}
.profile-stat { text-align: center; }
.profile-stat-val { display: block; font-size: 36rpx; font-weight: bold; color: #4FD1C5; }
.profile-stat-label { display: block; font-size: 22rpx; color: #6B7280; margin-top: 8rpx; }
/* ===== 主内容区 ===== */ /* ===== 主内容区 ===== */
.main-content { padding: 0 32rpx 48rpx; } .main-content { }
/* 卡片通用 */ /* 卡片通用 */
.card { .card {
background: #1A1A1A; border-radius: 24rpx; padding: 40rpx; background: #1A1A1A; border-radius: 24rpx; padding: 32rpx;
margin-bottom: 32rpx; border: 1rpx solid rgba(75,85,99,0.5); margin-bottom: 24rpx; border: 1rpx solid rgba(75,85,99,0.5);
} }
.card-header { display: flex; align-items: center; gap: 16rpx; margin-bottom: 32rpx; } .card-header { display: flex; align-items: center; gap: 16rpx; margin-bottom: 32rpx; }
.card-icon { font-size: 40rpx; } .card-icon { font-size: 40rpx; }
.card-icon-img { width: 40rpx; height: 40rpx; flex-shrink: 0; }
.card-title { font-size: 32rpx; font-weight: bold; color: #fff; } .card-title { font-size: 32rpx; font-weight: bold; color: #fff; }
/* 分享收益 */ /* 分享收益 */
@@ -92,16 +107,20 @@
.earnings-val.primary { color: #4FD1C5; } .earnings-val.primary { color: #4FD1C5; }
.earnings-label { display: block; font-size: 24rpx; color: #6B7280; margin-top: 8rpx; } .earnings-label { display: block; font-size: 24rpx; color: #6B7280; margin-top: 8rpx; }
/* 阅读统计 */ /* 阅读统计 - 统一高度避免真机错位 */
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24rpx; } .stats-grid {
.stat-box { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24rpx;
background: #252525; border-radius: 20rpx; padding: 20rpx; align-items: stretch;
display: flex; flex-direction: column; align-items: center; justify-content: center;
aspect-ratio: 2/1;
} }
.stat-icon { font-size: 40rpx; margin-bottom: 8rpx; color: #4FD1C5; } .stat-box {
.stat-num { font-size: 36rpx; font-weight: bold; color: #fff; } background: #252525; border-radius: 20rpx; padding: 24rpx;
.stat-label { font-size: 20rpx; color: #6B7280; margin-top: 4rpx; } display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 140rpx;
}
.stat-icon { font-size: 40rpx; margin-bottom: 8rpx; color: #4FD1C5; flex-shrink: 0; }
.stat-icon-img { width: 44rpx; height: 44rpx; margin-bottom: 8rpx; flex-shrink: 0; display: block; }
.stat-num { font-size: 36rpx; font-weight: bold; color: #fff; line-height: 1.2; }
.stat-label { font-size: 20rpx; color: #6B7280; margin-top: 4rpx; line-height: 1.2; }
/* 最近阅读 */ /* 最近阅读 */
.recent-list { display: flex; flex-direction: column; gap: 24rpx; } .recent-list { display: flex; flex-direction: column; gap: 24rpx; }
@@ -131,9 +150,15 @@
} }
.menu-icon-wrap .menu-icon { font-size: 32rpx; } .menu-icon-wrap .menu-icon { font-size: 32rpx; }
.icon-teal { background: rgba(79,209,197,0.2); } .icon-teal { background: rgba(79,209,197,0.2); }
.icon-teal .menu-icon { color: #4FD1C5; } .icon-teal .menu-icon,
.icon-teal .menu-icon-img { color: #4FD1C5; }
.icon-teal .menu-icon-img { width: 32rpx; height: 32rpx; }
.icon-blue { background: rgba(59,130,246,0.2); } .icon-blue { background: rgba(59,130,246,0.2); }
.icon-blue .menu-icon { color: #3B82F6; } .icon-blue .menu-icon,
.icon-blue .menu-icon-img { color: #3B82F6; }
.icon-blue .menu-icon-img { width: 32rpx; height: 32rpx; }
.icon-gray { background: rgba(156,163,175,0.15); }
.icon-gray .menu-icon-img { width: 32rpx; height: 32rpx; }
.menu-text { font-size: 28rpx; color: #E5E7EB; font-weight: 500; } .menu-text { font-size: 28rpx; color: #E5E7EB; font-weight: 500; }
.menu-arrow { font-size: 36rpx; color: #9CA3AF; } .menu-arrow { font-size: 36rpx; color: #9CA3AF; }

View File

@@ -33,11 +33,22 @@ Page({
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userInfo.id}`, silent: true }) const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userInfo.id}`, silent: true })
if (res?.success && res.data) { if (res?.success && res.data) {
const d = res.data const d = res.data
const e = (v) => (v == null || v === undefined ? '' : (String(v).trim() === '' || String(v).trim() === '未填写' ? '' : String(v).trim()))
const phone = d.phone || '' const phone = d.phone || ''
const wechat = d.wechatId || wx.getStorageSync('user_wechat') || '' const wechat = d.wechatId || wx.getStorageSync('user_wechat') || ''
this.setData({ this.setData({
profile: { profile: {
...d, ...d,
industry: e(d.industry),
position: e(d.position),
businessScale: e(d.businessScale || d.business_scale),
skills: e(d.skills),
storyBestMonth: e(d.storyBestMonth || d.story_best_month),
storyAchievement: e(d.storyAchievement || d.story_achievement),
storyTurning: e(d.storyTurning || d.story_turning),
helpOffer: e(d.helpOffer || d.help_offer),
helpNeed: e(d.helpNeed || d.help_need),
projectIntro: e(d.projectIntro || d.project_intro),
phoneMask: phone ? phone.slice(0, 3) + '****' + phone.slice(-2) : '', phoneMask: phone ? phone.slice(0, 3) + '****' + phone.slice(-2) : '',
wechatMask: wechat ? (wechat.length > 8 ? wechat.slice(0, 4) + '****' + wechat.slice(-3) : wechat) : '', wechatMask: wechat ? (wechat.length > 8 ? wechat.slice(0, 4) + '****' + wechat.slice(-3) : wechat) : '',
phone, phone,

View File

@@ -63,7 +63,7 @@
<text class="field-hint" wx:if="{{profile.wechat}}">复制</text> <text class="field-hint" wx:if="{{profile.wechat}}">复制</text>
</view> </view>
</view> </view>
<view class="field-empty" wx:if="{{!profile.industry && !profile.position && !profile.phone && !profile.wechat && !profile.skills}}"> <view class="field-empty" wx:if="{{!profile.industry && !profile.position && !profile.businessScale && !profile.skills && !profile.phone && !profile.wechat}}">
点击右上角 ⋯ 编辑完善资料 点击右上角 ⋯ 编辑完善资料
</view> </view>
</view> </view>

View File

@@ -65,9 +65,6 @@ Page({
showPosterModal: false, showPosterModal: false,
isPaying: false, isPaying: false,
isGeneratingPoster: false, isGeneratingPoster: false,
// 免费章节
freeIds: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
// 章节 mid扫码/海报分享用,便于分享 path 带 mid // 章节 mid扫码/海报分享用,便于分享 path 带 mid
sectionMid: null sectionMid: null
@@ -124,12 +121,13 @@ Page({
try { try {
const config = await accessManager.fetchLatestConfig() const config = await accessManager.fetchLatestConfig()
this.setData({ this.setData({
freeIds: config.freeChapters,
sectionPrice: config.prices?.section ?? 1, sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9 fullBookPrice: config.prices?.fullbook ?? 9.9
}) })
const accessState = await accessManager.determineAccessState(id, config.freeChapters) // 统一:先拉章节数据,用 isFree/price===0 判断免费
const chapterRes = await app.request({ url: `/api/miniprogram/book/chapter/${id}`, silent: true })
const accessState = await accessManager.determineAccessState(id, chapterRes)
const canAccess = accessManager.canAccessFullContent(accessState) const canAccess = accessManager.canAccessFullContent(accessState)
this.setData({ this.setData({
@@ -139,8 +137,8 @@ Page({
showPaywall: !canAccess showPaywall: !canAccess
}) })
// 【标准流程】3. 加载内容 // 加载内容(复用已拉取的章节数据,避免二次请求)
await this.loadContent(id, accessState) await this.loadContent(id, accessState, chapterRes)
// 【标准流程】4. 如果有权限,初始化阅读追踪 // 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) { if (canAccess) {
@@ -192,18 +190,22 @@ Page({
}, },
// 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成) // 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成)
async loadContent(id, accessState) { // prefetchedChapter若已有章节数据含 content则复用避免二次请求
async loadContent(id, accessState, prefetchedChapter) {
try { try {
const section = this.getSectionInfo(id)
const sectionPrice = this.data.sectionPrice ?? 1 const sectionPrice = this.data.sectionPrice ?? 1
if (section.price === undefined || section.price === null) { let res = prefetchedChapter
section.price = sectionPrice if (!res || !res.content) {
res = await app.request({ url: `/api/miniprogram/book/chapter/${id}`, silent: true })
}
const section = {
id: res.id || id,
title: res.sectionTitle || res.title || this.getSectionTitle(id),
isFree: res.isFree === true || (res.price !== undefined && res.price === 0),
price: res.price ?? sectionPrice
} }
this.setData({ section }) this.setData({ section })
// 从 API 获取内容
const res = await app.request({ url: `/api/miniprogram/book/chapter/${id}`, silent: true })
if (res && res.content) { if (res && res.content) {
const lines = res.content.split('\n').filter(line => line.trim()) const lines = res.content.split('\n').filter(line => line.trim())
const previewCount = Math.ceil(lines.length * 0.2) const previewCount = Math.ceil(lines.length * 0.2)
@@ -584,14 +586,14 @@ Page({
// 1. 刷新用户购买状态(从 orders 表拉取最新) // 1. 刷新用户购买状态(从 orders 表拉取最新)
await accessManager.refreshUserPurchaseStatus() await accessManager.refreshUserPurchaseStatus()
// 2. 重新拉取免费列表(极端情况:刚登录时当前章节可能改免费了) // 2. 重新拉取章节数据,用 isFree/price 判断免费
const config = await accessManager.fetchLatestConfig() const chapterRes = await app.request({
this.setData({ freeIds: config.freeChapters }) url: `/api/miniprogram/book/chapter/${this.data.sectionId}`,
silent: true
// 3. 重新判断当前章节权限 })
const newAccessState = await accessManager.determineAccessState( const newAccessState = await accessManager.determineAccessState(
this.data.sectionId, this.data.sectionId,
config.freeChapters chapterRes
) )
const canAccess = accessManager.canAccessFullContent(newAccessState) const canAccess = accessManager.canAccessFullContent(newAccessState)
@@ -602,9 +604,9 @@ Page({
showPaywall: !canAccess showPaywall: !canAccess
}) })
// 4. 如果已解锁,重新加载内容并初始化阅读追踪 // 3. 如果已解锁,重新加载内容并初始化阅读追踪
if (canAccess) { if (canAccess) {
await this.loadContent(this.data.sectionId, newAccessState) await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
readingTracker.init(this.data.sectionId) readingTracker.init(this.data.sectionId)
} }
@@ -855,19 +857,22 @@ Page({
// 2. 刷新用户购买状态 // 2. 刷新用户购买状态
await accessManager.refreshUserPurchaseStatus() await accessManager.refreshUserPurchaseStatus()
// 3. 重新判断当前章节权限(应为 unlocked_purchased // 3. 重新拉取章节并判断权限(应为 unlocked_purchased
const chapterRes = await app.request({
url: `/api/miniprogram/book/chapter/${this.data.sectionId}`,
silent: true
})
let newAccessState = await accessManager.determineAccessState( let newAccessState = await accessManager.determineAccessState(
this.data.sectionId, this.data.sectionId,
this.data.freeIds chapterRes
) )
// 如果权限未生效,再重试一次(可能回调延迟)
if (newAccessState !== 'unlocked_purchased') { if (newAccessState !== 'unlocked_purchased') {
console.log('[Pay] 权限未生效1秒后重试...') console.log('[Pay] 权限未生效1秒后重试...')
await this.sleep(1000) await this.sleep(1000)
newAccessState = await accessManager.determineAccessState( newAccessState = await accessManager.determineAccessState(
this.data.sectionId, this.data.sectionId,
this.data.freeIds chapterRes
) )
} }
@@ -880,7 +885,7 @@ Page({
}) })
// 4. 重新加载全文 // 4. 重新加载全文
await this.loadContent(this.data.sectionId, newAccessState) await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
// 5. 初始化阅读追踪 // 5. 初始化阅读追踪
if (canAccess) { if (canAccess) {
@@ -1188,14 +1193,20 @@ Page({
wx.showLoading({ title: '重试中...', mask: true }) wx.showLoading({ title: '重试中...', mask: true })
try { try {
// 重新拉取配置
const config = await accessManager.fetchLatestConfig() const config = await accessManager.fetchLatestConfig()
this.setData({ freeIds: config.freeChapters }) this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 重新判断权限 // 重新拉取章节,用 isFree/price 判断免费
const chapterRes = await app.request({
url: `/api/miniprogram/book/chapter/${this.data.sectionId}`,
silent: true
})
const newAccessState = await accessManager.determineAccessState( const newAccessState = await accessManager.determineAccessState(
this.data.sectionId, this.data.sectionId,
config.freeChapters chapterRes
) )
const canAccess = accessManager.canAccessFullContent(newAccessState) const canAccess = accessManager.canAccessFullContent(newAccessState)
@@ -1205,8 +1216,7 @@ Page({
showPaywall: !canAccess showPaywall: !canAccess
}) })
// 重新加载内容 await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
await this.loadContent(this.data.sectionId, newAccessState)
// 如果有权限,初始化阅读追踪 // 如果有权限,初始化阅读追踪
if (canAccess) { if (canAccess) {

View File

@@ -21,7 +21,6 @@ Page({
{ title: '链接资源', desc: '深度私域资源池', icon: '🔗' }, { title: '链接资源', desc: '深度私域资源池', icon: '🔗' },
{ title: '专属VIP标识', desc: '金色尊享光圈', icon: '✓' } { title: '专属VIP标识', desc: '金色尊享光圈', icon: '✓' }
], ],
profile: { vipName: '', vipProject: '', vipContact: '', vipAvatar: '', vipBio: '' },
purchasing: false purchasing: false
}, },
@@ -49,62 +48,10 @@ Page({
expireDateStr: expStr, expireDateStr: expStr,
price: d.price || 1980 price: d.price || 1980
}) })
if (d.isVip) this.loadProfile(userId)
} }
} catch (e) { console.log('[VIP] 加载失败', e) } } catch (e) { console.log('[VIP] 加载失败', e) }
}, },
async loadProfile(userId) {
try {
const res = await app.request(`/api/miniprogram/vip/profile?userId=${userId}`)
if (res?.success) {
const p = res.data
// 头像若为相对路径则补全
if (p.vipAvatar && !p.vipAvatar.startsWith('http')) {
p.vipAvatar = app.globalData.baseUrl.replace(/\/$/, '') + (p.vipAvatar.startsWith('/') ? p.vipAvatar : '/' + p.vipAvatar)
}
this.setData({ profile: p })
}
} catch (e) { console.log('[VIP] 资料加载失败', e) }
},
async onChooseVipAvatar() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempPath = res.tempFiles[0].tempFilePath
wx.showLoading({ title: '上传中...', mask: true })
try {
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempPath,
name: 'file',
formData: { folder: 'avatars' },
success: (r) => {
try {
const d = JSON.parse(r.data)
d.success ? resolve(d) : reject(new Error(d.error || '上传失败'))
} catch { reject(new Error('解析失败')) }
},
fail: reject
})
})
const path = uploadRes.url || uploadRes.data?.url || ''
const avatarUrl = path.startsWith('http') ? path : (app.globalData.baseUrl.replace(/\/$/, '') + (path.startsWith('/') ? path : '/' + path))
this.setData({ 'profile.vipAvatar': avatarUrl })
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '上传失败', icon: 'none' })
}
}
})
},
async handlePurchase() { async handlePurchase() {
let userId = app.globalData.userInfo?.id let userId = app.globalData.userInfo?.id
let openId = app.globalData.openId || app.globalData.userInfo?.open_id let openId = app.globalData.openId || app.globalData.userInfo?.open_id
@@ -156,24 +103,6 @@ Page({
} finally { this.setData({ purchasing: false }) } } finally { this.setData({ purchasing: false }) }
}, },
onVipNameInput(e) { this.setData({ 'profile.vipName': e.detail.value }) },
onVipProjectInput(e) { this.setData({ 'profile.vipProject': e.detail.value }) },
onVipContactInput(e) { this.setData({ 'profile.vipContact': e.detail.value }) },
onVipBioInput(e) { this.setData({ 'profile.vipBio': e.detail.value }) },
async saveProfile() {
const userId = app.globalData.userInfo?.id
if (!userId) return
const p = this.data.profile
try {
const res = await app.request('/api/miniprogram/vip/profile', {
method: 'POST', data: { userId, vipName: p.vipName, vipProject: p.vipProject, vipContact: p.vipContact, vipAvatar: p.vipAvatar, vipBio: p.vipBio }
})
if (res?.success) wx.showToast({ title: '资料已保存', icon: 'success' })
else wx.showToast({ title: res?.error || '保存失败', icon: 'none' })
} catch (e) { wx.showToast({ title: '保存失败', icon: 'none' }) }
},
goBack() { wx.navigateBack() }, goBack() { wx.navigateBack() },
onShareAppMessage() { onShareAppMessage() {

View File

@@ -8,14 +8,10 @@
<view class="nav-placeholder-r"></view> <view class="nav-placeholder-r"></view>
</view> </view>
<view style="height: {{statusBarHeight + 44}}px;"></view> <view style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 会员宣传区 - 设计稿 premium 卡片 --> <!-- 会员宣传区(已去掉 VIP PREMIUM 标签) -->
<view class="vip-hero {{isVip ? 'vip-hero-active' : ''}}"> <view class="vip-hero {{isVip ? 'vip-hero-active' : ''}}">
<text class="vip-hero-tag">VIP PREMIUM</text> <view class="vip-hero-title">加入卡若的</view>
<text class="vip-hero-title"> <view class="vip-hero-title gold">创业派对 会员</view>
加入卡若的
<text class="gold">创业派对</text>
会员
</text>
<text class="vip-hero-sub" wx:if="{{isVip}}">有效期至 {{expireDateStr}}(剩余{{daysRemaining}}天)</text> <text class="vip-hero-sub" wx:if="{{isVip}}">有效期至 {{expireDateStr}}(剩余{{daysRemaining}}天)</text>
<text class="vip-hero-sub" wx:else>一次加入 尊享终身陪伴与成长</text> <text class="vip-hero-sub" wx:else>一次加入 尊享终身陪伴与成长</text>
</view> </view>
@@ -54,47 +50,6 @@
{{purchasing ? "处理中..." : "¥" + price + "/年 加入创业派对"}} {{purchasing ? "处理中..." : "¥" + price + "/年 加入创业派对"}}
</view> </view>
</view> </view>
<view class="bottom-spacer" wx:if="{{!isVip}}"></view>
<!-- VIP资料填写仅VIP可见 -->
<view class="profile-card" wx:if="{{isVip}}">
<text class="profile-title">会员资料(展示在创业老板排行)</text>
<view class="avatar-row">
<view class="avatar-label">头像</view>
<view class="avatar-slot" bindtap="onChooseVipAvatar">
<image wx:if="{{profile.vipAvatar}}" class="avatar-img" src="{{profile.vipAvatar}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">
<text class="avatar-add">+</text>
<text class="avatar-hint">上传头像</text>
</view>
</view>
</view>
<view class="form-group">
<text class="form-label">姓名</text>
<view class="form-input">
<input type="nickname" placeholder="您的真实姓名" placeholder-class="form-placeholder" value="{{profile.vipName}}" bindinput="onVipNameInput" />
</view>
</view>
<view class="form-group">
<text class="form-label">项目名称</text>
<view class="form-input">
<input placeholder="您的项目/公司名称" placeholder-class="form-placeholder" value="{{profile.vipProject}}" bindinput="onVipProjectInput" />
</view>
</view>
<view class="form-group">
<text class="form-label">联系方式</text>
<view class="form-input">
<input placeholder="微信号或手机号" placeholder-class="form-placeholder" value="{{profile.vipContact}}" bindinput="onVipContactInput" />
</view>
</view>
<view class="form-group">
<text class="form-label">一句话简介</text>
<view class="form-input">
<input placeholder="简要描述您的业务" placeholder-class="form-placeholder" value="{{profile.vipBio}}" bindinput="onVipBioInput" />
</view>
</view>
<view class="save-btn-wrap">
<button class="save-btn" bindtap="saveProfile">保存资料</button>
</view>
</view>
<view class="bottom-space"></view> <view class="bottom-space"></view>
</view> </view>

View File

@@ -8,7 +8,7 @@
.vip-hero { margin: 24rpx; padding: 48rpx 32rpx; border-radius: 24rpx; background: linear-gradient(135deg, rgba(0,206,209,0.08), rgba(255,215,0,0.06)); border: 1rpx solid rgba(0,206,209,0.2); } .vip-hero { margin: 24rpx; padding: 48rpx 32rpx; border-radius: 24rpx; background: linear-gradient(135deg, rgba(0,206,209,0.08), rgba(255,215,0,0.06)); border: 1rpx solid rgba(0,206,209,0.2); }
.vip-hero-active { border-color: rgba(255,215,0,0.4); background: linear-gradient(135deg, rgba(255,215,0,0.15), rgba(0,206,209,0.08)); } .vip-hero-active { border-color: rgba(255,215,0,0.4); background: linear-gradient(135deg, rgba(255,215,0,0.15), rgba(0,206,209,0.08)); }
.vip-hero-tag { display: inline-block; background: rgba(0,206,209,0.15); color: #00CED1; font-size: 22rpx; padding: 6rpx 16rpx; border-radius: 16rpx; margin-bottom: 20rpx; } .vip-hero-tag { display: inline-block; background: rgba(0,206,209,0.15); color: #00CED1; font-size: 22rpx; padding: 6rpx 16rpx; border-radius: 16rpx; margin-bottom: 20rpx; }
.vip-hero-title { display: block; font-size: 44rpx; font-weight: bold; color: #fff; margin-top: 12rpx; } .vip-hero-title { display: block; font-size: 44rpx; font-weight: bold; color: #fff; }
.gold { color: #FFD700; } .gold { color: #FFD700; }
.vip-hero-sub { display: block; font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 12rpx; } .vip-hero-sub { display: block; font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 12rpx; }

View File

@@ -24,12 +24,47 @@
"miniprogram": { "miniprogram": {
"list": [ "list": [
{ {
"name": "pages/about/about", "name": "pages/vip/vip",
"pathName": "pages/about/about", "pathName": "pages/vip/vip",
"query": "", "query": "",
"scene": null, "scene": null,
"launchMode": "default" "launchMode": "default"
}, },
{
"name": "pages/member-detail/member-detail",
"pathName": "pages/member-detail/member-detail",
"query": "id=ogpTW5QBRQNUOm4-zvg8it2XySrI",
"launchMode": "default",
"scene": null
},
{
"name": "pages/vip/vip",
"pathName": "pages/vip/vip",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "pages/member-detail/member-detail",
"pathName": "pages/member-detail/member-detail",
"query": "id=ogpTW5cteGWqwOsRzh_CCIdKWGSE",
"launchMode": "default",
"scene": null
},
{
"name": "pages/about/about",
"pathName": "pages/about/about",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "pages/about/about",
"pathName": "pages/about/about",
"query": "",
"launchMode": "default",
"scene": null
},
{ {
"name": "pages/vip/vip", "name": "pages/vip/vip",
"pathName": "pages/vip/vip", "pathName": "pages/vip/vip",

View File

@@ -18,45 +18,44 @@ class ChapterAccessManager {
} }
/** /**
* 拉取最新配置(免费章节列表、价格等 * 拉取最新配置(价格等,免费章节数据为准
*/ */
async fetchLatestConfig() { async fetchLatestConfig() {
try { try {
const res = await app.request({ url: '/api/miniprogram/config', silent: true, timeout: 3000 }) const res = await app.request({ url: '/api/miniprogram/config', silent: true, timeout: 3000 })
if (res.success && res.freeChapters) { if (res.success && res.prices) {
return { return {
freeChapters: res.freeChapters,
prices: res.prices || { section: 1, fullbook: 9.9 } prices: res.prices || { section: 1, fullbook: 9.9 }
} }
} }
} catch (e) { } catch (e) {
console.warn('[AccessManager] 获取配置失败,使用默认配置:', e) console.warn('[AccessManager] 获取配置失败,使用默认配置:', e)
} }
// 默认配置
return { return {
freeChapters: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
prices: { section: 1, fullbook: 9.9 } prices: { section: 1, fullbook: 9.9 }
} }
} }
/** /**
* 判断章节是否免费 * 判断章节是否免费(统一:章节 isFree 或 price===0
*/ */
isFreeChapter(sectionId, freeList) { isFreeFromChapterData(chapterData) {
return freeList.includes(sectionId) if (!chapterData) return false
if (chapterData.isFree === true) return true
if (chapterData.price !== undefined && chapterData.price === 0) return true
return false
} }
/** /**
* 【核心方法】确定章节权限状态 * 【核心方法】确定章节权限状态(统一以章节数据 isFree/price 为准)
* @param {string} sectionId - 章节ID * @param {string} sectionId - 章节ID
* @param {Array} freeList - 免费章节列表 * @param {object} chapterData - 章节接口返回 { isFree, price, ... },免费 = isFree 或 price===0
* @returns {Promise<string>} accessState * @returns {Promise<string>} accessState
*/ */
async determineAccessState(sectionId, freeList) { async determineAccessState(sectionId, chapterData) {
try { try {
// 1. 检查是否免费 // 1. 检查是否免费(统一:章节 isFree 或 price===0
if (this.isFreeChapter(sectionId, freeList)) { if (this.isFreeFromChapterData(chapterData)) {
console.log('[AccessManager] 免费章节:', sectionId) console.log('[AccessManager] 免费章节:', sectionId)
return this.accessStates.FREE return this.accessStates.FREE
} }

View File

@@ -7,8 +7,8 @@ import { UsersPage } from './pages/users/UsersPage'
import { DistributionPage } from './pages/distribution/DistributionPage' import { DistributionPage } from './pages/distribution/DistributionPage'
import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage' import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage'
import { ContentPage } from './pages/content/ContentPage' import { ContentPage } from './pages/content/ContentPage'
import { ChaptersPage } from './pages/chapters/ChaptersPage'
import { ReferralSettingsPage } from './pages/referral-settings/ReferralSettingsPage' import { ReferralSettingsPage } from './pages/referral-settings/ReferralSettingsPage'
import { AuthorSettingsPage } from './pages/author-settings/AuthorSettingsPage'
import { SettingsPage } from './pages/settings/SettingsPage' import { SettingsPage } from './pages/settings/SettingsPage'
import { PaymentPage } from './pages/payment/PaymentPage' import { PaymentPage } from './pages/payment/PaymentPage'
import { SitePage } from './pages/site/SitePage' import { SitePage } from './pages/site/SitePage'
@@ -18,6 +18,7 @@ import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
import { VipRolesPage } from './pages/vip-roles/VipRolesPage' import { VipRolesPage } from './pages/vip-roles/VipRolesPage'
import { MentorsPage } from './pages/mentors/MentorsPage' import { MentorsPage } from './pages/mentors/MentorsPage'
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage' import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
import { AdminUsersPage } from './pages/admin-users/AdminUsersPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage' import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage' import { NotFoundPage } from './pages/not-found/NotFoundPage'
@@ -33,11 +34,12 @@ function App() {
<Route path="distribution" element={<DistributionPage />} /> <Route path="distribution" element={<DistributionPage />} />
<Route path="withdrawals" element={<WithdrawalsPage />} /> <Route path="withdrawals" element={<WithdrawalsPage />} />
<Route path="content" element={<ContentPage />} /> <Route path="content" element={<ContentPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="referral-settings" element={<ReferralSettingsPage />} /> <Route path="referral-settings" element={<ReferralSettingsPage />} />
<Route path="author-settings" element={<AuthorSettingsPage />} />
<Route path="vip-roles" element={<VipRolesPage />} /> <Route path="vip-roles" element={<VipRolesPage />} />
<Route path="mentors" element={<MentorsPage />} /> <Route path="mentors" element={<MentorsPage />} />
<Route path="mentor-consultations" element={<MentorConsultationsPage />} /> <Route path="mentor-consultations" element={<MentorConsultationsPage />} />
<Route path="admin-users" element={<AdminUsersPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="payment" element={<PaymentPage />} /> <Route path="payment" element={<PaymentPage />} />
<Route path="site" element={<SitePage />} /> <Route path="site" element={<SitePage />} />

View File

@@ -31,6 +31,7 @@ const DialogContent = React.forwardRef<
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
aria-describedby={undefined}
className={cn( className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg', 'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg',
className, className,

View File

@@ -12,21 +12,30 @@ import {
Crown, Crown,
GraduationCap, GraduationCap,
Calendar, Calendar,
User,
ShieldCheck,
ChevronDown,
ChevronUp,
} from 'lucide-react' } from 'lucide-react'
import { get, post } from '@/api/client' import { get, post } from '@/api/client'
import { clearAdminToken } from '@/api/auth' import { clearAdminToken } from '@/api/auth'
const menuItems = [ // 主菜单(折叠后可见 4-5 项)
const primaryMenuItems = [
{ icon: LayoutDashboard, label: '数据概览', href: '/dashboard' }, { icon: LayoutDashboard, label: '数据概览', href: '/dashboard' },
{ icon: BookOpen, label: '内容管理', href: '/content' }, { icon: BookOpen, label: '内容管理', href: '/content' },
{ icon: Users, label: '用户管理', href: '/users' }, { icon: Users, label: '用户管理', href: '/users' },
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' }, { icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
{ icon: User, label: '作者详情', href: '/author-settings' },
]
// 折叠区「更多」
const moreMenuItems = [
{ icon: ShieldCheck, label: '管理员', href: '/admin-users' },
{ icon: GraduationCap, label: '导师管理', href: '/mentors' }, { icon: GraduationCap, label: '导师管理', href: '/mentors' },
{ icon: Calendar, label: '导师预约', href: '/mentor-consultations' }, { icon: Calendar, label: '导师预约', href: '/mentor-consultations' },
{ icon: Wallet, label: '交易中心', href: '/distribution' }, { icon: Wallet, label: '交易中心', href: '/distribution' },
{ icon: GitMerge, label: '匹配记录', href: '/match-records' }, { icon: GitMerge, label: '匹配记录', href: '/match-records' },
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' }, { icon: CreditCard, label: '推广设置', href: '/referral-settings' },
{ icon: Settings, label: '系统设置', href: '/settings' },
] ]
export function AdminLayout() { export function AdminLayout() {
@@ -34,10 +43,15 @@ export function AdminLayout() {
const navigate = useNavigate() const navigate = useNavigate()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const [authChecked, setAuthChecked] = useState(false) const [authChecked, setAuthChecked] = useState(false)
const [moreExpanded, setMoreExpanded] = useState(false)
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
}, []) }, [])
useEffect(() => {
const inMore = moreMenuItems.some((item) => location.pathname === item.href)
if (inMore) setMoreExpanded(true)
}, [location.pathname])
useEffect(() => { useEffect(() => {
if (!mounted) return if (!mounted) return
@@ -89,8 +103,8 @@ export function AdminLayout() {
<p className="text-xs text-gray-400 mt-1">Soul创业派对</p> <p className="text-xs text-gray-400 mt-1">Soul创业派对</p>
</div> </div>
<nav className="flex-1 p-4 space-y-1"> <nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{menuItems.map((item) => { {primaryMenuItems.map((item) => {
const isActive = location.pathname === item.href const isActive = location.pathname === item.href
return ( return (
<Link <Link
@@ -102,11 +116,55 @@ export function AdminLayout() {
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white' : 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
}`} }`}
> >
<item.icon className="w-5 h-5" /> <item.icon className="w-5 h-5 shrink-0" />
<span className="text-sm">{item.label}</span> <span className="text-sm">{item.label}</span>
</Link> </Link>
) )
})} })}
<button
type="button"
onClick={() => setMoreExpanded(!moreExpanded)}
className="w-full flex items-center justify-between gap-3 px-4 py-3 text-gray-400 hover:bg-gray-700/50 hover:text-white rounded-lg transition-colors"
>
<span className="flex items-center gap-3">
{moreExpanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
<span className="text-sm"></span>
</span>
</button>
{moreExpanded && (
<div className="space-y-1 pl-4">
{moreMenuItems.map((item) => {
const isActive = location.pathname === item.href
return (
<Link
key={item.href}
to={item.href}
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors ${
isActive
? 'bg-[#38bdac]/20 text-[#38bdac] font-medium'
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
}`}
>
<item.icon className="w-5 h-5 shrink-0" />
<span className="text-sm">{item.label}</span>
</Link>
)
})}
</div>
)}
<div className="pt-4 mt-4 border-t border-gray-700/50">
<Link
to="/settings"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
location.pathname === '/settings'
? 'bg-[#38bdac]/20 text-[#38bdac] font-medium'
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
}`}
>
<Settings className="w-5 h-5 shrink-0" />
<span className="text-sm"></span>
</Link>
</div>
</nav> </nav>
<div className="p-4 border-t border-gray-700/50 space-y-1"> <div className="p-4 border-t border-gray-700/50 space-y-1">

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { import {
Card, Card,
CardContent, CardContent,
@@ -28,8 +28,6 @@ import {
import { import {
BookOpen, BookOpen,
Settings2, Settings2,
ChevronRight,
CheckCircle,
Edit3, Edit3,
Save, Save,
X, X,
@@ -39,9 +37,9 @@ import {
Plus, Plus,
Image as ImageIcon, Image as ImageIcon,
Search, Search,
Trash2,
} from 'lucide-react' } from 'lucide-react'
import { get, put, del } from '@/api/client' import { get, put, del } from '@/api/client'
import { ChapterTree } from './ChapterTree'
import { apiUrl } from '@/api/client' import { apiUrl } from '@/api/client'
interface SectionListItem { interface SectionListItem {
@@ -114,10 +112,23 @@ function buildTree(sections: SectionListItem[]): Part[] {
isNew: s.isNew, isNew: s.isNew,
}) })
} }
return Array.from(partMap.values()).map((p) => ({ const parts = Array.from(partMap.values()).map((p) => ({
...p, ...p,
chapters: Array.from(p.chapters.values()), chapters: Array.from(p.chapters.values()),
})) }))
// 固定顺序:序言首位,附录/尾声末位
const orderKey = (t: string) => {
if (t.includes('序言')) return 0
if (t.includes('附录')) return 2
if (t.includes('尾声')) return 3
return 1
}
return parts.sort((a, b) => {
const ka = orderKey(a.title)
const kb = orderKey(b.title)
if (ka !== kb) return ka - kb
return 0
})
} }
export function ContentPage() { export function ContentPage() {
@@ -153,9 +164,6 @@ export function ContentPage() {
'/api/db/book?action=list', '/api/db/book?action=list',
) )
setSectionsList(Array.isArray(data?.sections) ? data.sections : []) setSectionsList(Array.isArray(data?.sections) ? data.sections : [])
if (expandedParts.length === 0 && tree.length > 0) {
setExpandedParts([tree[0].id])
}
} catch (e) { } catch (e) {
console.error(e) console.error(e)
setSectionsList([]) setSectionsList([])
@@ -168,11 +176,6 @@ export function ContentPage() {
loadList() loadList()
}, []) }, [])
useEffect(() => {
if (!loading && tree.length > 0 && expandedParts.length === 0) {
setExpandedParts([tree[0].id])
}
}, [loading, tree.length, expandedParts.length])
const togglePart = (partId: string) => { const togglePart = (partId: string) => {
setExpandedParts((prev) => setExpandedParts((prev) =>
@@ -180,6 +183,32 @@ export function ContentPage() {
) )
} }
const handleReorderTree = useCallback(
(items: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[]): Promise<void> => {
const prev = sectionsList
const newList: SectionListItem[] = items.flatMap((it) => {
const s = prev.find((x) => x.id === it.id)
if (!s) return []
return [{ ...s, partId: it.partId, partTitle: it.partTitle, chapterId: it.chapterId, chapterTitle: it.chapterTitle }]
})
setSectionsList(newList)
put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
.then((res) => {
if (res && (res as { success?: boolean }).success === false) {
setSectionsList(prev)
alert('排序失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : '未知错误'))
}
})
.catch((e) => {
setSectionsList(prev)
console.error('排序失败:', e)
alert('排序失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
})
return Promise.resolve()
},
[sectionsList],
)
const handleDeleteSection = async (section: Section & { filePath?: string }) => { const handleDeleteSection = async (section: Section & { filePath?: string }) => {
if (!confirm(`确定要删除章节「${section.title}」吗?此操作不可恢复。`)) return if (!confirm(`确定要删除章节「${section.title}」吗?此操作不可恢复。`)) return
try { try {
@@ -546,7 +575,7 @@ export function ContentPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-gray-300"></Label> <Label className="text-gray-300"> is_free price=0 </Label>
<div className="flex items-center h-10"> <div className="flex items-center h-10">
<label className="flex items-center cursor-pointer"> <label className="flex items-center cursor-pointer">
<input <input
@@ -685,6 +714,23 @@ export function ContentPage() {
</TabsList> </TabsList>
<TabsContent value="chapters" className="space-y-4"> <TabsContent value="chapters" className="space-y-4">
{/* 书籍信息卡片 */}
<div className="rounded-2xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between shadow-sm">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[#38bdac] flex items-center justify-center text-white shadow-lg shadow-[#38bdac]/20 shrink-0">
<BookOpen className="w-6 h-6" />
</div>
<div>
<h2 className="font-bold text-base text-white leading-tight mb-1">SOUL的创业实验场</h2>
<p className="text-xs text-gray-500">Soul派对房的真实商业故事</p>
</div>
</div>
<div className="text-center shrink-0">
<span className="block text-2xl font-bold text-[#38bdac]">{totalSections}</span>
<span className="text-xs text-gray-500"></span>
</div>
</div>
<Button <Button
onClick={() => setShowNewSectionModal(true)} onClick={() => setShowNewSectionModal(true)}
className="w-full bg-[#38bdac]/10 hover:bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30" className="w-full bg-[#38bdac]/10 hover:bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30"
@@ -699,85 +745,14 @@ export function ContentPage() {
<span className="ml-2 text-gray-400">...</span> <span className="ml-2 text-gray-400">...</span>
</div> </div>
) : ( ) : (
tree.map((part, partIndex) => ( <ChapterTree
<Card key={part.id} className="bg-[#0f2137] border-gray-700/50 shadow-xl overflow-hidden"> parts={tree}
<CardHeader expandedParts={expandedParts}
className="cursor-pointer hover:bg-[#162840] transition-colors" onTogglePart={togglePart}
onClick={() => togglePart(part.id)} onReorder={handleReorderTree}
> onReadSection={handleReadSection}
<div className="flex items-center justify-between"> onDeleteSection={handleDeleteSection}
<div className="flex items-center gap-3"> />
<span className="text-[#38bdac] font-mono text-sm">
0{partIndex + 1}
</span>
<CardTitle className="text-white">{part.title}</CardTitle>
<Badge variant="outline" className="text-gray-400 border-gray-600">
{part.chapters.reduce((sum, ch) => sum + ch.sections.length, 0)}
</Badge>
</div>
<ChevronRight
className={`w-5 h-5 text-gray-400 transition-transform ${expandedParts.includes(part.id) ? 'rotate-90' : ''}`}
/>
</div>
</CardHeader>
{expandedParts.includes(part.id) && (
<CardContent className="pt-0 pb-4">
<div className="space-y-3 pl-8 border-l-2 border-gray-700">
{part.chapters.map((chapter) => (
<div key={chapter.id} className="space-y-2">
<h4 className="font-medium text-gray-300">{chapter.title}</h4>
<div className="space-y-1">
{chapter.sections.map((section) => (
<div
key={section.id}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[#162840] text-sm group transition-colors"
>
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-[#38bdac]" />
<span className="text-gray-400">{section.title}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[#38bdac] font-medium">
{section.price === 0 ? '免费' : `¥${section.price}`}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleReadSection(section)}
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleReadSection(section)}
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Edit3 className="w-4 h-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSection(section)}
className="text-gray-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
))
)} )}
</TabsContent> </TabsContent>

View File

@@ -85,7 +85,7 @@ const DEFAULT_CONFIG: MatchConfig = {
{ {
id: 'mentor', id: 'mentor',
label: '导师顾问', label: '导师顾问',
matchLabel: '商业顾问', matchLabel: '导师顾问',
icon: '❤️', icon: '❤️',
matchFromDB: false, matchFromDB: false,
showJoinAfterMatch: true, showJoinAfterMatch: true,

View File

@@ -29,8 +29,6 @@ import {
MapPin, MapPin,
BookOpen, BookOpen,
Gift, Gift,
X,
Plus,
Smartphone, Smartphone,
} from 'lucide-react' } from 'lucide-react'
import { get, post } from '@/api/client' import { get, post } from '@/api/client'
@@ -97,15 +95,6 @@ const defaultFeatures: FeatureConfig = {
export function SettingsPage() { export function SettingsPage() {
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings) const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [freeChapters, setFreeChapters] = useState<string[]>([
'preface',
'epilogue',
'1.1',
'appendix-1',
'appendix-2',
'appendix-3',
])
const [newFreeChapter, setNewFreeChapter] = useState('')
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures) const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig) const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
@@ -128,13 +117,11 @@ export function SettingsPage() {
try { try {
const res = await get<{ const res = await get<{
success?: boolean success?: boolean
freeChapters?: string[]
featureConfig?: Partial<FeatureConfig> featureConfig?: Partial<FeatureConfig>
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo } siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo }
mpConfig?: Partial<MpConfig> mpConfig?: Partial<MpConfig>
}>('/api/admin/settings') }>('/api/admin/settings')
if (!res || (res as { success?: boolean }).success === false) return if (!res || (res as { success?: boolean }).success === false) return
if (Array.isArray(res.freeChapters) && res.freeChapters.length) setFreeChapters(res.freeChapters)
if (res.featureConfig && Object.keys(res.featureConfig).length) if (res.featureConfig && Object.keys(res.featureConfig).length)
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig })) setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
if (res.mpConfig && typeof res.mpConfig === 'object') if (res.mpConfig && typeof res.mpConfig === 'object')
@@ -193,7 +180,6 @@ export function SettingsPage() {
setIsSaving(true) setIsSaving(true)
try { try {
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', { const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
freeChapters,
featureConfig, featureConfig,
siteSettings: { siteSettings: {
sectionPrice: localSettings.sectionPrice, sectionPrice: localSettings.sectionPrice,
@@ -222,17 +208,6 @@ export function SettingsPage() {
} }
} }
const addFreeChapter = () => {
if (newFreeChapter && !freeChapters.includes(newFreeChapter)) {
setFreeChapters([...freeChapters, newFreeChapter])
setNewFreeChapter('')
}
}
const removeFreeChapter = (chapter: string) => {
setFreeChapters(freeChapters.filter((c) => c !== chapter))
}
if (loading) return <div className="p-8 text-gray-500">...</div> if (loading) return <div className="p-8 text-gray-500">...</div>
return ( return (
@@ -498,53 +473,6 @@ export function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Gift className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{freeChapters.map((chapter) => (
<span
key={chapter}
className="inline-flex items-center gap-1 bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30 px-3 py-1 rounded-md text-sm"
>
{chapter}
<button
type="button"
onClick={() => removeFreeChapter(chapter)}
className="ml-1 hover:text-red-400"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<Input
className="bg-[#0a1628] border-gray-700 text-white flex-1"
placeholder="输入章节ID如 1.2、2.1、preface"
value={newFreeChapter}
onChange={(e) => setNewFreeChapter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addFreeChapter()}
/>
<Button onClick={addFreeChapter} className="bg-[#38bdac] hover:bg-[#2da396]">
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<p className="text-xs text-gray-500">
常用ID: preface(), epilogue(), appendix-1/2/3(), 1.1/1.2()
</p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl"> <Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader> <CardHeader>
<CardTitle className="text-white flex items-center gap-2"> <CardTitle className="text-white flex items-center gap-2">

View File

@@ -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"}

View File

@@ -18,8 +18,11 @@ type AdminClaims struct {
Role string `json:"role"` Role string `json:"role"`
} }
// IssueAdminJWT 签发管理端 JWT使用 ADMIN_SESSION_SECRET 签名 // IssueAdminJWT 签发管理端 JWT使用 ADMIN_SESSION_SECRET 签名role 为空时默认 admin
func IssueAdminJWT(secret, username string) (string, error) { func IssueAdminJWT(secret, username, role string) (string, error) {
if role == "" {
role = "admin"
}
now := time.Now() now := time.Now()
claims := AdminClaims{ claims := AdminClaims{
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
@@ -28,7 +31,7 @@ func IssueAdminJWT(secret, username string) (string, error) {
Subject: "admin", Subject: "admin",
}, },
Username: username, Username: username,
Role: "admin", Role: role,
} }
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return tok.SignedString([]byte(secret)) return tok.SignedString([]byte(secret))

View File

@@ -42,6 +42,12 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.MentorConsultation{}); err != nil { if err := db.AutoMigrate(&model.MentorConsultation{}); err != nil {
log.Printf("database: mentor_consultations migrate warning: %v", err) log.Printf("database: mentor_consultations migrate warning: %v", err)
} }
if err := db.AutoMigrate(&model.AuthorConfig{}); err != nil {
log.Printf("database: author_config migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.AdminUser{}); err != nil {
log.Printf("database: admin_users migrate warning: %v", err)
}
log.Println("database: connected") log.Println("database: connected")
return nil return nil
} }

View File

@@ -5,8 +5,12 @@ import (
"soul-api/internal/auth" "soul-api/internal/auth"
"soul-api/internal/config" "soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
) )
// AdminCheck GET /api/admin 鉴权检查JWTAuthorization Bearer 或 Cookie已登录返回 success 或概览占位 // AdminCheck GET /api/admin 鉴权检查JWTAuthorization Bearer 或 Cookie已登录返回 success 或概览占位
@@ -39,7 +43,7 @@ func AdminCheck(c *gin.Context) {
}) })
} }
// AdminLogin POST /api/admin 登录(校验 ADMIN_USERNAME/PASSWORD,返回 JWT前端存 token 并带 Authorization: Bearer // AdminLogin POST /api/admin 登录(优先校验 admin_users 表,表空时回退 ADMIN_USERNAME/PASSWORD 并自动初始化
func AdminLogin(c *gin.Context) { func AdminLogin(c *gin.Context) {
cfg := config.Get() cfg := config.Get()
if cfg == nil { if cfg == nil {
@@ -56,11 +60,69 @@ func AdminLogin(c *gin.Context) {
} }
username := trimSpace(body.Username) username := trimSpace(body.Username)
password := body.Password password := body.Password
db := database.DB()
// 1. 尝试从 admin_users 表校验
var u model.AdminUser
err := db.Where("username = ?", username).First(&u).Error
if err == nil {
if u.Status != "active" {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "账号已禁用"})
return
}
if bcryptErr := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); bcryptErr != nil {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, u.Username, u.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "签发失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"token": token,
"user": gin.H{"id": u.ID, "username": u.Username, "role": u.Role, "name": u.Name},
})
return
}
// 2. 表内无匹配:若表为空且 env 账号正确,则创建初始 super_admin 并登录
if err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "系统错误"})
return
}
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
if username != cfg.AdminUsername || password != cfg.AdminPassword { if username != cfg.AdminUsername || password != cfg.AdminPassword {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"}) c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
return return
} }
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, username) // 表为空时初始化超级管理员
var cnt int64
if db.Model(&model.AdminUser{}).Count(&cnt).Error != nil || cnt > 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "初始化失败"})
return
}
initial := model.AdminUser{
Username: cfg.AdminUsername,
PasswordHash: string(hash),
Role: "super_admin",
Name: "卡若",
Status: "active",
}
if err := db.Create(&initial).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "初始化失败"})
return
}
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, initial.Username, initial.Role)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "签发失败"}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "签发失败"})
return return
@@ -68,9 +130,7 @@ func AdminLogin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"token": token, "token": token,
"user": gin.H{ "user": gin.H{"id": initial.ID, "username": initial.Username, "role": initial.Role, "name": initial.Name},
"id": "admin", "username": cfg.AdminUsername, "role": "admin", "name": "卡若",
},
}) })
} }

View File

@@ -84,6 +84,9 @@ func AdminChaptersAction(c *gin.Context) {
var body struct { var body struct {
Action string `json:"action"` Action string `json:"action"`
ID string `json:"id"` ID string `json:"id"`
ChapterID string `json:"chapterId"` // 前端兼容section id
SectionTitle string `json:"sectionTitle"`
Ids []string `json:"ids"` // reorder新顺序的 section id 列表
Price *float64 `json:"price"` Price *float64 `json:"price"`
IsFree *bool `json:"isFree"` IsFree *bool `json:"isFree"`
Status *string `json:"status"` Status *string `json:"status"`
@@ -94,26 +97,63 @@ func AdminChaptersAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return return
} }
resolveID := func() string {
if body.ID != "" {
return body.ID
}
return body.ChapterID
}
db := database.DB() db := database.DB()
if body.Action == "updatePrice" && body.ID != "" && body.Price != nil { if body.Action == "updatePrice" {
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("price", *body.Price) id := resolveID()
} if id != "" && body.Price != nil {
if body.Action == "toggleFree" && body.ID != "" && body.IsFree != nil { db.Model(&model.Chapter{}).Where("id = ?", id).Update("price", *body.Price)
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("is_free", *body.IsFree)
}
if body.Action == "updateStatus" && body.ID != "" && body.Status != nil {
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("status", *body.Status)
}
if body.Action == "updateEdition" && body.ID != "" {
updates := make(map[string]interface{})
if body.EditionStandard != nil {
updates["edition_standard"] = *body.EditionStandard
} }
if body.EditionPremium != nil { }
updates["edition_premium"] = *body.EditionPremium if body.Action == "toggleFree" {
id := resolveID()
if id != "" && body.IsFree != nil {
db.Model(&model.Chapter{}).Where("id = ?", id).Update("is_free", *body.IsFree)
} }
if len(updates) > 0 { }
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates) if body.Action == "updateStatus" {
id := resolveID()
if id != "" && body.Status != nil {
db.Model(&model.Chapter{}).Where("id = ?", id).Update("status", *body.Status)
}
}
if body.Action == "rename" {
id := resolveID()
if id != "" && body.SectionTitle != "" {
db.Model(&model.Chapter{}).Where("id = ?", id).Update("section_title", body.SectionTitle)
}
}
if body.Action == "delete" {
id := resolveID()
if id != "" {
db.Where("id = ?", id).Delete(&model.Chapter{})
}
}
if body.Action == "reorder" && len(body.Ids) > 0 {
for i, id := range body.Ids {
if id != "" {
db.Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i)
}
}
}
if body.Action == "updateEdition" {
id := resolveID()
if id != "" {
updates := make(map[string]interface{})
if body.EditionStandard != nil {
updates["edition_standard"] = *body.EditionStandard
}
if body.EditionPremium != nil {
updates["edition_premium"] = *body.EditionPremium
}
if len(updates) > 0 {
db.Model(&model.Chapter{}).Where("id = ?", id).Updates(updates)
}
} }
} }
c.JSON(http.StatusOK, gin.H{"success": true}) c.JSON(http.StatusOK, gin.H{"success": true})

View File

@@ -16,9 +16,18 @@ import (
var excludeParts = []string{"序言", "尾声", "附录"} var excludeParts = []string{"序言", "尾声", "附录"}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表) // BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
func BookAllChapters(c *gin.Context) { func BookAllChapters(c *gin.Context) {
q := database.DB().Model(&model.Chapter{})
if c.Query("excludeFixed") == "1" {
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
}
var list []model.Chapter var list []model.Chapter
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil { if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return return
} }

View File

@@ -16,9 +16,8 @@ import (
) )
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐) // GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
// 从 system_config 读取 free_chapters、mp_config、feature_config、chapter_config合并后返回 // 从 system_config 读取 chapter_config、feature_config、mp_config合并后返回(免费以章节 is_free/price 为准)
func GetPublicDBConfig(c *gin.Context) { func GetPublicDBConfig(c *gin.Context) {
defaultFree := []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"}
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9} defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true} defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}
apiDomain := "https://soulapi.quwanzhi.com" apiDomain := "https://soulapi.quwanzhi.com"
@@ -36,16 +35,15 @@ func GetPublicDBConfig(c *gin.Context) {
} }
out := gin.H{ out := gin.H{
"success": true, "success": true,
"freeChapters": defaultFree, "prices": defaultPrices,
"prices": defaultPrices, "features": defaultFeatures,
"features": defaultFeatures, "mpConfig": defaultMp,
"mpConfig": defaultMp, "configs": gin.H{},
"configs": gin.H{}, // 兼容 miniprogram 备用格式 res.configs.feature_config
} }
db := database.DB() db := database.DB()
keys := []string{"chapter_config", "free_chapters", "feature_config", "mp_config"} keys := []string{"chapter_config", "feature_config", "mp_config"}
for _, k := range keys { for _, k := range keys {
var row model.SystemConfig var row model.SystemConfig
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil { if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
@@ -58,17 +56,6 @@ func GetPublicDBConfig(c *gin.Context) {
switch k { switch k {
case "chapter_config": case "chapter_config":
if m, ok := val.(map[string]interface{}); ok { if m, ok := val.(map[string]interface{}); ok {
if v, ok := m["freeChapters"].([]interface{}); ok && len(v) > 0 {
arr := make([]string, 0, len(v))
for _, x := range v {
if s, ok := x.(string); ok {
arr = append(arr, s)
}
}
if len(arr) > 0 {
out["freeChapters"] = arr
}
}
if v, ok := m["prices"].(map[string]interface{}); ok { if v, ok := m["prices"].(map[string]interface{}); ok {
out["prices"] = v out["prices"] = v
} }
@@ -77,19 +64,6 @@ func GetPublicDBConfig(c *gin.Context) {
} }
out["configs"].(gin.H)["chapter_config"] = m out["configs"].(gin.H)["chapter_config"] = m
} }
case "free_chapters":
if arr, ok := val.([]interface{}); ok && len(arr) > 0 {
ss := make([]string, 0, len(arr))
for _, x := range arr {
if s, ok := x.(string); ok {
ss = append(ss, s)
}
}
if len(ss) > 0 {
out["freeChapters"] = ss
}
out["configs"].(gin.H)["free_chapters"] = arr
}
case "feature_config": case "feature_config":
if m, ok := val.(map[string]interface{}); ok { if m, ok := val.(map[string]interface{}); ok {
// 合并到 features不整体覆盖以保留 chapter_config 里的 // 合并到 features不整体覆盖以保留 chapter_config 里的
@@ -158,7 +132,7 @@ func DBConfigGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": data}) c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
} }
// AdminSettingsGet GET /api/admin/settings 系统设置页专用:仅返回免费章节、功能开关、站点/作者与价格、小程序配置 // AdminSettingsGet GET /api/admin/settings 系统设置页专用:仅返回功能开关、站点/作者与价格、小程序配置
func AdminSettingsGet(c *gin.Context) { func AdminSettingsGet(c *gin.Context) {
db := database.DB() db := database.DB()
apiDomain := "https://soulapi.quwanzhi.com" apiDomain := "https://soulapi.quwanzhi.com"
@@ -174,12 +148,11 @@ func AdminSettingsGet(c *gin.Context) {
} }
out := gin.H{ out := gin.H{
"success": true, "success": true,
"freeChapters": []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"},
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}, "featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}}, "siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
"mpConfig": defaultMp, "mpConfig": defaultMp,
} }
keys := []string{"free_chapters", "feature_config", "site_settings", "mp_config"} keys := []string{"feature_config", "site_settings", "mp_config"}
for _, k := range keys { for _, k := range keys {
var row model.SystemConfig var row model.SystemConfig
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil { if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
@@ -190,18 +163,6 @@ func AdminSettingsGet(c *gin.Context) {
continue continue
} }
switch k { switch k {
case "free_chapters":
if arr, ok := val.([]interface{}); ok && len(arr) > 0 {
ss := make([]string, 0, len(arr))
for _, x := range arr {
if s, ok := x.(string); ok {
ss = append(ss, s)
}
}
if len(ss) > 0 {
out["freeChapters"] = ss
}
}
case "feature_config": case "feature_config":
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 { if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
out["featureConfig"] = m out["featureConfig"] = m
@@ -226,10 +187,9 @@ func AdminSettingsGet(c *gin.Context) {
c.JSON(http.StatusOK, out) c.JSON(http.StatusOK, out)
} }
// AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存免费章节、功能开关、站点/作者与价格、小程序配置 // AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存功能开关、站点/作者与价格、小程序配置
func AdminSettingsPost(c *gin.Context) { func AdminSettingsPost(c *gin.Context) {
var body struct { var body struct {
FreeChapters []string `json:"freeChapters"`
FeatureConfig map[string]interface{} `json:"featureConfig"` FeatureConfig map[string]interface{} `json:"featureConfig"`
SiteSettings map[string]interface{} `json:"siteSettings"` SiteSettings map[string]interface{} `json:"siteSettings"`
MpConfig map[string]interface{} `json:"mpConfig"` MpConfig map[string]interface{} `json:"mpConfig"`
@@ -256,12 +216,6 @@ func AdminSettingsPost(c *gin.Context) {
} }
return db.Save(&row).Error return db.Save(&row).Error
} }
if body.FreeChapters != nil {
if err := saveKey("free_chapters", "免费章节ID列表", body.FreeChapters); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存免费章节失败: " + err.Error()})
return
}
}
if body.FeatureConfig != nil { if body.FeatureConfig != nil {
if err := saveKey("feature_config", "功能开关配置", body.FeatureConfig); err != nil { if err := saveKey("feature_config", "功能开关配置", body.FeatureConfig); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存功能开关失败: " + err.Error()}) c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存功能开关失败: " + err.Error()})
@@ -366,6 +320,140 @@ func AdminReferralSettingsPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"}) c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"})
} }
func authorConfigToResponse(row *model.AuthorConfig) gin.H {
defaultStats := []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}}
defaultHighlights := []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"}
var stats []gin.H
if row.Stats != "" {
_ = json.Unmarshal([]byte(row.Stats), &stats)
}
if len(stats) == 0 {
stats = defaultStats
}
var highlights []string
if row.Highlights != "" {
_ = json.Unmarshal([]byte(row.Highlights), &highlights)
}
if len(highlights) == 0 {
highlights = defaultHighlights
}
return gin.H{
"name": row.Name,
"avatar": row.Avatar,
"avatarImg": row.AvatarImg,
"title": row.Title,
"bio": row.Bio,
"stats": stats,
"highlights": highlights,
}
}
// AdminAuthorSettingsGet GET /api/admin/author-settings 作者详情配置(管理端专用)
func AdminAuthorSettingsGet(c *gin.Context) {
defaultAuthor := gin.H{
"name": "卡若",
"avatar": "K",
"avatarImg": "",
"title": "Soul派对房主理人 · 私域运营专家",
"bio": "每天早上6点到9点在Soul派对房分享真实的创业故事。专注私域运营与项目变现用云阿米巴模式帮助创业者构建可持续的商业体系。",
"stats": []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}},
"highlights": []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"},
}
db := database.DB()
var row model.AuthorConfig
if err := db.First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultAuthor})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": authorConfigToResponse(&row)})
}
// AdminAuthorSettingsPost POST /api/admin/author-settings 保存作者详情配置
func AdminAuthorSettingsPost(c *gin.Context) {
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
str := func(k string) string {
if v, ok := body[k]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
return ""
}
name := str("name")
if name == "" {
name = "卡若"
}
avatar := str("avatar")
if avatar == "" {
avatar = "K"
}
statsVal := body["stats"]
if statsVal == nil {
statsVal = []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}}
}
highlightsVal := body["highlights"]
if highlightsVal == nil {
highlightsVal = []string{}
}
statsBytes, _ := json.Marshal(statsVal)
highlightsBytes, _ := json.Marshal(highlightsVal)
db := database.DB()
var row model.AuthorConfig
err := db.First(&row).Error
if err != nil {
row = model.AuthorConfig{
Name: name,
Avatar: avatar,
AvatarImg: str("avatarImg"),
Title: str("title"),
Bio: str("bio"),
Stats: string(statsBytes),
Highlights: string(highlightsBytes),
}
err = db.Create(&row).Error
} else {
row.Name = name
row.Avatar = avatar
row.AvatarImg = str("avatarImg")
row.Title = str("title")
row.Bio = str("bio")
row.Stats = string(statsBytes)
row.Highlights = string(highlightsBytes)
err = db.Save(&row).Error
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "作者设置已保存"})
}
// MiniprogramAboutAuthor GET /api/miniprogram/about/author 小程序-关于作者页拉取作者配置(公开,无需鉴权)
func MiniprogramAboutAuthor(c *gin.Context) {
defaultAuthor := gin.H{
"name": "卡若",
"avatar": "K",
"avatarImg": "",
"title": "Soul派对房主理人 · 私域运营专家",
"bio": "每天早上6点到9点在Soul派对房分享真实的创业故事。",
"stats": []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}},
"highlights": []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"},
}
db := database.DB()
var row model.AuthorConfig
if err := db.First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultAuthor})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": authorConfigToResponse(&row)})
}
// DBConfigPost POST /api/db/config // DBConfigPost POST /api/db/config
func DBConfigPost(c *gin.Context) { func DBConfigPost(c *gin.Context) {
var body struct { var body struct {

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"context"
"net/http" "net/http"
"soul-api/internal/database" "soul-api/internal/database"
@@ -10,6 +11,12 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// listSelectCols 列表/导出不加载 content大幅加速
var listSelectCols = []string{
"id", "section_title", "price", "is_free", "is_new",
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
}
// sectionListItem 与前端 SectionListItem 一致(小写驼峰) // sectionListItem 与前端 SectionListItem 一致(小写驼峰)
type sectionListItem struct { type sectionListItem struct {
ID string `json:"id"` ID string `json:"id"`
@@ -34,7 +41,7 @@ func DBBookAction(c *gin.Context) {
switch action { switch action {
case "list": case "list":
var rows []model.Chapter var rows []model.Chapter
if err := db.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil { if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}}) c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
return return
} }
@@ -93,7 +100,7 @@ func DBBookAction(c *gin.Context) {
return return
case "export": case "export":
var rows []model.Chapter var rows []model.Chapter
if err := db.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil { if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return return
} }
@@ -179,7 +186,11 @@ func DBBookAction(c *gin.Context) {
} }
case http.MethodPut: case http.MethodPut:
var body struct { var body struct {
ID string `json:"id"` Action string `json:"action"`
// reorder新顺序支持跨篇跨章时附带 partId/chapterId
IDs []string `json:"ids"`
Items []reorderItem `json:"items"`
ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
Price *float64 `json:"price"` Price *float64 `json:"price"`
@@ -188,8 +199,57 @@ func DBBookAction(c *gin.Context) {
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版 EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版 EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
} }
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"}) c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.Action == "reorder" {
// 立即返回成功,后台异步执行排序更新
if len(body.Items) > 0 {
items := make([]reorderItem, len(body.Items))
copy(items, body.Items)
c.JSON(http.StatusOK, gin.H{"success": true})
go func() {
db := database.DB()
for i, it := range items {
if it.ID == "" {
continue
}
up := map[string]interface{}{"sort_order": i}
if it.PartID != "" {
up["part_id"] = it.PartID
}
if it.PartTitle != "" {
up["part_title"] = it.PartTitle
}
if it.ChapterID != "" {
up["chapter_id"] = it.ChapterID
}
if it.ChapterTitle != "" {
up["chapter_title"] = it.ChapterTitle
}
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
}
}()
return
}
if len(body.IDs) > 0 {
ids := make([]string, len(body.IDs))
copy(ids, body.IDs)
c.JSON(http.StatusOK, gin.H{"success": true})
go func() {
db := database.DB()
for i, id := range ids {
if id != "" {
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i).Error
}
}
}()
return
}
}
if body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return return
} }
price := 1.0 price := 1.0
@@ -228,6 +288,14 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"}) c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
} }
type reorderItem struct {
ID string `json:"id"`
PartID string `json:"partId"`
PartTitle string `json:"partTitle"`
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
}
type importItem struct { type importItem struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`

View File

@@ -85,7 +85,7 @@ func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
var defaultMatchTypes = []gin.H{ var defaultMatchTypes = []gin.H{
gin.H{"id": "partner", "label": "创业合伙", "matchLabel": "创业伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true}, gin.H{"id": "partner", "label": "创业合伙", "matchLabel": "创业伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
gin.H{"id": "investor", "label": "资源对接", "matchLabel": "资源对接", "icon": "👥", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true}, gin.H{"id": "investor", "label": "资源对接", "matchLabel": "资源对接", "icon": "👥", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
gin.H{"id": "mentor", "label": "导师顾问", "matchLabel": "商业顾问", "icon": "❤️", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true}, gin.H{"id": "mentor", "label": "导师顾问", "matchLabel": "导师顾问", "icon": "❤️", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
gin.H{"id": "team", "label": "团队招募", "matchLabel": "加入项目", "icon": "🎮", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true}, gin.H{"id": "team", "label": "团队招募", "matchLabel": "加入项目", "icon": "🎮", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
} }

View File

@@ -4,9 +4,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"soul-api/internal/database" "soul-api/internal/database"
@@ -17,6 +21,27 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
var (
orderPollLogger *log.Logger
orderPollLoggerOnce sync.Once
)
// orderPollLogf 将订单轮询检测日志写入 log/order-poll.log不输出到控制台
func orderPollLogf(format string, args ...interface{}) {
orderPollLoggerOnce.Do(func() {
_ = os.MkdirAll("log", 0755)
f, err := os.OpenFile(filepath.Join("log", "order-poll.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
orderPollLogger = log.New(io.Discard, "", 0)
return
}
orderPollLogger = log.New(f, "[OrderPoll] ", log.Ldate|log.Ltime)
})
if orderPollLogger != nil {
orderPollLogger.Printf(format, args...)
}
}
// MiniprogramLogin POST /api/miniprogram/login // MiniprogramLogin POST /api/miniprogram/login
func MiniprogramLogin(c *gin.Context) { func MiniprogramLogin(c *gin.Context) {
var req struct { var req struct {
@@ -355,7 +380,7 @@ func miniprogramPayGet(c *gin.Context) {
"transaction_id": transactionID, "transaction_id": transactionID,
"pay_time": now, "pay_time": now,
}) })
fmt.Printf("[PayGet] 主动同步订单已支付: %s\n", orderSn) orderPollLogf("主动同步订单已支付: %s", orderSn)
} }
case "CLOSED", "REVOKED", "PAYERROR": case "CLOSED", "REVOKED", "PAYERROR":
status = "failed" status = "failed"

View File

@@ -272,7 +272,7 @@ func VipMembers(c *gin.Context) {
} }
// formatVipMember 构建会员展示数据;优先 vip_*,无则回退到用户 nickname/avatar // formatVipMember 构建会员展示数据;优先 vip_*,无则回退到用户 nickname/avatar
// 用于首页超级个体、创业老板排行等场景,展示真实用户头像和昵称 // 用于首页超级个体、创业老板排行、会员详情页等场景;含 P3 资料扩展以对接 member-detail
func formatVipMember(u *model.User, isVip bool) gin.H { func formatVipMember(u *model.User, isVip bool) gin.H {
name := "" name := ""
if u.VipName != nil && *u.VipName != "" { if u.VipName != nil && *u.VipName != "" {
@@ -291,9 +291,9 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
if avatar == "" && u.Avatar != nil && *u.Avatar != "" { if avatar == "" && u.Avatar != nil && *u.Avatar != "" {
avatar = *u.Avatar avatar = *u.Avatar
} }
project := "" project := getStringValue(u.VipProject)
if u.VipProject != nil { if project == "" {
project = *u.VipProject project = getStringValue(u.ProjectIntro)
} }
bio := "" bio := ""
if u.VipBio != nil { if u.VipBio != nil {
@@ -303,24 +303,51 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
if u.VipContact != nil { if u.VipContact != nil {
contact = *u.VipContact contact = *u.VipContact
} }
if contact == "" {
contact = getStringValue(u.Phone)
}
vipRole := "" vipRole := ""
if u.VipRole != nil { if u.VipRole != nil {
vipRole = *u.VipRole vipRole = *u.VipRole
} }
return gin.H{ return gin.H{
"id": u.ID, "id": u.ID,
"name": name, "name": name,
"nickname": name, "nickname": name,
"avatar": avatar, "avatar": avatar,
"vip_name": name, "vip_name": name,
"vipName": name, "vipName": name,
"vipRole": vipRole, "vipRole": vipRole,
"vip_avatar": avatar, "vip_avatar": avatar,
"vipAvatar": avatar, "vipAvatar": avatar,
"vipProject": project, "vipProject": project,
"vipContact": contact, "vip_project": project,
"vipBio": bio, "vipContact": contact,
"is_vip": isVip, "vip_contact": contact,
"vipBio": bio,
"wechatId": getStringValue(u.WechatID),
"wechat_id": getStringValue(u.WechatID),
"phone": getStringValue(u.Phone),
"mbti": getStringValue(u.Mbti),
"region": getStringValue(u.Region),
"industry": getStringValue(u.Industry),
"position": getStringValue(u.Position),
"businessScale": getStringValue(u.BusinessScale),
"business_scale": getStringValue(u.BusinessScale),
"skills": getStringValue(u.Skills),
"storyBestMonth": getStringValue(u.StoryBestMonth),
"story_best_month": getStringValue(u.StoryBestMonth),
"storyAchievement": getStringValue(u.StoryAchievement),
"story_achievement": getStringValue(u.StoryAchievement),
"storyTurning": getStringValue(u.StoryTurning),
"story_turning": getStringValue(u.StoryTurning),
"helpOffer": getStringValue(u.HelpOffer),
"help_offer": getStringValue(u.HelpOffer),
"helpNeed": getStringValue(u.HelpNeed),
"help_need": getStringValue(u.HelpNeed),
"projectIntro": getStringValue(u.ProjectIntro),
"project_intro": getStringValue(u.ProjectIntro),
"is_vip": isVip,
} }
} }

View File

@@ -9,7 +9,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// AdminAuth 管理端鉴权:校验 JWTAuthorization: Bearer 或 Cookie admin_session未登录返回 401 const adminClaimsKey = "admin_claims"
// AdminAuth 管理端鉴权:校验 JWTAuthorization: Bearer 或 Cookie admin_session未登录返回 401通过则设置 admin_claims 到 context
func AdminAuth() gin.HandlerFunc { func AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
cfg := config.Get() cfg := config.Get()
@@ -18,10 +20,25 @@ func AdminAuth() gin.HandlerFunc {
return return
} }
token := auth.GetAdminJWTFromRequest(c.Request) token := auth.GetAdminJWTFromRequest(c.Request)
if _, ok := auth.ParseAdminJWT(token, cfg.AdminSessionSecret); !ok { claims, ok := auth.ParseAdminJWT(token, cfg.AdminSessionSecret)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未授权访问,请先登录"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未授权访问,请先登录"})
return return
} }
c.Set(adminClaimsKey, claims)
c.Next() c.Next()
} }
} }
// GetAdminClaims 从 context 获取 admin claims需在 AdminAuth 之后调用)
func GetAdminClaims(c *gin.Context) *auth.AdminClaims {
v, ok := c.Get(adminClaimsKey)
if !ok || v == nil {
return nil
}
claims, ok := v.(*auth.AdminClaims)
if !ok {
return nil
}
return claims
}

View File

@@ -66,7 +66,13 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.POST("/settings", handler.AdminSettingsPost) admin.POST("/settings", handler.AdminSettingsPost)
admin.GET("/referral-settings", handler.AdminReferralSettingsGet) admin.GET("/referral-settings", handler.AdminReferralSettingsGet)
admin.POST("/referral-settings", handler.AdminReferralSettingsPost) admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
admin.PUT("/orders/refund", handler.AdminOrderRefund) admin.PUT("/orders/refund", handler.AdminOrderRefund)
admin.GET("/users", handler.AdminUsersList)
admin.POST("/users", handler.AdminUsersAction)
admin.PUT("/users", handler.AdminUsersAction)
admin.DELETE("/users", handler.AdminUsersAction)
} }
// ----- 鉴权 ----- // ----- 鉴权 -----
@@ -276,6 +282,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/mentors", handler.MiniprogramMentorsList) miniprogram.GET("/mentors", handler.MiniprogramMentorsList)
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail) miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook) miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor)
} }
// ----- 提现 ----- // ----- 提现 -----

Binary file not shown.

View 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` 分支,约第 196206 行
---
## 二、数据库必须支持
### 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. 用 ⋮⋮ 拖拽某一节到另一位置,松手后应刷新并显示新顺序