feat: 定合并的稳定版本

This commit is contained in:
Alex-larget
2026-03-17 13:17:49 +08:00
parent fcc05b6420
commit 2f35520670
44 changed files with 2754 additions and 859 deletions

View File

@@ -0,0 +1,8 @@
# 产品经理 经验记录 - 2026-03-17
## 新版管理端迁移到稳定版(会议:实施方案确认)
- **需求基准**以稳定版小程序为准管理端支撑余额、代付、VIP、规则引擎等
- **内容管理**:以稳定版为主,不采纳新版
- **新版独有**API 文档、OSS、编辑禁用、鉴权 → 全部吸纳(用户决议「新版有的就迁移」)
- **待确认**RFM、用户旅程、神射手是否继续使用

View File

@@ -26,3 +26,10 @@
- 供小程序区分发起人/好友,展示不同 UI
- 字段:`initiatorUserId`(发起人 user_id
---
## 新版管理端迁移 - 后端任务会议2026-03-17
- **router 补齐**:迁移前注册 5 个路由:`db.GET("/users/rfm")``db.GET("/users/journey-stats")``admin.GET("/shensheshou/query")``admin.POST("/shensheshou/enrich")``admin.POST("/shensheshou/ingest")`
- **待确认**/api/admin/settings 是否已支持 ossConfig若不支持需补充

View File

@@ -19,3 +19,19 @@
- 小程序:代付详情页双态 UI、读页跳转
- 后端PayNotify beneficiaryUserID、detail initiatorUserId
---
## 新版管理端迁移到稳定版会议2026-03-17
### 决议
- **内容管理**:以稳定版为主,不采纳新版
- **新版独有**API 文档 Tab、api-docs 独立页、OSS 配置、编辑时手机号禁用、鉴权逻辑 → **全部吸纳**
- **后端**:迁移前补 routerusers/rfm、journey-stats、shensheshou 共 5 个)
### 影响角色
- 管理端开发工程师:主导迁移
- 后端开发router 补齐、ossConfig 确认
- 测试人员:迁移后验收

View File

@@ -22,9 +22,10 @@
| 2026-03-17 | 吸收需求代付美团式流程、PayNotify 权益归属、目录 loading、最新新增 5 条折叠 → 开发文档与 agent | 已完成 |
| 2026-03-17 | 乘风吸收经验与交互:迁移完成度与待办清单、运营与变更第十二部分 | 已完成 |
| 2026-03-17 | 吸收新需求代付统一到代付页gift=1&ref redirectTo→ 需求汇总、找朋友代付流程、运营与变更 | 已完成 |
| 2026-03-17 | 会议收尾:新版管理端迁移到稳定版实施方案确认;纪要、各角色经验入库、项目索引更新 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-17吸收代付与体验优化需求
**最后更新**2026-03-17会议收尾:新版管理端迁移实施方案

View File

@@ -32,6 +32,7 @@ soul-apiGo + Gin + GORM + MySQL提供三组路由`/api/miniprogram/*`
| 2026-03-16 | ParseAutoLinkContent 添加 data-label存客宝 create planType=1 sceneId=9 status=1 | 已完成 |
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析content_upload.py 与 chapters 一致性待核对 | 待续 |
| 2026-03-17 | 代付 PayNotify 权益归属修复beneficiaryUserID代付=发起人gift-pay detail 返回 initiatorUserId | 已完成 |
| 2026-03-17 | 会议新版管理端迁移router 补齐 users/rfm、journey-stats、shensheshou 共 5 个;确认 ossConfig | 进行中 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置

View File

@@ -8,6 +8,13 @@
管理端React + Vite + Tailwind主要功能用户管理、订单管理、提现审核、VIP 管理、内容/章节管理、配置项管理、数据统计。调用 `/api/admin/*``/api/db/*` 接口JWT Bearer 鉴权。
### 项目路径定义
| 版本 | 路径 | 说明 |
|------|------|------|
| **稳定版(主用)** | `soul-admin/` | 根目录,线上部署 |
| **新版(参考)** | `new-soul/soul-admin/` | 迁移时对照,含 ApiDocsPage 完整版、OSS region 等 |
---
## 开发进度
@@ -28,9 +35,14 @@
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
| 2026-03-16 | 链接人与事table 布局、planId/apiKey 列、复制图标、删除 Dialog 弹窗 | 已完成 |
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析派对AI 不新增管理端需求 | 已完成 |
| 2026-03-17 | 会议:新版管理端迁移到稳定版实施方案确认;新版独有全部吸纳,内容管理以稳定版为主 | 已完成 |
| 2026-03-17 | 吸收新版管理端定义new-soul/soul-admin迁移 ApiDocsPage 完整版、OSS region、鉴权失败 clearAdminToken | 已完成 |
| 2026-03-17 | 修复 DistributionPage Order.description用户余额人工调整后端 adjust API + 用户详情入口);代付列表页(后端 gift-pay-requests + 推广中心 Tab | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-16
**最后更新**2026-03-17
> soul-admin 构建仍有 DistributionPage Order.description 类型错误(与本次迁移无关),待修复。

View File

@@ -0,0 +1,15 @@
# 管理端开发工程师 经验记录 - 2026-03-17
## 新版管理端迁移到稳定版(会议:实施方案确认)
### 迁移策略
- **内容管理**以稳定版为主不采纳新版ContentPage 及关联组件不覆盖
- **新版独有全部吸纳**API 文档 Tab、api-docs 独立页、OSS 配置、编辑时手机号禁用、鉴权逻辑
- **必须保留**:用户详情余额、订单支付方式/代付、RechargeAlert、LinkedMp、用户规则、超级个体
### 实施任务
1. 从 new-soul/soul-admin 迁移ApiDocsPage、OSS 配置、api-docs 路由、编辑时手机号禁用、鉴权逻辑
2. 以稳定版为基准合并,内容管理不覆盖
3. 按模块分批Layout/Dashboard/设置 → 用户/订单/推广/提现/找伙伴

View File

@@ -0,0 +1,8 @@
# 软件测试 经验记录 - 2026-03-17
## 新版管理端迁移验收(会议:实施方案确认)
- **验收清单**:按《新版管理端迁移到稳定版-需求评估》§七
- **回归范围**:提现、分销、找伙伴、导师、设置等
- **风险**:合并时避免误覆盖稳定版独有逻辑,建议 diff 逐模块核对
- **三端联调**:管理端 ↔ soul-api 重点验证;用户规则、订单、余额展示

View File

@@ -0,0 +1,106 @@
# 会议纪要 - 2026-03-17 | 新版管理端迁移到稳定版实施方案确认
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-17
- **议题**:新版管理端迁移到稳定版 - 确认实施方案
- **触发方式**:乘风调动开发人员开会,并确认实施方案
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
---
## 各角色发言
### 【产品经理】
需求基准以稳定版小程序为准内容管理以稳定版为主验收标准见《需求评估》§七RFM、journey、神射手是否保留需运营确认。
### 【后端开发】
以稳定版为主,后端无需新增接口;若保留 RFM/journey/神射手需补 5 个 routerOSS 需确认 /api/admin/settings 是否支持 ossConfig建议先补 router 再迁移。
### 【管理端开发工程师】
内容管理以稳定版为准不覆盖;必须保留用户详情余额、订单支付方式/代付、RechargeAlert、LinkedMp 等可吸纳编辑禁用、鉴权、API 文档、OSS按模块分批合并。
### 【小程序开发工程师】
本次主要影响管理端,小程序无需改动;迁移后做三端联调验证用户规则、订单、余额。
### 【测试人员】
按《需求评估》§七验收;回归提现、分销、找伙伴等;合并时避免误覆盖稳定版独有逻辑。
---
## 讨论过程
- 乘风确认:后端 router 补齐建议迁移前完成
- 产品经理OSS 按实际部署需求,用户决议「新版有的就迁移」→ OSS 纳入
- 管理端:同意 OSS 纳入,新版独有能力全部吸纳
---
## 会议决议
1. **新版有的就迁移**API 文档 Tab、api-docs 独立页、OSS 配置、编辑时手机号禁用、鉴权逻辑优化,全部吸纳到稳定版
2. **内容管理**:以稳定版为主,不采纳新版
3. **后端 router**:迁移前补齐 users/rfm、users/journey-stats、shensheshou 共 5 个路由(若运营使用)
4. **实施顺序**Phase 0 后端补 router → Phase 1 基础模块 → Phase 2 业务模块 → Phase 3 内容保持 → Phase 4 验收
---
## 待办事项(乘风指派)
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 后端开发 | soul-api router 注册 users/rfm、users/journey-stats、shensheshou 共 5 个路由 | 高 | 迁移前 |
| 后端开发 | 确认 /api/admin/settings 是否支持 ossConfig若不支持则补充 | 中 | 迁移前 |
| 管理端开发工程师 | 从 new-soul/soul-admin 迁移ApiDocsPage、OSS 配置、api-docs 路由、编辑时手机号禁用、鉴权逻辑 | 高 | - |
| 管理端开发工程师 | 以稳定版为基准合并,内容管理不覆盖,其他模块选择性合并 | 高 | - |
| 测试人员 | 迁移完成后按《需求评估》§七执行验收,三端联调 | 中 | 迁移后 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | RFM、用户旅程、神射手是否继续使用 | 产品/运营 | (待补充) |
| 2 | /api/admin/settings 是否已支持 ossConfig | 后端开发 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 新版管理端迁移以稳定版为基准内容管理以稳定版为主新版独有能力API 文档、OSS、编辑禁用、鉴权全部吸纳
### 后端开发
- 迁移前需补 routerusers/rfm、users/journey-stats、shensheshou 共 5 个;需确认 settings 的 ossConfig 支持
### 管理端开发工程师
- 迁移策略:内容管理不覆盖;其他模块以稳定版为主;吸纳新版 ApiDocsPage、OSS、编辑禁用、鉴权按模块分批合并
### 小程序开发工程师
- 管理端迁移不影响小程序;迁移后做三端联调验证
### 测试人员
- 验收按《需求评估》§七;合并时需 diff 核对避免误覆盖
### 团队共享
- 新版管理端迁移到稳定版:内容管理以稳定版为主,新版独有能力全部吸纳;详见 agent/团队/evolution/2026-03-17.md
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-17.md`*

View File

@@ -75,3 +75,4 @@ YYYY-MM-DD_会议主题.md
| 2026-03-11 | 开发团队对齐业务逻辑与以界面定需求·会议收尾 | 产品、后端、管理端、小程序、团队 | [2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md](2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md) |
| 2026-03-16 | 链接人与事与存客宝对接优化 | 管理端、后端、团队 | [2026-03-16_链接人与事与存客宝对接优化.md](2026-03-16_链接人与事与存客宝对接优化.md) |
| 2026-03-16 | new-soul 新需求与当前项目差异分析 | 产品、后端、管理端、小程序、测试 | [2026-03-16_new-soul新需求与当前项目差异分析.md](2026-03-16_new-soul新需求与当前项目差异分析.md) |
| 2026-03-17 | 新版管理端迁移到稳定版实施方案确认 | 产品、后端、管理端、小程序、测试 | [2026-03-17_新版管理端迁移到稳定版实施方案确认.md](2026-03-17_新版管理端迁移到稳定版实施方案确认.md) |

View File

@@ -15,9 +15,10 @@ alwaysApply: true
| 子项目 | 目录 | 用途 | 后端对接 |
|--------|------|------|----------|
| 小程序 | miniprogram/ | 微信原生小程序 C 端 | soul-api |
| 管理端 | soul-admin/ | React 管理后台 | soul-api |
| 管理端 | soul-admin/ | React 管理后台(稳定版,主用) | soul-api |
| API 后端 | soul-api/ | Go + Gin + GORM 接口服务 | - |
| 预览/参考 | next-project/ | 仅预览,非线上 | 不依赖 |
| **新版管理端** | **new-soul/soul-admin/** | 新版参考实现,迁移时对照 | soul-api |
## 核心原则

View File

@@ -6,7 +6,6 @@
"pages/my/my",
"pages/read/read",
"pages/link-preview/link-preview",
"pages/about/about",
"pages/agreement/agreement",
"pages/privacy/privacy",
"pages/referral/referral",

View File

@@ -31,7 +31,7 @@
<text class="doc-p">我们可能适时修订本协议,修订后将在小程序内公示。若您继续使用服务,即视为接受修订后的协议。</text>
<text class="doc-section">七、联系我们</text>
<text class="doc-p">如有疑问,请通过小程序内「关于作者」或 Soul 派对房与我们联系。</text>
<text class="doc-p">如有疑问,请通过 Soul 派对房与我们联系。</text>
</view>
</scroll-view>
</view>

View File

@@ -44,7 +44,10 @@ Page({
dailyChapters: [],
// book/parts 加载中
partsLoading: true
partsLoading: true,
// 功能配置(搜索开关)
searchEnabled: true
},
onLoad() {
@@ -57,6 +60,24 @@ Page({
this.loadVipStatus()
this.loadParts()
this.loadDailyChapters()
this.loadFeatureConfig()
},
async loadFeatureConfig() {
try {
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
return
}
const res = await app.request({ url: '/api/miniprogram/config', silent: true })
const features = (res && res.features) || {}
const searchEnabled = features.searchEnabled !== false
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
this.setData({ searchEnabled })
} catch (e) {
this.setData({ searchEnabled: true })
}
},
// 懒加载:仅拉取篇章列表 + totalSections + fixedSections
@@ -280,6 +301,7 @@ Page({
// 跳转到搜索页
goToSearch() {
if (!this.data.searchEnabled) return
trackClick('chapters', 'nav_click', '搜索')
wx.navigateTo({ url: '/pages/search/search' })
},

View File

@@ -5,7 +5,7 @@
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<view class="nav-left">
<view class="search-btn" bindtap="goToSearch">
<view class="search-btn" wx:if="{{searchEnabled}}" bindtap="goToSearch">
<text class="search-icon">🔍</text>
</view>
</view>

View File

@@ -65,7 +65,10 @@ Page({
featuredExpanded: false,
latestExpanded: false,
featuredSectionsFull: [], // 展开时用 book/hot 加载的完整列表
featuredExpandedLoading: false
featuredExpandedLoading: false,
// 功能配置(搜索开关)
searchEnabled: true
},
onLoad(options) {
@@ -84,6 +87,7 @@ Page({
}
wx.showShareMenu({ withShareTimeline: true })
this.loadFeatureConfig()
this.initData()
},
@@ -308,8 +312,26 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' })
},
async loadFeatureConfig() {
try {
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
return
}
const res = await app.request({ url: '/api/miniprogram/config', silent: true })
const features = (res && res.features) || {}
const searchEnabled = features.searchEnabled !== false
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
this.setData({ searchEnabled })
} catch (e) {
this.setData({ searchEnabled: true })
}
},
// 跳转到搜索页
goToSearch() {
if (!this.data.searchEnabled) return
wx.navigateTo({ url: '/pages/search/search' })
},
@@ -330,10 +352,6 @@ Page({
wx.navigateTo({ url: '/pages/vip/vip' })
},
goToAbout() {
wx.navigateTo({ url: '/pages/about/about' })
},
async onLinkKaruo() {
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {

View File

@@ -24,8 +24,8 @@
</view>
</view>
<!-- 搜索栏 -->
<view class="search-bar" bindtap="goToSearch">
<!-- 搜索栏(根据配置显示) -->
<view class="search-bar" wx:if="{{searchEnabled}}" bindtap="goToSearch">
<view class="search-icon-wrap">
<text class="search-icon-text">🔍</text>
</view>

View File

@@ -38,6 +38,8 @@ Page({
// 功能配置
matchEnabled: false,
referralEnabled: true,
searchEnabled: true,
// VIP状态
isVip: false,
@@ -113,10 +115,14 @@ Page({
try {
const res = await app.request('/api/miniprogram/config')
const features = (res && res.features) || (res && res.data && res.data.features) || {}
this.setData({ matchEnabled: features.matchEnabled === true })
const matchEnabled = features.matchEnabled === true
const referralEnabled = features.referralEnabled !== false
const searchEnabled = features.searchEnabled !== false
app.globalData.features = { matchEnabled, referralEnabled, searchEnabled }
this.setData({ matchEnabled, referralEnabled, searchEnabled })
} catch (error) {
console.log('加载功能配置失败:', error)
this.setData({ matchEnabled: false })
this.setData({ matchEnabled: false, referralEnabled: true, searchEnabled: true })
}
},
@@ -759,7 +765,7 @@ Page({
handleMenuTap(e) {
const id = e.currentTarget.dataset.id
if (!this.data.isLoggedIn && id !== 'about') {
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
@@ -770,7 +776,6 @@ Page({
referral: '/pages/referral/referral',
withdrawRecords: '/pages/withdraw-records/withdraw-records',
wallet: '/pages/wallet/wallet',
about: '/pages/about/about',
settings: '/pages/settings/settings'
}
@@ -792,11 +797,6 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' })
},
// 跳转到关于页
goToAbout() {
wx.navigateTo({ url: '/pages/about/about' })
},
// 跳转到匹配
goToMatch() {
wx.switchTab({ url: '/pages/match/match' })
@@ -808,6 +808,7 @@ Page({
this.showLogin()
return
}
if (!this.data.referralEnabled) return
wx.navigateTo({ url: '/pages/referral/referral' })
},

View File

@@ -55,11 +55,11 @@
<text class="profile-stat-val">{{readCountText}}</text>
<text class="profile-stat-label">已读章节</text>
</view>
<view class="profile-stat" bindtap="goToReferral">
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<text class="profile-stat-val">{{referralCount}}</text>
<text class="profile-stat-label">推荐好友</text>
</view>
<view class="profile-stat" bindtap="goToReferral">
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<text class="profile-stat-val">{{earnings === '-' ? '--' : earnings}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
@@ -148,7 +148,7 @@
</view>
</view>
<!-- 我的订单 + 关于作者 + 设置 -->
<!-- 我的订单 + 设置 -->
<view class="card menu-card">
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
<view class="menu-left">
@@ -164,13 +164,6 @@
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="handleMenuTap" data-id="about">
<view class="menu-left">
<view class="menu-icon-wrap icon-blue"><image class="menu-icon-img" src="/assets/icons/info-blue.svg" mode="aspectFit"/></view>
<text class="menu-text">关于作者</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" wx:if="{{showSettingsEntry}}" 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>

View File

@@ -34,7 +34,7 @@
<text class="doc-p">我们可能适时更新本政策,更新后将通过小程序内公示等方式通知您。继续使用即视为接受更新后的政策。</text>
<text class="doc-section">八、联系我们</text>
<text class="doc-p">如有隐私相关疑问或投诉,请通过小程序内「关于作者」或 Soul 派对房与我们联系。</text>
<text class="doc-p">如有隐私相关疑问或投诉,请通过 Soul 派对房与我们联系。</text>
</view>
</scroll-view>
</view>

914
soul-admin/dist/assets/index-BC7lp6bO.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-C2K6IQif.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BHi-SnBy.css">
<script type="module" crossorigin src="/assets/index-BC7lp6bO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BcWuvM_a.css">
</head>
<body>
<div id="root"></div>

View File

@@ -19,6 +19,7 @@ import { MentorsPage } from './pages/mentors/MentorsPage'
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
import { FindPartnerPage } from './pages/find-partner/FindPartnerPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { ApiDocsPage } from './pages/api-docs/ApiDocsPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
function App() {
@@ -47,6 +48,7 @@ function App() {
<Route path="match" element={<MatchPage />} />
<Route path="match-records" element={<MatchRecordsPage />} />
<Route path="api-doc" element={<ApiDocPage />} />
<Route path="api-docs" element={<ApiDocsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>

View File

@@ -123,6 +123,12 @@ export function UserDetailModal({
const [vipForm, setVipForm] = useState({ isVip: false, vipExpireDate: '', vipRole: '', vipName: '', vipProject: '', vipContact: '', vipBio: '' })
const [vipRoles, setVipRoles] = useState<{ id: number; name: string }[]>([])
// 调整余额
const [adjustBalanceOpen, setAdjustBalanceOpen] = useState(false)
const [adjustAmount, setAdjustAmount] = useState('')
const [adjustRemark, setAdjustRemark] = useState('')
const [adjustLoading, setAdjustLoading] = useState(false)
// 用户资料完善(神射手)
const [sssLoading, setSssLoading] = useState(false)
const [sssData, setSssData] = useState<ShensheShouData | null>(null)
@@ -287,6 +293,29 @@ export function UserDetailModal({
} catch { toast.error('修改失败') } finally { setPasswordSaving(false) }
}
async function handleAdjustBalance() {
if (!user) return
const amt = parseFloat(adjustAmount)
if (Number.isNaN(amt) || amt === 0) { toast.error('请输入有效金额(正数增加、负数扣减)'); return }
setAdjustLoading(true)
try {
const res = await post<{ success?: boolean; error?: string }>(`/api/admin/users/${user.id}/balance/adjust`, {
amount: amt,
remark: adjustRemark || undefined,
})
if (res?.success) {
toast.success('余额已调整')
setAdjustBalanceOpen(false)
setAdjustAmount('')
setAdjustRemark('')
loadUserDetail()
onUserUpdated?.()
} else {
toast.error('调整失败: ' + (res?.error || ''))
}
} catch { toast.error('调整失败') } finally { setAdjustLoading(false) }
}
// 用户资料完善查询(支持多维度)
async function handleSSSQuery() {
if (!sssQueryPhone && !sssQueryOpenId && !sssQueryWechatId) {
@@ -382,6 +411,7 @@ export function UserDetailModal({
if (!open) return null
return (
<>
<Dialog open={open} onOpenChange={() => onClose()}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader>
@@ -468,7 +498,11 @@ export function UserDetailModal({
placeholder="输入手机号"
value={editPhone}
onChange={(e) => setEditPhone(e.target.value)}
disabled={!!user?.phone}
/>
{user?.phone && (
<p className="text-xs text-gray-500"></p>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
@@ -521,11 +555,21 @@ export function UserDetailModal({
¥{(user.pendingEarnings ?? 0).toFixed(2)}
</p>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-[#38bdac]">
¥{(balanceData?.balance ?? 0).toFixed(2)}
</p>
<div className="p-4 bg-[#0a1628] rounded-lg flex flex-col justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-[#38bdac]">
¥{(balanceData?.balance ?? 0).toFixed(2)}
</p>
</div>
<Button
size="sm"
variant="outline"
className="mt-2 border-[#38bdac]/50 text-[#38bdac] hover:bg-[#38bdac]/10 text-xs"
onClick={() => { setAdjustAmount(''); setAdjustRemark(''); setAdjustBalanceOpen(true) }}
>
</Button>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
@@ -1084,5 +1128,44 @@ export function UserDetailModal({
)}
</DialogContent>
</Dialog>
<Dialog open={adjustBalanceOpen} onOpenChange={setAdjustBalanceOpen}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white" showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-gray-300 text-sm"></Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white mt-1"
placeholder="正数增加,负数扣减,如 10 或 -5"
value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)}
/>
</div>
<div>
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white mt-1"
placeholder="如:活动补偿"
value={adjustRemark}
onChange={(e) => setAdjustRemark(e.target.value)}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setAdjustBalanceOpen(false)} className="border-gray-600 text-gray-300">
</Button>
<Button onClick={handleAdjustBalance} disabled={adjustLoading} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
{adjustLoading ? '提交中...' : '确认调整'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -10,7 +10,7 @@ import {
GitMerge,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { clearAdminToken } from '@/api/auth'
import { clearAdminToken, getAdminToken } from '@/api/auth'
import { RechargeAlert } from '@/components/RechargeAlert'
// 主菜单5 项平铺,按 Mycontent-temp 新规范)
@@ -36,22 +36,31 @@ export function AdminLayout() {
if (!mounted) return
setAuthChecked(false)
let cancelled = false
// 鉴权优化:先检查 token无 token 直接跳登录,避免无效请求
if (!getAdminToken()) {
navigate('/login', { replace: true })
return
}
get<{ success?: boolean }>('/api/admin')
.then((data) => {
if (cancelled) return
if (data && (data as { success?: boolean }).success !== false) {
setAuthChecked(true)
} else {
navigate('/login', { replace: true })
clearAdminToken()
navigate('/login', { replace: true, state: { from: location.pathname } })
}
})
.catch(() => {
if (!cancelled) navigate('/login', { replace: true })
if (!cancelled) {
clearAdminToken()
navigate('/login', { replace: true, state: { from: location.pathname } })
}
})
return () => {
cancelled = true
}
}, [mounted, navigate])
}, [location.pathname, mounted, navigate])
const handleLogout = async () => {
clearAdminToken()

View File

@@ -0,0 +1,443 @@
/**
* API 接口完整文档页 - 内容管理相关接口
* 深色主题,与 Admin 整体风格一致
* 来源new-soul/soul-admin
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BookOpen, User, Tag, Search, Trophy, Smartphone, Key } from 'lucide-react'
interface EndpointBlockProps {
method: string
url: string
desc?: string
headers?: string[]
body?: string
response?: string
}
function EndpointBlock({ method, url, desc, headers, body, response }: EndpointBlockProps) {
const methodColor =
method === 'GET'
? 'text-emerald-400'
: method === 'POST'
? 'text-amber-400'
: method === 'PUT'
? 'text-blue-400'
: method === 'DELETE'
? 'text-rose-400'
: 'text-gray-400'
return (
<div className="rounded-lg bg-[#0a1628]/60 border border-gray-700/50 p-4 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-mono font-semibold ${methodColor}`}>{method}</span>
<code className="text-sm text-[#38bdac] break-all">{url}</code>
</div>
{desc && <p className="text-gray-400 text-sm">{desc}</p>}
{headers && headers.length > 0 && (
<div>
<p className="text-gray-500 text-xs mb-1">Headers</p>
<pre className="text-xs text-gray-300 font-mono overflow-x-auto p-2 rounded bg-black/30">
{headers.join('\n')}
</pre>
</div>
)}
{body && (
<div>
<p className="text-gray-500 text-xs mb-1">Request Body (JSON)</p>
<pre className="text-xs text-green-400/90 font-mono overflow-x-auto p-2 rounded bg-black/30 whitespace-pre-wrap">
{body}
</pre>
</div>
)}
{response && (
<div>
<p className="text-gray-500 text-xs mb-1">Response Example</p>
<pre className="text-xs text-amber-200/80 font-mono overflow-x-auto p-2 rounded bg-black/30 whitespace-pre-wrap">
{response}
</pre>
</div>
)}
</div>
)
}
export function ApiDocsPage() {
const baseHeaders = ['Authorization: Bearer {token}', 'Content-Type: application/json']
return (
<div className="p-8 w-full bg-[#0a1628] text-white">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">API </h1>
<p className="text-gray-400 mt-1">
· RESTful · /api · Bearer Token
</p>
</div>
{/* 1. Authentication */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Key className="w-5 h-5 text-[#38bdac]" />
1. Authentication
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="POST"
url="/api/admin"
desc="登录,返回 JWT token"
headers={['Content-Type: application/json']}
body={`{
"username": "admin",
"password": "your_password"
}`}
response={`{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_at": "2026-03-16T12:00:00Z"
}`}
/>
</CardContent>
</Card>
{/* 2. Chapters */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<BookOpen className="w-5 h-5 text-[#38bdac]" />
2. (Chapters)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/book?action=chapters"
desc="获取章节树"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "id": "part-1", "title": "第一篇", "children": [...] },
{ "id": "section-1", "title": "第1节", "price": 1.0, "isFree": false }
]
}`}
/>
<EndpointBlock
method="GET"
url="/api/db/book?action=section&id={id}"
desc="获取单篇内容"
headers={baseHeaders}
response={`{
"success": true,
"data": {
"id": "section-1",
"title": "标题",
"content": "正文...",
"price": 1.0,
"isFree": false,
"partId": "part-1",
"chapterId": "ch-1"
}
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/book"
desc="新建章节 (action=create-section)"
headers={baseHeaders}
body={`{
"action": "create-section",
"title": "新章节标题",
"content": "正文内容",
"price": 0,
"isFree": true,
"partId": "part-1",
"chapterId": "ch-1",
"partTitle": "第一篇",
"chapterTitle": "第1章"
}`}
response={`{
"success": true,
"data": { "id": "section-new-id", "title": "新章节标题", ... }
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/book"
desc="更新章节内容 (action=update-section)"
headers={baseHeaders}
body={`{
"action": "update-section",
"id": "section-1",
"title": "更新后的标题",
"content": "更新后的正文",
"price": 1.0,
"isFree": false
}`}
response={`{
"success": true,
"data": { "id": "section-1", "title": "更新后的标题", ... }
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/book"
desc="删除章节 (action=delete-section)"
headers={baseHeaders}
body={`{
"action": "delete-section",
"id": "section-1"
}`}
response={`{
"success": true,
"message": "已删除"
}`}
/>
<EndpointBlock
method="POST"
url="/api/admin/content/upload"
desc="上传图片(管理端)"
headers={baseHeaders}
body={`FormData: file (binary)`}
response={`{
"success": true,
"url": "/uploads/images/xxx.jpg",
"data": { "url", "fileName", "size", "type" }
}`}
/>
</CardContent>
</Card>
{/* 3. Persons */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<User className="w-5 h-5 text-[#38bdac]" />
3. (@Mentions)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/persons"
desc="人物列表"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "personId": "p1", "label": "张三", "aliases": ["老张"], ... }
]
}`}
/>
<EndpointBlock
method="GET"
url="/api/db/person?personId={id}"
desc="人物详情"
headers={baseHeaders}
response={`{
"success": true,
"data": {
"personId": "p1",
"label": "张三",
"aliases": ["老张"],
"description": "..."
}
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/persons"
desc="新增/更新人物(含 aliases 字段)"
headers={baseHeaders}
body={`{
"personId": "p1",
"label": "张三",
"aliases": ["老张", "张三丰"],
"description": "可选描述"
}`}
response={`{
"success": true,
"data": { "personId": "p1", "label": "张三", ... }
}`}
/>
<EndpointBlock
method="DELETE"
url="/api/db/persons?personId={id}"
desc="删除人物"
headers={baseHeaders}
response={`{
"success": true,
"message": "已删除"
}`}
/>
</CardContent>
</Card>
{/* 4. LinkTags */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Tag className="w-5 h-5 text-[#38bdac]" />
4. (#LinkTags)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/link-tags"
desc="标签列表"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "tagId": "t1", "label": "官网", "aliases": [], "type": "url", "url": "https://..." }
]
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/link-tags"
desc="新增/更新标签(含 aliases, type: url/miniprogram/ckb"
headers={baseHeaders}
body={`{
"tagId": "t1",
"label": "官网",
"aliases": ["官方网站"],
"type": "url",
"url": "https://example.com"
}
// type 可选: url | miniprogram | ckb`}
response={`{
"success": true,
"data": { "tagId": "t1", "label": "官网", "type": "url", ... }
}`}
/>
<EndpointBlock
method="DELETE"
url="/api/db/link-tags?tagId={id}"
desc="删除标签"
headers={baseHeaders}
response={`{
"success": true,
"message": "已删除"
}`}
/>
</CardContent>
</Card>
{/* 5. Search */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Search className="w-5 h-5 text-[#38bdac]" />
5.
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/search?q={keyword}"
desc="搜索(标题优先 3 条 + 内容匹配)"
headers={baseHeaders}
response={`{
"success": true,
"data": {
"titleMatches": [{ "id": "s1", "title": "...", "snippet": "..." }],
"contentMatches": [{ "id": "s2", "title": "...", "snippet": "..." }]
}
}`}
/>
</CardContent>
</Card>
{/* 6. Ranking */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Trophy className="w-5 h-5 text-[#38bdac]" />
6.
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/book?action=ranking"
desc="排行榜数据"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "id": "s1", "title": "...", "clickCount": 100, "payCount": 50, "hotScore": 120, "hotRank": 1 }
]
}`}
/>
</CardContent>
</Card>
{/* 7. Miniprogram */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Smartphone className="w-5 h-5 text-[#38bdac]" />
7.
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/miniprogram/book/all-chapters"
desc="全部章节(小程序用)"
headers={['Content-Type: application/json']}
response={`{
"success": true,
"data": [ { "id": "s1", "title": "...", "price": 1.0, "isFree": false }, ... ]
}`}
/>
<EndpointBlock
method="GET"
url="/api/miniprogram/balance?userId={id}"
desc="查余额"
headers={['Content-Type: application/json']}
response={`{
"success": true,
"data": { "balance": 100.50, "userId": "xxx" }
}`}
/>
<EndpointBlock
method="POST"
url="/api/miniprogram/balance/gift"
desc="代付"
headers={['Content-Type: application/json']}
body={`{
"userId": "xxx",
"amount": 10.00,
"remark": "可选备注"
}`}
response={`{
"success": true,
"data": { "balance": 110.50 }
}`}
/>
<EndpointBlock
method="POST"
url="/api/miniprogram/balance/gift/redeem"
desc="领取代付"
headers={['Content-Type: application/json']}
body={`{
"code": "GIFT_XXXX"
}`}
response={`{
"success": true,
"data": { "amount": 10.00, "balance": 120.50 }
}`}
/>
</CardContent>
</Card>
<p className="text-gray-500 text-xs mt-6">
使 /api/admin/*/api/db/*使 /api/miniprogram/* soul-api
</p>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import {
TrendingUp,
Clock,
Wallet,
Gift,
Search,
RefreshCw,
CheckCircle,
@@ -108,6 +109,7 @@ interface Order {
bookName?: string
chapterTitle?: string
sectionTitle?: string
description?: string
amount: number
status: string
paymentMethod?: string
@@ -122,7 +124,7 @@ interface Order {
}
export function DistributionPage() {
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals' | 'settings'>(
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals' | 'giftPay' | 'settings'>(
'overview',
)
const [orders, setOrders] = useState<Order[]>([])
@@ -144,6 +146,25 @@ export function DistributionPage() {
const [rejectWithdrawalId, setRejectWithdrawalId] = useState<string | null>(null)
const [rejectReason, setRejectReason] = useState('')
const [rejectLoading, setRejectLoading] = useState(false)
const [giftPayRequests, setGiftPayRequests] = useState<Array<{
id: string
requestSn: string
initiatorUserId: string
initiatorNick?: string
productType: string
productId: string
amount: number
description: string
status: string
payerUserId?: string
payerNick?: string
orderId?: string
expireAt: string
createdAt: string
}>>([])
const [giftPayPage, setGiftPayPage] = useState(1)
const [giftPayTotal, setGiftPayTotal] = useState(0)
const [giftPayStatusFilter, setGiftPayStatusFilter] = useState('')
useEffect(() => {
loadInitialData()
@@ -161,7 +182,10 @@ export function DistributionPage() {
if (['orders', 'bindings', 'withdrawals'].includes(activeTab)) {
loadTabData(activeTab, true)
}
}, [page, pageSize, statusFilter, searchTerm])
if (activeTab === 'giftPay') {
loadTabData('giftPay', true)
}
}, [page, pageSize, statusFilter, searchTerm, giftPayPage, giftPayStatusFilter])
async function loadInitialData() {
setError(null)
@@ -284,6 +308,30 @@ export function DistributionPage() {
}
break
}
case 'giftPay': {
try {
const params = new URLSearchParams({
page: String(giftPayPage),
pageSize: '20',
...(giftPayStatusFilter && { status: giftPayStatusFilter }),
})
const res = await get<{ success?: boolean; data?: typeof giftPayRequests; total?: number }>(
`/api/admin/gift-pay-requests?${params}`,
)
if (res?.success && res.data) {
setGiftPayRequests(res.data)
setGiftPayTotal(res.total ?? res.data.length)
} else {
setGiftPayRequests([])
setGiftPayTotal(0)
}
} catch (e) {
console.error(e)
setError('加载代付请求失败')
setGiftPayRequests([])
}
break
}
}
setLoadedTabs((prev) => new Set(prev).add(tab))
} catch (e) {
@@ -470,6 +518,7 @@ export function DistributionPage() {
{ key: 'orders', label: '订单管理', icon: DollarSign },
{ key: 'bindings', label: '绑定管理', icon: Link2 },
{ key: 'withdrawals', label: '提现审核', icon: Wallet },
{ key: 'giftPay', label: '代付请求', icon: Gift },
{ key: 'settings', label: '推广设置', icon: Settings },
].map((tab) => (
<button
@@ -479,6 +528,10 @@ export function DistributionPage() {
setActiveTab(tab.key as typeof activeTab)
setStatusFilter('all')
setSearchTerm('')
if (tab.key === 'giftPay') {
setGiftPayStatusFilter('')
setGiftPayPage(1)
}
}}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.key
@@ -1213,6 +1266,97 @@ export function DistributionPage() {
</Card>
</div>
)}
{activeTab === 'giftPay' && (
<div className="space-y-4">
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-4">
<CardTitle className="text-white"></CardTitle>
<div className="flex gap-2 items-center">
<select
className="bg-[#0a1628] border border-gray-700 text-white rounded px-3 py-1.5 text-sm"
value={giftPayStatusFilter}
onChange={(e) => { setGiftPayStatusFilter(e.target.value); setGiftPayPage(1) }}
>
<option value=""></option>
<option value="pending"></option>
<option value="paid"></option>
<option value="cancelled"></option>
<option value="expired"></option>
</select>
<Button size="sm" variant="outline" onClick={() => loadTabData('giftPay', true)} className="border-gray-600 text-gray-300">
<RefreshCw className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-700/50">
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400">/</th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{giftPayRequests.map((r) => (
<tr key={r.id} className="hover:bg-[#0a1628]">
<td className="p-4 font-mono text-xs text-gray-400">{r.requestSn}</td>
<td className="p-4">
<p className="text-white text-sm">{r.initiatorNick || r.initiatorUserId}</p>
</td>
<td className="p-4">
<p className="text-white">{r.productType} · ¥{r.amount.toFixed(2)}</p>
{r.description && <p className="text-gray-500 text-xs">{r.description}</p>}
</td>
<td className="p-4 text-gray-400">{r.payerNick || (r.payerUserId ? r.payerUserId : '-')}</td>
<td className="p-4">
<Badge
className={
r.status === 'paid'
? 'bg-green-500/20 text-green-400 border-0'
: r.status === 'pending'
? 'bg-amber-500/20 text-amber-400 border-0'
: 'bg-gray-500/20 text-gray-400 border-0'
}
>
{r.status === 'paid' ? '已支付' : r.status === 'pending' ? '待支付' : r.status === 'cancelled' ? '已取消' : '已过期'}
</Badge>
</td>
<td className="p-4 text-gray-400 text-sm">
{r.createdAt ? new Date(r.createdAt).toLocaleString('zh-CN') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{giftPayRequests.length === 0 && !loading && (
<p className="text-center py-8 text-gray-500"></p>
)}
{giftPayTotal > 20 && (
<div className="mt-4 flex justify-center">
<Pagination
page={giftPayPage}
totalPages={Math.ceil(giftPayTotal / 20)}
total={giftPayTotal}
pageSize={20}
onPageChange={setGiftPayPage}
onPageSizeChange={() => {}}
/>
</div>
)}
</CardContent>
</Card>
</div>
)}
</>
)}

View File

@@ -34,10 +34,13 @@ import {
Smartphone,
ShieldCheck,
Link2,
FileText,
Cloud,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
import { AdminUsersPage } from '@/pages/admin-users/AdminUsersPage'
import { ApiDocsPage } from '@/pages/api-docs/ApiDocsPage'
interface AuthorInfo {
name?: string
@@ -60,7 +63,6 @@ interface FeatureConfig {
matchEnabled: boolean
referralEnabled: boolean
searchEnabled: boolean
aboutEnabled: boolean
}
interface MpConfig {
@@ -70,6 +72,14 @@ interface MpConfig {
minWithdraw?: number
}
interface OssConfig {
endpoint?: string
bucket?: string
region?: string
accessKeyId?: string
accessKeySecret?: string
}
const defaultMpConfig: MpConfig = {
appId: 'wxb8bbb2b10dec74aa',
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE',
@@ -98,10 +108,9 @@ const defaultFeatures: FeatureConfig = {
matchEnabled: true,
referralEnabled: true,
searchEnabled: true,
aboutEnabled: true,
}
const TAB_KEYS = ['system', 'author', 'admin'] as const
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
type TabKey = (typeof TAB_KEYS)[number]
export function SettingsPage() {
@@ -112,6 +121,7 @@ export function SettingsPage() {
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [ossConfig, setOssConfig] = useState<OssConfig>({})
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
@@ -135,12 +145,15 @@ export function SettingsPage() {
featureConfig?: Partial<FeatureConfig>
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo; ckbLeadApiKey?: string }
mpConfig?: Partial<MpConfig>
ossConfig?: Partial<OssConfig>
}>('/api/admin/settings')
if (!res || (res as { success?: boolean }).success === false) return
if (res.featureConfig && Object.keys(res.featureConfig).length)
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
if (res.mpConfig && typeof res.mpConfig === 'object')
setMpConfig((prev) => ({ ...prev, ...res.mpConfig }))
if (res.ossConfig && typeof res.ossConfig === 'object')
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
if (res.siteSettings && typeof res.siteSettings === 'object') {
const s = res.siteSettings
setLocalSettings((prev) => ({
@@ -211,6 +224,15 @@ export function SettingsPage() {
mchId: mpConfig.mchId || '',
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
},
ossConfig: Object.keys(ossConfig).length
? {
endpoint: ossConfig.endpoint ?? '',
bucket: ossConfig.bucket ?? '',
region: ossConfig.region ?? '',
accessKeyId: ossConfig.accessKeyId ?? '',
accessKeySecret: ossConfig.accessKeySecret ?? '',
}
: undefined,
})
if (!res || (res as { success?: boolean }).success === false) {
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
@@ -273,6 +295,13 @@ export function SettingsPage() {
<ShieldCheck className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="api-docs"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<FileText className="w-4 h-4 mr-2" />
API
</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-0">
@@ -595,7 +624,7 @@ export function SettingsPage() {
</Label>
</div>
<p className="text-xs text-gray-400 ml-6"></p>
<p className="text-xs text-gray-400 ml-6"></p>
</div>
<Switch
id="search-enabled"
@@ -604,23 +633,6 @@ export function SettingsPage() {
onCheckedChange={(checked) => handleFeatureSwitch('searchEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="about-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">访</p>
</div>
<Switch
id="about-enabled"
checked={featureConfig.aboutEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('aboutEnabled', checked)}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
<p className="text-xs text-blue-300">
@@ -629,6 +641,78 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Cloud className="w-5 h-5 text-[#38bdac]" />
OSS
</CardTitle>
<CardDescription className="text-gray-400">
endpointbucketaccessKey /
</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">Endpoint</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="oss-cn-hangzhou.aliyuncs.com"
value={ossConfig.endpoint ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, endpoint: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">Bucket</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="bucket 名称"
value={ossConfig.bucket ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, bucket: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">Region</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="oss-cn-hangzhou"
value={ossConfig.region ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, region: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AccessKey ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="AccessKey ID"
value={ossConfig.accessKeyId ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeyId: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AccessKey Secret</Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="AccessKey Secret"
value={ossConfig.accessKeySecret ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeySecret: e.target.value }))
}
/>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
@@ -639,6 +723,10 @@ export function SettingsPage() {
<TabsContent value="admin" className="mt-0">
<AdminUsersPage />
</TabsContent>
<TabsContent value="api-docs" className="mt-0">
<ApiDocsPage />
</TabsContent>
</Tabs>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./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/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.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","./src/utils/toast.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./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/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.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","./src/utils/toast.ts"],"version":"5.6.3"}

View File

@@ -341,6 +341,53 @@ func AdminUserBalanceGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": balance, "transactions": transactions}})
}
// AdminUserBalanceAdjust POST /api/admin/users/:id/balance/adjust 管理端-人工调整用户余额
func AdminUserBalanceAdjust(c *gin.Context) {
userID := c.Param("id")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少用户ID"})
return
}
var req struct {
Amount float64 `json:"amount" binding:"required"` // 正数增加,负数扣减
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
return
}
if req.Amount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "调整金额不能为 0"})
return
}
db := database.DB()
err := db.Transaction(func(tx *gorm.DB) error {
var ub model.UserBalance
if err := tx.Where("user_id = ?", userID).First(&ub).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ub = model.UserBalance{UserID: userID, Balance: 0}
} else {
return err
}
}
newBalance := ub.Balance + req.Amount
if newBalance < 0 {
return fmt.Errorf("调整后余额不能为负,当前余额 %.2f", ub.Balance)
}
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = ?, updated_at = NOW()", userID, newBalance)
txID := fmt.Sprintf("bt_adj_%d", time.Now().UnixNano()%100000000000)
return tx.Create(&model.BalanceTransaction{
ID: txID, UserID: userID, Type: "admin_adjust", Amount: req.Amount,
CreatedAt: time.Now(),
}).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": "余额已调整"})
}
// ConfirmBalanceRechargeByOrder 支付成功后确认充值(幂等),供 PayNotify 和 activateOrderBenefits 调用
func ConfirmBalanceRechargeByOrder(db *gorm.DB, order *model.Order) error {
if order == nil || order.ProductType != "balance_recharge" {

View File

@@ -391,17 +391,58 @@ func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium boo
return cnt > 0
}
// previewContent 取内容的前 50%(不少于 100 个字符),并追加省略提示
func previewContent(content string) string {
// getUnpaidPreviewPercent 从 system_config 读取 unpaid_preview_percent默认 20
func getUnpaidPreviewPercent(db *gorm.DB) int {
var row model.SystemConfig
if err := db.Where("config_key = ?", "unpaid_preview_percent").First(&row).Error; err != nil || len(row.ConfigValue) == 0 {
return 20
}
var val interface{}
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
return 20
}
switch v := val.(type) {
case float64:
p := int(v)
if p < 1 {
p = 1
}
if p > 100 {
p = 100
}
return p
case int:
if v < 1 {
return 1
}
if v > 100 {
return 100
}
return v
}
return 20
}
// previewContent 取内容的前 percent%(不少于 100 字,上限 500 字),并追加省略提示
func previewContent(content string, percent int) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
// 截取前 50% 的内容,保证有足够的预览长度
limit := total / 2
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
const maxPreview = 500
if limit > maxPreview {
limit = maxPreview
}
if limit > total {
limit = total
}
@@ -442,7 +483,8 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
} else if checkUserChapterAccess(db, userID, ch.ID, isPremium) {
returnContent = ch.Content
} else {
returnContent = previewContent(ch.Content)
percent := getUnpaidPreviewPercent(db)
returnContent = previewContent(ch.Content, percent)
}
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户

View File

@@ -19,7 +19,7 @@ import (
// 从 system_config 读取 chapter_config、feature_config、mp_config合并后返回免费以章节 is_free/price 为准)
func GetPublicDBConfig(c *gin.Context) {
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}
apiDomain := "https://soulapi.quwanzhi.com"
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
apiDomain = cfg.BaseURL
@@ -174,11 +174,12 @@ func AdminSettingsGet(c *gin.Context) {
}
out := gin.H{
"success": true,
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true},
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
"mpConfig": defaultMp,
"ossConfig": gin.H{},
}
keys := []string{"feature_config", "site_settings", "mp_config"}
keys := []string{"feature_config", "site_settings", "mp_config", "oss_config"}
for _, k := range keys {
var row model.SystemConfig
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
@@ -208,6 +209,10 @@ func AdminSettingsGet(c *gin.Context) {
}
out["mpConfig"] = merged
}
case "oss_config":
if m, ok := val.(map[string]interface{}); ok {
out["ossConfig"] = m
}
}
}
c.JSON(http.StatusOK, out)
@@ -219,6 +224,7 @@ func AdminSettingsPost(c *gin.Context) {
FeatureConfig map[string]interface{} `json:"featureConfig"`
SiteSettings map[string]interface{} `json:"siteSettings"`
MpConfig map[string]interface{} `json:"mpConfig"`
OssConfig map[string]interface{} `json:"ossConfig"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -260,6 +266,12 @@ func AdminSettingsPost(c *gin.Context) {
return
}
}
if body.OssConfig != nil {
if err := saveKey("oss_config", "阿里云 OSS 配置", body.OssConfig); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存 OSS 配置失败: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"})
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -417,3 +418,76 @@ func GiftPayMyPayments(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
}
// AdminGiftPayRequestsList GET /api/admin/gift-pay-requests 管理端-代付请求列表
func AdminGiftPayRequestsList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
status := strings.TrimSpace(c.Query("status"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
db := database.DB()
q := db.Model(&model.GiftPayRequest{})
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
q.Count(&total)
var list []model.GiftPayRequest
q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list)
userIDs := make(map[string]bool)
for _, r := range list {
userIDs[r.InitiatorUserID] = true
if r.PayerUserID != nil && *r.PayerUserID != "" {
userIDs[*r.PayerUserID] = true
}
}
nicknames := make(map[string]string)
if len(userIDs) > 0 {
ids := make([]string, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
var users []model.User
db.Select("id, nickname").Where("id IN ?", ids).Find(&users)
for _, u := range users {
if u.Nickname != nil {
nicknames[u.ID] = *u.Nickname
}
}
}
out := make([]gin.H, 0, len(list))
for _, r := range list {
initiatorNick := nicknames[r.InitiatorUserID]
payerNick := ""
if r.PayerUserID != nil {
payerNick = nicknames[*r.PayerUserID]
}
orderID := ""
if r.OrderID != nil {
orderID = *r.OrderID
}
out = append(out, gin.H{
"id": r.ID,
"requestSn": r.RequestSN,
"initiatorUserId": r.InitiatorUserID,
"initiatorNick": initiatorNick,
"productType": r.ProductType,
"productId": r.ProductID,
"amount": r.Amount,
"description": r.Description,
"status": r.Status,
"payerUserId": r.PayerUserID,
"payerNick": payerNick,
"orderId": orderID,
"expireAt": r.ExpireAt,
"createdAt": r.CreatedAt,
"updatedAt": r.UpdatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": total})
}

View File

@@ -83,13 +83,18 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/ckb/devices", handler.AdminCKBDevices)
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
admin.GET("/shensheshou/query", handler.AdminShensheShouQuery)
admin.POST("/shensheshou/enrich", handler.AdminShensheShouEnrich)
admin.POST("/shensheshou/ingest", handler.AdminShensheShouIngest)
admin.PUT("/orders/refund", handler.AdminOrderRefund)
admin.GET("/users/:id/balance", handler.AdminUserBalanceGet)
admin.POST("/users/:id/balance/adjust", handler.AdminUserBalanceAdjust)
admin.GET("/users", handler.AdminUsersList)
admin.POST("/users", handler.AdminUsersAction)
admin.PUT("/users", handler.AdminUsersAction)
admin.DELETE("/users", handler.AdminUsersAction)
admin.GET("/orders", handler.OrdersList)
admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList)
}
// ----- 鉴权 -----
@@ -159,6 +164,8 @@ func Setup(cfg *config.Config) *gin.Engine {
db.PUT("/users", handler.DBUsersAction)
db.DELETE("/users", handler.DBUsersDelete)
db.GET("/users/referrals", handler.DBUsersReferrals)
db.GET("/users/rfm", handler.DBUsersRFM)
db.GET("/users/journey-stats", handler.DBUsersJourneyStats)
db.GET("/vip-roles", handler.DBVipRolesList)
db.POST("/vip-roles", handler.DBVipRolesAction)
db.PUT("/vip-roles", handler.DBVipRolesAction)

View File

@@ -387,3 +387,26 @@ Mycontent-temp/miniprogram 为样式预览分支miniprogram 为线上主线
## 文档同步
- `开发文档/迁移完成度与待办清单.md`:搁置项更新为「稳定版已有」,无新增搁置
---
# 第十六部分:新版管理端迁移到稳定版 实施方案确认2026-03-17 会议)
## 会议决议
- **新版有的就迁移**API 文档 Tab、api-docs 独立页、OSS 配置、编辑时手机号禁用、鉴权逻辑优化 → 全部吸纳
- **内容管理**:以稳定版为主,不采纳新版
- **后端**:迁移前补齐 routerusers/rfm、journey-stats、shensheshou 共 5 个)
## 开发任务指派(乘风)
| 责任角色 | 任务 |
|---------|------|
| 后端开发 | soul-api router 注册 users/rfm、users/journey-stats、shensheshou 共 5 个;确认 /api/admin/settings 是否支持 ossConfig |
| 管理端开发工程师 | 从 new-soul/soul-admin 迁移 ApiDocsPage、OSS 配置、api-docs 路由、编辑时手机号禁用、鉴权逻辑;以稳定版为基准合并,内容管理不覆盖 |
| 测试人员 | 迁移完成后按《需求评估》§七验收,三端联调 |
## 相关文档
- `开发文档/新版管理端迁移到稳定版-需求评估.md`
- `.cursor/meeting/2026-03-17_新版管理端迁移到稳定版实施方案确认.md`

View File

@@ -54,10 +54,10 @@
| **/login** | 登录 | `POST /api/admin/login` |
| **/dashboard** | 数据概览:用户/订单/收入、最近订单、新用户 | `GET /api/admin``GET /api/orders`、用户与订单统计 |
| **/content** | 内容管理章节树、API 文档入口 | `GET /api/db/chapters`、内容相关 db 接口 |
| **/users** | 用户管理:列表、搜索、用户详情、设置 VIP | `GET /api/db/users``PUT /api/db/users`SetVipModal 相关更新 |
| **/users** | 用户管理:列表、搜索、用户详情、设置 VIP、用户规则、超级个体、用户旅程 | `GET /api/db/users``PUT /api/db/users``GET/POST/PUT/DELETE /api/db/user-rules``GET /api/admin/users/:id/balance` |
| **/find-partner** | 找伙伴CKB 配置、匹配池、导师、匹配记录、资源对接等 Tab | `GET /api/db/config/full?key=ckb_config`、匹配与导师相关 db/admin 接口 |
| **/distribution** | 推广中心:分销统计、推广设置入口 | `GET /api/db/distribution`、订单统计 |
| **/orders** | 订单列表:筛选、分页、退款、用户/推荐人信息 | `GET /api/orders``PUT /api/admin/orders/refund` |
| **/orders** | 订单列表:筛选、分页、退款、用户/推荐人信息、支付方式(微信/余额/代付) | `GET /api/orders``PUT /api/admin/orders/refund` |
| **/withdrawals** | 提现列表:审核、打款、状态 | `GET /api/admin/withdrawals`、提现审核接口 |
| **/settings** | 系统设置:作者设置、管理员、功能开关、站点、推广设置、免费章节等 | `GET /api/db/config/full``POST /api/db/config` 等 |
| **/vip-roles** | VIP 角色管理CRUD 预设角色 | `GET /api/db/vip-roles``POST /api/db/vip-roles` 等 |
@@ -125,3 +125,4 @@
| 日期 | 变更内容 |
|------|----------|
| 2026-03-11 | 初版小程序与管理端界面清单、业务逻辑对齐VIP 资料以用户资料为准、三端路由、免费章与 VIP、分销提现与需求汇总、README、运营与变更同步。 |
| 2026-03-17 | 管理端清单补充:用户规则、用户余额、订单支付方式;详见《管理端迁移分析-基于小程序功能.md》。 |

View File

@@ -0,0 +1,153 @@
# 新版管理端迁移到稳定版 - 需求评估
> 乘风整理。迁移方向new-soul/soul-admin → soul-admin稳定版。以稳定版真实需求为基准做评估与调整。
> 更新日期2026-03-17
---
## 一、评估基准
| 基准 | 说明 |
|------|------|
| **稳定版小程序** | 已梳理完毕,定义 C 端能力余额、代付、VIP、规则引擎、埋点、找伙伴、推广等 |
| **稳定版管理端** | 目标端,需支撑上述小程序的运营与管理 |
| **以界面定需求** | 管理端界面清单见《以界面定需求》§三;业务逻辑对齐见§四 |
---
## 二、稳定版管理端必须保留的能力(不可丢)
以下能力对应小程序功能,迁移时**必须保留**,新版若缺失则需从稳定版补齐。
| 能力 | 对应小程序 | 稳定版现状 | 迁移时动作 |
|------|------------|------------|------------|
| 用户详情 - 当前余额 | wallet、read/vip 余额支付 | ✅ 有 | **保留**,新版无则从稳定版代码带入 |
| 订单 - 支付方式(微信/余额/支付宝) | 余额购买、代付 | ✅ 有 | **保留**,新版无则从稳定版代码带入 |
| 订单 - 代付标识、代付人 | gift-pay | ✅ 有 | **保留** |
| 用户规则 CRUD | ruleEngine | ✅ 有 | **保留** |
| 超级个体/VIP 管理 | vip、member-detail | ✅ 有 | **保留** |
| 提现审核、分销、找伙伴、导师 | 对应小程序页 | ✅ 有 | **保留** |
| **内容管理(文章内容)** | chapters、read | ✅ 有 | **以稳定版为主**,不采纳新版 |
| RechargeAlert | 商户号余额不足提示 | ✅ 有 | **保留**,运营需及时感知 |
| LinkedMp关联小程序 | 多小程序场景 | ✅ 有 | **保留**,若业务使用 |
---
## 三、稳定版可选能力(依赖后端,当前 404
以下能力稳定版 UI 已有,但 soul-api **router 未注册**,会 404。迁移时需决策**修后端** 或 **暂隐 UI**
| 能力 | 接口 | 调用位置 | 建议 |
|------|------|----------|------|
| 用户列表 RFM 排序 | `GET /api/db/users/rfm` | UsersPage | 若运营需要 RFM 分析 → 补 router否则迁移时可隐 |
| 用户旅程总览 | `GET /api/db/users/journey-stats` | UsersPage | 若运营需要 → 补 router否则迁移时可隐 |
| 神射手(用户资料完善) | `GET/POST /api/admin/shensheshou/*` | UserDetailModal | 若使用神射手 → 补 router否则迁移时可隐 |
---
## 四、新版独有、稳定版可吸纳的能力
以下为新版有、稳定版无,迁移时**可选择性吸纳**到稳定版。
| 能力 | 说明 | 需求评估 | 建议 |
|------|------|----------|------|
| **OSS 配置** | 阿里云对象存储 endpoint、bucket、accessKey 等 | 新版有 → 迁移 | **吸纳**2026-03-17 会议决议) |
| **API 文档 Tab** | 系统设置内嵌 ApiDocsPage | 新版有 → 迁移 | **吸纳** |
| **api-docs 独立页** | `/api-docs` 路由 | 新版有 → 迁移 | **吸纳** |
| **编辑时手机号禁用** | 编辑用户时手机号不可改 | 新版有 → 迁移 | **吸纳** |
| **鉴权逻辑优化** | 先检查 token 再请求 | 新版有 → 迁移 | **吸纳** |
---
## 五、迁移策略(按模块)
### 5.1 内容管理(文章内容)— 以稳定版为主
| 策略 | 说明 |
|------|------|
| **ContentPage** | 完全以稳定版为准,不采纳新版 |
| **章节树、内容编辑、LinkTag、Person、LinkedMp** | 稳定版实现为准 |
| **富文本、排序、预览、免费章节配置** | 稳定版为准 |
迁移时:内容管理相关代码**不覆盖**,保持稳定版实现。
---
### 5.2 其他模块迁移策略
| 模块 | 策略 | 说明 |
|------|------|------|
| **用户管理** | 以稳定版为主 | 用户详情余额、RFM、旅程、规则、超级个体均保留稳定版可吸纳新版「编辑时手机号禁用」 |
| **订单** | 以稳定版为主 | 支付方式、代付、导出格式不覆盖 |
| **推广中心** | 以稳定版为主 | 保持稳定版实现 |
| **提现** | 以稳定版为主 | 保持稳定版实现 |
| **找伙伴** | 以稳定版为主 | CKB、匹配池、导师、资源对接等保持稳定版 |
| **系统设置** | 稳定版为主 + 吸纳 | 保持稳定版结构;可吸纳新版 API 文档 Tab、OSS 配置 |
| **Layout** | 以稳定版为主 | RechargeAlert、菜单结构保留可吸纳新版鉴权逻辑 |
| **Dashboard** | 以稳定版为主 | 保持稳定版 |
| **支付/站点/小程序码/导师/API 文档** | 以稳定版为主 | 保持稳定版API 文档可吸纳新版 ApiDocsPage |
---
### 5.3 需调整的项
| 项 | 调整说明 |
|----|----------|
| **后端 router** | 若保留 RFM、journey-stats、神射手需 soul-api 补 5 个路由 |
| **OSS** | 若吸纳,需 soul-api 支持 ossConfig/api/admin/settings 需确认) |
| **ApiDocsPage** | 新版有独立实现,可复制到稳定版并接入设置 Tab 或独立路由 |
---
### 5.4 不采纳的项
| 项 | 说明 |
|----|------|
| 新版 ContentPage | 内容管理以稳定版为准 |
| 新版 Layout 结构差异 | 系统设置分区等保持稳定版 |
| 新版部分 UI 细节 | grid-cols、间距等以稳定版为准 |
---
## 六、需求优先级与实施顺序
| 阶段 | 内容 | 优先级 |
|------|------|:------:|
| **1. 必须保留** | 确保迁移后稳定版不丢失:用户详情余额、订单支付方式/代付、RechargeAlert、LinkedMp | P0 |
| **2. 后端补齐** | 若保留 RFM/journey/神射手soul-api router 注册 5 个路由 | P1 |
| **3. 吸纳优化** | 编辑时手机号禁用、鉴权逻辑、API 文档 Tab | P2 |
| **4. 按需吸纳** | OSS 配置、api-docs 独立页 | P3 |
---
## 七、验收标准(迁移完成后)
| 验收项 | 标准 |
|--------|------|
| 用户详情 | 基础信息 Tab 有「当前余额」卡,数据来自 `/api/admin/users/:id/balance` |
| 订单页 | 支付方式列含微信/余额/支付宝,代付订单展示代付人 |
| 用户规则 | 规则配置 Tab 可 CRUD与小程序 ruleEngine 联动 |
| RechargeAlert | 余额不足等错误时顶部红条可展示 |
| 内容管理 | 章节树、文章编辑、LinkTag、Person、LinkedMp 等以稳定版为准 |
| 其他 | 提现、分销、找伙伴、设置等页面功能正常 |
---
## 八、迁移策略汇总
| 原则 | 模块 |
|------|------|
| **以稳定版为主(不覆盖)** | 内容管理、用户管理、订单、推广、提现、找伙伴、Layout、Dashboard |
| **吸纳新版** | 编辑时手机号禁用、鉴权逻辑、API 文档 Tab、OSS 配置(按需) |
| **内容管理** | **明确以稳定版为主**章节树、文章编辑、LinkTag、Person、LinkedMp 等均不采纳新版 |
---
## 九、总结
| 类型 | 说明 |
|------|------|
| **必须保留** | 余额、代付、规则、VIP、RechargeAlert、LinkedMp、**内容管理** |
| **可选(依赖后端)** | RFM、journey-stats、神射手 |
| **可吸纳** | OSS、API 文档、编辑禁用、鉴权优化 |
| **结论** | 内容管理以稳定版为主;其他模块以稳定版为主,选择性吸纳新版优化;后端按需补 router。 |

View File

@@ -0,0 +1,175 @@
# Soul 创业派对稳定版 - 源码质量分析报告
> 分析时间2026-03-17
> 分析人:乘风(老板分身)
---
## 一、高优先级(建议优先处理)
### 1. soul-api敏感配置默认值泄露
**位置**`soul-api/internal/config/config.go` 第 174237 行
**问题**AppSecret、MchKey、APIv3Key、adminPassword、adminSessionSecret、证书序列号等敏感信息有硬编码默认值。若 `.env` 未配置,将使用这些默认值,存在泄露与撞库风险。
**建议**
- 生产环境强制从环境变量读取,缺则 `log.Fatal` 启动失败
- 或使用占位符如 `"MUST_SET_IN_ENV"`,启动时校验并拒绝
---
### 2. soul-api`/api/user/track` 无鉴权
**位置**`soul-api/internal/router/router.go` 第 260261 行;`soul-api/internal/handler/user.go` 第 535 行
**问题**`GET /api/user/track?userId=xxx` 无 AdminAuth任何人可查任意用户行为轨迹。管理端 `UserDetailModal` 调用此接口展示用户行为。
**建议**
- 将接口迁至 `/api/admin/user/track``/api/db/user/track`,并加 `AdminAuth()` 中间件
- 或保留路径,在 handler 内校验管理员 token
---
### 3. miniprogram`payment.js` 使用未定义变量
**位置**`miniprogram/utils/payment.js` 第 25 行
**问题**:使用 `app.globalData.apiBase`,而 app 中仅有 `baseUrl`,会导致 `undefined + '/payment/create'`,请求失败。
**说明**:当前支付流程在 `read.js` 中直接调用 `/api/miniprogram/pay`,未使用 `payment.js`,该文件疑似废弃。若确认废弃,建议删除;若保留,需改为 `baseUrl` 并统一走 miniprogram 支付接口。
---
## 二、中优先级
### 4. miniprogram`goToMatch()` 重复定义
**位置**`miniprogram/pages/my/my.js` 第 801、816 行
**问题**:两个完全相同的 `goToMatch()`,后者覆盖前者,易造成维护困惑。
**建议**:删除其中一个。
---
### 5. miniprogram敏感信息硬编码
**位置**`miniprogram/app.js` 第 1825 行
**问题**appId、mchId、withdrawSubscribeTmplId 写死在代码中,与后端 config 重复。
**建议**:优先从 `/api/miniprogram/config``mpConfig` 读取,代码中仅保留开发兜底。
---
### 6. miniprogram开发接口暴露
**位置**`miniprogram/pages/settings/settings.js` 第 416 行
**问题**`dev/login-as` 在开发/体验版可见,生产需确保后端关闭或鉴权。
**建议**:后端对该接口做环境或 IP 白名单限制。
---
### 7. soul-adminRichEditor innerHTML 潜在 XSS
**位置**`soul-admin/src/components/RichEditor.tsx` 第 177 行
**问题**`popup.innerHTML = items.map(...)` 直接拼接 `item.name``item.label`,若含 HTML 可能 XSS。
**建议**:对 `name``label` 做 HTML 转义,或使用 `textContent` 渲染。
---
### 8. miniprogram`totalSections: 62` 多处硬编码
**位置**`app.js``index.js``my.js``chapters.js`
**问题**:章节总数写死为 62与真实数据可能不一致。
**建议**:从 `book/stats``all-chapters` 动态获取,或统一从 config 读取。
---
### 9. miniprogram废弃/备份文件
**位置**
- `miniprogram/pages/read/read.js.backup`
- `miniprogram/pages/referral/referral.wxss.backup`
**问题**:备份文件混入源码,增加噪音。
**建议**:若无需保留,可删除或移出版本库。
---
### 10. soul-admin`/api/user/track` 三端边界
**位置**`soul-admin/src/components/modules/user/UserDetailModal.tsx` 第 192 行
**问题**:管理端调用 `/api/user/track`(非 admin/db 路径),与「管理端只调 admin/db」约定不符。
**建议**:后端将 track 查询迁至 `/api/admin/user/track``/api/db/user/track`,管理端同步改路径。
---
## 三、低优先级
### 11. soul-api`DBUsersList` 函数过长
**位置**`soul-api/internal/handler/db.go`
**建议**:拆分为统计、过滤、分页等子函数,提升可读性。
---
### 12. soul-api`AdminWithdrawTest` 默认值
**位置**`soul-api/internal/handler/withdraw.go` 第 137138 行
**问题**:测试接口默认 userId、amount 写死,易误用。
**建议**:仅开发环境启用,或强制从请求体传入。
---
### 13. miniprogram错误处理不统一
**问题**:部分 `app.request``try/catch` 并提示用户,部分 `catch (e) {}` 静默吞错。
**建议**:统一约定:用户可见操作需提示,静默请求可吞错但需 `console.warn`
---
### 14. soul-admin上传逻辑重复
**位置**`ContentPage.tsx``MentorsPage.tsx`
**建议**:抽取公共 `uploadFile(apiUrl, file)` 方法复用。
---
### 15. 性能config / book 请求缓存
**问题**`/api/miniprogram/config``book/all-chapters` 等频繁请求,无本地缓存。
**建议**:对 config 做短期缓存(如 5 分钟),减少重复请求。
---
## 四、已确认无问题
- **三端路由边界**:小程序统一走 `/api/miniprogram/*`,管理端走 `/api/admin/*``/api/db/*`,主体符合约定
- **支付流程**`read.js` 正确使用 `/api/miniprogram/pay`,未依赖有问题的 `payment.js`
- **read.js.backup**:未在业务中引用,可安全清理
---
## 五、建议处理顺序
1. **立即**soul-api 敏感默认值、`/api/user/track` 鉴权
2. **短期**`payment.js` 废弃或修复、`goToMatch` 去重、`totalSections` 动态化
3. **中期**:管理端 track 路径迁移、RichEditor XSS 修复、配置与缓存优化
4. **长期**:代码结构优化、备份文件清理、错误处理统一

View File

@@ -0,0 +1,123 @@
# 稳定版:管理端与小程序功能对接分析
> 分析时间2026-03-17
> 分析人:乘风(老板分身)
> 更新2026-03-17 已完成 referralEnabled、searchEnabled 对接,删除 aboutEnabled 及关于作者页
## 一、结论概览
| 类型 | 数量 | 说明 |
|-----|------|------|
| ✅ 已对接 | 3 | matchEnabled、referralEnabled、searchEnabled |
| 🗑️ 已删除 | 1 | aboutEnabled、关于作者页 |
| ⚠️ 需确认 | 1 | site_settings 与 chapter_config 价格同步 |
---
## 二、功能开关feature_config对接情况
### 管理端配置项SettingsPage 系统设置)
| 开关 | 管理端 | 后端 config 返回 | 小程序使用 |
|-----|--------|------------------|------------|
| **matchEnabled** | ✅ 有 | ✅ features.matchEnabled | ✅ **已用**custom-tab-bar 控制找伙伴 tabmy 页根据此隐藏/显示找伙伴入口 |
| **referralEnabled** | ✅ 有 | ✅ features.referralEnabled | ✅ **已用**my 页推荐好友/我的收益入口 `wx:if`goToReferral 二次校验 |
| **searchEnabled** | ✅ 有 | ✅ features.searchEnabled | ✅ **已用**:首页搜索栏、目录页搜索按钮 `wx:if` |
| ~~aboutEnabled~~ | 🗑️ 已删除 | - | 关于作者页已移除 |
### 小程序实际使用(对接后)
- **custom-tab-bar**:读取 `matchEnabled`,控制找伙伴 tab
- **my.js**`loadFeatureConfig()` 取 matchEnabled、referralEnabled、searchEnabled存 globalData.features
- **my.wxml**:推荐好友/我的收益 `wx:if="{{referralEnabled}}"`;已删除关于作者菜单
- **index**`loadFeatureConfig()` 取 searchEnabled搜索栏 `wx:if="{{searchEnabled}}"`
- **chapters**`loadFeatureConfig()` 取 searchEnabled搜索按钮 `wx:if="{{searchEnabled}}"`
---
## 三、其他配置对接情况
### 已对接
| 配置项 | 来源 | 小程序使用位置 |
|--------|------|----------------|
| prices (section/fullbook) | chapter_config | read.js 展示价格、chapterAccessManager |
| userDiscount | referral_config | referral 页展示好友优惠 |
| linkTags | link_tag 表 | read.js onLinkTagTap |
| linkedMiniprograms | system_config | read.js 跳转关联小程序 |
| mpConfig | mp_config | 支付、提现等 |
### 需确认
| 配置项 | 说明 |
|--------|------|
| site_settings (sectionPrice, baseBookPrice) | 管理端保存到 `site_settings`,小程序 config 的 prices 来自 `chapter_config`。需确认两处是否同步,或是否有单独维护 chapter_config 的流程。 |
---
## 四、建议改造(补齐未用开关)
### 1. 推广开关 referralEnabled
**影响入口**我的页「推荐好友」「我的收益」统计、goToReferral 跳转
**改造建议**
- my.js`loadFeatureConfig()` 增加 `referralEnabled`
- my.wxml`profile-stat`(推荐好友、我的收益)加 `wx:if="{{referralEnabled}}"`
- 若有单独推广入口(如菜单项),也加 `wx:if="{{referralEnabled}}"`
### 2. 搜索开关 searchEnabled
**影响入口**:首页搜索栏、目录页搜索按钮
**改造建议**
- app.js 或各页:从 config 拉取 `searchEnabled` 存 globalData
- index.wxml搜索栏加 `wx:if="{{searchEnabled}}"`
- chapters.wxml搜索按钮加 `wx:if="{{searchEnabled}}"`
### 3. 关于作者开关 aboutEnabled
**影响入口**:我的页「关于作者」菜单项
**改造建议**
- my.js`loadFeatureConfig()` 增加 `aboutEnabled`
- my.wxml关于作者 menu-item 加 `wx:if="{{aboutEnabled}}"`
---
## 五、数据流说明
```
管理端 SettingsPage
→ POST /api/admin/settings { featureConfig }
→ 写入 system_config.feature_config
小程序
→ GET /api/miniprogram/config
→ GetPublicDBConfig 读取 feature_config合并到 features
→ 返回 { features: { matchEnabled, referralEnabled, searchEnabled, aboutEnabled } }
当前:仅 matchEnabled 被小程序使用
```
---
## 六、附录config 接口返回结构GetPublicDBConfig
```json
{
"success": true,
"prices": { "section": 1, "fullbook": 9.9 },
"features": {
"matchEnabled": true,
"referralEnabled": true,
"searchEnabled": true,
"aboutEnabled": true
},
"mpConfig": { "appId", "apiDomain", "buyerDiscount", ... },
"userDiscount": 5,
"linkTags": [...],
"linkedMiniprograms": [...],
"configs": { "chapter_config", "feature_config", "mp_config" }
}
```

View File

@@ -0,0 +1,36 @@
# 管理端两版界面差异(新需求参考)
> 迁移方向:新版 → 稳定版。界面差异用于需求评估与迁移取舍。
> 更新日期2026-03-17
---
## 一、稳定版有、新版无(迁移时必须保留)
迁移时以稳定版为主,以下能力**不可丢失**。
| 页面/组件 | 界面差异 | 说明 |
|-----------|----------|------|
| **用户详情** | 基础信息 Tab 有「当前余额」卡片 | 对应小程序 wallet、余额支付 |
| **订单页** | 支付方式(微信/余额/支付宝)、代付标识、代付人 | 对应小程序 gift-pay、余额购买 |
| **用户列表** | RFM 排序模式、RFM 分值列 | 运营分析用(依赖后端 users/rfm |
| **用户列表** | 用户旅程总览 Tab | 依赖后端 users/journey-stats |
| **内容管理** | 关联小程序管理LinkedMpPage | 多小程序场景 |
| **全局** | RechargeAlert 充值告警条 | 商户号余额不足提示 |
---
## 二、新版有、稳定版无(迁移时可吸纳)
| 页面/组件 | 界面差异 | 建议 |
|-----------|----------|------|
| **系统设置** | API 文档 Tab、OSS 配置 | 按需吸纳 |
| **路由** | `/api-docs` 独立页 | 按需吸纳 |
| **用户编辑** | 编辑时手机号禁用 | 建议吸纳 |
| **Layout** | 鉴权逻辑优化 | 建议吸纳 |
---
## 三、与需求评估的关系
详见《新版管理端迁移到稳定版-需求评估.md》。

View File

@@ -0,0 +1,89 @@
# 管理端迁移分析:基于现有小程序功能
> 乘风整理。以 miniprogram 当前功能为基准,分析稳定版与新版管理端的差异及迁移建议。
> 更新日期2026-03-17
---
## 一、背景与范围
| 项目 | 路径 | 说明 |
|------|------|------|
| **稳定版管理端** | `soul-admin/` | 根目录,当前主用 |
| **新版管理端** | `new-soul/soul-admin/` | 新版,与 new-soul 配套 |
| **分析基准** | miniprogram 当前页面与接口app.json、以界面定需求.md |
**迁移方向**:新版 → 稳定版。分析目标:**以稳定版真实需求为基准,评估新版迁移时的保留项与吸纳项**。详见《新版管理端迁移到稳定版-需求评估.md》。
---
## 二、稳定版 vs 新版 差异对比
| 能力 | 稳定版 soul-admin | 新版 new-soul/soul-admin |
|------|-------------------|---------------------------|
| **用户详情-余额** | ✅ 有 balanceData、余额 Tab、`GET /api/admin/users/:id/balance` | ❌ 无余额展示 |
| **订单-支付方式** | ✅ 展示 wechat/balance/alipay、代付人 payerNickname | ❌ 仅 wechat/alipay无 balance、无代付 |
| **用户规则** | ✅ 规则配置 Tab、user-rules CRUD | ✅ 已有 |
| **超级个体/VIP** | ✅ 有 | ✅ 有 |
| **用户详情-VIP 表单** | ✅ 含 isVip、vipExpireDate、vipRole 等完整字段 | ⚠️ 有 vipForm但 payload 不含 isVip/vipExpireDate 等handleSave 仅保存基础字段handleSaveVip 单独保存 VIP |
| **LinkedMp 页** | 稳定版有 `/linked-mp` | 新版无(需确认是否需迁移) |
| **ApiDocs 页** | 单页 api-doc | 新版多 api-docs 页 |
---
## 三、小程序功能 → 管理端能力对照
以下按 miniprogram 核心能力,逐项对照管理端是否已覆盖(以稳定版为基准)。
| 小程序能力 | 小程序页面/接口 | 管理端需支持 | 稳定版 | 新版 |
|------------|-----------------|--------------|:------:|:----:|
| **余额体系** | wallet、read/vip 余额支付 | 用户余额查看、交易记录 | ✅ | ❌ |
| **代付(美团式)** | gift-pay/detail、list | 订单页展示代付信息 | ✅ | ❌ |
| **规则引擎** | ruleEngine | 用户规则 CRUD | ✅ | ✅ |
| **VIP** | vip、member-detail | VIP 状态、超级个体 | ✅ | ✅ |
| **订单** | purchases、read/vip 支付 | 订单列表、支付方式、退款 | ✅ | ⚠️ 缺余额/代付 |
| **提现、分销、找伙伴、导师、内容** | 对应页面 | 已有 | ✅ | ✅ |
---
## 四、迁移时稳定版必须保留(新版缺失则从稳定版带入)
### 4.1 用户详情 - 余额展示
| 项 | 说明 |
|----|------|
| **目标** | 迁移后稳定版保留「当前余额」卡 |
| **接口** | `GET /api/admin/users/:id/balance` |
| **实现** | 稳定版 UserDetailModal 已有,合并时不可覆盖 |
### 4.2 订单页 - 支付方式与代付
| 项 | 说明 |
|----|------|
| **目标** | 迁移后稳定版保留 balance、代付人展示 |
| **接口** | `GET /api/admin/orders` 已返回 |
| **实现** | 稳定版 OrdersPage 已有,合并时不可覆盖 |
### 4.3 LinkedMp、RechargeAlert
稳定版 ContentPage 嵌入 LinkedMpPage、Layout 有 RechargeAlert合并时保留。
---
## 五、稳定版待补充(两版共用)
| 优先级 | 项 | 说明 |
|:------:|----|------|
| P2 | 用户余额人工调整 | 用户详情增加「调整余额」入口,需 soul-api 新增 `POST /api/admin/users/:id/balance/adjust` |
| P2 | 代付列表页 | 新增代付请求维度管理,需 soul-api 新增 `GET /api/admin/gift-pay-requests` |
| P3 | 埋点看板 | 可选,低优先级 |
---
## 六、总结
| 类型 | 说明 |
|------|------|
| **新版迁移** | 用户详情余额、订单页支付方式(余额/代付——2 项,参考稳定版实现 |
| **稳定版待补充** | 用户余额人工调整、代付列表页——2 项,两版共用后端接口 |
| **结论** | 新版管理端需从稳定版迁移余额与代付相关展示能力,接口已就绪,以 UI 与数据绑定为主。