更新管理端迁移Mycontent-temp的菜单与布局规范,确保主导航收敛并优化隐藏页面入口。新增相关会议记录与文档,反映团队讨论的最新决策与实施建议。

This commit is contained in:
Alex-larget
2026-03-10 18:06:10 +08:00
parent e23eba5d3e
commit aebb533507
82 changed files with 2376 additions and 1126 deletions

View File

@@ -0,0 +1,13 @@
# 产品经理 经验记录 - 2026-03-10
## 管理端迁移 Mycontent-temp信息架构与验收口径
- **主导航收敛**:侧栏只保留运营主链路 5 项(概览/内容/用户/找伙伴/推广),系统设置固定在底部;其余能力不删除但不占主导航入口。
- **入口承载策略**:非主菜单页面(订单/提现/推广设置/VIP角色/导师等)通过“概览卡片/页面内按钮/系统设置 Tab”进入确保可达且路径更短。
- **验收标准**
- 菜单与布局一致(新规范)
- 隐藏页面路由仍可访问(功能不丢)
- author/admin 设置统一在 `/settings?tab=...` 承载,旧路径可兼容跳转
> 详见会议纪要:`.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md`

View File

@@ -4,3 +4,4 @@
|------|------|------|
| 2026-03-05 | 分支冲突后需求文档与实现一致性核对 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-05 | 文章详情@某人高亮与一键加好友验收标准与待确认 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp主导航收敛与隐藏页面入口承载策略 | [2026-03-10.md](./2026-03-10.md) |

View File

@@ -0,0 +1,36 @@
# 后端工程师 经验记录 - 2026-03-10
## 管理端迁移 Mycontent-temp后端视角注意点
- **接口边界不变**:管理端迁移/重构只允许调用 `/api/admin/*``/api/db/*``/api/orders`,严禁引入 `/api/miniprogram/*`
- **概览聚合接口可选**`/api/admin/dashboard/overview` 可作为“优化项”提供更轻量的统计聚合,但必须保留**降级策略**(用 `/api/db/users` + `/api/orders` 拼)以免阻塞前端迁移与部署节奏。
- **鉴权一致性**:页面入口/菜单变化不影响鉴权口径,仍以 `GET /api/admin` 作为 session/token 校验401 统一跳登录并清 token。
> 详见会议纪要:`.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md`
---
## 新增聚合接口 UserDashboardStats
**场景**:小程序「我的」页需要一个聚合接口返回阅读统计,避免多次请求。
**接口**`GET /api/miniprogram/user/dashboard-stats?userId=xxx`
**数据来源**
- `readSectionIds` / `readCount``reading_progress` WHERE `user_id = userId`
- `totalReadMinutes``SUM(duration) / 60`(秒转分,最小值 1 分钟)
- `recentChapters``reading_progress` ORDER BY `last_open_at DESC` JOIN `chapters`(最近 5 条**去重**
- `matchHistory``match_records` COUNT WHERE `user_id = userId`
**三处 bug 修复点**(对比 Mycontent-temp 参考版发现):
| Bug | 错误做法 | 正确做法 |
|-----|---------|---------|
| 最近阅读重复 | 直接取前 5 条(同章节可重复) | `seenRecent` map 去重,保证 5 条不重复 |
| 阅读时长最小值 | 不足 60 秒返回 0 | `if totalReadSeconds > 0 && totalReadMinutes == 0 { totalReadMinutes = 1 }` |
| DB 错误状态码 | 返回 200 + `success:false` | 返回 HTTP 500 `InternalServerError` |
**规则沉淀**:新增聚合接口时,先参考已有版本实现,对比 diff 后修复潜在 bug再提交。
> 详见会议纪要:`.cursor/meeting/2026-03-10_小程序新旧版对比与dashboard接口新增.md`

View File

@@ -4,3 +4,4 @@
|------|------|------|
| 2026-03-05 | soul-api 合并状态确认orders、distribution 接口核对 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-05 | 文章详情@某人content 内嵌 @ 标记、miniprogram 添加好友接口 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp接口边界不变overview 聚合接口可选但需降级 | [2026-03-10.md](./2026-03-10.md) |

View File

@@ -75,3 +75,44 @@
- 后端 / 运维:需要在本地或服务器上快速拉起 macOS VM 做兼容性验证或演示。
- 团队:对外说明 **“我们不支持 Docker macOS统一用龙虾方案”**。
---
## 管理端迁移 Mycontent-temp菜单/布局新规范基线
### 决议(团队共享)
- **目标态基线**:以 `Mycontent-temp/soul-admin``AdminLayout` + `SettingsPage` 作为“新规范基线”,后续管理端所有菜单/布局调整按该基线执行,避免两套后台并行发散。
- **主导航收敛**:侧栏只保留 5 个主入口(概览/内容/用户/找伙伴/推广),系统设置固定底部,取消“更多”折叠入口。
- **功能不丢但入口收敛**:订单/提现/推广设置/VIP角色/导师等页面保留路由可达,入口通过概览卡片或页面内跳转承载;作者/管理员设置并入 `/settings?tab=author|admin`
### 实施建议
- 迁移时优先保证:**鉴权一致GET /api/admin**、**路由可达性**、**菜单一致性**,再逐步优化概览聚合接口与快捷入口。
---
## 新旧版代码对比方法论Mycontent-temp vs miniprogram
### 场景
存在两个并行代码库(主线 + 预览版),需要判断哪个版本更可靠,以及如何安全地吸收另一版的优点。
### 最佳实践
1. **批量 diff 优先于逐文件比较**:用 PowerShell 批量对比 WXSS/JS/WXML 文件,精确列出「相同/有差异」的文件清单,再聚焦差异文件逐一分析。
2. **以功能完整性为基准**:不以「新/旧」日期判断优劣,而以**功能是否完整**为主要依据本次判断旧版miniprogram才是功能更完整的版本。
3. **差异归类**
- **旧版有、新版没有** → 旧版是主线,保留旧版
- **新版有、旧版没有** → 评估是否需要移植(如 dashboard-stats 调用)
- **样式差异** → 对比具体行数,判断是改进还是遗漏
4. **接口对比时对照新版参考修复 bug**:新版的接口实现即使存在,也可能有遗漏;参考后自行修复(去重、最小值、错误码等)再提交。
### 适用场景
- 分支合并前的功能完整性分析
- 迁移预览版到主线时的取舍决策
- 跨版本 bug 溯源
> 同时影响:小程序开发工程师、后端工程师
> 详见会议纪要:`.cursor/meeting/2026-03-10_小程序新旧版对比与dashboard接口新增.md`

View File

@@ -5,3 +5,4 @@
| 日期 | 摘要 | 文件 |
|------|------|------|
| 2026-03-05 | 分支冲突后各端完整性自查流程 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp菜单/布局新规范基线与入口收敛规则 | [2026-03-10.md](./2026-03-10.md) |

View File

@@ -0,0 +1,46 @@
# 小程序开发工程师 经验记录 - 2026-03-10
## 管理端迁移 Mycontent-temp小程序侧关注点
- **菜单/布局迁移不应影响小程序接口**:管理端仅重排入口与页面承载,禁止因此改动小程序端接口路径或混用 `/api/miniprogram/*`
- **内容编辑产物稳定性**:如果管理端迁移导致内容从 Markdown → HTMLTipTap或 mention/tag 的序列化结构变化,小程序阅读页解析必须同步升级并回归兼容。
- **验收建议**:迁移期间抽样验证“新编辑器保存的内容”在小程序阅读页可正常渲染与交互(@ 点击加好友、# 标签等)。
> 详见会议纪要:`.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md`
---
## my.js 阅读统计改为后端接口loadDashboardStats 移植)
**场景**:旧版 `my.js``initUserStatus()` 用本地缓存随机数时间(`Math.floor(Math.random() * 200) + 50`)和标题占位(`章节 ${id}`)展示统计,不准确且不一致。
**解决方案**
1. 移植 Mycontent-temp 中的 `loadDashboardStats()` 方法
2. 调用 `/api/miniprogram/user/dashboard-stats?userId=xxx`
3. 同步 `readSectionIds``app.globalData` 和 Storage
4. 返回真实 `recentChapters`(含标题/mid`readCount``totalReadMinutes``matchHistory`
**initUserStatus 改造要点**
- `readCount` 初始化为 `0`(不再读本地缓存)
- `recentChapters` 初始化为 `[]`
- `totalReadTime` 初始化为 `0`(不再用随机数)
- 新增 `matchHistory: 0` 字段
- 登录状态下额外调用 `this.loadDashboardStats()`
**规则沉淀**`my.js` 阅读统计必须走后端接口,禁止用本地缓存 + 随机数占位展示统计数据。
---
## 富文本渲染现状(技术债,待实施)
**现状**`contentParser.js` 将 TipTap HTML 剥成纯文本,`<strong>`/`<h2>`/`<ul>` 等格式全部丢失,只保留 `@mention` 高亮。
**建议方案**(待实施):
- 有权限的完整内容改用 `<rich-text nodes="{{richNodes}}">` 渲染
- 预处理时将 TipTap mention span 替换为 `<span style="color:#00CED1">@昵称</span>`
- 付费墙预览段落保留纯文本
**验收前置问题**:确认 DB 中文章内容格式(纯文本 vs TipTap HTML是否真的用了格式化标记。
> 详见会议纪要:`.cursor/meeting/2026-03-10_小程序新旧版对比与dashboard接口新增.md`

View File

@@ -6,3 +6,4 @@
| 2026-03-03 | 我的页面卡片区边距优化16rpx 推荐值 | [2026-03-03.md](./2026-03-03.md) |
| 2026-03-05 | 分支合并后核心流程自测app.json 拆行orders 接口确认 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-05 | 文章详情@某人高亮与一键加好友(解析@、调添加好友接口) | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp关注内容产物格式与阅读页解析兼容回归 | [2026-03-10.md](./2026-03-10.md) |

View File

@@ -25,7 +25,10 @@
| 2026-02-27 | 小程序、团队 | 最佳实践 | SKILL-小程序开发 §6、SKILL-管理端开发 §4.1 | 输入框 padding 用 view/div 包裹 |
| 2026-02-28 | 小程序、管理端 | 最佳实践 | miniprogram §6、admin §4.1 | input 边距口诀「外边包 view、内部 width 100%」match 弹窗已修正 |
| 2026-03-03 | 小程序 | 最佳实践 | miniprogram §8 | 我的页面卡片区边距 16rpx个人中心类页面布局规范 |
| 2026-03-10 | 团队 | 架构/运维约定 | lobster-macos-vm Skill | Windows 上统一使用 WSL2+QEMU+OneClick 的龙虾方案安装 macOS 虚拟机,禁止 Docker 直跑 macOS |
| 2026-03-10 | 团队 | 架构/运维约定 | lobster-macos-vm Skill | Windows 上统一使用 WSL2+QEMU+OneClick 的"龙虾"方案安装 macOS 虚拟机,禁止 Docker 直跑 macOS |
| 2026-03-10 | 小程序 | 最佳实践 | miniprogram-dev SKILL §my | my.js 阅读统计改为后端接口loadDashboardStats禁止用随机数时间/标题占位 |
| 2026-03-10 | 后端 | bug 修复 | api-dev SKILL | 聚合接口三处修复recentChapters 去重、totalReadMinutes 最小1分钟、DB 错误返回 500 |
| 2026-03-10 | 团队 | 方法论 | - | 新旧版代码对比:以功能完整性为基准,批量 diff + 分类取舍,不以日期判优劣 |
---

View File

@@ -19,9 +19,10 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。
| 2026-02-28 | stitch_soul 需求评审:内容→会员→导师变现路径,待产品补充正式需求文档 | 待续 |
| 2026-03-05 | 分支冲突后功能完整性分析会议:核对需求文档与实现一致性 | 待续 |
| 2026-03-05 | 文章详情@某人加好友方案讨论:验收标准、添加好友接口 path 待确认 | 待续 |
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp新菜单/布局与入口收敛验收口径确定 | 待续 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-05
**最后更新**2026-03-10

View File

@@ -20,9 +20,11 @@ soul-apiGo + Gin + GORM + MySQL提供三组路由`/api/miniprogram/*`
| 2026-02-28 | stitch_soul 需求评审:需梳理 chapter/book/vip设计导师/预约/会员权益模型与接口 | 待续 |
| 2026-03-05 | 分支冲突后功能完整性分析会议:在 soul-api 确认合并状态,核对 orders、distribution 接口 | 待续 |
| 2026-03-05 | 文章详情@某人加好友方案讨论content 内嵌 @ 标记、miniprogram 添加好友接口 | 待续 |
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp后端接口边界不变overview 聚合接口可选但需降级 | 待续 |
| 2026-03-10 | 新增 GET /api/miniprogram/user/dashboard-stats聚合阅读统计接口含去重、min1分钟、500错误码修复 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-05
**最后更新**2026-03-10dashboard-stats 接口新增完成)

View File

@@ -17,9 +17,11 @@ Soul 创业派对全项目架构与约定路由隔离miniprogram/admin/db
| 2026-02-27 | 项目索引初始化;团队经验库目录建立 | 已完成 |
| 2026-02-28 | stitch_soul 需求评审:内容→会员→导师变现路径,需与现有三端架构协同 | 已完成 |
| 2026-03-08 | 文章阅读付费规则澄清:免费章节以 free_chapters 为准VIP 全章免费;后端 book.go 合并配置修复 | 已完成 |
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp新菜单/布局作为团队规范基线,避免两套后台并行发散 | 待续 |
| 2026-03-10 | 新旧版代码对比方法论:以功能完整性而非日期判断优劣,批量 diff + 分类取舍 + 移植修复 | 已完成 |
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
---
**最后更新**2026-03-08
**最后更新**2026-03-10新旧版对比方法论沉淀

View File

@@ -23,9 +23,12 @@
| 2026-03-03 | 吸收经验我的页面卡片区边距优化16rpx 为个人中心类页面推荐值,已升级 SKILL §8 | 已完成 |
| 2026-03-05 | 分支冲突后功能完整性分析会议:修正 app.json 拆行、核心流程自测、确认 orders 接口 | 待续 |
| 2026-03-05 | 文章详情@某人加好友方案讨论:阅读页解析 @、高亮可点击、调添加好友接口 | 待续 |
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp小程序侧关注内容产物格式变化与阅读页兼容回归 | 待续 |
| 2026-03-10 | 移植 loadDashboardStats()my.js 阅读统计改为后端接口,去除随机数时间/标题占位 | 已完成 |
| 2026-03-10 | 富文本渲染技术债分析contentParser.js 剥除 HTML 格式,建议改 rich-text 组件(待实施) | 待续 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-05
**最后更新**2026-03-10loadDashboardStats 移植完成)

View File

@@ -19,7 +19,8 @@
| 2026-02-28 | stitch_soul 需求评审:关键场景为阅读/付费/会员/导师预约/资料;待需求确定后补充联调用例 | 待续 |
| 2026-03-05 | 分支冲突后功能完整性分析会议:制定「分支合并后回归清单」 | 待续 |
| 2026-03-05 | 文章详情@某人加好友方案讨论@ 展示与添加好友用例、联调与回归 | 待续 |
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp回归重点为菜单一致性、隐藏路由可达性、鉴权跳转 | 待续 |
---
**最后更新**2026-03-05
**最后更新**2026-03-10

View File

@@ -20,9 +20,10 @@
| 2026-02-28 | stitch_soul 需求评审:待后端方案确定后规划章节/导师/会员/预约管理页面 | 待续 |
| 2026-03-05 | 分支冲突后功能完整性分析会议:全功能自测,记录 404/异常接口 | 待续 |
| 2026-03-05 | 文章详情@某人加好友方案讨论:编辑页插入 @用户、保存约定 content 格式 | 待续 |
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp 新菜单/布局主导航收敛、author/admin 并入 Settings Tab | 待续 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-05
**最后更新**2026-03-10

View File

@@ -0,0 +1,18 @@
# 管理端开发工程师 经验记录 - 2026-03-10
## 会议结论:管理端迁移 Mycontent-temp 菜单/布局
- **目标态基线**:以 `Mycontent-temp/soul-admin` 为“新规范基线”,旧 `soul-admin` 若继续演进则对齐其实现,避免两套后台信息架构发散。
- **菜单信息架构**
- 侧栏主菜单固定 5 项:数据概览 / 内容管理 / 用户管理 / 找伙伴 / 推广中心
- 系统设置固定在侧栏底部
- 取消「更多」折叠入口
- **路由与入口策略**
- `author-settings``admin-users` 不再作为独立菜单/页面入口,统一并入 `/settings?tab=author|admin`
- 订单/提现/推广设置/VIP角色/导师等页面**保留路由可达**,但不进入侧栏主菜单;入口通过概览卡片/页面内跳转承载
- **实现抓手**
- `AdminLayout.tsx`:用 `primaryMenuItems` 平铺主菜单Settings 单独固定
- `App.tsx`:对旧路径用 `Navigate` 做兼容跳转(减少断链风险)
> 详见会议纪要:`.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md`

View File

@@ -4,3 +4,4 @@
|------|------|------|
| 2026-03-05 | 分支合并后全功能自测404/异常接口记录 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-05 | 文章详情@某人:编辑页插入 @用户、保存约定 content 格式 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp 菜单/布局主导航收敛、Settings Tab 承载 author/admin | [2026-03-10.md](./2026-03-10.md) |

View File

@@ -0,0 +1,12 @@
# 软件测试 经验记录 - 2026-03-10
## 管理端迁移 Mycontent-temp测试与回归关注点
- **菜单一致性**:侧栏必须是 5 个主入口 + 底部系统设置;不再出现“更多”折叠。
- **路由可达性回归**:菜单入口减少不等于功能减少,需要覆盖“隐藏路由仍可访问”的用例:
- 订单、提现、推广设置、VIP角色、导师、导师预约、二维码、站点、支付、API 文档、匹配记录等。
- **鉴权回归**:任意页面刷新都必须先 `GET /api/admin` 校验;失效则跳登录并清 token。
- **导航兼容**:旧路径(如 `/author-settings``/admin-users`)应跳转到 `/settings?tab=author|admin`
> 详见会议纪要:`.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md`

View File

@@ -4,3 +4,4 @@
|------|------|------|
| 2026-03-05 | 分支合并后回归清单制定;三端联调验证 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-05 | 文章详情@某人@ 展示与添加好友用例、联调与回归点 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp菜单一致性、隐藏路由可达性、鉴权与跳转回归 | [2026-03-10.md](./2026-03-10.md) |

View File

@@ -0,0 +1,108 @@
# 会议纪要 - 2026-03-10 | 小程序新旧版对比分析 & dashboard-stats 接口新增
## 基本信息
- **时间**2026-03-10
- **议题**Mycontent-temp vs miniprogram 小程序新旧版功能/样式对比loadDashboardStats 移植;后端新增聚合接口
- **参与角色**:小程序开发工程师、后端工程师、团队(乘风)
---
## 讨论过程
### 一、新旧版小程序对比分析
用户指出 `Mycontent-temp/miniprogram` 为新版(仅供预览),`miniprogram` 为旧版(线上正确版本)。
**功能差异**(旧版反而功能更完整):
| 功能 | 新版 (Mycontent-temp) | 旧版 (miniprogram) |
|-----|------|------|
| 首页「我的阅读」进度卡 | ❌ 缺失 | ✅ 有(已读/待读/篇章/章节四统计) |
| 首页章数徽章/Banner篇章名 | ❌ 缺失 | ✅ 有 |
| 最新新增日期/描述 | ❌ 缺失 | ✅ 有 |
| 目录页 VIP 权限 | ❌ 无 isVip | ✅ 支持 isVip + isPremium 增值章节 |
| VIP 全局状态管理 | ❌ 不写 globalData | ✅ isVip/vipExpireDate 同步到 globalData + Storage |
| 阅读页 contentParagraphs fallback | ❌ 无 | ✅ 有降级渲染 |
| my.js 阅读统计 | ✅ loadDashboardStats后端接口| ❌ 本地缓存占位(随机时间/标题占位) |
**样式差异**(仅 2 处,全局 app.wxss 完全相同):
1. `chapters.wxss`:旧版多 `.tag-vip`(金色增值标签样式)
2. `read.wxss`:旧版 `.paragraph .mention``padding: 0 4rpx`
**结论**:新版的亮点仅为 `loadDashboardStats` 后端接口调用,其余功能旧版更完整。
### 二、my.js loadDashboardStats 移植
将 Mycontent-temp 中的 `loadDashboardStats()` 移植到旧版 `miniprogram/pages/my/my.js`
**改动:**
1. `initUserStatus()` 改造:去掉本地缓存占位(随机时间、标题 `章节 ${id}`),初始化清零后调 `loadDashboardStats()`
2. 新增 `loadDashboardStats()` 方法:调用 `/api/miniprogram/user/dashboard-stats?userId=xxx`,同步 `readSectionIds` 到 globalData 和 Storage获取真实 `recentChapters``readCount``totalReadMinutes``matchHistory`
### 三、后端 dashboard-stats 接口新增
`soul-api/internal/handler/user.go` 新增 `UserDashboardStats`,路由注册:`GET /api/miniprogram/user/dashboard-stats`
参考 Mycontent-temp 实现并优化了 3 处 bug
1. **去重**`seenRecent` map 防止同一章节重复出现在「最近阅读」
2. **最小 1 分钟**:阅读不足 60 秒时显示 1 分钟而非 0
3. **错误状态码**DB 失败返回 500 而非 200+`success:false`
编译通过,数据来源:
- `readSectionIds`/`readCount``reading_progress`
- `totalReadMinutes``duration` ÷ 60秒转分
- `recentChapters``reading_progress` JOIN `chapters`(最近 5 条去重)
- `matchHistory``match_records` 计数
### 四、富文本渲染现状分析(未实施,待办)
两版均为纯文本渲染TipTap HTML 格式(粗体、标题、列表、引用等)被 `contentParser.js` 全部剥除。
建议方案:用 `<rich-text>` 组件渲染 HTML@mention 替换为带颜色 span通过外层 bindtap + dataset 实现点击。**本次未实施。**
---
## 会议决议
1.**旧版miniprogram为线上正确版本**,新版仅供样式预览,不反向同步功能
2. ✅ **loadDashboardStats 已移植**到旧版 my.js阅读统计改为后端真实数据
3.**后端 dashboard-stats 接口已实现并编译通过**,可直接上线使用
4. ⬜ **富文本渲染**待后续迭代:用 `<rich-text>` 替换当前纯文本渲染
---
## 待办事项
| 责任角色 | 任务 | 状态 |
|---------|------|------|
| 小程序开发工程师 | 富文本渲染升级rich-text 组件 + mention 处理) | 待实施 |
| 后端工程师 | dashboard-stats 接口上线验证 | 待验证 |
| 小程序开发工程师 | 首页阅读进度卡确认是否需要(新版没有,旧版有) | 待确认 |
---
## 问题与作答区
| 编号 | 问题 | 作答 |
|------|------|------|
| Q1 | 内容在 DB 中是纯文本还是 TipTap HTML富文本渲染是否紧迫 | (待确认) |
| Q2 | 首页「我的阅读」进度卡是否保留?(新版已去掉) | (待确认) |
| Q3 | dashboard-stats 接口是否需要加缓存(高频调用场景)? | (待确认) |
---
## 各角色经验与业务理解更新
### 小程序开发工程师
- `my.js` 阅读统计来源改为后端接口,不再用本地缓存随机占位
- 富文本渲染为当前技术债,`contentParser.js` 仅剥 HTML 无格式保留
- 新旧版对比方法论:批量 WXSS/JS 文件 diff 精确定位差异
### 后端工程师
- 新增聚合接口时,优先参考已有实现版本,对比后修复 bug去重、min值、状态码
- `dashboard-stats` 数据模型reading_progress JOIN chapters + match_records count
### 团队
- Mycontent-temp 是预览分支,功能不及主线,不作为迁移基准
- 新旧版并存时,以功能更完整的主线版本为准,按需吸收新版的接口优化

View File

@@ -0,0 +1,82 @@
# 会议纪要 - 2026-03-10 | 文章详情三端功能对齐与开发
## 基本信息
- **时间**2026-03-10
- **议题**:文章详情 @某人/@linkTag/图片 三端功能对齐,发现 Bug完成开发
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师
---
## 现状摸底(开发前)
| 功能 | 后端 | 管理端 | 小程序 | 状态 |
|---|---|---|---|---|
| @mention 存储格式 | content 字段存 TipTap HTML | RichEditor 插入 `<span data-type="mention">` | contentParser 解析 ✅ | ✅ 正常 |
| @mention 点击加好友 | CKBLead 接口 **只用全局 Key** | — | 已传 targetUserId后端接不住 | ❌ 缺密钥路由 |
| #linkTag 存储格式 | content 字段存 `<a href>` | insertLinkTag 插入标准 `<a>` | **未解析,直接被剥离** | ❌ 不可点 |
| 图片展示 | 存 `<img src>` | 编辑器支持上传插图 | **未解析,被剥离** | ❌ 不显示 |
| loadContent 重复定义 | — | — | 两个同名函数,旧版覆盖新版 | ❌ Bug |
---
## 本次完成的开发
### 后端soul-api
1. **`model/person.go`** 加 `CkbApiKey` 字段VARCHAR 100
2. **`handler/db_person.go`** `DBPersonSave` 接收并存储 `ckbApiKey`
3. **`model/ckb_lead.go`** 加 `TargetPersonID``Source` 字段落库
4. **`handler/ckb.go` `CKBLead`** 接收 `targetUserId`/`targetNickname`/`source`,查 `persons.ckb_api_key`,有则用,无则 fallback 全局 Key成功文案动态化"提交成功XXX 会尽快联系您"
5. **`scripts/add-persons-ckb-api-key.sql`** 手动迁移备用脚本
### 管理端soul-admin
1. **`components/RichEditor.tsx`** `PersonItem` 接口加 `ckbApiKey?: string`
2. **`pages/content/ContentPage.tsx`**
- `loadPersons` 映射 `ckbApiKey`
- `newPerson` state 加 `ckbApiKey`
- Person 配置卡片加「存客宝密钥」输入框(新建时可填)
- Person 列表每行加铅笔编辑按钮,展开内联输入框可直接修改密钥
- 密钥状态 badge有密钥显示绿色 `密钥 ✓`,无则灰色 `用默认密钥`
### 小程序miniprogram
1. **`utils/contentParser.js`** 全面重写:
- 新增 `parseBlockToSegments`:统一处理 mention / linkTag(span) / linkTag(a) / image 四种内联元素
- 新增解码工具函数 `decodeEntities`
- 纯图片行独立成段,不与文字混排
2. **`pages/read/read.js`**
- 删除重复的旧版 `loadContent`Bug覆盖新版
- 新版 `loadContent` 补全:成功后写本地缓存,缓存降级时恢复 partTitle/chapterTitle
- 新增 `onLinkTagTap`:内页路径直接 `navigateTo`,外链复制到剪贴板并提示
- 新增 `onImageTap`:点击图片全屏预览(`wx.previewImage`
3. **`pages/read/read.wxml`** 段落块新增渲染分支:
- `linkTag``<text class="link-tag" bindtap="onLinkTagTap">#{{label}}</text>`
- `image``<image class="content-image" bindtap="onImageTap">`
4. **`pages/read/read.wxss`** 新增 `.link-tag`(金色 #FFD700)、`.content-image`(宽度铺满)样式
---
## 完整加好友链路
```
管理端Person 配置填写存客宝密钥ckbApiKey
文章写入 content → "@[卡若](karuo)" / "#[标签名](url)" / <img src>
小程序 contentParser 解析 → segments
↓ 点击 @卡若
POST /api/miniprogram/ckb/lead
{ targetUserId: "karuo", targetNickname: "卡若", source: "article_mention" }
后端查 persons WHERE person_id='karuo' → 取 ckb_api_key
→ 有:用该人专属密钥推存客宝
→ 无fallback 全局 CKB_LEAD_API_KEY
落库 ckb_lead_recordstarget_person_id, source
```
---
## 待办
| 角色 | 任务 |
|---|---|
| 产品经理 | 确认 #linkTag 外链的交互体验(复制 vs 打开 webview是否符合预期 |
| 测试人员 | 联调:@某人点击 → 存客宝各渠道收线索;#标签点击 → 复制/跳转;图片点击 → 全屏预览 |

View File

@@ -0,0 +1,131 @@
# 会议纪要 - 2026-03-10 | 管理端迁移 Mycontent-temp 菜单/布局讨论
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-10 15:10
- **议题**:管理端改为使用 `Mycontent-temp/soul-admin` 这套“新菜单 + 新布局”规范;基于现有 `soul-admin` 功能,明确菜单/布局改造方式与功能适配点
- **触发方式**:开个会议研究下
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
---
## 各角色发言
### 【产品经理】
- **目标**:后台的导航与信息架构要“更运营化”:核心入口更少、更聚焦;次要功能不消失但不占主导航。
- **新规范**(以 `Mycontent-temp/soul-admin/src/layouts/AdminLayout.tsx` 为准):
- 侧栏主菜单平铺 5 个:**数据概览 / 内容管理 / 用户管理 / 找伙伴 / 推广中心**
- **系统设置**固定在侧栏下方
- 原「更多」折叠的部分要么隐藏入口(从概览/页面内跳转进入),要么并入系统设置 Tab
- **验收**:菜单一致;旧功能可达;用户操作路径更短(尤其内容/找伙伴/推广)。
### 【后端开发】
- 管理端迁移/重构不改变接口边界:只允许 `/api/admin/*``/api/db/*``/api/orders`
- 路由与菜单调整不需要新增后端接口;若概览页需要聚合接口(如 `/api/admin/dashboard/overview`)可作为“优化项”,同时保留降级方案(用现有 users/orders 拼)。
- 需要注意“路由别名/跳转”不应影响鉴权:`GET /api/admin` 校验逻辑保持不变。
### 【管理端开发工程师】
- 新工程的关键差异点:
- `AdminLayout`:取消「更多」折叠,主菜单平铺;`/settings` 永远在底部。
- 路由:保留历史页面路由,但**不一定在菜单出现**`/author-settings``/admin-users` 变为 `Navigate``/settings?tab=author|admin`(页面承载搬到 Settings Tab
- 迁移策略建议:
- **以 `Mycontent-temp/soul-admin` 为“样板/目标态”**,把现有 `soul-admin` 中已实现的页面与功能对齐过去(或反向:把目标态布局/菜单 port 回现有项目)。
- 保持路由路径尽量不变(避免大量链接/收藏失效),通过菜单“入口收敛”达成产品目标。
### 【小程序开发工程师】
- 小程序侧只关心“内容编辑产物能否稳定下发/解析”,管理端菜单迁移不应改变内容接口或字段。
- 若管理端页面拆分导致内容结构改动(例如富文本 HTML、mention/tag 的数据结构),必须提前同步小程序解析策略并回归阅读页兼容。
### 【测试人员】
- 重点回归点:
- **路由可达性**:菜单入口虽减少,但旧页面必须仍能通过路由访问(尤其订单/提现/推广设置/导师等)。
- **鉴权**:任意页面刷新后均能正确校验 token 并跳转登录(`GET /api/admin`)。
- **信息架构一致**:侧栏 5 项 + 系统设置固定位置;`author/admin` 设置从 `/settings` 的 tab 进入。
---
## 讨论过程
- 对照了两套工程:
-`soul-admin`:主菜单 3 项 + 「更多」折叠VIP角色/作者详情/管理员/导师/导师预约/推广中心/找伙伴/匹配记录/推广设置)
-`Mycontent-temp/soul-admin`:主菜单 5 项平铺(概览/内容/用户/找伙伴/推广),系统设置固定;作者/管理员并入 Settings Tabs其余页面保持路由但不占侧栏入口
- 达成一致:**以新工程布局/菜单为准**,旧功能以“路由可达 + 概览/页面内跳转”方式保留。
---
## 会议决议
1. **目标态以 `Mycontent-temp/soul-admin` 为准**:菜单与布局“照新不照旧”,旧工程如需改造则对齐该实现。
2. **侧栏信息架构**
- 主菜单固定 5 项:数据概览、内容管理、用户管理、找伙伴、推广中心
- 系统设置固定在侧栏底部
- 取消「更多」折叠入口
3. **功能入口收敛规则**
- `author-settings``admin-users` 不再作为独立菜单项,统一并入 `/settings?tab=author|admin`
- 订单/提现/推广设置/VIP角色/导师等页面:**保留路由**,但入口不进入侧栏主菜单(可由概览卡片、页面内按钮或系统设置进入)
4. **接口与边界不变**:管理端继续只调用 `/api/admin/*``/api/db/*``/api/orders`,不得引入 `/api/miniprogram/*`
5. **待确认项**
- 哪些“非主菜单页面”需要在概览页提供快捷入口(订单、提现、推广设置等)的优先级排序。
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 管理端开发工程师 | 基于 `Mycontent-temp/soul-admin` 梳理:侧栏主菜单 5 项、Settings Tab 承载 author/admin、其余页面入口方案概览卡片/页面内跳转) | 高 | 2026-03-11 |
| 产品经理 | 给出“非主菜单页面”的入口优先级(概览要露出哪些快捷卡片/按钮) | 中 | 2026-03-11 |
| 后端开发 | 确认概览聚合接口 `/api/admin/dashboard/overview` 是否作为正式接口上线;若不上线,确认降级方案字段口径 | 中 | 2026-03-12 |
| 测试人员 | 输出菜单/路由/鉴权回归清单(含隐藏路由可达性) | 中 | 2026-03-12 |
| 小程序开发工程师 | 关注内容编辑产物格式是否变化;若变更,补充阅读页兼容用例 | 低 | 持续 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 非主菜单页面(订单/提现/推广设置/VIP角色/导师等)哪些必须在概览页提供快捷入口? | 产品经理 | (待补充) |
| 2 | `Mycontent-temp/soul-admin` 是否作为线上唯一管理端工程(替换旧 `soul-admin`),还是旧工程按新规范改造? | 团队 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 菜单信息架构收敛:主导航只保留运营主链路入口,次要功能“可达但不抢入口”。
### 后端开发
- 概览聚合接口可以作为优化项但必须保留降级users+orders确保不阻塞前端迁移。
### 管理端开发工程师
- 迁移以 `Mycontent-temp/soul-admin` 为目标态;作者/管理员并入 Settings Tab取消“更多”折叠。
### 小程序开发工程师
- 管理端迁移不应影响小程序接口边界;若内容格式变更需及时同步解析策略并回归。
### 测试人员
- 菜单减少不等于功能减少:必须覆盖“隐藏路由可达性 + 鉴权跳转 + 新侧栏一致性”。
### 团队共享
- 统一以 `Mycontent-temp/soul-admin``AdminLayout`/`SettingsPage` 为“新规范基线”,后续所有菜单/布局调整按该基线执行,避免两套后台并行发散。
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-10.md`*

View File

@@ -68,3 +68,6 @@ YYYY-MM-DD_会议主题.md
| 2026-03-09 | 代码完整性分析与分支合并准备 | 产品、后端、管理端、小程序、测试 | [2026-03-09_代码完整性分析与分支合并准备.md](2026-03-09_代码完整性分析与分支合并准备.md) |
| 2026-03-09 | devlop 与 yongxu 分支差异分析 | 产品、后端、管理端、小程序、测试 | [2026-03-09_devlop与yongxu分支差异分析会议.md](2026-03-09_devlop与yongxu分支差异分析会议.md) |
| 2026-03-09 | dev 分支需求分析与 yongxu 迁移方案 | 产品、后端、管理端、小程序 | [2026-03-09_dev分支需求分析与yongxu迁移方案.md](2026-03-09_dev分支需求分析与yongxu迁移方案.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp 菜单/布局讨论 | 产品、后端、管理端、小程序、测试 | [2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md](2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md) |
| 2026-03-10 | 小程序新旧版对比分析与 dashboard-stats 接口新增 | 小程序、后端、团队 | [2026-03-10_小程序新旧版对比与dashboard接口新增.md](2026-03-10_小程序新旧版对比与dashboard接口新增.md) |
| 2026-03-10 | 文章详情三端功能对齐与开发(@mention/#linkTag/图片 | 产品、后端、管理端、小程序 | [2026-03-10_文章详情三端功能对齐与开发.md](2026-03-10_文章详情三端功能对齐与开发.md) |

View File

@@ -111,31 +111,26 @@ Page({
const { isLoggedIn, userInfo } = app.globalData
if (isLoggedIn && userInfo) {
const readIds = app.globalData.readSectionIds || []
const recentList = readIds.slice(-5).reverse().map(id => ({
id,
mid: app.getSectionMid(id),
title: `章节 ${id}`
}))
const userId = userInfo.id || ''
const userIdShort = userId.length > 20 ? userId.slice(0, 10) + '...' + userId.slice(-6) : userId
const userWechat = wx.getStorageSync('user_wechat') || userInfo.wechat || ''
// 先设基础信息;收益由 loadMyEarnings 专用接口拉取,加载前用 - 占位
// 先设基础信息;阅读统计与收益再分别从后端刷新
this.setData({
isLoggedIn: true,
userInfo,
userIdShort,
userWechat,
readCount: Math.min(app.getReadCount(), this.data.totalSections || 62),
readCount: 0,
referralCount: userInfo.referralCount || 0,
earnings: '-',
pendingEarnings: '-',
earningsLoading: true,
recentChapters: recentList,
totalReadTime: Math.floor(Math.random() * 200) + 50
recentChapters: [],
totalReadTime: 0,
matchHistory: 0
})
this.loadDashboardStats()
this.loadMyEarnings()
this.loadPendingConfirm()
this.loadVipStatus()
@@ -149,11 +144,48 @@ Page({
earnings: '-',
pendingEarnings: '-',
earningsLoading: false,
recentChapters: []
recentChapters: [],
totalReadTime: 0,
matchHistory: 0
})
}
},
async loadDashboardStats() {
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const res = await app.request({
url: `/api/miniprogram/user/dashboard-stats?userId=${encodeURIComponent(userId)}`,
silent: true
})
if (!res?.success || !res.data) return
const readSectionIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds : []
app.globalData.readSectionIds = readSectionIds
wx.setStorageSync('readSectionIds', readSectionIds)
const recentChapters = Array.isArray(res.data.recentChapters)
? res.data.recentChapters.map((item) => ({
id: item.id,
mid: item.mid || app.getSectionMid(item.id),
title: item.title || `章节 ${item.id}`
}))
: []
this.setData({
readCount: Number(res.data.readCount || 0),
totalReadTime: Number(res.data.totalReadMinutes || 0),
matchHistory: Number(res.data.matchHistory || 0),
recentChapters
})
} catch (e) {
console.log('[My] 拉取阅读统计失败:', e && e.message)
}
},
// 拉取待确认收款列表(用于「确认收款」按钮)
async loadPendingConfirm() {
const userInfo = app.globalData.userInfo

View File

@@ -77,6 +77,16 @@ Page({
async onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
// 预加载 linkTags 配置(供 onLinkTagTap 旧格式降级匹配 type 用)
if (!app.globalData.linkTagsConfig) {
app.request({ url: '/api/miniprogram/config', silent: true }).then(cfg => {
if (cfg && Array.isArray(cfg.linkTags)) {
app.globalData.linkTagsConfig = cfg.linkTags
}
}).catch(() => {})
}
// 支持 scene扫码、mid、id、ref
const sceneStr = (options && options.scene) || ''
const parsed = parseScene(sceneStr)
@@ -196,9 +206,9 @@ Page({
})
},
// 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成)
// prefetchedChapter若已有章节数据含 content则复用避免二次请求
// 加载章节内容:优先复用 prefetchedChapter 避免二次请求,失败时降级本地缓存
async loadContent(id, accessState, prefetchedChapter) {
const cacheKey = `chapter_${id}`
try {
const sectionPrice = this.data.sectionPrice ?? 1
let res = prefetchedChapter
@@ -212,7 +222,7 @@ Page({
price: res.price ?? sectionPrice
}
this.setData({ section })
if (res && res.content) {
const { lines, segments } = contentParser.parseContent(res.content)
const previewCount = Math.ceil(lines.length * 0.2)
@@ -226,29 +236,29 @@ Page({
}
if (res.mid) updates.sectionMid = res.mid
this.setData(updates)
// 如果有权限,标记为已读
// 写入本地缓存,供离线/重试降级使用
try { wx.setStorageSync(cacheKey, res) } catch (_) {}
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
}
} catch (e) {
console.error('[Read] 加载内容失败:', e)
// 尝试从本地缓存加载
const cacheKey = `chapter_${id}`
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
try {
const cached = wx.getStorageSync(cacheKey)
if (cached && cached.content) {
const { lines, segments } = contentParser.parseContent(cached.content)
const previewCount = Math.ceil(lines.length * 0.2)
this.setData({
content: cached.content,
contentParagraphs: lines,
contentSegments: segments,
previewParagraphs: lines.slice(0, previewCount)
previewParagraphs: lines.slice(0, previewCount),
partTitle: cached.partTitle || '',
chapterTitle: cached.chapterTitle || ''
})
console.log('[Read] 从本地缓存加载成功')
return
}
} catch (cacheErr) {
console.warn('[Read] 本地缓存也失败:', cacheErr)
@@ -315,48 +325,6 @@ Page({
return `/api/miniprogram/book/chapter/${finalId}`
},
// 加载内容 - 三级降级方案API → 本地缓存 → 备用API
async loadContent(id) {
const cacheKey = `chapter_${id}`
// 1. 优先从API获取
try {
const res = await this.fetchChapterWithTimeout(id, 5000)
if (res && res.content) {
this.setChapterContent(res)
// 成功后缓存到本地
wx.setStorageSync(cacheKey, res)
console.log('[Read] 从API加载成功:', id)
return
}
} catch (e) {
console.warn('[Read] API加载失败尝试本地缓存:', e.message)
}
// 2. API失败尝试从本地缓存读取
try {
const cached = wx.getStorageSync(cacheKey)
if (cached && cached.content) {
this.setChapterContent(cached)
console.log('[Read] 从本地缓存加载成功:', id)
// 后台静默刷新
this.silentRefresh(id)
return
}
} catch (e) {
console.warn('[Read] 本地缓存读取失败')
}
// 3. 都失败,显示加载中并持续重试
this.setData({
contentParagraphs: ['章节内容加载中...', '正在尝试连接服务器,请稍候...'],
contentSegments: contentParser.parseContent('章节内容加载中...\n正在尝试连接服务器请稍候...').segments,
previewParagraphs: ['章节内容加载中...']
})
// 延迟重试最多3次
this.retryLoadContent(id, 3)
},
// 带超时的章节请求
fetchChapterWithTimeout(id, timeout = 5000) {
@@ -476,6 +444,69 @@ Page({
getApp().goBackOrToHome()
},
// 点击正文中的 #链接标签:外链复制到剪贴板,小程序内页直接跳转
onLinkTagTap(e) {
let url = (e.currentTarget.dataset.url || '').trim()
const label = (e.currentTarget.dataset.label || '').trim()
let tagType = (e.currentTarget.dataset.tagType || '').trim()
let pagePath = (e.currentTarget.dataset.pagePath || '').trim()
// 旧格式(<a href>tagType 为空 → 按 label 从缓存 linkTags 补充类型信息
if (!tagType && label) {
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
if (cached) {
tagType = cached.type || 'url'
pagePath = cached.pagePath || ''
if (!url) url = cached.url || ''
}
}
// CKB 类型:复用 @mention 加好友流程,弹出留资表单
if (tagType === 'ckb') {
// 触发通用加好友(无特定 personId使用全局 CKB Key
this.onMentionTap({ currentTarget: { dataset: { userId: '', nickname: label } } })
return
}
// 小程序内部路径pagePath 或 url 以 /pages/ 开头)
const internalPath = pagePath || (url.startsWith('/pages/') ? url : '')
if (internalPath) {
wx.navigateTo({ url: internalPath, fail: () => wx.switchTab({ url: internalPath }) })
return
}
// 外部 URL优先用 wx.openLink 在浏览器打开,旧版微信降级复制
if (url) {
if (typeof wx.openLink === 'function') {
wx.openLink({
url,
fail: () => {
// openLink 不支持(如不在微信内),降级复制
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: `链接已复制`, icon: 'none', duration: 2000 })
})
}
})
} else {
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: `链接已复制`, icon: 'none', duration: 2000 })
})
}
return
}
wx.showToast({ title: '暂无跳转地址', icon: 'none' })
},
// 点击正文图片 → 全屏预览
onImageTap(e) {
const src = e.currentTarget.dataset.src
if (!src) return
wx.previewImage({ current: src, urls: [src] })
},
// 点击正文中的 @某人:确认弹窗 → 登录/资料校验 → 调用 ckb/lead 加好友留资
onMentionTap(e) {
const userId = e.currentTarget.dataset.userId

View File

@@ -42,12 +42,14 @@
<view class="skeleton skeleton-5"></view>
</view>
<!-- 完整内容 - 免费或已购买(支持 @ 高亮可点 -->
<!-- 完整内容 - 免费或已购买(支持 @ mention / #linkTag / 图片 -->
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text>
<text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}">#{{seg.label}}</text>
<image wx:elif="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
</block>
</view>

View File

@@ -189,6 +189,21 @@
padding: 0 4rpx;
}
/* 正文内 #链接标签 高亮可点 */
.paragraph .link-tag {
color: #FFD700;
font-weight: 500;
padding: 0 4rpx;
}
/* 正文内图片 */
.content-image {
width: 100%;
border-radius: 8rpx;
margin: 16rpx 0;
display: block;
}
.preview {
position: relative;
}

View File

@@ -43,8 +43,18 @@ Page({
const dt = new Date(d.expireDate)
expStr = `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
}
// 同步 VIP 状态到全局(与「我的」页保持一致)
const isVip = !!d.isVip
app.globalData.isVip = isVip
app.globalData.vipExpireDate = d.expireDate || expStr || ''
const userInfo = app.globalData.userInfo || {}
userInfo.isVip = isVip
userInfo.vipExpireDate = app.globalData.vipExpireDate
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
this.setData({
isVip: d.isVip,
isVip,
daysRemaining: d.daysRemaining,
expireDateStr: expStr,
price: d.price || 1980
@@ -109,12 +119,8 @@ Page({
try {
await new Promise(r => setTimeout(r, 1500))
await accessManager.refreshUserPurchaseStatus()
// 重新拉取 VIP 状态并同步到全局
await this.loadVipInfo()
app.globalData.hasFullBook = true
const userInfo = app.globalData.userInfo || {}
userInfo.hasFullBook = true
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
const pages = getCurrentPages()
pages.forEach(p => {
if (typeof p.initUserStatus === 'function') p.initUserStatus()

View File

@@ -125,6 +125,15 @@ class ChapterAccessManager {
app.globalData.hasFullBook = true
}
// VIP 会员:标记全局 isVip供超级个体解锁、前端展示等使用
if (purchaseData.reason === 'has_vip') {
app.globalData.isVip = true
const userInfo = app.globalData.userInfo || {}
userInfo.isVip = true
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
}
if (!app.globalData.purchasedSections.includes(sectionId)) {
app.globalData.purchasedSections = [...app.globalData.purchasedSections, sectionId]
}

View File

@@ -1,26 +1,107 @@
/**
* Soul创业派对 - 内容解析工具
* 解析 TipTap HTML(含 <span data-type="mention">为阅读页可展示的 segments
* 解析 TipTap HTML 为阅读页可展示的 segments
*
* segment 类型:
* { type: 'text', text }
* { type: 'mention', userId, nickname } — @某人,点击加好友
* { type: 'linkTag', label, url } — #链接标签,点击跳转
* { type: 'image', src, alt } — 图片
*/
/**
* 判断内容是否为 HTML含标签
*/
/** 判断内容是否为 HTML */
function isHtmlContent(content) {
if (!content || typeof content !== 'string') return false
const trimmed = content.trim()
return trimmed.includes('<') && trimmed.includes('>') && /<[a-z][^>]*>/i.test(trimmed)
}
/** 解码常见 HTML 实体 */
function decodeEntities(str) {
return str
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
/**
* HTML 中解析出段落与 mention 片段
* TipTap mention: <span data-type="mention" data-id="..." data-label="...">@nickname</span>
* 将一个 HTML block 字符串解析为 segments 数组
* 处理三种内联元素mention / linkTag(span) / linkTag(a) / img
*/
function parseBlockToSegments(block) {
const segs = []
// 合并匹配所有内联元素
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|<img[^>]*\/?>/gi
let lastEnd = 0
let m
while ((m = tokenRe.exec(block)) !== null) {
// 前置纯文本
const before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
if (before.trim()) segs.push({ type: 'text', text: before })
const tag = m[0]
if (/data-type="mention"/i.test(tag)) {
// @mention — TipTap mention span
const idMatch = tag.match(/data-id="([^"]*)"/)
const labelMatch = tag.match(/data-label="([^"]*)"/)
const innerText = tag.replace(/<[^>]+>/g, '')
const userId = idMatch ? idMatch[1].trim() : ''
const nickname = labelMatch ? labelMatch[1].trim() : innerText.replace(/^@/, '').trim()
if (userId || nickname) segs.push({ type: 'mention', userId, nickname })
} else if (/data-type="linkTag"/i.test(tag)) {
// #linkTag — 自定义 span 格式data-type="linkTag" data-url="..." data-tag-type="..." data-page-path="..."
const urlMatch = tag.match(/data-url="([^"]*)"/)
const tagTypeMatch = tag.match(/data-tag-type="([^"]*)"/)
const pagePathMatch = tag.match(/data-page-path="([^"]*)"/)
const tagIdMatch = tag.match(/data-tag-id="([^"]*)"/)
const innerText = tag.replace(/<[^>]+>/g, '').replace(/^#/, '').trim()
const url = urlMatch ? urlMatch[1] : ''
const tagType = tagTypeMatch ? tagTypeMatch[1] : 'url'
const pagePath = pagePathMatch ? pagePathMatch[1] : ''
const tagId = tagIdMatch ? tagIdMatch[1] : ''
segs.push({ type: 'linkTag', label: innerText || '#', url, tagType, pagePath, tagId })
} else if (/^<a /i.test(tag)) {
// #linkTag — 旧格式 <a href>insertLinkTag 旧版产生url 可能为空)
// m[1] = href, m[2] = innerText以 # 开头)
const url = m[1] || ''
const label = (m[2] || '').replace(/^#/, '').trim()
// 旧格式没有 tagType在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
} else if (/^<img /i.test(tag)) {
// 图片
const srcMatch = tag.match(/src="([^"]*)"/)
const altMatch = tag.match(/alt="([^"]*)"/)
if (srcMatch) {
segs.push({ type: 'image', src: srcMatch[1], alt: altMatch ? altMatch[1] : '' })
}
}
lastEnd = m.index + tag.length
}
// 尾部纯文本
const after = decodeEntities(block.slice(lastEnd).replace(/<[^>]+>/g, ''))
if (after.trim()) segs.push({ type: 'text', text: after })
return segs
}
/**
* 从 HTML 中解析出 lines纯文本行和 segments含富文本片段
*/
function parseHtmlToSegments(html) {
const lines = []
const segments = []
// 1. 块级元素拆成段落
// 1. 块级标签换行,保留内联标签供后续解析
let text = html
text = text.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
text = text.replace(/<p[^>]*>/gi, '')
@@ -35,37 +116,31 @@ function parseHtmlToSegments(html) {
text = text.replace(/<li[^>]*>/gi, '• ')
text = text.replace(/<\/li>/gi, '\n')
// 2. 逐段解析:提取文本与 mention
// 2. 逐段解析
const blocks = text.split(/\n+/)
for (const block of blocks) {
const blockSegments = []
const mentionRe = /<span[^>]*data-type="mention"[^>]*>([^<]*)<\/span>/gi
let lastEnd = 0
let m
while ((m = mentionRe.exec(block)) !== null) {
const before = block.slice(lastEnd, m.index).replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
if (before) blockSegments.push({ type: 'text', text: before })
const idMatch = m[0].match(/data-id="([^"]*)"/)
const labelMatch = m[0].match(/data-label="([^"]*)"/)
const userId = idMatch ? idMatch[1].trim() : ''
const nickname = labelMatch ? labelMatch[1].trim() : (m[1] || '').replace(/^@/, '').trim()
blockSegments.push({ type: 'mention', userId, nickname })
lastEnd = m.index + m[0].length
}
const after = block.slice(lastEnd).replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
if (after) blockSegments.push({ type: 'text', text: after })
const lineText = block.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim()
if (lineText) {
lines.push(lineText)
segments.push(blockSegments.length ? blockSegments : [{ type: 'text', text: lineText }])
if (!block.trim()) continue
const blockSegs = parseBlockToSegments(block)
if (!blockSegs.length) continue
// 纯图片行独立成段
if (blockSegs.length === 1 && blockSegs[0].type === 'image') {
lines.push('')
segments.push(blockSegs)
continue
}
// 行纯文本用于 linespreviewParagraphs 降级展示)
const lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim()
lines.push(lineText)
segments.push(blockSegs)
}
return { lines, segments }
}
/**
* 纯文本按行解析(无 mention
*/
/** 纯文本按行解析(无 HTML 标签) */
function parsePlainTextToSegments(text) {
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const segments = lines.map(line => [{ type: 'text', text: line }])
@@ -74,8 +149,8 @@ function parsePlainTextToSegments(text) {
/**
* 将原始内容解析为 contentSegments用于阅读页展示
* @param {string} rawContent - 原始内容TipTap HTML 或纯文本)
* @returns {{ lines: string[], segments: Array<Array<{type, text?, userId?, nickname?}>> }}
* @param {string} rawContent
* @returns {{ lines: string[], segments: Array<Array<segment>> }}
*/
function parseContent(rawContent) {
if (!rawContent || typeof rawContent !== 'string') {

View File

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

View File

@@ -1,4 +1,4 @@
.rich-editor-wrapper {
.rich-editor-wrapper {
border: 1px solid #374151;
border-radius: 0.5rem;
background: #0a1628;
@@ -142,6 +142,17 @@
font-weight: 500;
}
/* #linkTag 高亮:与小程序 read.wxss .link-tag 金黄色保持一致 */
.link-tag-node {
background: rgba(255, 215, 0, 0.12);
color: #FFD700;
border-radius: 4px;
padding: 1px 4px;
font-weight: 500;
cursor: default;
user-select: all;
}
.mention-popup {
position: fixed;
z-index: 9999;

View File

@@ -1,4 +1,4 @@
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import { useEditor, EditorContent, type Editor, Node, mergeAttributes } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
@@ -16,6 +16,7 @@ export interface PersonItem {
id: string
name: string
label?: string
ckbApiKey?: string // 存客宝密钥,留空则 fallback 全局 Key
}
export interface LinkTagItem {
@@ -102,6 +103,50 @@ function markdownToHtml(md: string): string {
return result.join('')
}
/**
* LinkTagExtension — 自定义 TipTap 内联节点,保留所有 data-* 属性
* 解决insertContent(html) 会经过 TipTap schema 导致自定义属性被丢弃的问题
*/
const LinkTagExtension = Node.create({
name: 'linkTag',
group: 'inline',
inline: true,
selectable: true,
atom: true,
addAttributes() {
return {
label: { default: '' },
url: { default: '' },
tagType: { default: 'url', parseHTML: (el: HTMLElement) => el.getAttribute('data-tag-type') || 'url' },
tagId: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-tag-id') || '' },
pagePath: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-page-path') || '' },
}
},
parseHTML() {
return [{ tag: 'span[data-type="linkTag"]', getAttrs: (el: HTMLElement) => ({
label: el.textContent?.replace(/^#/, '').trim() || '',
url: el.getAttribute('data-url') || '',
tagType: el.getAttribute('data-tag-type') || 'url',
tagId: el.getAttribute('data-tag-id') || '',
pagePath: el.getAttribute('data-page-path')|| '',
}) }]
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderHTML({ node, HTMLAttributes }: { node: any; HTMLAttributes: Record<string, any> }) {
return ['span', mergeAttributes(HTMLAttributes, {
'data-type': 'linkTag',
'data-url': node.attrs.url,
'data-tag-type': node.attrs.tagType,
'data-tag-id': node.attrs.tagId,
'data-page-path': node.attrs.pagePath,
class: 'link-tag-node',
}), `#${node.attrs.label}`]
},
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MentionSuggestion = (persons: PersonItem[]): any => ({
items: ({ query }: { query: string }) =>
@@ -196,6 +241,7 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
HTMLAttributes: { class: 'mention-tag' },
suggestion: MentionSuggestion(persons),
}),
LinkTagExtension,
Placeholder.configure({ placeholder }),
Table.configure({ resizable: true }),
TableRow, TableCell, TableHeader,
@@ -242,10 +288,16 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
const insertLinkTag = useCallback((tag: LinkTagItem) => {
if (!editor) return
// 通过自定义扩展节点插入,确保 data-* 属性不被 TipTap schema 丢弃
editor.chain().focus().insertContent({
type: 'text',
marks: [{ type: 'link', attrs: { href: tag.url, target: '_blank' } }],
text: `#${tag.label}`,
type: 'linkTag',
attrs: {
label: tag.label,
url: tag.url || '',
tagType: tag.type || 'url',
tagId: tag.id || '',
pagePath: tag.pagePath || '',
},
}).run()
}, [editor])

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import {
Dialog,
@@ -106,13 +107,13 @@ export function SetVipModal({
async function handleSave() {
if (!userId) return
if (form.isVip && !form.vipExpireDate.trim()) {
alert('开启 VIP 时请填写有效到期日')
toast.error('开启 VIP 时请填写有效到期日')
return
}
if (form.isVip && form.vipExpireDate.trim()) {
const d = new Date(form.vipExpireDate)
if (isNaN(d.getTime())) {
alert('到期日格式无效,请使用 YYYY-MM-DD')
toast.error('到期日格式无效,请使用 YYYY-MM-DD')
return
}
}
@@ -132,15 +133,15 @@ export function SetVipModal({
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) {
alert('VIP 设置已保存')
toast.success('VIP 设置已保存')
onSaved?.()
onClose()
} else {
alert('保存失败: ' + (data as { error?: string })?.error)
toast.error('保存失败: ' + (data as { error?: string })?.error)
}
} catch (e) {
console.error('Save VIP error:', e)
alert('保存失败')
toast.error('保存失败')
} finally {
setSaving(false)
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
@@ -201,7 +202,7 @@ export function UserDetailModal({
}
async function handleSyncCKB() {
if (!user?.phone) { alert('用户未绑定手机号,无法同步'); return }
if (!user?.phone) { toast.info('用户未绑定手机号,无法同步'); return }
setSyncing(true)
try {
const data = await post<{ success?: boolean; error?: string }>('/api/ckb/sync', {
@@ -209,11 +210,11 @@ export function UserDetailModal({
phone: user.phone,
userId: user.id,
})
if (data?.success) { alert('同步成功'); loadUserDetail() }
else alert('同步失败: ' + (data as { error?: string })?.error)
if (data?.success) { toast.success('同步成功'); loadUserDetail() }
else toast.error('同步失败: ' + (data as { error?: string })?.error)
} catch (e) {
console.error('Sync CKB error:', e)
alert('同步失败')
toast.error('同步失败')
} finally {
setSyncing(false)
}
@@ -231,15 +232,15 @@ export function UserDetailModal({
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) {
alert('保存成功')
toast.success('保存成功')
loadUserDetail()
onUserUpdated?.()
} else {
alert('保存失败: ' + (data as { error?: string })?.error)
toast.error('保存失败: ' + (data as { error?: string })?.error)
}
} catch (e) {
console.error('Save user error:', e)
alert('保存失败')
toast.error('保存失败')
} finally {
setSaving(false)
}
@@ -256,20 +257,20 @@ export function UserDetailModal({
async function handleSavePassword() {
if (!user) return
if (!newPassword) { alert('请输入新密码'); return }
if (newPassword !== confirmPassword) { alert('两次密码不一致'); return }
if (newPassword.length < 6) { alert('密码至少 6 位'); return }
if (!newPassword) { toast.error('请输入新密码'); return }
if (newPassword !== confirmPassword) { toast.error('两次密码不一致'); return }
if (newPassword.length < 6) { toast.error('密码至少 6 位'); return }
setPasswordSaving(true)
try {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: user.id, password: newPassword })
if (data?.success) { alert('修改成功'); setNewPassword(''); setConfirmPassword('') }
else alert('修改失败: ' + (data?.error || ''))
} catch { alert('修改失败') } finally { setPasswordSaving(false) }
if (data?.success) { toast.success('修改成功'); setNewPassword(''); setConfirmPassword('') }
else toast.error('修改失败: ' + (data?.error || ''))
} catch { toast.error('修改失败') } finally { setPasswordSaving(false) }
}
async function handleSaveVip() {
if (!user) return
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) { alert('开启 VIP 请填写有效到期日'); return }
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) { toast.error('开启 VIP 请填写有效到期日'); return }
setVipSaving(true)
try {
const payload = {
@@ -283,9 +284,9 @@ export function UserDetailModal({
vipBio: vipForm.vipBio || undefined,
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) { alert('VIP 设置已保存'); loadUserDetail(); onUserUpdated?.() }
else alert('保存失败: ' + (data?.error || ''))
} catch { alert('保存失败') } finally { setVipSaving(false) }
if (data?.success) { toast.success('VIP 设置已保存'); loadUserDetail(); onUserUpdated?.() }
else toast.error('保存失败: ' + (data?.error || ''))
} catch { toast.error('保存失败') } finally { setVipSaving(false) }
}
// 用户资料完善查询(支持多维度)
@@ -657,9 +658,9 @@ export function UserDetailModal({
if (!ckbWechatOwner || !user) return
try {
await put('/api/db/users', { id: user.id, wechatId: ckbWechatOwner })
alert('已保存微信归属')
toast.success('已保存微信归属')
loadUserDetail()
} catch { alert('保存失败') }
} catch { toast.error('保存失败') }
}}
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-400 border border-purple-500/30 shrink-0"
>

View File

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

View File

@@ -3,41 +3,22 @@ import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
import {
LayoutDashboard,
Users,
CreditCard,
Settings,
LogOut,
Wallet,
BookOpen,
GitMerge,
Crown,
GraduationCap,
Calendar,
User,
ShieldCheck,
ChevronDown,
ChevronUp,
Handshake,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { clearAdminToken } from '@/api/auth'
// 主菜单(核心运营 3 项
// 主菜单(5 项平铺,按 Mycontent-temp 新规范
const primaryMenuItems = [
{ icon: LayoutDashboard, label: '数据概览', href: '/dashboard' },
{ icon: BookOpen, label: '内容管理', href: '/content' },
{ icon: Users, label: '用户管理', href: '/users' },
]
// 折叠区「更多」(字典类 + 业务)
const moreMenuItems = [
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
{ icon: User, label: '作者详情', href: '/author-settings' },
{ icon: ShieldCheck, label: '管理员', href: '/admin-users' },
{ icon: GraduationCap, label: '导师管理', href: '/mentors' },
{ icon: Calendar, label: '导师预约', href: '/mentor-consultations' },
{ icon: GitMerge, label: '找伙伴', href: '/find-partner' },
{ icon: Wallet, label: '推广中心', href: '/distribution' },
{ icon: Handshake, label: '找伙伴', href: '/find-partner' },
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
]
export function AdminLayout() {
@@ -45,15 +26,10 @@ export function AdminLayout() {
const navigate = useNavigate()
const [mounted, setMounted] = useState(false)
const [authChecked, setAuthChecked] = useState(false)
const [moreExpanded, setMoreExpanded] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
const inMore = moreMenuItems.some((item) => location.pathname === item.href)
if (inMore) setMoreExpanded(true)
}, [location.pathname])
useEffect(() => {
if (!mounted) return
@@ -123,37 +99,6 @@ export function AdminLayout() {
</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"

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@@ -97,7 +98,7 @@ export function AuthorSettingsPage() {
}
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 : ''))
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
return
}
setSaving(false)
@@ -109,7 +110,7 @@ export function AuthorSettingsPage() {
setTimeout(() => msg.remove(), 2000)
} catch (e) {
console.error(e)
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
@@ -136,11 +137,11 @@ export function AuthorSettingsPage() {
if (data?.success && data?.url) {
setConfig((prev) => ({ ...prev, avatarImg: data.url }))
} else {
alert('上传失败: ' + (data?.error || '未知错误'))
toast.error('上传失败: ' + (data?.error || '未知错误'))
}
} catch (err) {
console.error(err)
alert('上传失败')
toast.error('上传失败')
} finally {
setUploadingAvatar(false)
if (avatarInputRef.current) avatarInputRef.current.value = ''

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { get, post } from '@/api/client'
interface Section {
@@ -80,7 +81,7 @@ export function ChaptersPage() {
data: { price: editPrice },
})
if (result?.success) {
alert('价格更新成功')
toast.success('价格更新成功')
setEditingSection(null)
loadChapters()
}
@@ -97,7 +98,7 @@ export function ChaptersPage() {
data: { isFree: !currentFree },
})
if (result?.success) {
alert('状态更新成功')
toast.success('状态更新成功')
loadChapters()
}
} catch (e) {

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import toast from '@/utils/toast'
import {
Card,
CardContent,
@@ -9,7 +10,6 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import RichEditor, { type PersonItem, type LinkTagItem, type RichEditorRef } from '@/components/RichEditor'
import '@/components/RichEditor.css'
import {
@@ -44,6 +44,8 @@ import {
Star,
Hash,
ExternalLink,
Pencil,
Check,
} from 'lucide-react'
import { get, put, post, del } from '@/api/client'
import { ChapterTree } from './ChapterTree'
@@ -199,6 +201,10 @@ export function ContentPage() {
content: '',
editionStandard: true,
editionPremium: false,
isFree: false,
isNew: false,
isPinned: false,
hotScore: 0,
})
const [editingPart, setEditingPart] = useState<{ id: string; title: string } | null>(null)
@@ -227,7 +233,9 @@ export function ContentPage() {
const [previewPercentSaving, setPreviewPercentSaving] = useState(false)
const [persons, setPersons] = useState<PersonItem[]>([])
const [linkTags, setLinkTags] = useState<LinkTagItem[]>([])
const [newPerson, setNewPerson] = useState({ personId: '', name: '', label: '' })
const [newPerson, setNewPerson] = useState({ personId: '', name: '', label: '', ckbApiKey: '' })
const [editingPersonKey, setEditingPersonKey] = useState<string | null>(null) // 正在编辑密钥的 personId
const [editingPersonKeyValue, setEditingPersonKeyValue] = useState('')
const [newLinkTag, setNewLinkTag] = useState({ tagId: '', label: '', url: '', type: 'url' as 'url' | 'miniprogram' | 'ckb', appId: '', pagePath: '' })
const richEditorRef = useRef<RichEditorRef>(null)
@@ -280,13 +288,13 @@ export function ContentPage() {
.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 : '未知错误'))
toast.error('排序失败: ' + ((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 : '网络或服务异常'))
toast.error('排序失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
})
return Promise.resolve()
},
@@ -300,14 +308,14 @@ export function ContentPage() {
`/api/db/book?id=${encodeURIComponent(section.id)}`,
)
if (res && (res as { success?: boolean }).success !== false) {
alert('已删除')
toast.success('已删除')
loadList()
} else {
alert('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
toast.error('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('删除失败')
toast.error('删除失败')
}
}
@@ -341,7 +349,7 @@ export function ContentPage() {
const { readWeight, recencyWeight, payWeight } = rankingWeights
const sum = readWeight + recencyWeight + payWeight
if (Math.abs(sum - 1) > 0.001) {
alert('三个权重之和必须等于 1')
toast.error('三个权重之和必须等于 1')
return
}
setRankingWeightsSaving(true)
@@ -352,14 +360,14 @@ export function ContentPage() {
description: '文章排名算法权重',
})
if (res && (res as { success?: boolean }).success !== false) {
alert('已保存')
toast.success('排名权重已保存')
loadList()
} else {
alert('保存失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : ''))
toast.error('保存失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : ''))
}
} catch (e) {
console.error(e)
alert('保存失败')
toast.error('保存失败')
} finally {
setRankingWeightsSaving(false)
}
@@ -379,8 +387,8 @@ export function ContentPage() {
const loadPersons = useCallback(async () => {
try {
const data = await get<{ success?: boolean; persons?: { personId: string; name: string; label?: string }[] }>('/api/db/persons')
if (data?.success && data.persons) setPersons(data.persons.map(p => ({ id: p.personId, name: p.name, label: p.label })))
const data = await get<{ success?: boolean; persons?: { personId: string; name: string; label?: string; ckbApiKey?: string }[] }>('/api/db/persons')
if (data?.success && data.persons) setPersons(data.persons.map(p => ({ id: p.personId, name: p.name, label: p.label, ckbApiKey: p.ckbApiKey })))
} catch { /* ignore */ }
}, [])
@@ -418,7 +426,7 @@ export function ContentPage() {
}, [])
const handleSavePreviewPercent = async () => {
if (previewPercent < 1 || previewPercent > 100) { alert('预览比例需在 1~100 之间'); return }
if (previewPercent < 1 || previewPercent > 100) { toast.error('预览比例需在 1~100 之间'); return }
setPreviewPercentSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
@@ -426,9 +434,9 @@ export function ContentPage() {
value: previewPercent,
description: '小程序未付费内容默认预览比例(%)',
})
if (res && (res as { success?: boolean }).success !== false) alert('已保存')
else alert('保存失败: ' + ((res as { error?: string }).error || ''))
} catch { alert('保存失败') } finally { setPreviewPercentSaving(false) }
if (res && (res as { success?: boolean }).success !== false) toast.success('预览比例已保存')
else toast.error('保存失败: ' + ((res as { error?: string }).error || ''))
} catch { toast.error('保存失败') } finally { setPreviewPercentSaving(false) }
}
useEffect(() => { loadPinnedSections(); loadPreviewPercent(); loadPersons(); loadLinkTags() }, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags])
@@ -489,7 +497,7 @@ export function ContentPage() {
editionPremium: false,
})
if (data && !(data as { success?: boolean }).success) {
alert('无法读取文件内容: ' + ((data as { error?: string }).error || '未知错误'))
toast.error('无法读取文件内容: ' + ((data as { error?: string }).error || '未知错误'))
}
}
} catch (e) {
@@ -540,15 +548,15 @@ export function ContentPage() {
await handleTogglePin(effectiveId)
}
if (res && (res as { success?: boolean }).success !== false) {
alert(`已保存章节: ${editingSection.title}`)
toast.success(`已保存${editingSection.title}`)
setEditingSection(null)
loadList()
} else {
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('保存失败')
toast.error('保存失败')
} finally {
setIsSaving(false)
}
@@ -556,33 +564,51 @@ export function ContentPage() {
const handleCreateSection = async () => {
if (!newSection.id || !newSection.title) {
alert('请填写章节ID和标题')
toast.error('请填写章节ID和标题')
return
}
setIsSaving(true)
try {
const currentPart = tree.find((p) => p.id === newSection.partId)
const currentChapter = currentPart?.chapters.find((c) => c.id === newSection.chapterId)
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
id: newSection.id,
title: newSection.title,
price: newSection.price,
price: newSection.isFree ? 0 : newSection.price,
content: newSection.content,
partId: newSection.partId,
partTitle: currentPart?.title ?? '',
chapterId: newSection.chapterId,
chapterTitle: currentChapter?.title ?? '',
isFree: newSection.isFree,
isNew: newSection.isNew,
editionStandard: newSection.editionPremium ? false : (newSection.editionStandard ?? true),
editionPremium: newSection.editionPremium ?? false,
hotScore: newSection.hotScore ?? 0,
saveToFile: false,
})
if (res && (res as { success?: boolean }).success !== false) {
alert(`章节创建成功: ${newSection.title}`)
if (newSection.isPinned) {
const next = [...pinnedSectionIds, newSection.id]
setPinnedSectionIds(next)
try {
await post<{ success?: boolean }>('/api/db/config', {
key: 'pinned_section_ids',
value: next,
description: '强制置顶章节ID列表精选推荐/首页最新更新)',
})
} catch { /* ignore */ }
}
toast.success(`章节创建成功:${newSection.title}`)
setShowNewSectionModal(false)
setNewSection({ id: '', title: '', price: 1, partId: 'part-1', chapterId: 'chapter-1', content: '', editionStandard: true, editionPremium: false })
setNewSection({ id: '', title: '', price: 1, partId: 'part-1', chapterId: 'chapter-1', content: '', editionStandard: true, editionPremium: false, isFree: false, isNew: false, isPinned: false, hotScore: 0 })
loadList()
} else {
alert('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
toast.error('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('创建失败')
toast.error('创建失败')
} finally {
setIsSaving(false)
}
@@ -617,14 +643,20 @@ export function ContentPage() {
items,
})
if (res && (res as { success?: boolean }).success !== false) {
const newTitle = editingPart.title.trim()
setSectionsList((prev) =>
prev.map((s) =>
s.partId === editingPart.id ? { ...s, partTitle: newTitle } : s
)
)
setEditingPart(null)
loadList()
} else {
alert('更新篇名失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
toast.error('更新篇名失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('更新篇名失败')
toast.error('更新篇名失败')
} finally {
setIsSavingPartTitle(false)
}
@@ -642,6 +674,10 @@ export function ContentPage() {
content: '',
editionStandard: true,
editionPremium: false,
isFree: false,
isNew: false,
isPinned: false,
hotScore: 0,
})
setShowNewSectionModal(true)
}
@@ -666,14 +702,24 @@ export function ContentPage() {
}))
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
if (res && (res as { success?: boolean }).success !== false) {
const newTitle = editingChapter.title.trim()
const partId = editingChapter.part.id
const chapterId = editingChapter.chapter.id
setSectionsList((prev) =>
prev.map((s) =>
s.partId === partId && s.chapterId === chapterId
? { ...s, chapterTitle: newTitle }
: s
)
)
setEditingChapter(null)
loadList()
} else {
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('保存失败')
toast.error('保存失败')
} finally {
setIsSavingChapterTitle(false)
}
@@ -682,7 +728,7 @@ export function ContentPage() {
const handleDeleteChapter = async (part: Part, chapter: Chapter) => {
const sectionIds = chapter.sections.map((s) => s.id)
if (sectionIds.length === 0) {
alert('该章下无小节,无需删除')
toast.info('该章下无小节,无需删除')
return
}
if (!confirm(`确定要删除「第${part.chapters.indexOf(chapter) + 1}章 | ${chapter.title}」吗?将删除共 ${sectionIds.length} 节,此操作不可恢复。`)) return
@@ -693,13 +739,13 @@ export function ContentPage() {
loadList()
} catch (e) {
console.error(e)
alert('删除失败')
toast.error('删除失败')
}
}
const handleCreatePart = async () => {
if (!newPartTitle.trim()) {
alert('请输入篇名')
toast.error('请输入篇名')
return
}
setIsSavingPart(true)
@@ -719,16 +765,16 @@ export function ContentPage() {
saveToFile: false,
})
if (res && (res as { success?: boolean }).success !== false) {
alert(`篇「${newPartTitle}」创建成功,请编辑占位节`)
toast.success(`篇「${newPartTitle}」创建成功`)
setShowNewPartModal(false)
setNewPartTitle('')
loadList()
} else {
alert('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
toast.error('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('创建失败')
toast.error('创建失败')
} finally {
setIsSavingPart(false)
}
@@ -736,13 +782,13 @@ export function ContentPage() {
const handleBatchMoveToTarget = async () => {
if (selectedSectionIds.length === 0) {
alert('请先勾选要移动的章节')
toast.error('请先勾选要移动的章节')
return
}
const targetPart = tree.find((p) => p.id === batchMoveTargetPartId)
const targetChapter = targetPart?.chapters.find((c) => c.id === batchMoveTargetChapterId)
if (!targetPart || !targetChapter || !batchMoveTargetPartId || !batchMoveTargetChapterId) {
alert('请选择目标篇和章')
toast.error('请选择目标篇和章')
return
}
setIsMoving(true)
@@ -788,7 +834,7 @@ export function ContentPage() {
{ action: 'reorder', items: reorderItems },
)
if (reorderRes && (reorderRes as { success?: boolean }).success !== false) {
alert(`已移动 ${selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}`)
toast.success(`已移动 ${selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}`)
setShowBatchMoveModal(false)
setSelectedSectionIds([])
await loadList()
@@ -807,7 +853,7 @@ export function ContentPage() {
}
const res = await put<{ success?: boolean; error?: string; count?: number }>('/api/db/book', payload)
if (res && (res as { success?: boolean }).success !== false) {
alert(`已移动 ${(res as { count?: number }).count ?? selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}`)
toast.success(`已移动 ${(res as { count?: number }).count ?? selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}`)
setShowBatchMoveModal(false)
setSelectedSectionIds([])
await loadList()
@@ -817,11 +863,11 @@ export function ContentPage() {
const fallbackOk = await tryFallbackReorder()
if (fallbackOk) return
}
alert('移动失败: ' + errorMessage)
toast.error('移动失败: ' + errorMessage)
}
} catch (e) {
console.error(e)
alert('移动失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
toast.error('移动失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
} finally {
setIsMoving(false)
}
@@ -836,7 +882,7 @@ export function ContentPage() {
const handleDeletePart = async (part: Part) => {
const sectionIds = sectionsList.filter((s) => s.partId === part.id).map((s) => s.id)
if (sectionIds.length === 0) {
alert('该篇下暂无小节可删除')
toast.info('该篇下暂无小节可删除')
return
}
if (!confirm(`确定要删除「${part.title}」整篇吗?将删除共 ${sectionIds.length} 节内容,此操作不可恢复。`)) return
@@ -847,7 +893,7 @@ export function ContentPage() {
loadList()
} catch (e) {
console.error(e)
alert('删除失败')
toast.error('删除失败')
}
}
@@ -863,13 +909,13 @@ export function ContentPage() {
} else {
setSearchResults([])
if (data && !(data as { success?: boolean }).success) {
alert('搜索失败: ' + (data as { error?: string }).error)
toast.error('搜索失败: ' + (data as { error?: string }).error)
}
}
} catch (e) {
console.error(e)
setSearchResults([])
alert('搜索失败')
toast.error('搜索失败')
} finally {
setIsSearching(false)
}
@@ -908,17 +954,17 @@ export function ContentPage() {
</div>
</div>
{/* 新建章节弹窗 */}
{/* 新建章节弹窗:与编辑章节样式功能一致 */}
<Dialog open={showNewSectionModal} onOpenChange={setShowNewSectionModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto" showCloseButton>
<DialogHeader>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0" showCloseButton>
<DialogHeader className="shrink-0 px-6 pt-6 pb-2">
<DialogTitle className="text-white flex items-center gap-2">
<Plus className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex-1 overflow-y-auto min-h-0 px-6 space-y-4 py-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-gray-300">ID *</Label>
<Input
@@ -933,13 +979,68 @@ export function ContentPage() {
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
value={newSection.price}
onChange={(e) => setNewSection({ ...newSection, price: Number(e.target.value) })}
value={newSection.isFree ? 0 : newSection.price}
onChange={(e) =>
setNewSection({
...newSection,
price: Number(e.target.value),
isFree: Number(e.target.value) === 0,
})
}
disabled={newSection.isFree}
/>
</div>
<div className="space-y-2 col-span-2">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex items-center h-10">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={newSection.isFree}
onChange={(e) =>
setNewSection({
...newSection,
isFree: e.target.checked,
price: e.target.checked ? 0 : 1,
})
}
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
<span className="ml-2 text-gray-400 text-sm"></span>
</label>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex items-center h-10">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={newSection.isNew}
onChange={(e) => setNewSection({ ...newSection, isNew: e.target.checked })}
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
<span className="ml-2 text-gray-400 text-sm"> NEW</span>
</label>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex items-center h-10">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={newSection.isPinned}
onChange={(e) => setNewSection({ ...newSection, isPinned: e.target.checked })}
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-amber-400 focus:ring-amber-400"
/>
<span className="ml-2 text-gray-400 text-sm"></span>
</label>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 h-10">
<label className="flex items-center cursor-pointer">
<input
type="radio"
@@ -962,6 +1063,22 @@ export function ContentPage() {
</label>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="number"
step="0.1"
min="0"
className="bg-[#0a1628] border-gray-700 text-white"
value={newSection.hotScore ?? 0}
onChange={(e) =>
setNewSection({
...newSection,
hotScore: Math.max(0, parseFloat(e.target.value) || 0),
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> *</Label>
@@ -975,7 +1092,17 @@ export function ContentPage() {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Select value={newSection.partId} onValueChange={(v) => setNewSection({ ...newSection, partId: v, chapterId: 'chapter-1' })}>
<Select
value={newSection.partId}
onValueChange={(v) => {
const part = tree.find((p) => p.id === v)
setNewSection({
...newSection,
partId: v,
chapterId: part?.chapters[0]?.id ?? 'chapter-1',
})
}}
>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue />
</SelectTrigger>
@@ -1015,16 +1142,25 @@ export function ContentPage() {
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> (Markdown格式)</Label>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[300px] font-mono text-sm placeholder:text-gray-500"
placeholder="输入章节内容..."
value={newSection.content}
onChange={(e) => setNewSection({ ...newSection, content: e.target.value })}
<Label className="text-gray-300"> @链接AI人物 #</Label>
<RichEditor
content={newSection.content || ''}
onChange={(html) => setNewSection({ ...newSection, content: html })}
onImageUpload={async (file: File) => {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'book-images')
const res = await fetch(apiUrl('/api/upload'), { method: 'POST', body: formData, headers: { Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}` } })
const data = await res.json()
return data?.data?.url || data?.url || ''
}}
persons={persons}
linkTags={linkTags}
placeholder="开始编辑内容... 输入 @ 可链接AI人物工具栏可插入 #链接标签"
/>
</div>
</div>
<DialogFooter>
<DialogFooter className="shrink-0 px-6 py-4 border-t border-gray-700/50">
<Button
variant="outline"
onClick={() => setShowNewSectionModal(false)}
@@ -1960,12 +2096,16 @@ export function ContentPage() {
<Label className="text-gray-400 text-xs">/</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="如 超级个体" value={newPerson.label} onChange={e => setNewPerson({ ...newPerson, label: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-48" placeholder="xxxx-xxxx-xxxx-xxxx" value={newPerson.ckbApiKey} onChange={e => setNewPerson({ ...newPerson, ckbApiKey: e.target.value })} />
</div>
<Button size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white h-8" onClick={async () => {
if (!newPerson.name) return alert('名称必填')
if (!newPerson.name) { toast.error('名称必填'); return }
const payload = { ...newPerson }
if (!payload.personId) payload.personId = newPerson.name.toLowerCase().replace(/\s+/g, '_') + '_' + Date.now().toString(36)
await post('/api/db/persons', payload)
setNewPerson({ personId: '', name: '', label: '' })
setNewPerson({ personId: '', name: '', label: '', ckbApiKey: '' })
loadPersons()
}}>
<Plus className="w-3 h-3 mr-1" />
@@ -1973,18 +2113,56 @@ export function ContentPage() {
</div>
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{persons.map(p => (
<div key={p.id} className="flex items-center justify-between bg-[#0a1628] rounded px-3 py-2">
<div className="flex items-center gap-3 text-sm">
<span className="text-[#38bdac] font-bold text-base">@{p.name}</span>
<span className="text-gray-600 text-xs font-mono">{p.id}</span>
{p.label && <Badge variant="secondary" className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]">{p.label}</Badge>}
<div key={p.id} className="bg-[#0a1628] rounded px-3 py-2 space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-sm">
<span className="text-[#38bdac] font-bold text-base">@{p.name}</span>
<span className="text-gray-600 text-xs font-mono">{p.id}</span>
{p.label && <Badge variant="secondary" className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]">{p.label}</Badge>}
{p.ckbApiKey
? <Badge variant="secondary" className="bg-green-500/20 text-green-300 border-green-500/30 text-[10px]"> </Badge>
: <Badge variant="secondary" className="bg-gray-500/20 text-gray-500 border-gray-500/30 text-[10px]"></Badge>
}
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-[#38bdac] h-6 px-2" title="编辑密钥" onClick={() => {
setEditingPersonKey(p.id)
setEditingPersonKeyValue(p.ckbApiKey || '')
}}>
<Pencil className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
await del(`/api/db/persons?personId=${p.id}`)
loadPersons()
}}>
<X className="w-3 h-3" />
</Button>
</div>
</div>
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
await del(`/api/db/persons?personId=${p.id}`)
loadPersons()
}}>
<X className="w-3 h-3" />
</Button>
{editingPersonKey === p.id && (
<div className="flex items-center gap-2 pt-0.5">
<Input
className="bg-[#0d1f35] border-gray-600 text-white h-7 text-xs flex-1"
placeholder="输入存客宝密钥,留空则用默认"
value={editingPersonKeyValue}
onChange={e => setEditingPersonKeyValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Escape') setEditingPersonKey(null)
}}
autoFocus
/>
<Button size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white h-7 px-2" onClick={async () => {
await post('/api/db/persons', { personId: p.id, name: p.name, label: p.label, ckbApiKey: editingPersonKeyValue })
setEditingPersonKey(null)
loadPersons()
}}>
<Check className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" className="text-gray-400 h-7 px-2" onClick={() => setEditingPersonKey(null)}>
<X className="w-3 h-3" />
</Button>
</div>
)}
</div>
))}
{persons.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">AI人物 @链接</div>}
@@ -2040,7 +2218,7 @@ export function ContentPage() {
</div>
)}
<Button size="sm" className="bg-amber-500 hover:bg-amber-600 text-white h-8" onClick={async () => {
if (!newLinkTag.tagId || !newLinkTag.label) return alert('标签ID和显示文字必填')
if (!newLinkTag.tagId || !newLinkTag.label) { toast.error('标签ID和显示文字必填'); return }
const payload = { ...newLinkTag }
if (payload.type === 'miniprogram' && payload.appId) payload.url = `weixin://dl/business/?appid=${payload.appId}&path=${payload.pagePath}`
await post('/api/db/link-tags', payload)

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
@@ -29,6 +29,15 @@ interface UserRow {
createdAt?: string
}
interface DashboardStatsRes {
success?: boolean
totalUsers?: number
paidOrderCount?: number
paidUserCount?: number
totalRevenue?: number
conversionRate?: number
}
interface DashboardOverviewRes {
success?: boolean
totalUsers?: number
@@ -36,8 +45,6 @@ interface DashboardOverviewRes {
paidUserCount?: number
totalRevenue?: number
conversionRate?: number
totalMatches?: number
matchRevenue?: number
recentOrders?: OrderRow[]
newUsers?: UserRow[]
}
@@ -56,7 +63,9 @@ interface OrdersRes {
export function DashboardPage() {
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(true)
const [statsLoading, setStatsLoading] = useState(true)
const [ordersLoading, setOrdersLoading] = useState(true)
const [usersLoading, setUsersLoading] = useState(true)
const [users, setUsers] = useState<UserRow[]>([])
const [purchases, setPurchases] = useState<OrderRow[]>([])
const [totalUsersCount, setTotalUsersCount] = useState(0)
@@ -67,75 +76,108 @@ export function DashboardPage() {
const [detailUserId, setDetailUserId] = useState<string | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
async function loadData() {
setIsLoading(true)
const showError = (err: unknown) => {
const e = err as Error & { status?: number; name?: string }
if (e?.status === 401) setLoadError('登录已过期,请重新登录')
else if (e?.name === 'AbortError') return
else setLoadError('加载失败,请检查网络或联系管理员')
}
async function loadAll(signal?: AbortSignal) {
const init = signal ? { signal } : undefined
// 1. 优先加载统计(轻量)
setStatsLoading(true)
setLoadError(null)
try {
try {
const data = await get<DashboardOverviewRes>('/api/admin/dashboard/overview')
if (data?.success) {
setTotalUsersCount(data.totalUsers ?? 0)
setPaidOrderCount(data.paidOrderCount ?? 0)
setTotalRevenue(data.totalRevenue ?? 0)
setConversionRate(data.conversionRate ?? 0)
setPurchases(data.recentOrders ?? [])
setUsers(data.newUsers ?? [])
return
}
} catch (e) {
console.error('数据概览接口失败,尝试降级拉取', e)
const stats = await get<DashboardStatsRes>('/api/admin/dashboard/stats', init)
if (stats?.success) {
setTotalUsersCount(stats.totalUsers ?? 0)
setPaidOrderCount(stats.paidOrderCount ?? 0)
setTotalRevenue(stats.totalRevenue ?? 0)
setConversionRate(stats.conversionRate ?? 0)
}
// 降级:新接口未部署或失败时,用原有接口拉取用户与订单
const [usersData, ordersData] = await Promise.all([
get<UsersRes>('/api/db/users?page=1&pageSize=10'),
get<OrdersRes>('/api/orders?page=1&pageSize=20&status=paid'),
])
const totalUsers = typeof usersData?.total === 'number' ? usersData.total : (usersData?.users?.length ?? 0)
const orders = ordersData?.orders ?? []
const total = typeof ordersData?.total === 'number' ? ordersData.total : orders.length
const paidOrders = orders.filter((p) => p.status === 'paid' || p.status === 'completed' || p.status === 'success')
const revenue = paidOrders.reduce((sum, p) => sum + Number(p.amount || 0), 0)
const paidUserIds = new Set(paidOrders.map((p) => p.userId).filter(Boolean))
const rate = totalUsers > 0 && paidUserIds.size > 0 ? (paidUserIds.size / totalUsers) * 100 : 0
setTotalUsersCount(totalUsers)
setPaidOrderCount(total)
setTotalRevenue(revenue)
setConversionRate(rate)
setPurchases(orders.slice(0, 5))
setUsers(usersData?.users ?? [])
} catch (fallbackErr) {
console.error('降级拉取失败', fallbackErr)
const err = fallbackErr as Error & { status?: number; name?: string }
if (err?.status === 401) {
setLoadError('登录已过期,请重新登录')
} else if (err?.name === 'AbortError') {
setLoadError('请求超时,请检查网络后点击重试')
} else {
setLoadError('加载失败,请检查网络或联系管理员')
} catch (e) {
if ((e as Error)?.name !== 'AbortError') {
console.error('stats 失败,尝试 overview 降级', e)
try {
const overview = await get<DashboardOverviewRes>('/api/admin/dashboard/overview', init)
if (overview?.success) {
setTotalUsersCount(overview.totalUsers ?? 0)
setPaidOrderCount(overview.paidOrderCount ?? 0)
setTotalRevenue(overview.totalRevenue ?? 0)
setConversionRate(overview.conversionRate ?? 0)
}
} catch (e2) {
showError(e2)
}
}
} finally {
setIsLoading(false)
setStatsLoading(false)
}
// 2. 并行加载订单和用户
setOrdersLoading(true)
setUsersLoading(true)
const loadOrders = async () => {
try {
const res = await get<{ success?: boolean; recentOrders?: OrderRow[] }>(
'/api/admin/dashboard/recent-orders',
init
)
if (res?.success && res.recentOrders) setPurchases(res.recentOrders)
else throw new Error('no data')
} catch (e) {
if ((e as Error)?.name !== 'AbortError') {
try {
const ordersData = await get<OrdersRes>('/api/orders?page=1&pageSize=20&status=paid', init)
const orders = ordersData?.orders ?? []
const paid = orders.filter((p) =>
['paid', 'completed', 'success'].includes(p.status || '')
)
setPurchases(paid.slice(0, 5))
} catch {
setPurchases([])
}
}
} finally {
setOrdersLoading(false)
}
}
const loadUsers = async () => {
try {
const res = await get<{ success?: boolean; newUsers?: UserRow[] }>(
'/api/admin/dashboard/new-users',
init
)
if (res?.success && res.newUsers) setUsers(res.newUsers)
else throw new Error('no data')
} catch (e) {
if ((e as Error)?.name !== 'AbortError') {
try {
const usersData = await get<UsersRes>('/api/db/users?page=1&pageSize=10', init)
setUsers(usersData?.users ?? [])
} catch {
setUsers([])
}
}
} finally {
setUsersLoading(false)
}
}
await Promise.all([loadOrders(), loadUsers()])
}
useEffect(() => {
loadData()
const timer = setInterval(loadData, 30000)
return () => clearInterval(timer)
const ctrl = new AbortController()
loadAll(ctrl.signal)
const timer = setInterval(() => loadAll(), 30000)
return () => {
ctrl.abort()
clearInterval(timer)
}
}, [])
if (isLoading) {
return (
<div className="p-8 w-full">
<h1 className="text-2xl font-bold mb-8 text-white"></h1>
<div className="flex flex-col items-center justify-center py-24">
<RefreshCw className="w-12 h-12 text-[#38bdac] animate-spin mb-4" />
<span className="text-gray-400">...</span>
</div>
</div>
)
}
const totalUsers = totalUsersCount
const formatOrderProduct = (p: OrderRow) => {
@@ -174,7 +216,7 @@ export function DashboardPage() {
const stats = [
{
title: '总用户数',
value: totalUsers,
value: statsLoading ? null : totalUsers,
icon: Users,
color: 'text-blue-400',
bg: 'bg-blue-500/20',
@@ -182,7 +224,7 @@ export function DashboardPage() {
},
{
title: '总收入',
value: `¥${(totalRevenue ?? 0).toFixed(2)}`,
value: statsLoading ? null : `¥${(totalRevenue ?? 0).toFixed(2)}`,
icon: TrendingUp,
color: 'text-[#38bdac]',
bg: 'bg-[#38bdac]/20',
@@ -190,7 +232,7 @@ export function DashboardPage() {
},
{
title: '订单数',
value: paidOrderCount,
value: statsLoading ? null : paidOrderCount,
icon: ShoppingBag,
color: 'text-purple-400',
bg: 'bg-purple-500/20',
@@ -198,7 +240,7 @@ export function DashboardPage() {
},
{
title: '转化率',
value: `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
value: statsLoading ? null : `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
icon: BookOpen,
color: 'text-orange-400',
bg: 'bg-orange-500/20',
@@ -214,7 +256,7 @@ export function DashboardPage() {
<span>{loadError}</span>
<button
type="button"
onClick={() => loadData()}
onClick={() => loadAll()}
className="text-amber-400 hover:text-amber-300 underline"
>
@@ -236,7 +278,16 @@ export function DashboardPage() {
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-2xl font-bold text-white">{stat.value}</div>
<div className="text-2xl font-bold text-white min-h-[2rem] flex items-center">
{stat.value != null ? (
stat.value
) : (
<span className="inline-flex items-center gap-2 text-gray-500">
<RefreshCw className="w-4 h-4 animate-spin" />
</span>
)}
</div>
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-[#38bdac] transition-colors" />
</div>
</CardContent>
@@ -250,16 +301,28 @@ export function DashboardPage() {
<CardTitle className="text-white"></CardTitle>
<button
type="button"
onClick={() => loadData()}
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1"
onClick={() => loadAll()}
disabled={ordersLoading || usersLoading}
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1 disabled:opacity-50"
title="刷新"
>
<RefreshCw className="w-3.5 h-3.5" />
{(ordersLoading || usersLoading) ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
30
</button>
</CardHeader>
<CardContent>
<div className="space-y-3">
{ordersLoading && purchases.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<RefreshCw className="w-8 h-8 animate-spin mb-2" />
<span className="text-sm">...</span>
</div>
) : (
<>
{purchases
.slice(0, 5)
.map((p) => {
@@ -347,12 +410,14 @@ export function DashboardPage() {
</div>
)
})}
{purchases.length === 0 && (
{purchases.length === 0 && !ordersLoading && (
<div className="text-center py-12">
<ShoppingBag className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500"></p>
</div>
)}
</>
)}
</div>
</CardContent>
</Card>
@@ -363,6 +428,13 @@ export function DashboardPage() {
</CardHeader>
<CardContent>
<div className="space-y-3">
{usersLoading && users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<RefreshCw className="w-8 h-8 animate-spin mb-2" />
<span className="text-sm">...</span>
</div>
) : (
<>
{users
.slice(0, 5)
.map((u) => (
@@ -392,9 +464,11 @@ export function DashboardPage() {
</p>
</div>
))}
{users.length === 0 && (
{users.length === 0 && !usersLoading && (
<p className="text-gray-500 text-center py-8"></p>
)}
</>
)}
</div>
</CardContent>
</Card>
@@ -404,7 +478,7 @@ export function DashboardPage() {
open={showDetailModal}
onClose={() => { setShowDetailModal(false); setDetailUserId(null) }}
userId={detailUserId}
onUserUpdated={loadData}
onUserUpdated={() => loadAll()}
/>
</div>
)

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import {
Users,
TrendingUp,
@@ -307,13 +308,13 @@ export function DistributionPage() {
)
if (!res?.success) {
const detail = res?.message || res?.error || '操作失败'
alert(detail)
toast.error(detail)
return
}
await refreshCurrentTab()
} catch (e) {
console.error(e)
alert('操作失败')
toast.error('操作失败')
}
}
@@ -326,13 +327,13 @@ export function DistributionPage() {
{ id, action: 'reject', errorMessage: reason },
)
if (!res?.success) {
alert(res?.error || '操作失败')
toast.error(res?.error || '操作失败')
return
}
await refreshCurrentTab()
} catch (e) {
console.error(e)
alert('操作失败')
toast.error('操作失败')
}
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import toast from '@/utils/toast'
import { useEffect, useMemo, useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -130,9 +131,9 @@ export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: Works
value: { routes: routeMap, docNotes, docContent },
description: '存客宝接口配置',
})
alert(res?.success !== false ? '存客宝配置已保存' : `保存失败: ${res?.error || '未知错误'}`)
toast.error(res?.success !== false ? '存客宝配置已保存' : `保存失败: ${res?.error || '未知错误'}`)
} catch (e) {
alert(`保存失败: ${e instanceof Error ? e.message : '网络错误'}`)
toast.error(`保存失败: ${e instanceof Error ? e.message : '网络错误'}`)
} finally {
setIsSaving(false)
}
@@ -144,7 +145,7 @@ export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: Works
const testOne = async (idx: number) => {
const t = tests[idx]
if (t.method === 'POST' && !testPhone.trim() && !testWechat.trim()) { alert('请填写测试手机号'); return }
if (t.method === 'POST' && !testPhone.trim() && !testWechat.trim()) { toast.error('请填写测试手机号'); return }
const next = [...tests]
next[idx] = { ...t, status: 'testing', message: undefined, responseTime: undefined }
setTests(next)
@@ -169,7 +170,7 @@ export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: Works
}
const testAll = async () => {
if (!testPhone.trim() && !testWechat.trim()) { alert('请填写测试手机号'); return }
if (!testPhone.trim() && !testWechat.trim()) { toast.error('请填写测试手机号'); return }
for (let i = 0; i < tests.length; i++) await testOne(i)
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from '@/components/ui/card'
@@ -97,8 +98,8 @@ export function MatchPoolTab() {
setIsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', { key: 'match_config', value: config, description: '匹配功能配置' })
alert(res?.success !== false ? '配置保存成功!' : '保存失败: ' + (res?.error || '未知错误'))
} catch (e) { console.error(e); alert('保存失败') }
toast.error(res?.success !== false ? '配置保存成功!' : '保存失败: ' + (res?.error || '未知错误'))
} catch (e) { console.error(e); toast.error('保存失败') }
finally { setIsSaving(false) }
}
@@ -113,13 +114,13 @@ export function MatchPoolTab() {
setShowTypeModal(true)
}
const handleSaveType = () => {
if (!formData.id || !formData.label) { alert('请填写类型ID和名称'); return }
if (!formData.id || !formData.label) { toast.error('请填写类型ID和名称'); return }
const newTypes = [...config.matchTypes]
if (editingType) {
const idx = newTypes.findIndex(t => t.id === editingType.id)
if (idx !== -1) newTypes[idx] = { ...formData }
} else {
if (newTypes.some(t => t.id === formData.id)) { alert('类型ID已存在'); return }
if (newTypes.some(t => t.id === formData.id)) { toast.error('类型ID已存在'); return }
newTypes.push({ ...formData })
}
setConfig({ ...config, matchTypes: newTypes })

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
@@ -40,7 +41,7 @@ export function ResourceDockingTab() {
const pushToCKB = async (record: MatchRecord) => {
if (!record.phone && !record.wechatId) {
alert('该记录无联系方式,无法推送到存客宝')
toast.info('该记录无联系方式,无法推送到存客宝')
return
}
setPushingId(record.id)
@@ -52,9 +53,9 @@ export function ResourceDockingTab() {
userId: record.userId,
name: record.userNickname || '',
})
alert(res?.message || (res?.success ? '推送成功' : '推送失败'))
toast.error(res?.message || (res?.success ? '推送成功' : '推送失败'))
} catch (e) {
alert('推送失败: ' + (e instanceof Error ? e.message : '网络错误'))
toast.error('推送失败: ' + (e instanceof Error ? e.message : '网络错误'))
} finally {
setPushingId(null)
}

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import {
Card,
@@ -159,13 +160,13 @@ export function MatchPage() {
description: '匹配功能配置',
})
if (res && (res as { success?: boolean }).success !== false) {
alert('配置保存成功!')
toast.success('配置保存成功!')
} else {
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error('保存配置失败:', e)
alert('保存失败')
toast.error('保存失败')
} finally {
setIsSaving(false)
}
@@ -203,7 +204,7 @@ export function MatchPage() {
const handleSaveType = () => {
if (!formData.id || !formData.label) {
alert('请填写类型ID和名称')
toast.error('请填写类型ID和名称')
return
}
const newTypes = [...config.matchTypes]
@@ -212,7 +213,7 @@ export function MatchPage() {
if (index !== -1) newTypes[index] = { ...formData }
} else {
if (newTypes.some((t) => t.id === formData.id)) {
alert('类型ID已存在')
toast.error('类型ID已存在')
return
}
newTypes.push({ ...formData })

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect, useRef } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -88,11 +89,11 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
if (data?.success && data?.url) {
setForm((f) => ({ ...f, avatar: data.url }))
} else {
alert('上传失败: ' + (data?.error || '未知错误'))
toast.error('上传失败: ' + (data?.error || '未知错误'))
}
} catch (err) {
console.error(err)
alert('上传失败')
toast.error('上传失败')
} finally {
setUploadingAvatar(false)
if (avatarInputRef.current) avatarInputRef.current.value = ''
@@ -161,7 +162,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
const handleSave = async () => {
if (!form.name.trim()) {
alert('导师姓名不能为空')
toast.error('导师姓名不能为空')
return
}
setSaving(true)
@@ -191,7 +192,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
setShowModal(false)
load()
} else {
alert('更新失败: ' + (data as { error?: string })?.error)
toast.error('更新失败: ' + (data as { error?: string })?.error)
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/mentors', payload)
@@ -199,12 +200,12 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
setShowModal(false)
load()
} else {
alert('新增失败: ' + (data as { error?: string })?.error)
toast.error('新增失败: ' + (data as { error?: string })?.error)
}
}
} catch (e) {
console.error('Save error:', e)
alert('保存失败')
toast.error('保存失败')
} finally {
setSaving(false)
}
@@ -215,10 +216,10 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/mentors?id=${id}`)
if (data?.success) load()
else alert('删除失败: ' + (data as { error?: string })?.error)
else toast.error('删除失败: ' + (data as { error?: string })?.error)
} catch (e) {
console.error('Delete error:', e)
alert('删除失败')
toast.error('删除失败')
}
}

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -167,7 +168,7 @@ export function OrdersPage() {
function handleExport() {
if (purchases.length === 0) {
alert('暂无数据可导出')
toast.info('暂无数据可导出')
return
}
const headers = ['订单号', '用户', '手机号', '商品', '金额', '支付方式', '状态', '退款原因', '分销佣金', '下单时间']

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -82,10 +83,10 @@ export function PaymentPage() {
value: localSettings,
description: '支付方式配置',
})
alert('配置已保存!')
toast.success('配置已保存!')
} catch (error) {
console.error('保存失败:', error)
alert('保存失败: ' + (error instanceof Error ? error.message : String(error)))
toast.error('保存失败: ' + (error instanceof Error ? error.message : String(error)))
} finally {
setLoading(false)
}

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import {
Card,
@@ -68,11 +69,11 @@ export function QRCodesPage() {
value: updatedLiveQRCodes,
description: '群活码配置',
})
alert('群活码配置已保存!')
toast.success('群活码配置已保存!')
await loadConfig()
} catch (e) {
console.error(e)
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
}
}
@@ -89,17 +90,17 @@ export function QRCodesPage() {
},
description: '支付方式配置',
})
alert('微信群链接已保存!用户支付成功后将自动跳转')
toast.success('微信群链接已保存!用户支付成功后将自动跳转')
await loadConfig()
} catch (e) {
console.error(e)
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
}
}
const handleTestJump = () => {
if (wechatGroupUrl) window.open(wechatGroupUrl, '_blank')
else alert('请先配置微信群链接')
else toast.error('请先配置微信群链接')
}
return (

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@@ -72,15 +73,15 @@ export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embe
}
const res = await post<{ success?: boolean; error?: string }>('/api/admin/referral-settings', body)
if (!res || (res as { success?: boolean }).success === false) {
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
return
}
alert(
toast.success(
'✅ 分销配置已保存成功!\n\n• 小程序与网站的推广规则会一起生效\n• 绑定关系会使用新的天数配置\n• 佣金比例会立即应用到新订单\n\n如有缓存请刷新前台/小程序页面。',
)
} catch (e) {
console.error(e)
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import {
Card,
@@ -93,10 +94,10 @@ export function SitePage() {
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
alert('配置已保存')
toast.success('配置已保存')
} catch (e) {
console.error(e)
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import toast from '@/utils/toast'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
@@ -113,6 +114,7 @@ export function UsersPage() {
const initialVipFilter = poolParam === 'vip' ? 'vip' : poolParam === 'complete' ? 'complete' : 'all'
const [vipFilter, setVipFilter] = useState<'all' | 'vip' | 'complete'>(initialVipFilter)
const [isLoading, setIsLoading] = useState(true)
const [isRefreshLoading, setIsRefreshLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rfmSortMode, setRfmSortMode] = useState(false)
const [rfmSortDir, setRfmSortDir] = useState<'desc' | 'asc'>('desc')
@@ -154,8 +156,9 @@ export function UsersPage() {
const [journeyLoading, setJourneyLoading] = useState(false)
// ===== 用户列表 =====
async function loadUsers() {
async function loadUsers(fromRefresh = false) {
setIsLoading(true)
if (fromRefresh) setIsRefreshLoading(true)
setError(null)
try {
if (rfmSortMode) {
@@ -198,6 +201,7 @@ export function UsersPage() {
setError('网络错误')
} finally {
setIsLoading(false)
if (fromRefresh) setIsRefreshLoading(false)
}
}
@@ -232,8 +236,8 @@ export function UsersPage() {
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/users?id=${encodeURIComponent(userId)}`)
if (data?.success) loadUsers()
else alert('删除失败: ' + (data?.error || ''))
} catch { alert('删除失败') }
else toast.error('删除失败: ' + (data?.error || ''))
} catch { toast.error('删除失败') }
}
const handleEditUser = (user: User) => {
@@ -249,19 +253,19 @@ export function UsersPage() {
}
async function handleSaveUser() {
if (!formData.phone || !formData.nickname) { alert('请填写手机号和昵称'); return }
if (!formData.phone || !formData.nickname) { toast.error('请填写手机号和昵称'); return }
setIsSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser.id, nickname: formData.nickname, isAdmin: formData.isAdmin, hasFullBook: formData.hasFullBook, ...(formData.password && { password: formData.password }) })
if (!data?.success) { alert('更新失败: ' + (data?.error || '')); return }
if (!data?.success) { toast.error('更新失败: ' + (data?.error || '')); return }
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/users', { phone: formData.phone, nickname: formData.nickname, password: formData.password, isAdmin: formData.isAdmin })
if (!data?.success) { alert('创建失败: ' + (data?.error || '')); return }
if (!data?.success) { toast.error('创建失败: ' + (data?.error || '')); return }
}
setShowUserModal(false)
loadUsers()
} catch { alert('保存失败') } finally { setIsSaving(false) }
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
}
async function handleViewReferrals(user: User) {
@@ -283,18 +287,18 @@ export function UsersPage() {
}, [])
async function handleSaveRule() {
if (!ruleForm.title) { alert('请填写规则标题'); return }
if (!ruleForm.title) { toast.error('请填写规则标题'); return }
setIsSaving(true)
try {
if (editingRule) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/user-rules', { id: editingRule.id, ...ruleForm })
if (!data?.success) { alert('更新失败: ' + (data?.error || '')); return }
if (!data?.success) { toast.error('更新失败: ' + (data?.error || '')); return }
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/user-rules', ruleForm)
if (!data?.success) { alert('创建失败: ' + (data?.error || '')); return }
if (!data?.success) { toast.error('创建失败: ' + (data?.error || '')); return }
}
setShowRuleModal(false); loadRules()
} catch { alert('保存失败') } finally { setIsSaving(false) }
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
}
async function handleDeleteRule(id: number) {
@@ -319,18 +323,18 @@ export function UsersPage() {
}, [])
async function handleSaveVipRole() {
if (!vipRoleForm.name) { alert('请填写角色名称'); return }
if (!vipRoleForm.name) { toast.error('请填写角色名称'); return }
setIsSaving(true)
try {
if (editingVipRole) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/vip-roles', { id: editingVipRole.id, ...vipRoleForm })
if (!data?.success) { alert('更新失败'); return }
if (!data?.success) { toast.error('更新失败'); return }
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', vipRoleForm)
if (!data?.success) { alert('创建失败'); return }
if (!data?.success) { toast.error('创建失败'); return }
}
setShowVipRoleModal(false); loadVipRoles()
} catch { alert('保存失败') } finally { setIsSaving(false) }
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
}
async function handleDeleteVipRole(id: number) {
@@ -387,11 +391,11 @@ export function UsersPage() {
<div className="flex items-center gap-3 mb-4 justify-end flex-wrap">
<Button
variant="outline"
onClick={loadUsers}
disabled={isLoading}
onClick={() => loadUsers(true)}
disabled={isRefreshLoading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshLoading ? 'animate-spin' : ''}`} />
</Button>
<select
value={vipFilter}

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -70,7 +71,7 @@ export function VipRolesPage() {
const handleSave = async () => {
if (!formName.trim()) {
alert('角色名称不能为空')
toast.error('角色名称不能为空')
return
}
setSaving(true)
@@ -85,7 +86,7 @@ export function VipRolesPage() {
setShowModal(false)
loadRoles()
} else {
alert('更新失败: ' + (data as { error?: string })?.error)
toast.error('更新失败: ' + (data as { error?: string })?.error)
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', {
@@ -96,12 +97,12 @@ export function VipRolesPage() {
setShowModal(false)
loadRoles()
} else {
alert('新增失败: ' + (data as { error?: string })?.error)
toast.error('新增失败: ' + (data as { error?: string })?.error)
}
}
} catch (e) {
console.error('Save error:', e)
alert('保存失败')
toast.error('保存失败')
} finally {
setSaving(false)
}
@@ -112,10 +113,10 @@ export function VipRolesPage() {
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/vip-roles?id=${id}`)
if (data?.success) loadRoles()
else alert('删除失败: ' + (data as { error?: string })?.error)
else toast.error('删除失败: ' + (data as { error?: string })?.error)
} catch (e) {
console.error('Delete error:', e)
alert('删除失败')
toast.error('删除失败')
}
}

View File

@@ -1,3 +1,4 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
@@ -130,9 +131,9 @@ export function WithdrawalsPage() {
{ id, action: 'approve' },
)
if (data?.success) loadWithdrawals()
else alert('操作失败: ' + (data?.error ?? ''))
else toast.error('操作失败: ' + (data?.error ?? ''))
} catch {
alert('操作失败')
toast.error('操作失败')
} finally {
setProcessing(null)
}
@@ -148,9 +149,9 @@ export function WithdrawalsPage() {
{ id, action: 'reject', errorMessage: reason },
)
if (data?.success) loadWithdrawals()
else alert('操作失败: ' + (data?.error ?? ''))
else toast.error('操作失败: ' + (data?.error ?? ''))
} catch {
alert('操作失败')
toast.error('操作失败')
} finally {
setProcessing(null)
}

View File

@@ -0,0 +1,93 @@
/**
* 轻量 toast 工具,无需第三方库
* 用法toast.success('保存成功') | toast.error('保存失败') | toast.info('...')
*/
type ToastType = 'success' | 'error' | 'info'
const COLORS: Record<ToastType, { bg: string; border: string; icon: string }> = {
success: { bg: '#f0fdf4', border: '#22c55e', icon: '✓' },
error: { bg: '#fef2f2', border: '#ef4444', icon: '✕' },
info: { bg: '#eff6ff', border: '#3b82f6', icon: '' },
}
function show(message: string, type: ToastType = 'info', duration = 3000) {
const id = `toast-${Date.now()}`
const c = COLORS[type]
const el = document.createElement('div')
el.id = id
el.setAttribute('role', 'alert')
Object.assign(el.style, {
position: 'fixed',
top: '24px',
right: '24px',
zIndex: '9999',
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '12px 18px',
borderRadius: '10px',
background: c.bg,
border: `1.5px solid ${c.border}`,
boxShadow: '0 4px 20px rgba(0,0,0,.12)',
fontSize: '14px',
color: '#1a1a1a',
fontWeight: '500',
maxWidth: '380px',
lineHeight: '1.5',
opacity: '0',
transform: 'translateY(-8px)',
transition: 'opacity .22s ease, transform .22s ease',
pointerEvents:'none',
})
const iconEl = document.createElement('span')
Object.assign(iconEl.style, {
width: '20px',
height: '20px',
borderRadius: '50%',
background: c.border,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent:'center',
fontSize: '12px',
fontWeight: '700',
flexShrink: '0',
})
iconEl.textContent = c.icon
const textEl = document.createElement('span')
textEl.textContent = message
el.appendChild(iconEl)
el.appendChild(textEl)
document.body.appendChild(el)
// 入场动画
requestAnimationFrame(() => {
el.style.opacity = '1'
el.style.transform = 'translateY(0)'
})
// 自动消失
const timer = setTimeout(() => dismiss(id), duration)
function dismiss(elId: string) {
clearTimeout(timer)
const target = document.getElementById(elId)
if (!target) return
target.style.opacity = '0'
target.style.transform = 'translateY(-8px)'
setTimeout(() => target.parentNode?.removeChild(target), 250)
}
}
const toast = {
success: (msg: string, duration?: number) => show(msg, 'success', duration),
error: (msg: string, duration?: number) => show(msg, 'error', duration),
info: (msg: string, duration?: number) => show(msg, 'info', duration),
}
export default toast

View File

@@ -45,3 +45,6 @@ CORS_ORIGINS=http://localhost:5175,http://localhost:5174,http://127.0.0.1:5174,h
# 存客宝-链接卡若:请求到存客宝添加好友使用的 apiKey与 join/match 不同)
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
# 定时任务密钥:调用 /api/cron/* 时须携带 X-Cron-Secret: <value>(留空则不校验,生产需配置)
CRON_SECRET=

View File

@@ -10,6 +10,10 @@ APP_VERSION=0.0.0
# 数据库(测试环境 souldev
DB_DSN=souldev:RXW2FeRcRdH2GtXy@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/souldev?charset=utf8mb4&parseTime=True
# 远程库启动时跳过 AutoMigrate表已存在避免 information_schema 慢查询);首次建表时注释掉
SKIP_AUTO_MIGRATE=1
# 慢查询阈值(ms),默认 5000避免 200ms 刷屏;设为 0 可恢复 GORM 默认
# SLOW_SQL_THRESHOLD_MS=5000
# DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
# 统一 API 域名(测试环境)
API_BASE_URL=https://souldev.quwanzhi.com
@@ -47,3 +51,6 @@ CORS_ORIGINS=http://localhost:5175,http://localhost:5174,http://127.0.0.1:5174,h
# 存客宝-链接卡若:请求到存客宝添加好友使用的 apiKey与 join/match 不同)
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
# 定时任务密钥:调用 /api/cron/* 时须携带请求头 X-Cron-Secret: <value>(留空则开发模式不校验)
CRON_SECRET=

View File

@@ -2,22 +2,51 @@ package database
import (
"log"
"os"
"strconv"
"strings"
"time"
"soul-api/internal/model"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var db *gorm.DB
// Init 使用 DSN 连接 MySQL供 handler 通过 DB() 使用
func Init(dsn string) error {
// 慢查询阈值:默认 5 秒,避免 GORM 默认 200ms 导致控制台刷屏;可通过 SLOW_SQL_THRESHOLD_MS 覆盖
slowMs := 5000
if s := os.Getenv("SLOW_SQL_THRESHOLD_MS"); s != "" {
if n, e := strconv.Atoi(s); e == nil && n > 0 {
slowMs = n
}
}
gormLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Duration(slowMs) * time.Millisecond,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
var err error
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: gormLogger})
if err != nil {
return err
}
skipMigrate := strings.ToLower(strings.TrimSpace(os.Getenv("SKIP_AUTO_MIGRATE")))
if skipMigrate == "1" || skipMigrate == "true" || skipMigrate == "yes" {
log.Println("database: SKIP_AUTO_MIGRATE enabled, skipping schema migration")
log.Println("database: connected")
return nil
}
if err := db.AutoMigrate(&model.WechatCallbackLog{}); err != nil {
log.Printf("database: wechat_callback_logs migrate warning: %v", err)
}

View File

@@ -0,0 +1,194 @@
package handler
import (
"encoding/json"
"net/http"
"sync"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var paidStatuses = []string{"paid", "completed", "success"}
// AdminDashboardStats GET /api/admin/dashboard/stats
// 轻量聚合:总用户、付费订单数、付费用户数、总营收、转化率(无订单/用户明细)
func AdminDashboardStats(c *gin.Context) {
db := database.DB()
var (
totalUsers int64
paidOrderCount int64
totalRevenue float64
paidUserCount int64
)
var wg sync.WaitGroup
wg.Add(4)
go func() { defer wg.Done(); db.Model(&model.User{}).Count(&totalUsers) }()
go func() { defer wg.Done(); db.Model(&model.Order{}).Where("status IN ?", paidStatuses).Count(&paidOrderCount) }()
go func() {
defer wg.Done()
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).
Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue)
}()
go func() {
defer wg.Done()
db.Table("orders").Where("status IN ?", paidStatuses).
Select("COUNT(DISTINCT user_id)").Scan(&paidUserCount)
}()
wg.Wait()
conversionRate := 0.0
if totalUsers > 0 && paidUserCount > 0 {
conversionRate = float64(paidUserCount) / float64(totalUsers) * 100
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"totalUsers": totalUsers,
"paidOrderCount": paidOrderCount,
"paidUserCount": paidUserCount,
"totalRevenue": totalRevenue,
"conversionRate": conversionRate,
})
}
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders
func AdminDashboardRecentOrders(c *gin.Context) {
db := database.DB()
var recentOrders []model.Order
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(5).Find(&recentOrders)
c.JSON(http.StatusOK, gin.H{"success": true, "recentOrders": buildRecentOrdersOut(db, recentOrders)})
}
// AdminDashboardNewUsers GET /api/admin/dashboard/new-users
func AdminDashboardNewUsers(c *gin.Context) {
db := database.DB()
var newUsers []model.User
db.Model(&model.User{}).Order("created_at DESC").Limit(10).Find(&newUsers)
c.JSON(http.StatusOK, gin.H{"success": true, "newUsers": buildNewUsersOut(newUsers)})
}
// AdminDashboardOverview GET /api/admin/dashboard/overview
// 数据概览:总用户、付费订单数、付费用户数、总营收、转化率、最近订单、新用户
// 优化6 组查询并行执行,减少总耗时
func AdminDashboardOverview(c *gin.Context) {
db := database.DB()
var (
totalUsers int64
paidOrderCount int64
totalRevenue float64
paidUserCount int64
recentOrders []model.Order
newUsers []model.User
)
var wg sync.WaitGroup
wg.Add(6)
go func() {
defer wg.Done()
db.Model(&model.User{}).Count(&totalUsers)
}()
go func() {
defer wg.Done()
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).Count(&paidOrderCount)
}()
go func() {
defer wg.Done()
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).
Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue)
}()
go func() {
defer wg.Done()
db.Table("orders").Where("status IN ?", paidStatuses).
Select("COUNT(DISTINCT user_id)").Scan(&paidUserCount)
}()
go func() {
defer wg.Done()
db.Where("status IN ?", paidStatuses).
Order("created_at DESC").Limit(5).Find(&recentOrders)
}()
go func() {
defer wg.Done()
db.Model(&model.User{}).Order("created_at DESC").Limit(10).Find(&newUsers)
}()
wg.Wait()
conversionRate := 0.0
if totalUsers > 0 && paidUserCount > 0 {
conversionRate = float64(paidUserCount) / float64(totalUsers) * 100
}
recentOut := buildRecentOrdersOut(db, recentOrders)
newOut := buildNewUsersOut(newUsers)
c.JSON(http.StatusOK, gin.H{
"success": true,
"totalUsers": totalUsers,
"paidOrderCount": paidOrderCount,
"paidUserCount": paidUserCount,
"totalRevenue": totalRevenue,
"conversionRate": conversionRate,
"recentOrders": recentOut,
"newUsers": newOut,
})
}
func dashStr(s *string) string {
if s == nil || *s == "" {
return ""
}
return *s
}
func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
if len(recentOrders) == 0 {
return nil
}
userIDs := make(map[string]bool)
for _, o := range recentOrders {
if o.UserID != "" {
userIDs[o.UserID] = true
}
}
ids := make([]string, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
var users []model.User
db.Where("id IN ?", ids).Find(&users)
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
out := make([]gin.H, 0, len(recentOrders))
for _, o := range recentOrders {
b, _ := json.Marshal(o)
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
if u := userMap[o.UserID]; u != nil {
m["userNickname"] = dashStr(u.Nickname)
m["userAvatar"] = dashStr(u.Avatar)
} else {
m["userNickname"] = ""
m["userAvatar"] = ""
}
out = append(out, m)
}
return out
}
func buildNewUsersOut(newUsers []model.User) []gin.H {
out := make([]gin.H, 0, len(newUsers))
for _, u := range newUsers {
out = append(out, gin.H{
"id": u.ID,
"nickname": dashStr(u.Nickname),
"phone": dashStr(u.Phone),
"referralCode": dashStr(u.ReferralCode),
"createdAt": u.CreatedAt,
})
}
return out
}

View File

@@ -5,6 +5,8 @@ import (
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -109,8 +111,61 @@ func getFreeChapterIDs(db *gorm.DB) map[string]bool {
return ids
}
// checkUserChapterAccess 判断 userId 是否有权读取 chapterIDVIP / 全书购买 / 单章购买)
// isPremium=true 表示增值版fullbook 买断不含增值版
func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool {
if userID == "" {
return false
}
// VIPis_vip=1 且未过期
var u model.User
if err := db.Select("id", "is_vip", "vip_expire_date", "has_full_book").Where("id = ?", userID).First(&u).Error; err != nil {
return false
}
if u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) {
return true
}
// 全书买断(不含增值版)
if !isPremium && u.HasFullBook != nil && *u.HasFullBook {
return true
}
// 全书订单(兜底)
if !isPremium {
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND product_type = 'fullbook' AND status = 'paid'", userID).Count(&cnt)
if cnt > 0 {
return true
}
}
// 单章购买
var cnt int64
db.Model(&model.Order{}).Where(
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid'",
userID, chapterID,
).Count(&cnt)
return cnt > 0
}
// previewContent 取内容的前 20%(最多前 200 个字符),并追加省略提示
func previewContent(content string) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
limit := total / 5
if limit < 100 {
limit = 100
}
if limit > total {
limit = total
}
runes := []rune(content)
return string(runes[:limit]) + "\n\n……购买后阅读完整内容"
}
// findChapterAndRespond 按条件查章节并返回统一格式
// 免费判断优先级system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content否则返回 previewContent
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
var ch model.Chapter
db := database.DB()
@@ -122,30 +177,43 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
isFreeFromConfig := getFreeChapterIDs(db)[ch.ID]
isFree := isFreeFromConfig
if !isFree && ch.IsFree != nil && *ch.IsFree {
isFree = true
}
if !isFree && ch.Price != nil && *ch.Price == 0 {
isFree = true
}
// 确定返回的 content免费直接返回付费须校验购买权限
userID := c.Query("userId")
isPremium := ch.EditionPremium != nil && *ch.EditionPremium
var returnContent string
if isFree {
returnContent = ch.Content
} else if checkUserChapterAccess(db, userID, ch.ID, isPremium) {
returnContent = ch.Content
} else {
returnContent = previewContent(ch.Content)
}
out := gin.H{
"success": true,
"data": ch,
"content": ch.Content,
"content": returnContent,
"chapterTitle": ch.ChapterTitle,
"partTitle": ch.PartTitle,
"id": ch.ID,
"mid": ch.MID,
"sectionTitle": ch.SectionTitle,
"isFree": isFree,
}
isFreeFromConfig := getFreeChapterIDs(db)[ch.ID]
if isFreeFromConfig {
out["isFree"] = true
out["price"] = float64(0)
} else {
if ch.IsFree != nil {
out["isFree"] = *ch.IsFree
}
if ch.Price != nil {
out["price"] = *ch.Price
if *ch.Price == 0 {
out["isFree"] = true
}
}
} else if ch.Price != nil {
out["price"] = *ch.Price
}
c.JSON(http.StatusOK, out)
}

View File

@@ -290,15 +290,17 @@ func CKBSync(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// CKBLead POST /api/miniprogram/ckb/lead 小程序-链接卡若:上报线索到存客宝,便于卡若添加好友
// 请求体phone(可选)、wechatId可选、name可选、userId可选用于补全昵称
// 至少传 phone 或 wechatId 之一;签名规则同 api_v1.md
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)或文章@某人(点击 mention
// 请求体phone/wechatId至少一个、userId补全昵称、targetUserId被@的 personId、targetNickname、source
func CKBLead(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
TargetUserID string `json:"targetUserId"` // 被@的 personId文章 mention 场景)
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
Source string `json:"source"` // index_lead / article_mention
}
_ = c.ShouldBindJSON(&body)
phone := strings.TrimSpace(body.Phone)
@@ -308,16 +310,37 @@ func CKBLead(c *gin.Context) {
return
}
name := strings.TrimSpace(body.Name)
db := database.DB()
if name == "" && body.UserID != "" {
var u model.User
if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
name = *u.Nickname
}
}
if name == "" {
name = "小程序用户"
}
db := database.DB()
// 确定使用哪个存客宝密钥
// 优先级:被@人物的 ckb_api_key > 全局 CKB_LEAD_API_KEY > 代码内置 ckbAPIKey
leadKey := ckbAPIKey
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
leadKey = cfg.CkbLeadAPIKey
}
targetName := strings.TrimSpace(body.TargetNickname) // 被@人的显示名,用于成功文案
if body.TargetUserID != "" {
var p model.Person
if db.Where("person_id = ?", body.TargetUserID).First(&p).Error == nil {
if p.CkbApiKey != "" {
leadKey = p.CkbApiKey
}
if targetName == "" {
targetName = p.Name
}
}
}
// 去重限频2 分钟内同一用户/手机/微信只能提交一次
var cond []string
var args []interface{}
if body.UserID != "" {
@@ -332,7 +355,6 @@ func CKBLead(c *gin.Context) {
cond = append(cond, "wechat_id = ?")
args = append(args, wechatId)
}
// 2 分钟内同一用户/手机/微信只能提交一次(与前端限频一致)
if len(cond) > 0 {
cutoff := time.Now().Add(-2 * time.Minute)
var recentCount int64
@@ -341,30 +363,32 @@ func CKBLead(c *gin.Context) {
return
}
}
// 是否曾留资过(仅用于成功后的提示文案)
repeatedSubmit := false
if len(cond) > 0 {
var existCount int64
repeatedSubmit = db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Count(&existCount) == nil && existCount > 0
}
source := strings.TrimSpace(body.Source)
if source == "" {
source = "index_lead"
}
paramsJSON, _ := json.Marshal(map[string]interface{}{
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
"targetUserId": body.TargetUserID, "source": source,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Params: string(paramsJSON),
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
TargetPersonID: body.TargetUserID,
Source: source,
Params: string(paramsJSON),
}).Error
ts := time.Now().Unix()
// 链接卡若GET + query便于浏览器测试传参name, phone, wechatId, apiKey, timestamp, sign
leadKey := ckbAPIKey
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
leadKey = cfg.CkbLeadAPIKey
}
params := map[string]interface{}{
"name": name,
"timestamp": ts,
@@ -399,17 +423,19 @@ func CKBLead(c *gin.Context) {
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
msg := "提交成功,卡若会尽快联系您"
if result.Message == "已存在" {
msg = "您已留资,我们会尽快联系您"
// 成功文案:有被@的人则用 TA 的名字,否则用"对方"
who := targetName
if who == "" {
who = "对方"
}
msg := fmt.Sprintf("提交成功,%s 会尽快联系您", who)
if repeatedSubmit {
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
msg = fmt.Sprintf("您已留资过,我们已再次通知 %s,请耐心等待添加", who)
}
data := gin.H{}
if result.Data != nil {

View File

@@ -101,6 +101,22 @@ func GetPublicDBConfig(c *gin.Context) {
if _, has := out["userDiscount"]; !has {
out["userDiscount"] = float64(5)
}
// 链接标签列表(小程序 onLinkTagTap 需要知道 type用于 ckb/miniprogram 的特殊处理)
var linkTagRows []model.LinkTag
if err := db.Order("label ASC").Find(&linkTagRows).Error; err == nil {
tags := make([]gin.H, 0, len(linkTagRows))
for _, t := range linkTagRows {
tags = append(tags, gin.H{
"tagId": t.TagID,
"label": t.Label,
"url": t.URL,
"type": t.Type,
"pagePath": t.PagePath,
"appId": t.AppID,
})
}
out["linkTags"] = tags
}
c.JSON(http.StatusOK, out)
}

View File

@@ -163,18 +163,21 @@ func DBBookAction(c *gin.Context) {
}
wordCount := len(item.Content)
status := "published"
editionStandard, editionPremium := true, false
ch := model.Chapter{
ID: item.ID,
PartID: strPtr(item.PartID, "part-1"),
PartTitle: strPtr(item.PartTitle, "未分类"),
ChapterID: strPtr(item.ChapterID, "chapter-1"),
ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
SectionTitle: item.Title,
Content: item.Content,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,
Status: &status,
ID: item.ID,
PartID: strPtr(item.PartID, "part-1"),
PartTitle: strPtr(item.PartTitle, "未分类"),
ChapterID: strPtr(item.ChapterID, "chapter-1"),
ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
SectionTitle: item.Title,
Content: item.Content,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,
Status: &status,
EditionStandard: &editionStandard,
EditionPremium: &editionPremium,
}
err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error
if err == gorm.ErrRecordNotFound {
@@ -212,14 +215,19 @@ func DBBookAction(c *gin.Context) {
TargetChapterID string `json:"targetChapterId"`
TargetPartTitle string `json:"targetPartTitle"`
TargetChapterTitle string `json:"targetChapterTitle"`
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Price *float64 `json:"price"`
IsFree *bool `json:"isFree"`
IsNew *bool `json:"isNew"` // stitch_soul标记最新新增
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Price *float64 `json:"price"`
IsFree *bool `json:"isFree"`
IsNew *bool `json:"isNew"` // stitch_soul标记最新新增
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
PartID string `json:"partId"`
PartTitle string `json:"partTitle"`
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
HotScore *float64 `json:"hotScore"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -315,13 +323,88 @@ func DBBookAction(c *gin.Context) {
if body.IsNew != nil {
updates["is_new"] = *body.IsNew
}
// 默认普通版:未传时按普通版处理
if body.EditionStandard != nil {
updates["edition_standard"] = *body.EditionStandard
} else if body.EditionPremium == nil {
updates["edition_standard"] = true
updates["edition_premium"] = false
}
if body.EditionPremium != nil {
updates["edition_premium"] = *body.EditionPremium
}
err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
if body.HotScore != nil {
updates["hot_score"] = *body.HotScore
}
if body.PartID != "" {
updates["part_id"] = body.PartID
}
if body.PartTitle != "" {
updates["part_title"] = body.PartTitle
}
if body.ChapterID != "" {
updates["chapter_id"] = body.ChapterID
}
if body.ChapterTitle != "" {
updates["chapter_title"] = body.ChapterTitle
}
var existing model.Chapter
err := db.Where("id = ?", body.ID).First(&existing).Error
if err == gorm.ErrRecordNotFound {
// 新建Create
partID := body.PartID
if partID == "" {
partID = "part-1"
}
partTitle := body.PartTitle
if partTitle == "" {
partTitle = "未分类"
}
chapterID := body.ChapterID
if chapterID == "" {
chapterID = "chapter-1"
}
chapterTitle := body.ChapterTitle
if chapterTitle == "" {
chapterTitle = "未分类"
}
editionStandard, editionPremium := true, false
if body.EditionPremium != nil && *body.EditionPremium {
editionStandard, editionPremium = false, true
} else if body.EditionStandard != nil {
editionStandard = *body.EditionStandard
}
status := "published"
ch := model.Chapter{
ID: body.ID,
PartID: partID,
PartTitle: partTitle,
ChapterID: chapterID,
ChapterTitle: chapterTitle,
SectionTitle: body.Title,
Content: body.Content,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,
Status: &status,
EditionStandard: &editionStandard,
EditionPremium: &editionPremium,
}
if body.IsNew != nil {
ch.IsNew = body.IsNew
}
if err := db.Create(&ch).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
err = db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return

View File

@@ -24,9 +24,10 @@ func DBPersonList(c *gin.Context) {
// DBPersonSave POST /api/db/persons 管理端-新增或更新人物
func DBPersonSave(c *gin.Context) {
var body struct {
PersonID string `json:"personId"`
Name string `json:"name"`
Label string `json:"label"`
PersonID string `json:"personId"`
Name string `json:"name"`
Label string `json:"label"`
CkbApiKey string `json:"ckbApiKey"` // 存客宝密钥,留空则 fallback 全局 Key
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -44,11 +45,12 @@ func DBPersonSave(c *gin.Context) {
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
existing.Name = body.Name
existing.Label = body.Label
existing.CkbApiKey = body.CkbApiKey
db.Save(&existing)
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
return
}
p := model.Person{PersonID: body.PersonID, Name: body.Name, Label: body.Label}
p := model.Person{PersonID: body.PersonID, Name: body.Name, Label: body.Label, CkbApiKey: body.CkbApiKey}
if err := db.Create(&p).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return

View File

@@ -220,6 +220,13 @@ func miniprogramPayPost(c *gin.Context) {
db := database.DB()
// -------- V1.1 后端价格:从 DB 读取标准价,客户端传值仅用于日志对比,实际以后端计算为准 --------
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
if priceErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
return
}
// 查询用户的有效推荐人(先查 binding再查 referralCode
var referrerID *string
if req.UserID != "" {
@@ -244,8 +251,8 @@ func miniprogramPayPost(c *gin.Context) {
}
}
// 有推荐人时应用好友优惠(无论是 binding 还是 referralCode
finalAmount := req.Amount
// 有推荐人时应用好友优惠,以后端标准价为基准计算最终金额,忽略客户端传值
finalAmount := standardPrice
if referrerID != nil {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
@@ -253,7 +260,7 @@ func miniprogramPayPost(c *gin.Context) {
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
discountRate := userDiscount / 100
finalAmount = req.Amount * (1 - discountRate)
finalAmount = standardPrice * (1 - discountRate)
if finalAmount < 0.01 {
finalAmount = 0.01
}
@@ -261,6 +268,11 @@ func miniprogramPayPost(c *gin.Context) {
}
}
}
// 记录客户端与后端金额差异(仅日志,不拦截)
if req.Amount-finalAmount > 0.05 || finalAmount-req.Amount > 0.05 {
fmt.Printf("[PayCreate] 金额差异: 客户端=%.2f 后端=%.2f productType=%s productId=%s userId=%s\n",
req.Amount, finalAmount, req.ProductType, req.ProductID, req.UserID)
}
// 生成订单号
orderSn := wechat.GenerateOrderSn()
@@ -372,7 +384,7 @@ func miniprogramPayGet(c *gin.Context) {
switch tradeState {
case "SUCCESS":
status = "paid"
// 若微信已支付,主动同步到本地 orders(不等 PayNotify便于购买次数即时生效
// V1.3 修复:主动同步到本地 orders并激活对应权益VIP/全书),避免等待 PayNotify 延迟
db := database.DB()
var order model.Order
if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err == nil && order.Status != nil && *order.Status != "paid" {
@@ -382,7 +394,13 @@ func miniprogramPayGet(c *gin.Context) {
"transaction_id": transactionID,
"pay_time": now,
})
order.Status = strToPtr("paid")
order.PayTime = &now
orderPollLogf("主动同步订单已支付: %s", orderSn)
// 激活权益
if order.UserID != "" {
activateOrderBenefits(db, order.UserID, order.ProductType, now)
}
}
case "CLOSED", "REVOKED", "PAYERROR":
status = "failed"
@@ -484,17 +502,12 @@ func MiniprogramPayNotify(c *gin.Context) {
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
} else if attach.ProductType == "vip" {
// VIP 支付成功:更新 users.is_vip、vip_expire_date、vip_activated_at排序后付款在前
expireDate := time.Now().AddDate(0, 0, 365)
// V4.2 修复:续费时累加剩余天数(从 max(now, vip_expire_date) 加 365 天
vipActivatedAt := time.Now()
if order.PayTime != nil {
vipActivatedAt = *order.PayTime
}
db.Model(&model.User{}).Where("id = ?", buyerUserID).Updates(map[string]interface{}{
"is_vip": true,
"vip_expire_date": expireDate,
"vip_activated_at": vipActivatedAt,
})
expireDate := activateVIP(db, buyerUserID, 365, vipActivatedAt)
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
} else if attach.ProductType == "match" {
fmt.Printf("[PayNotify] 用户购买匹配次数: %s订单 %s\n", buyerUserID, orderSn)
@@ -781,9 +794,12 @@ func MiniprogramUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
return
}
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
id, "paid", "fullbook", "vip").Count(&cnt)
// V4.1 修复is_vip 同时校验过期时间is_vip=1 且 vip_expire_date>NOW而非仅凭订单数量
isVipActive, _ := isVipFromUsers(db, id)
if !isVipActive {
// 兜底orders 表有有效 VIP 订单
isVipActive, _ = isVipFromOrders(db, id)
}
// 用户信息与会员资料vip*、P3 资料扩展,供会员详情页完整展示
item := gin.H{
"id": user.ID,
@@ -808,7 +824,7 @@ func MiniprogramUsers(c *gin.Context) {
"helpOffer": getStringValue(user.HelpOffer),
"helpNeed": getStringValue(user.HelpNeed),
"projectIntro": getStringValue(user.ProjectIntro),
"is_vip": cnt > 0,
"is_vip": isVipActive,
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
return
@@ -819,15 +835,101 @@ func MiniprogramUsers(c *gin.Context) {
list := make([]gin.H, 0, len(users))
for i := range users {
u := &users[i]
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
u.ID, "paid", "fullbook", "vip").Count(&cnt)
// V4.1is_vip 同时校验过期时间
uvip, _ := isVipFromUsers(db, u.ID)
if !uvip {
uvip, _ = isVipFromOrders(db, u.ID)
}
list = append(list, gin.H{
"id": u.ID,
"nickname": getStringValue(u.Nickname),
"avatar": getStringValue(u.Avatar),
"is_vip": cnt > 0,
"is_vip": uvip,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// strToPtr 返回字符串指针(辅助函数)
func strToPtr(s string) *string { return &s }
// activateVIP 为用户激活 VIP续费时从 max(now, vip_expire_date) 累加 days 天
// 返回最终过期时间
func activateVIP(db *gorm.DB, userID string, days int, activatedAt time.Time) time.Time {
var u model.User
db.Select("id", "is_vip", "vip_expire_date").Where("id = ?", userID).First(&u)
base := activatedAt
if u.VipExpireDate != nil && u.VipExpireDate.After(base) {
base = *u.VipExpireDate // 续费累加
}
expireDate := base.AddDate(0, 0, days)
db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"is_vip": true,
"vip_expire_date": expireDate,
"vip_activated_at": activatedAt,
})
return expireDate
}
// activateOrderBenefits 订单支付成功后激活对应权益VIP / 全书)
func activateOrderBenefits(db *gorm.DB, userID, productType string, payTime time.Time) {
switch productType {
case "fullbook":
db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true)
case "vip":
activateVIP(db, userID, 365, payTime)
}
}
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
// productType: fullbook / vip / section / match
// productId: 章节购买时为章节 ID
func getStandardPrice(db *gorm.DB, productType, productID string) (float64, error) {
switch productType {
case "fullbook", "vip", "match":
// 从 system_config 读取
configKey := "chapter_config"
if productType == "vip" {
configKey = "vip_config"
}
var row model.SystemConfig
if err := db.Where("config_key = ?", configKey).First(&row).Error; err == nil {
var cfg map[string]interface{}
if json.Unmarshal(row.ConfigValue, &cfg) == nil {
fieldMap := map[string]string{
"fullbook": "fullbookPrice",
"vip": "price",
"match": "matchPrice",
}
if v, ok := cfg[fieldMap[productType]].(float64); ok && v > 0 {
return v, nil
}
}
}
// 兜底默认值
defaults := map[string]float64{"fullbook": 9.9, "vip": 1980, "match": 68}
if p, ok := defaults[productType]; ok {
return p, nil
}
return 0, fmt.Errorf("未知商品类型: %s", productType)
case "section":
if productID == "" {
return 0, fmt.Errorf("单章购买缺少 productId")
}
var ch model.Chapter
if err := db.Select("id", "price", "is_free").Where("id = ?", productID).First(&ch).Error; err != nil {
return 0, fmt.Errorf("章节不存在: %s", productID)
}
if ch.IsFree != nil && *ch.IsFree {
return 0, fmt.Errorf("该章节为免费章节,无需支付")
}
if ch.Price == nil || *ch.Price <= 0 {
return 0, fmt.Errorf("章节价格未配置: %s", productID)
}
return *ch.Price, nil
default:
return 0, fmt.Errorf("未知商品类型: %s", productType)
}
}

View File

@@ -38,6 +38,17 @@ func ReferralBind(c *gin.Context) {
}
db := database.DB()
// V3.1 修复:若同时提供了 openId 和 userId校验 userId 对应的用户确实拥有该 openId
// 防止攻击者伪造他人 userId 来绑定推荐关系
if req.UserID != "" && req.OpenID != "" {
var cnt int64
db.Model(&model.User{}).Where("id = ? AND open_id = ?", req.UserID, req.OpenID).Count(&cnt)
if cnt == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户身份校验失败"})
return
}
}
bindingDays := defaultBindingDays
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {

View File

@@ -69,6 +69,7 @@ func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model
return base * vipOrderShareNonVip
}
// 内容订单:若有推荐人且 userDiscount>0反推原价否则按实付
// 设计意图:推广者拿的是折前原价的佣金,好友折扣由平台承担,不影响推广者收益
commissionBase := order.Amount
if userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || (order.ReferralCode != nil && *order.ReferralCode != "")) {
if (1 - userDiscount) > 0 {

View File

@@ -639,3 +639,91 @@ func UserUpdate(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"})
}
// UserDashboardStats GET /api/miniprogram/user/dashboard-stats?userId=
// 小程序「我的」页聚合统计:已读章节列表、最近阅读、总阅读时长、匹配历史数
func UserDashboardStats(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
return
}
db := database.DB()
// 1. 拉取该用户所有阅读进度记录,按最近打开时间倒序
var progressList []model.ReadingProgress
if err := db.Where("user_id = ?", userID).Order("last_open_at DESC").Find(&progressList).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取阅读统计失败"})
return
}
// 2. 遍历:统计 readSectionIds / totalReadSeconds同时去重取最近 5 个不重复章节
readCount := len(progressList)
totalReadSeconds := 0
recentIDs := make([]string, 0, 5)
seenRecent := make(map[string]bool)
readSectionIDs := make([]string, 0, len(progressList))
for _, item := range progressList {
totalReadSeconds += item.Duration
if item.SectionID != "" {
readSectionIDs = append(readSectionIDs, item.SectionID)
// 去重:同一章节只保留最近一次
if !seenRecent[item.SectionID] && len(recentIDs) < 5 {
seenRecent[item.SectionID] = true
recentIDs = append(recentIDs, item.SectionID)
}
}
}
// 不足 60 秒但有阅读记录时,至少显示 1 分钟
totalReadMinutes := totalReadSeconds / 60
if totalReadSeconds > 0 && totalReadMinutes == 0 {
totalReadMinutes = 1
}
// 3. 批量查 chapters 获取真实标题与 mid
chapterMap := make(map[string]model.Chapter)
if len(recentIDs) > 0 {
var chapters []model.Chapter
if err := db.Select("id", "mid", "section_title").Where("id IN ?", recentIDs).Find(&chapters).Error; err == nil {
for _, ch := range chapters {
chapterMap[ch.ID] = ch
}
}
}
// 按最近阅读顺序组装,标题 fallback 为 section_id
recentChapters := make([]gin.H, 0, len(recentIDs))
for _, id := range recentIDs {
ch, ok := chapterMap[id]
title := id
mid := 0
if ok {
if ch.SectionTitle != "" {
title = ch.SectionTitle
}
mid = ch.MID
}
recentChapters = append(recentChapters, gin.H{
"id": id,
"mid": mid,
"title": title,
})
}
// 4. 匹配历史数(该用户发起的匹配次数)
var matchHistory int64
db.Model(&model.MatchRecord{}).Where("user_id = ?", userID).Count(&matchHistory)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"readCount": readCount,
"totalReadMinutes": totalReadMinutes,
"recentChapters": recentChapters,
"matchHistory": matchHistory,
"readSectionIds": readSectionIDs,
},
})
}

View File

@@ -0,0 +1,30 @@
package middleware
import (
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
)
// CronAuth 定时任务鉴权:校验 X-Cron-Secret 请求头或 ?secret= 参数与 CRON_SECRET 环境变量一致
// 若 CRON_SECRET 未配置则直接放行(开发环境兼容)
func CronAuth() gin.HandlerFunc {
return func(c *gin.Context) {
secret := strings.TrimSpace(os.Getenv("CRON_SECRET"))
if secret == "" {
c.Next()
return
}
provided := c.GetHeader("X-Cron-Secret")
if provided == "" {
provided = c.Query("secret")
}
if provided != secret {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "cron secret 不匹配"})
return
}
c.Next()
}
}

View File

@@ -21,6 +21,7 @@ type Chapter struct {
// 普通版/增值版:两者分开互斥,添加文章时勾选归属
EditionStandard *bool `gorm:"column:edition_standard" json:"editionStandard,omitempty"` // 是否属于普通版
EditionPremium *bool `gorm:"column:edition_premium" json:"editionPremium,omitempty"` // 是否属于增值版
HotScore int `gorm:"column:hot_score;default:0" json:"hotScore"` // 热度分,用于排名算法
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}

View File

@@ -4,14 +4,16 @@ import "time"
// CkbLeadRecord 链接卡若留资记录(独立表,便于后续链接其他用户等扩展)
type CkbLeadRecord struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
Nickname string `gorm:"column:nickname;size:100" json:"nickname"`
Phone string `gorm:"column:phone;size:20" json:"phone"`
WechatID string `gorm:"column:wechat_id;size:100" json:"wechatId"`
Name string `gorm:"column:name;size:100" json:"name"` // 用户填的姓名/昵称
Params string `gorm:"column:params;type:json" json:"params"` // 完整传参 JSON
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
Nickname string `gorm:"column:nickname;size:100" json:"nickname"`
Phone string `gorm:"column:phone;size:20" json:"phone"`
WechatID string `gorm:"column:wechat_id;size:100" json:"wechatId"`
Name string `gorm:"column:name;size:100" json:"name"` // 用户填的姓名/昵称
TargetPersonID string `gorm:"column:target_person_id;size:100" json:"targetPersonId"` // 被@的人物 personId
Source string `gorm:"column:source;size:50" json:"source"` // 来源index_lead / article_mention
Params string `gorm:"column:params;type:json" json:"params"` // 完整传参 JSON
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (CkbLeadRecord) TableName() string { return "ckb_lead_records" }

View File

@@ -4,12 +4,13 @@ import "time"
// Person @提及人物配置ContentPage 用)
type Person struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
Name string `gorm:"column:name;size:100" json:"name"`
Label string `gorm:"column:label;size:200" json:"label"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
Name string `gorm:"column:name;size:100" json:"name"`
Label string `gorm:"column:label;size:200" json:"label"`
CkbApiKey string `gorm:"column:ckb_api_key;size:100;default:''" json:"ckbApiKey"` // 存客宝密钥,留空则 fallback 全局 Key
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Person) TableName() string { return "persons" }

View File

@@ -48,6 +48,10 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.POST("/content", handler.AdminContent)
admin.PUT("/content", handler.AdminContent)
admin.DELETE("/content", handler.AdminContent)
admin.GET("/dashboard/stats", handler.AdminDashboardStats)
admin.GET("/dashboard/recent-orders", handler.AdminDashboardRecentOrders)
admin.GET("/dashboard/new-users", handler.AdminDashboardNewUsers)
admin.GET("/dashboard/overview", handler.AdminDashboardOverview)
admin.GET("/distribution/overview", handler.AdminDistributionOverview)
admin.GET("/payment", handler.AdminPayment)
admin.POST("/payment", handler.AdminPayment)
@@ -73,20 +77,19 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.POST("/users", handler.AdminUsersAction)
admin.PUT("/users", handler.AdminUsersAction)
admin.DELETE("/users", handler.AdminUsersAction)
admin.GET("/orders", handler.OrdersList)
}
// ----- 鉴权 -----
api.POST("/auth/login", handler.AuthLogin)
api.POST("/auth/reset-password", handler.AuthResetPassword)
// ----- 书籍/章节 -----
// ----- 书籍/章节(只读,写操作由 /api/db/book 管理端路由承担) -----
api.GET("/book/all-chapters", handler.BookAllChapters)
api.GET("/book/chapter/:id", handler.BookChapterByID)
api.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
api.GET("/book/chapters", handler.BookChapters)
api.POST("/book/chapters", handler.BookChapters)
api.PUT("/book/chapters", handler.BookChapters)
api.DELETE("/book/chapters", handler.BookChapters)
// POST/PUT/DELETE /api/book/chapters 已移除:写操作须由管理端 /api/db/bookAdminAuth完成
api.GET("/book/hot", handler.BookHot)
api.GET("/book/recommended", handler.BookRecommended)
api.GET("/book/latest-chapters", handler.BookLatestChapters)
@@ -109,11 +112,15 @@ func Setup(cfg *config.Config) *gin.Engine {
// ----- 内容 -----
api.GET("/content", handler.ContentGet)
// ----- 定时任务 -----
api.GET("/cron/sync-orders", handler.CronSyncOrders)
api.POST("/cron/sync-orders", handler.CronSyncOrders)
api.GET("/cron/unbind-expired", handler.CronUnbindExpired)
api.POST("/cron/unbind-expired", handler.CronUnbindExpired)
// ----- 定时任务(须携带 X-Cron-Secret 请求头,与 .env CRON_SECRET 一致) -----
cron := api.Group("/cron")
cron.Use(middleware.CronAuth())
{
cron.GET("/sync-orders", handler.CronSyncOrders)
cron.POST("/sync-orders", handler.CronSyncOrders)
cron.GET("/unbind-expired", handler.CronUnbindExpired)
cron.POST("/unbind-expired", handler.CronUnbindExpired)
}
// ----- 数据库(管理端) -----
db := api.Group("/db")
@@ -180,8 +187,7 @@ func Setup(cfg *config.Config) *gin.Engine {
// ----- 菜单 -----
api.GET("/menu", handler.MenuGet)
// ----- 订单 -----
api.GET("/orders", handler.OrdersList)
// /api/orders 已移入 admin 组(需鉴权),见下方
// ----- 支付 -----
api.POST("/payment/alipay/notify", handler.PaymentAlipayNotify)
@@ -269,6 +275,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.PUT("/user/addresses/:id", handler.UserAddressesByID)
miniprogram.DELETE("/user/addresses/:id", handler.UserAddressesByID)
miniprogram.GET("/user/check-purchased", handler.UserCheckPurchased)
miniprogram.GET("/user/dashboard-stats", handler.UserDashboardStats)
miniprogram.GET("/user/profile", handler.UserProfileGet)
miniprogram.POST("/user/profile", handler.UserProfilePost)
miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus)

View File

@@ -0,0 +1,2 @@
-- 为 chapters 表新增 hot_score 字段(热度分,用于排名算法)
ALTER TABLE chapters ADD COLUMN hot_score INT NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,6 @@
-- persons 表新增 ckb_api_key 字段
-- 作用:存储该 @人物 在存客宝的接入密钥,点击加好友时用该 Key 推线索;留空则 fallback 全局 CKB_LEAD_API_KEY
-- 执行mysql -u user -p db < soul-api/scripts/add-persons-ckb-api-key.sql
ALTER TABLE persons
ADD COLUMN ckb_api_key VARCHAR(100) NOT NULL DEFAULT '' AFTER label;

View File

@@ -199,3 +199,44 @@ VIP 接口、章节推荐逻辑、数据库依赖
## 会议纪要
- 开发进度同步会议纪要:`.cursor/会议记录/2026-02-27_开发进度同步会议.md`
---
# 第八部分:小程序新旧版对比与 dashboard-stats 接口新增2026-03-10 橙子同步)
## 背景
Mycontent-temp/miniprogram 为样式预览分支miniprogram 为线上主线版本。通过批量 diff 发现新版缺失了多项功能,但有一个关键优化点值得移植。
## 本次变更
### 1. 小程序 my.js — loadDashboardStats 移植
- **问题**:旧版 `initUserStatus()` 用本地缓存随机时间(`Math.floor(Math.random() * 200) + 50`)和标题占位展示阅读统计,数据不准
- **修复**:移植新版 `loadDashboardStats()` 方法,调用后端接口获取真实数据
- **接口**`GET /api/miniprogram/user/dashboard-stats?userId=xxx`
- **同步**`readSectionIds` 同步到 `app.globalData` 和 Storage
### 2. 后端 soul-api — UserDashboardStats 接口新增
- **路由**`GET /api/miniprogram/user/dashboard-stats`
- **文件**`soul-api/internal/handler/user.go``router/router.go`
- **数据**readCount、totalReadMinutes最小1分钟、recentChapters最近5条去重、matchHistory、readSectionIds
- **修复点**去重逻辑、min1分钟、DB 错误返回 500
### 3. 新旧版功能对比结论
| 功能 | 线上主线miniprogram | 预览版Mycontent-temp |
|------|-----|-----|
| 首页阅读进度卡 | ✅ 有 | ❌ 缺失 |
| 目录 VIP/增值权限 | ✅ 完整 | ❌ 简化 |
| VIP 全局状态 globalData | ✅ 同步 | ❌ 不写 |
| my.js 阅读统计 | ✅ 已迁至后端接口(本次) | ✅ 有(参考来源) |
## 技术债
- 富文本渲染两版均为纯文本HTML 格式被剥除),建议改用 `<rich-text>` 组件,待确认内容格式后实施
## 会议纪要
- `.cursor/meeting/2026-03-10_小程序新旧版对比与dashboard接口新增.md`

View File

@@ -38,3 +38,5 @@ IP 设定、风格、输出规范(见原卡若角色设定)。
| 2026-02 | VIP 排序:后付款/后设置在前;支持手动排序 | 已完成 | vip_activated_at、vip_sortVipMembers 排序逻辑 |
| 2026-02 | VIP 角色:可选择 + 可手动填写 | 已完成 | vip_roles 表VIP 角色管理页SetVipModal 角色下拉 |
| 2026-03-08 | 文章阅读付费规则:免费章节以 free_chapters 为准VIP 全章免费 | 已完成 | soul-api book.go 合并 free_chapterscheck-purchased 已支持 VIP |
| 2026-03-10 | 小程序「我的」页阅读统计改为后端接口(真实数据) | 已完成 | my.js loadDashboardStatssoul-api GET /api/miniprogram/user/dashboard-stats |
| 2026-03-10 | 富文本渲染升级TipTap HTML → rich-text 组件,保留 @mention 交互) | 待实施 | 确认 DB 内容格式后实施;当前 contentParser.js 为纯文本剥除 |

View File

@@ -0,0 +1,53 @@
# soul-admin 与 Mycontent-temp 内容管理页面对比
> 目标soul-admin 为功能基准,新建/编辑样式功能要一模一样。
## 一、新建章节弹窗
| 项目 | soul-admin | Mycontent-temp |
|------|------------|---------------|
| 弹窗宽度 | max-w-2xl | max-w-2xl |
| 章节ID | ✅ | ✅ |
| 价格 | ✅ | ✅ |
| 文章类型(普通版/增值版) | ✅ | ❌ 无 |
| 章节标题 | ✅ | ✅ |
| 所属篇 | ✅ | ✅ |
| 所属章 | ✅ | ✅ |
| 内容 | Markdown Textarea | Markdown Textarea |
| 免费 | ❌ 无 | ❌ 无 |
| 最新新增 | ❌ 无 | ❌ 无 |
| 小程序直推 | ❌ 无 | ❌ 无 |
| 热度分 | ❌ 无 | ❌ 无 |
**结论**:新建与编辑不一致。编辑有 RichEditor、免费、最新新增、置顶、热度分新建用 Textarea 且缺这些字段。
## 二、编辑章节弹窗
| 项目 | soul-admin | Mycontent-temp |
|------|------------|---------------|
| 弹窗宽度 | max-w-4xl | max-w-4xl |
| 内容 | RichEditor | RichEditor |
| 文章类型 | ✅ | ❌ 无 |
| 免费 | ✅ | ✅ |
| 最新新增 | ✅ | ✅ |
| 小程序直推 | ✅ | ✅ |
| 热度分 | ✅ | ✅ |
| 付款记录 | ✅ | ✅ |
| editionStandard/editionPremium 读取 | ✅ | ❌ 无 |
**结论**soul-admin 编辑功能完整Mycontent-temp 缺文章类型。
## 三、执行项soul-admin✅ 已完成
1. **新建弹窗**:与编辑弹窗样式功能统一 ✅
- 布局改为 max-w-4xl、flex flex-col
- 内容改为 RichEditor
- 补充:免费、最新新增、小程序直推、热度分、文章类型
- 字段顺序与编辑一致
2. **文章详情**内容区RichEditor处理完善 ✅
- 新建、编辑均使用 RichEditor支持 @人物#标签、图片上传
3. **后端 db_book**:支持新建章节 Create ✅
- 新增 body 字段PartID、PartTitle、ChapterID、ChapterTitle、HotScore
- 新建时 FirstOrCreate 逻辑:不存在则 Create存在则 Updates

View File

@@ -70,3 +70,8 @@
- `SKILL.md`(根目录)— 技术栈已过期(写的是 Next.js以 README 与 .cursor/skills 为准
- `小程序管理/scripts/__pycache__/*.pyc` — Python 缓存
- `小程序管理/scripts/reports/*.json`4 份)— 脚本生成的一次性报告
## 已移除文件2026-03-10 橙子清理)
- `api_v1.md` — 存客宝对外获客 API 文档,项目已明确搁置对接,文档无继续维护价值
- `小程序功能与管理端配置补齐分析.md` — 2026-02-25 一次性补齐分析,全部项目已标注 ✅ 已完成,分析目的达成

View File

@@ -1,413 +0,0 @@
# 对外获客线索上报接口文档V1
## 一、接口概述
- **接口名称**:对外获客线索上报接口
- **接口用途**:供第三方系统向【存客宝】上报客户线索(手机号 / 微信号等),用于后续的跟进、标签管理和画像分析。
- **接口协议**HTTP
- **请求方式**`POST`
- **请求地址** `https://ckbapi.quwanzhi.com/v1/api/scenarios`
> 具体 URL 以实际环境配置为准。
- **数据格式**
- 推荐:`application/json`
- 兼容:`application/x-www-form-urlencoded`
- **字符编码**`UTF-8`
---
## 二、鉴权与签名
### 2.1 必填鉴权字段
| 字段名 | 类型 | 必填 | 说明 |
|-------------|--------|------|---------------------------------------|
| `apiKey` | string | 是 | 分配给第三方的接口密钥(每个任务唯一)|
| `sign` | string | 是 | 签名值 |
| `timestamp` | int | 是 | 秒级时间戳(与服务器时间差不超过 5 分钟) |
### 2.2 时间戳校验
服务器会校验 `timestamp` 是否在当前时间前后 **5 分钟** 内:
- 通过条件:`|server_time - timestamp| <= 300`
- 超出范围则返回:`请求已过期`
### 2.3 签名生成规则
接口采用自定义签名机制。**签名字段为 `sign`,生成步骤如下:**
假设本次请求的所有参数为 `params`,其中包括业务参数 + `apiKey` + `timestamp` + `sign` + 可能存在的 `portrait` 对象。
#### 第一步:移除特定字段
`params` 中移除以下字段:
- `sign` —— 自身不参与签名
- `apiKey` —— 不参与参数拼接,仅在最后一步参与二次 MD5
- `portrait` —— 整个画像对象不参与签名(即使内部还有子字段)
> 说明:`portrait` 通常是一个 JSON 对象,字段较多,为避免签名实现复杂且双方难以对齐,统一不参与签名。
#### 第二步:移除空值字段
从剩余参数中,移除值为:
- `null`
- 空字符串 `''`
的字段,这些字段不参与签名。
#### 第三步:按参数名升序排序
对剩余参数按**参数名(键名)升序排序**,排序规则为标准的 ASCII 升序:
```text
例如: name, phone, source, timestamp
```
#### 第四步:拼接参数值
将排序后的参数 **只取“值”**,按顺序直接拼接为一个字符串,中间不加任何分隔符:
- 示例:
排序后参数为:
```text
name = 张三
phone = 13800000000
source = 微信广告
timestamp = 1710000000
```
则拼接:
```text
stringToSign = "张三13800000000微信广告1710000000"
```
#### 第五步:第一次 MD5
对上一步拼接得到的字符串做一次 MD5
\[
\text{firstMd5} = \text{MD5}(\text{stringToSign})
\]
#### 第六步:拼接 apiKey 再次 MD5
将第一步的结果与 `apiKey` 直接拼接,再做一次 MD5得到最终签名值
\[
\text{sign} = \text{MD5}(\text{firstMd5} + \text{apiKey})
\]
#### 第七步:放入请求
将第六步得到的 `sign` 填入请求参数中的 `sign` 字段即可。
> 建议:
> - 使用小写 MD5 字符串(双方约定统一即可)。
> - 请确保参与签名的参数与最终请求发送的参数一致(包括是否传空值)。
### 2.4 签名示例PHP 伪代码)
```php
$params = [
'apiKey' => 'YOUR_API_KEY',
'timestamp' => '1710000000',
'phone' => '13800000000',
'name' => '张三',
'source' => '微信广告',
'remark' => '通过H5落地页留资',
// 'portrait' => [...], // 如有画像,这里会存在,但不参与签名
// 'sign' => '待生成',
];
// 1. 去掉 sign、apiKey、portrait
unset($params['sign'], $params['apiKey'], $params['portrait']);
// 2. 去掉空值
$params = array_filter($params, function($value) {
return !is_null($value) && $value !== '';
});
// 3. 按键名升序排序
ksort($params);
// 4. 拼接参数值
$stringToSign = implode('', array_values($params));
// 5. 第一次 MD5
$firstMd5 = md5($stringToSign);
// 6. 第二次 MD5拼接 apiKey
$apiKey = 'YOUR_API_KEY';
$sign = md5($firstMd5 . $apiKey);
// 将 $sign 作为字段发送
$params['sign'] = $sign;
```
---
## 三、请求参数说明
### 3.1 主标识字段(至少传一个)
| 字段名 | 类型 | 必填 | 说明 |
|-----------|--------|------|-------------------------------------------|
| `wechatId`| string | 否 | 微信号,存在时优先作为主标识 |
| `phone` | string | 否 | 手机号,当 `wechatId` 为空时用作主标识 |
### 3.2 基础信息字段
| 字段名 | 类型 | 必填 | 说明 |
|------------|--------|------|-------------------------|
| `name` | string | 否 | 客户姓名 |
| `source` | string | 否 | 线索来源描述,如“百度推广”、“抖音直播间” |
| `remark` | string | 否 | 备注信息 |
| `tags` | string | 否 | 逗号分隔的“微信标签”,如:`"高意向,电商,女装"` |
| `siteTags` | string | 否 | 逗号分隔的“站内标签”,用于站内进一步细分 |
### 3.3 用户画像字段 `portrait`(可选)
`portrait` 为一个对象JSON用于记录用户的行为画像数据。
#### 3.3.1 基本示例
```json
"portrait": {
"type": 1,
"source": 1,
"sourceData": {
"age": 28,
"gender": "female",
"city": "上海",
"productId": "P12345",
"pageUrl": "https://example.com/product/123"
},
"remark": "画像-基础属性",
"uniqueId": "user_13800000000_20250301_001"
}
```
#### 3.3.2 字段详细说明
| 字段名 | 类型 | 必填 | 说明 |
|-----------------------|--------|------|----------------------------------------|
| `portrait.type` | int | 否 | 画像类型,枚举值:<br>0-浏览<br>1-点击<br>2-下单/购买<br>3-注册<br>4-互动<br>默认值0 |
| `portrait.source` | int | 否 | 画像来源,枚举值:<br>0-本站<br>1-老油条<br>2-老坑爹<br>默认值0 |
| `portrait.sourceData` | object | 否 | 画像明细数据(键值对,会存储为 JSON 格式)<br>可包含任意业务相关的键值对年龄、性别、城市、商品ID、页面URL等 |
| `portrait.remark` | string | 否 | 画像备注信息最大长度100字符 |
| `portrait.uniqueId` | string | 否 | 画像去重用唯一 ID<br>用于防止重复记录,相同 `uniqueId` 的画像数据在半小时内会被合并统计count字段累加<br>建议格式:`{来源标识}_{用户标识}_{时间戳}_{序号}` |
#### 3.3.3 画像类型type说明
| 值 | 类型 | 说明 | 适用场景 |
|---|------|------|---------|
| 0 | 浏览 | 用户浏览了页面或内容 | 页面访问、商品浏览、文章阅读等 |
| 1 | 点击 | 用户点击了某个元素 | 按钮点击、链接点击、广告点击等 |
| 2 | 下单/购买 | 用户完成了购买行为 | 订单提交、支付完成等 |
| 3 | 注册 | 用户完成了注册 | 账号注册、会员注册等 |
| 4 | 互动 | 用户进行了互动行为 | 点赞、评论、分享、咨询等 |
#### 3.3.4 画像来源source说明
| 值 | 来源 | 说明 |
|---|------|------|
| 0 | 本站 | 来自本站的数据 |
| 1 | 老油条 | 来自"老油条"系统的数据 |
| 2 | 老坑爹 | 来自"老坑爹"系统的数据 |
#### 3.3.5 sourceData 数据格式说明
`sourceData` 是一个 JSON 对象,可以包含任意业务相关的键值对。常见字段示例:
```json
{
"age": 28,
"gender": "female",
"city": "上海",
"province": "上海市",
"productId": "P12345",
"productName": "商品名称",
"category": "女装",
"price": 299.00,
"pageUrl": "https://example.com/product/123",
"referrer": "https://www.baidu.com",
"device": "mobile",
"browser": "WeChat"
}
```
> **注意**
> - `sourceData` 中的数据类型可以是字符串、数字、布尔值等
> - 嵌套对象会被序列化为 JSON 字符串存储
> - 建议根据实际业务需求定义字段结构
#### 3.3.6 uniqueId 去重机制说明
- **作用**:防止重复记录相同的画像数据
- **规则**:相同 `uniqueId` 的画像数据在 **半小时内** 会被合并统计,`count` 字段会自动累加
- **建议格式**`{来源标识}_{用户标识}_{时间戳}_{序号}`
- 示例:`site_13800000000_1710000000_001`
- 示例:`wechat_wxid_abc123_1710000000_001`
- **注意事项**
- 如果不传 `uniqueId`,系统会为每条画像数据创建新记录
- 如果需要在半小时内多次统计同一行为,应使用相同的 `uniqueId`
- 如果需要在半小时后重新统计,应使用不同的 `uniqueId`(建议修改时间戳部分)
> **重要提示**`portrait` **整体不参与签名计算**,但会参与业务处理。系统会根据 `uniqueId` 自动处理去重和统计。
---
## 四、请求示例
### 4.1 JSON 请求示例(无画像)
```json
{
"apiKey": "YOUR_API_KEY",
"timestamp": 1710000000,
"phone": "13800000000",
"name": "张三",
"source": "微信广告",
"remark": "通过H5落地页留资",
"tags": "高意向,电商",
"siteTags": "新客,女装",
"sign": "根据签名规则生成的MD5字符串"
}
```
### 4.2 JSON 请求示例(带微信号与画像)
```json
{
"apiKey": "YOUR_API_KEY",
"timestamp": 1710000000,
"wechatId": "wxid_abcdefg123",
"phone": "13800000001",
"name": "李四",
"source": "小程序落地页",
"remark": "点击【立即咨询】按钮",
"tags": "中意向,直播",
"siteTags": "复购,高客单",
"portrait": {
"type": 1,
"source": 0,
"sourceData": {
"age": 28,
"gender": "female",
"city": "上海",
"pageUrl": "https://example.com/product/123",
"productId": "P12345"
},
"remark": "画像-点击行为",
"uniqueId": "site_13800000001_1710000000_001"
},
"sign": "根据签名规则生成的MD5字符串"
}
```
### 4.3 JSON 请求示例(多种画像类型)
#### 4.3.1 浏览行为画像
```json
{
"apiKey": "YOUR_API_KEY",
"timestamp": 1710000000,
"phone": "13800000002",
"name": "王五",
"source": "百度推广",
"portrait": {
"type": 0,
"source": 0,
"sourceData": {
"pageUrl": "https://example.com/product/456",
"productName": "商品名称",
"category": "女装",
"stayTime": 120,
"device": "mobile"
},
"remark": "商品浏览",
"uniqueId": "site_13800000002_1710000000_001"
},
"sign": "根据签名规则生成的MD5字符串"
}
```
```
---
## 五、响应说明
### 5.1 成功响应
**1新增线索成功**
```json
{
"code": 200,
"message": "新增成功",
"data": "13800000000"
}
```
**2线索已存在**
```json
{
"code": 200,
"message": "已存在",
"data": "13800000000"
}
```
> `data` 字段返回本次线索的主标识 `wechatId` 或 `phone`。
### 5.2 常见错误响应
```json
{ "code": 400, "message": "apiKey不能为空", "data": null }
{ "code": 400, "message": "sign不能为空", "data": null }
{ "code": 400, "message": "timestamp不能为空", "data": null }
{ "code": 400, "message": "请求已过期", "data": null }
{ "code": 401, "message": "无效的apiKey", "data": null }
{ "code": 401, "message": "签名验证失败", "data": null }
{ "code": 500, "message": "系统错误: 具体错误信息", "data": null }
```
---
## 六、常见问题FAQ
### Q1: 如果同一个用户多次上报相同的行为,会如何处理?
**A**: 如果使用相同的 `uniqueId`,系统会在半小时内合并统计,`count` 字段会累加。如果使用不同的 `uniqueId`,会创建多条记录。
### Q2: portrait 字段是否必须传递?
**A**: 不是必须的。`portrait` 字段是可选的,只有在需要记录用户画像数据时才传递。
### Q3: sourceData 中可以存储哪些类型的数据?
**A**: `sourceData` 是一个 JSON 对象,可以存储任意键值对。支持字符串、数字、布尔值等基本类型,嵌套对象会被序列化为 JSON 字符串。
### Q4: uniqueId 的作用是什么?
**A**: `uniqueId` 用于防止重复记录。相同 `uniqueId` 的画像数据在半小时内会被合并统计,避免重复数据。
### Q5: 画像数据如何与用户关联?
**A**: 系统会根据请求中的 `wechatId` 或 `phone` 自动匹配 `traffic_pool` 表中的用户,并将画像数据关联到对应的 `trafficPoolId`。
---

View File

@@ -1,168 +0,0 @@
# 小程序功能与管理端配置补齐分析
> 基于 miniprogram 功能分析,梳理需管理端补齐的配置与功能。
> 更新日期2026-02-25
> **2026-02-25 已补齐**mp_config 管理端、网站配置持久化、支付/二维码 POST、小程序 config 读取
---
## 一、小程序配置来源总览
| 配置类型 | 来源 | 管理端入口 | 状态 |
|----------|------|------------|------|
| 免费章节 | system_config.free_chapters | 系统设置 | ✅ 已有 |
| 价格 (section/fullbook) | chapter_config / site_settings | 系统设置 | ✅ 已有 |
| 功能开关 (match/referral/search/about) | feature_config | 系统设置 | ✅ 已有 |
| 找伙伴配置 | match_config | 找伙伴配置页 | ✅ 已有 |
| 推广/分销 | referral_config | 推广设置 | ✅ 已有 |
| 小程序专用 (mp_config) | mp_config | 系统设置 → 小程序配置 | ✅ 已补齐 |
| 订阅消息模板 ID | mp_config 或 app.js 兜底 | 系统设置 → 小程序配置 | ✅ 已补齐 |
| 微信支付商户号 | mp_config 或 app.js 兜底 | 系统设置 → 小程序配置 | ✅ 已补齐 |
| API 地址 (baseUrl) | app.js 硬编码 | 发版时改 baseUrl | 已移除配置 |
| 网站/站点配置 | site_config, page_config | 网站配置 | ✅ 已持久化 |
---
## 二、小程序功能模块与配置依赖
### 2.1 核心配置接口:`GET /api/miniprogram/config`
**返回字段**(来自 GetPublicDBConfig
| 字段 | 说明 | 管理端对应 |
|------|------|------------|
| freeChapters | 免费章节 ID 列表 | 系统设置 → 免费章节 |
| prices | { section, fullbook } | 系统设置 → 价格设置 |
| features | matchEnabled, referralEnabled, searchEnabled, aboutEnabled | 系统设置 → 功能开关 |
| mpConfig | appId, apiDomain, buyerDiscount, referralBindDays, minWithdraw | **无管理端** |
| userDiscount | 好友购买优惠 % | 推广设置 |
### 2.2 找伙伴配置:`GET /api/miniprogram/match/config`
| 字段 | 说明 | 管理端对应 |
|------|------|------------|
| matchTypes | 匹配类型列表 | 找伙伴配置页 |
| freeMatchLimit | 每日免费匹配次数 | 找伙伴配置页 |
| matchPrice | 单次匹配价格(元) | 找伙伴配置页 |
| settings | enableFreeMatches, enablePaidMatches, maxMatchesPerDay | 找伙伴配置页 |
### 2.3 小程序 app.js 硬编码项
```javascript
// 当前硬编码,无法通过管理端修改
baseUrl: 'http://localhost:8080', // 开发/生产需改代码
appId: 'wxb8bbb2b10dec74aa',
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-...', // 提现订阅消息模板
mchId: '1318592501', // 微信支付商户号
```
---
## 三、管理端需补齐项(按优先级)
### P0 - 必须补齐
| 项 | 说明 | 建议方案 |
|----|------|----------|
| **小程序专用配置 (mp_config)** | appId、apiDomain、minWithdraw 等,小程序从 config 读取 | 在「系统设置」或新建「小程序配置」卡片,支持编辑并写入 system_config.mp_config |
| **订阅消息模板 ID** | 提现申请需用户授权订阅,模板 ID 现硬编码 | 管理端增加「提现订阅模板 ID」配置写入 mp_config 或单独 key小程序启动时从 config 拉取 |
| **API 地址 (baseUrl)** | 开发/生产切换需改 app.js | 方案 A从 mp_config.apiDomain 下发,小程序优先用接口返回值;方案 B保留硬编码仅文档说明切换方式 |
### P1 - 建议补齐
| 项 | 说明 | 建议方案 |
|----|------|----------|
| **微信支付商户号** | 支付回调、对账依赖 mchId现硬编码 | 管理端「支付配置」或「小程序配置」增加 mchId 字段;后端从配置读取,小程序可不改(支付由后端发起) |
| **网站配置持久化** | SitePage 当前保存仅前端状态,未调用后端 | 对接 POST /api/db/config按 key 保存 site_config、page_config、menu_config |
| **支付/二维码配置持久化** | PaymentPage、QRCodesPage 使用 POST /api/config | soul-api 无 POST /api/config需新增或改为 POST /api/db/config { key, value } |
### P2 - 可选优化
| 项 | 说明 | 建议方案 |
|----|------|----------|
| **referral_config 的 withdrawFee** | 文档有提现手续费ReferralSettingsPage 未展示 | 若业务需要,在推广设置页增加「提现手续费」字段 |
| **主题色/品牌色** | app.js theme 硬编码 | 若需多端统一,可从 site_config 或 mp_config 下发 |
---
## 四、配置与 system_config 键名映射
| system_config.config_key | 管理端页面 | 说明 |
|--------------------------|------------|------|
| free_chapters | 系统设置 | 免费章节 ID 数组 |
| feature_config | 系统设置 | 功能开关对象 |
| site_settings | 系统设置 | 价格、作者信息 |
| chapter_config | (可选) | 合并 freeChapters + pricesGetPublicDBConfig 优先用 |
| match_config | 找伙伴配置 | 匹配类型、免费次数、价格 |
| referral_config | 推广设置 | 分销比例、提现门槛、绑定期等 |
| mp_config | **待新增** | 小程序专用appId, apiDomain, withdrawSubscribeTmplId, mchId 等 |
| site_config | 网站配置 | 站点名称、logo 等(需持久化) |
| page_config | 网站配置 | 页面标题(需持久化) |
| menu_config | 网站配置 | 菜单开关(需持久化) |
| payment_methods | 支付配置 | 微信/支付宝活码等(需确认 POST 接口) |
| live_qr_codes | 二维码管理 | 群活码(需确认 POST 接口) |
---
## 五、接口与数据流检查
### 5.1 管理端调用与后端支持
| 管理端页面 | 调用 | 后端支持 | 备注 |
|------------|------|----------|------|
| 系统设置 | GET/POST /api/admin/settings | ✅ | 写入 free_chapters, feature_config, site_settings |
| 推广设置 | GET/POST /api/admin/referral-settings | ✅ | 写入 referral_config |
| 找伙伴配置 | GET /api/db/config/full?key=match_config | ✅ | 需 AdminAuth |
| 找伙伴配置 | POST /api/db/config | ✅ | body: { key: 'match_config', value: {...} } |
| 支付配置 | GET/POST /api/config | ⚠️ | GET 有POST 无;需新增或改用 db/config |
| 网站配置 | GET /api/config | ⚠️ | GET 有,保存未对接 |
| 二维码管理 | GET/POST /api/config | ⚠️ | 同上 |
### 5.2 小程序读取链
```
小程序 onLoad / custom-tab-bar
→ GET /api/miniprogram/config
→ GetPublicDBConfig
→ 读取 system_config: chapter_config, free_chapters, feature_config, mp_config, referral_config
→ 返回 freeChapters, prices, features, mpConfig, userDiscount
```
---
## 六、实施建议(管理端开发任务)
### 任务 1新增「小程序配置」区块P0
- **位置**:系统设置页新增卡片,或独立「小程序配置」页
- **字段**
- API 域名 (apiDomain):如 `https://soulapi.quwanzhi.com`
- 小程序 AppID (appId):如 `wxb8bbb2b10dec74aa`
- 提现订阅模板 ID (withdrawSubscribeTmplId)
- 微信支付商户号 (mchId)(可选,后端也可用 env
- 最低提现金额 (minWithdraw)(可与 referral_config 同步)
- **存储**POST /api/admin/settings 扩展,或 POST /api/db/config { key: 'mp_config', value: {...} }
- **小程序**app.js 启动时请求 config若 mp_config 存在则覆盖 baseUrlwithdrawSubscribeTmplId 从 config 取
### 任务 2网站配置持久化P1
- SitePage 保存时调用 POST /api/db/config按 key 分别保存 site_config、page_config、menu_config
- 或扩展 AdminSettingsPost 支持 site_config 等
### 任务 3支付/二维码配置接口P1
- 方案 A新增 POST /api/admin/config支持 payment_methods、live_qr_codes 等 key
- 方案 BPaymentPage、QRCodesPage 改为调用 POST /api/db/configbody: { key, value }
---
## 七、附录:小程序页面与配置使用
| 页面 | 使用的配置 | 接口 |
|------|------------|------|
| custom-tab-bar | features.matchEnabled | GET /api/miniprogram/config |
| 阅读 read | freeChapters, prices | GET /api/miniprogram/config (chapterAccessManager) |
| 找伙伴 match | matchTypes, freeMatchLimit, matchPrice | GET /api/miniprogram/match/config |
| 我的 my | features, 收益/提现规则 | GET /api/miniprogram/config, referral/data |
| 分销 referral | shareRate, minWithdraw, bindingDays | GET /api/miniprogram/referral/data |
| 支付流程 | mchId (后端), openId | app.js 硬编码 + 后端 env |