更新小程序,新增VIP会员状态管理功能,优化章节解锁逻辑,支持VIP用户访问增值内容。调整用户详情页面,增加VIP相关字段和功能,提升用户体验。更新会议记录,反映最新讨论内容。
This commit is contained in:
157
.cursor/meeting/2026-03-09_devlop与yongxu分支差异分析会议.md
Normal file
157
.cursor/meeting/2026-03-09_devlop与yongxu分支差异分析会议.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 会议纪要 - 2026-03-09 | devlop 与 yongxu 分支差异分析
|
||||
|
||||
> 本文件由**助理橙子**在会议结束后自动生成。
|
||||
|
||||
---
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **时间**:2026-03-09
|
||||
- **议题**:分析 devlop(dev 分支 / Mycontent-temp)与 yongxu(当前分支)两个项目的区别
|
||||
- **触发方式**:开个会议
|
||||
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
|
||||
|
||||
---
|
||||
|
||||
## 各角色发言
|
||||
|
||||
### 【产品经理】
|
||||
|
||||
**分支定位**:
|
||||
- **devlop**:老板的老板主改,侧重内容管理、用户管理、找伙伴等管理端能力,开发文档已删除(迁移至他处)
|
||||
- **yongxu**:你主改,侧重 C 端体验:@提及、一键收款、个人资料页、找伙伴、推荐码绑定、分享带 ref、退款等
|
||||
|
||||
**差异要点**:
|
||||
- devlop 删除了整个 `开发文档/` 目录(约 100+ 文件),yongxu 保留
|
||||
- 需求文档(20260308 内容管理、用户管理、找伙伴)在 devlop 侧有更新,yongxu 侧沿用旧版
|
||||
- 合并后需确保:C 端功能(@提及、推荐码、一键收款)不丢,管理端能力(ContentPage、FindPartnerPage、UsersPage)不丢
|
||||
|
||||
### 【后端开发】
|
||||
|
||||
**devlop 独有**(yongxu 没有):
|
||||
- `admin_dashboard.go`、`admin_rfm.go`、`admin_shensheshou.go`、`admin_user_rules.go`
|
||||
- `db_book.go`、`db_person.go`(db 路由组扩展)
|
||||
- `ckb.go` 大改(存客宝扩展)
|
||||
- `match.go`、`match_records.go` 扩展
|
||||
- `book.go`、`user.go`、`cron.go` 等逻辑更新
|
||||
- `person.go`、`user_rule.go` 等 model 新增
|
||||
- 路由、数据库、配置等变更
|
||||
|
||||
**yongxu 独有**(devlop 没有):
|
||||
- @提及相关接口、免费章节判断、存客宝限频、退款逻辑等(在共同祖先 90d32a51 之后)
|
||||
|
||||
**共同修改文件(易冲突)**:
|
||||
- `soul-api/internal/handler/miniprogram.go`
|
||||
- `soul-api/internal/config/config.go`
|
||||
- `soul-api/internal/database/database.go`
|
||||
- `soul-api/internal/router/router.go`
|
||||
|
||||
### 【管理端开发工程师】
|
||||
|
||||
**devlop 独有**(yongxu 没有):
|
||||
- `ContentPage.tsx` 大改(约 1395 行变更)
|
||||
- `ChapterTree.tsx`、`ChaptersPage.tsx` 新增/重构
|
||||
- `FindPartnerPage.tsx` 及多 Tab:CKBConfigPanel、CKBStatsTab、FindPartnerTab、MatchPoolTab、MatchRecordsTab、MentorBookingTab、MentorTab、ResourceDockingTab、TeamRecruitTab
|
||||
- `RichEditor.tsx`、`UserDetailModal.tsx` 扩展
|
||||
- `UsersPage.tsx` 大改(约 1267 行)
|
||||
- `DashboardPage.tsx`、`DistributionPage.tsx`、`SettingsPage.tsx` 等更新
|
||||
- `client.ts`、`AdminLayout.tsx`、`App.tsx` 配置调整
|
||||
|
||||
**yongxu**:管理端改动较少,主要在小程序侧
|
||||
|
||||
**合并策略**:以 devlop 管理端为主,yongxu 若有管理端改动需手工合入
|
||||
|
||||
### 【小程序开发工程师】
|
||||
|
||||
**yongxu 独有**(相对共同祖先 90d32a51):
|
||||
- `app.js`:baseUrl 真实后端、goBackOrToHome、推荐码/访问记录、checkUpdate
|
||||
- `read/*`:@提及解析与高亮、mid 优先跳转
|
||||
- `chapters/*`:章节列表、分享
|
||||
- `index/*`:首页、已读/待读
|
||||
- `my/*`:个人中心、导航栏
|
||||
|
||||
**devlop 也改了同一批文件**:
|
||||
- `miniprogram/app.js`、`app.json`
|
||||
- `miniprogram/pages/chapters/chapters.js`、`chapters.json`
|
||||
- `miniprogram/pages/index/index.js`、`index.wxml`
|
||||
- `miniprogram/pages/my/my.js`
|
||||
- `miniprogram/pages/read/read.js`、`read.wxml`、`read.wxss`
|
||||
- `miniprogram/project.private.config.json`
|
||||
- `miniprogram/utils/readingTracker.js`
|
||||
|
||||
**合并重点**:上述文件两分支均有修改,合并时需保留 yongxu 的 @提及、推荐码、baseUrl、goBackOrToHome 等业务逻辑,同时接纳 devlop 的其它改动(若有)
|
||||
|
||||
### 【测试人员】
|
||||
|
||||
合并后需做:
|
||||
- 三端联调:小程序↔API、管理端↔API
|
||||
- 回归测试:@提及、推荐码、找伙伴、内容管理、用户管理、存客宝、一键收款、退款
|
||||
- 建议合并完成后拉一份回归清单,逐项验证
|
||||
|
||||
---
|
||||
|
||||
## 讨论过程
|
||||
|
||||
- 用户明确:Mycontent-temp 对应 dev 分支(devlop),当前打开的是 yongxu 分支
|
||||
- 基于 `git diff`、`git log` 分析两分支自共同祖先 90d32a51 以来的差异
|
||||
- 共识:devlop 改动量大(248 文件、约 6 万行变更),yongxu 改动小(7 文件、约 366 行),合并时需分模块处理
|
||||
|
||||
---
|
||||
|
||||
## 会议决议
|
||||
|
||||
1. **差异总结**:devlop 侧重管理端与脚本(内容管理、找伙伴、飞书导出、开发文档删除);yongxu 侧重 C 端(@提及、一键收款、推荐码、baseUrl 真实后端)
|
||||
2. **合并策略**:
|
||||
- 管理端、soul-api 新增能力:以 devlop 为主
|
||||
- 小程序:保留 yongxu 的 @提及、推荐码、goBackOrToHome、baseUrl 等,与 devlop 改动手工合并
|
||||
- 开发文档:若需保留,从 yongxu 恢复;若已迁移他处,可沿用 devlop 的删除
|
||||
3. **待确认项**:开发文档最终保留在仓库内还是迁移到外部?合并冲突时以哪边为准(按模块已约定)
|
||||
|
||||
---
|
||||
|
||||
## 待办事项
|
||||
|
||||
| 责任角色 | 任务 | 优先级 | 截止建议 |
|
||||
|---------|------|--------|---------|
|
||||
| 用户 | 确认开发文档保留策略 | 中 | 合并前 |
|
||||
| 用户 | 执行分支合并(如 git merge devlop 或 git merge yongxu) | 高 | 待用户操作 |
|
||||
| 助理橙子 | 合并时协助逐文件解决冲突 | 高 | 用户合并时 |
|
||||
| 测试人员 | 合并后回归测试 | 中 | 合并完成 |
|
||||
|
||||
---
|
||||
|
||||
## 问题与作答区
|
||||
|
||||
| # | 问题 | 责任角色 | 作答 |
|
||||
|---|------|---------|------|
|
||||
| 1 | 开发文档最终保留在仓库内还是迁移到外部? | 用户 | (待补充) |
|
||||
| 2 | 合并时以 devlop 为基准合并 yongxu,还是以 yongxu 为基准合并 devlop? | 用户 | (待补充) |
|
||||
|
||||
---
|
||||
|
||||
## 两分支差异速查表
|
||||
|
||||
| 维度 | devlop(dev 分支) | yongxu(当前分支) |
|
||||
|------|-------------------|---------------------|
|
||||
| 共同祖先 | 90d32a51 | 90d32a51 |
|
||||
| 独有提交数 | 约 200+ | 2 |
|
||||
| 变更文件数 | 248 | 7 |
|
||||
| 开发文档 | 已删除 | 保留 |
|
||||
| 小程序 | 有改动(与 yongxu 重叠) | @提及、推荐码、baseUrl、goBackOrToHome 等 |
|
||||
| 管理端 | ContentPage、FindPartnerPage、UsersPage 等大改 | 改动少 |
|
||||
| soul-api | admin_*、db_*、ckb、match 等扩展 | 免费章节、存客宝限频、退款等 |
|
||||
| 脚本 | 飞书导出、content_upload、Gitea 推送等 | 无 |
|
||||
| 会议纪要 | 合并策略、管理端与 API 分析等 | 代码完整性分析、各成员功能检测 |
|
||||
|
||||
---
|
||||
|
||||
## 各角色经验与业务理解更新
|
||||
|
||||
### 团队共享
|
||||
|
||||
- 分支差异分析会议:先确定共同祖先,再用 `git diff --stat`、`git log` 分模块梳理,便于制定合并策略
|
||||
- 多分支合并时按模块约定「以谁为主」:管理端/soul-api 以 devlop 为主,小程序保留 yongxu 业务逻辑
|
||||
|
||||
---
|
||||
|
||||
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-09.md`*
|
||||
165
.cursor/meeting/2026-03-09_dev分支需求分析与yongxu迁移方案.md
Normal file
165
.cursor/meeting/2026-03-09_dev分支需求分析与yongxu迁移方案.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# dev 分支需求分析与 yongxu 迁移方案
|
||||
|
||||
> 基于用户反馈:dev 分支只改了样式,未考虑后端/API/小程序三端协调,导致 bug 或功能不全;yongxu 分支功能完善。本文档分析 dev 需求、识别三端缺口,并给出「新功能迁入 yongxu + 补全功能」的执行方案。
|
||||
|
||||
---
|
||||
|
||||
## 一、dev 分支变更概览
|
||||
|
||||
### 1.1 变更性质(用户结论)
|
||||
|
||||
- **dev 侧重**:管理端 UI/样式、新页面布局
|
||||
- **问题**:未同步考虑 soul-api、miniprogram 的接口与逻辑,导致:
|
||||
- 管理端新页面调用的接口在 yongxu 的 soul-api 中不存在或不全
|
||||
- 小程序端与 dev 管理端配置/数据模型不一致
|
||||
- 三端联调时出现 bug 或功能不全
|
||||
|
||||
### 1.2 dev 相对 yongxu 的增量(按模块)
|
||||
|
||||
| 模块 | dev 新增/改动 | 依赖的 API/能力 |
|
||||
|------|---------------|-----------------|
|
||||
| **soul-admin** | ContentPage 大改、ChapterTree、RichEditor、@提及、persons、link-tags 配置 | `/api/db/book`(含 section-orders)、`/api/db/persons`、`/api/db/link-tags`、`/api/db/config/full?key=xxx` |
|
||||
| **soul-admin** | FindPartnerPage + 多 Tab(CKBConfigPanel、CKBStatsTab、MatchPoolTab 等) | `/api/db/config/full?key=ckb_config`、`/api/db/ckb-leads` |
|
||||
| **soul-admin** | UsersPage 大改、UserDetailModal 扩展 | `/api/db/users` 已有,可能需扩展字段 |
|
||||
| **soul-admin** | ChaptersPage 新增、DashboardPage 调整 | 依赖 db/book、db/users 等 |
|
||||
| **soul-admin** | DistributionPage、SettingsPage 等样式调整 | 现有 admin 接口 |
|
||||
| **soul-api** | admin_dashboard、admin_rfm、admin_shensheshou、admin_user_rules | 新增 handler |
|
||||
| **soul-api** | db_person、db_book 扩展(section-orders、move-sections) | 扩展 db_book action |
|
||||
| **soul-api** | ckb 扩展、match/match_records 扩展 | 扩展 ckb、match handler |
|
||||
| **miniprogram** | 部分页面简化/样式调整 | 与 yongxu 的 @提及、推荐码等可能冲突 |
|
||||
|
||||
---
|
||||
|
||||
## 二、三端缺口分析(yongxu 当前缺失)
|
||||
|
||||
### 2.1 soul-api 缺口(管理端新页面依赖)
|
||||
|
||||
| 接口/能力 | dev 管理端调用 | yongxu 是否已有 | 缺口说明 |
|
||||
|-----------|---------------|-----------------|----------|
|
||||
| `GET/POST/DELETE /api/db/persons` | ContentPage @提及人物配置 | ❌ 无 | 需新增 persons 表、model、handler |
|
||||
| `GET/POST/DELETE /api/db/link-tags` | ContentPage 链接标签配置 | ❌ 无 | 需新增 link_tags 表、model、handler |
|
||||
| `GET /api/db/book?action=section-orders&id=xxx` | ContentPage 章节内排序 | ❌ 无 | 需在 db_book 中扩展 action |
|
||||
| `PUT /api/db/book` action=move-sections | ContentPage 跨章移动 | ❌ 无 | 需在 db_book 中扩展 |
|
||||
| `GET /api/db/config/full?key=article_ranking_weights` | ContentPage | ✅ 有 | DBConfigGet 已支持 |
|
||||
| `GET /api/db/config/full?key=pinned_section_ids` | ContentPage | ✅ 有 | 同上 |
|
||||
| `GET /api/db/config/full?key=unpaid_preview_percent` | ContentPage | ✅ 有 | 同上 |
|
||||
| `GET /api/db/config/full?key=ckb_config` | FindPartnerPage CKBConfigPanel | ✅ 有 | 同上 |
|
||||
| `GET /api/db/ckb-leads?mode=submitted&page=1&pageSize=50` | FindPartnerPage 存客宝线索 | ❌ 无 | 需新增 ckb_leads 相关接口 |
|
||||
| admin_dashboard、admin_rfm、admin_shensheshou、admin_user_rules | Dashboard、神奢手等 | ❌ 无 | 可选,按业务需要 |
|
||||
|
||||
### 2.2 小程序端
|
||||
|
||||
- **yongxu 已完善**:@提及、推荐码、baseUrl、goBackOrToHome、一键收款、免费章节、存客宝限频、退款等
|
||||
- **策略**:**保留 yongxu 小程序全部逻辑**,不引入 dev 对 miniprogram 的简化(dev 可能删减了功能)
|
||||
|
||||
### 2.3 管理端(soul-admin)
|
||||
|
||||
- **yongxu 已有**:ContentPage、ChapterTree、UsersPage、DashboardPage 等基础版本
|
||||
- **dev 增量**:更丰富的 ContentPage(persons、link-tags、RichEditor @提及)、FindPartnerPage 整页、UsersPage 扩展
|
||||
- **策略**:在 soul-api 补全接口后,再选择性迁入 dev 的页面组件
|
||||
|
||||
---
|
||||
|
||||
## 三、迁移原则
|
||||
|
||||
1. **基准分支**:yongxu(功能完善、三端协调良好)
|
||||
2. **不引入**:dev 对 miniprogram 的改动(避免覆盖 yongxu 的 @提及、推荐码等)
|
||||
3. **分阶段**:先补 soul-api 缺口 → 再迁入 soul-admin 新页面/组件
|
||||
4. **三端协调**:每迁入一个管理端功能,必须确保 soul-api 已有对应接口,且小程序若依赖该配置则已对齐
|
||||
|
||||
---
|
||||
|
||||
## 四、执行方案(按优先级)
|
||||
|
||||
### 阶段 1:soul-api 补全(必须)
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| 1.1 | 新增 `db/persons` | model.Person、表 persons,GET/POST/DELETE,供 ContentPage @提及人物配置 |
|
||||
| 1.2 | 新增 `db/link-tags` | model.LinkTag、表 link_tags,GET/POST/DELETE,供 ContentPage 链接标签 |
|
||||
| 1.3 | 扩展 `db/book` | 增加 action=section-orders、action=move-sections(若 ContentPage 需要) |
|
||||
| 1.4 | 新增 `db/ckb-leads` | 若存在 ckb_leads 表,GET 支持 mode=submitted/contact 分页;否则先建表再实现 |
|
||||
|
||||
### 阶段 2:soul-admin 迁入(在阶段 1 完成后)
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| 2.1 | ContentPage 增强 | 从 dev 迁入 persons、link-tags 配置区,RichEditor @提及(需 persons 接口) |
|
||||
| 2.2 | FindPartnerPage | 从 dev 迁入整页 + Tab(CKBConfigPanel、CKBStatsTab 等),需 ckb_config、ckb-leads 接口 |
|
||||
| 2.3 | UsersPage 扩展 | 从 dev 迁入 UserDetailModal 等扩展(若字段与 yongxu 的 db/users 一致) |
|
||||
| 2.4 | 菜单与路由 | 在 AdminLayout、App.tsx 中增加 FindPartner 入口(若尚未有) |
|
||||
|
||||
### 阶段 3:可选(按业务需要)
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| 3.1 | admin_dashboard、admin_rfm 等 | 若 Dashboard 需要新统计维度,再补充 |
|
||||
| 3.2 | dev 的 scripts、飞书导出等 | 与三端功能无直接关系,可单独评估 |
|
||||
|
||||
---
|
||||
|
||||
## 五、补全功能清单(可直接用于开发)
|
||||
|
||||
### 5.1 soul-api 必须补全
|
||||
|
||||
```
|
||||
[ ] 1. persons 表 + model + GET/POST/DELETE /api/db/persons
|
||||
[ ] 2. link_tags 表 + model + GET/POST/DELETE /api/db/link-tags
|
||||
[ ] 3. db_book 扩展:GET ?action=section-orders&id=xxx
|
||||
[ ] 4. db_book 扩展:PUT action=move-sections(若 ContentPage 需要)
|
||||
[ ] 5. ckb_leads 表(若无)+ GET /api/db/ckb-leads?mode=xxx&page=1&pageSize=50
|
||||
```
|
||||
|
||||
### 5.2 soul-admin 迁入(依赖 5.1)
|
||||
|
||||
```
|
||||
[ ] 6. ContentPage:persons 配置区、link-tags 配置区
|
||||
[ ] 7. ContentPage:RichEditor @提及(调用 persons)
|
||||
[ ] 8. FindPartnerPage + Tabs(CKBConfigPanel、CKBStatsTab、FindPartnerTab 等)
|
||||
[ ] 9. AdminLayout:增加「找伙伴」菜单项(若尚无)
|
||||
[ ] 10. UsersPage:UserDetailModal 扩展(按需)
|
||||
```
|
||||
|
||||
### 5.3 小程序
|
||||
|
||||
```
|
||||
[ ] 无需变更,保留 yongxu 全部逻辑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、迁移操作建议
|
||||
|
||||
### 6.1 不推荐直接 merge devlop
|
||||
|
||||
- dev 与 yongxu 在 miniprogram、部分 soul-api 上存在冲突
|
||||
- 直接 merge 会覆盖 yongxu 的完善功能
|
||||
|
||||
### 6.2 推荐方式
|
||||
|
||||
1. **保持当前在 yongxu 分支**
|
||||
2. **按阶段 1**:在 yongxu 上直接开发 soul-api 补全(persons、link-tags、db_book 扩展、ckb-leads)
|
||||
3. **按阶段 2**:用 `git show devlop:path/to/file` 取出 dev 的 soul-admin 文件,手工合并到 yongxu,并确认调用的接口已在 soul-api 中存在
|
||||
4. **每完成一个功能**:过 soul-change-checklist,做三端联调验证
|
||||
|
||||
### 6.3 冲突文件处理(若必须 diff 参考)
|
||||
|
||||
- `miniprogram/*`:**以 yongxu 为准**,不采纳 dev 的改动
|
||||
- `soul-admin/*`:选择性采纳 dev 的 UI/组件,确保调用的 API 已在 yongxu 的 soul-api 中实现
|
||||
- `soul-api/*`:以 yongxu 为基础,缺什么补什么(persons、link-tags、ckb-leads 等)
|
||||
|
||||
---
|
||||
|
||||
## 七、总结
|
||||
|
||||
| 项目 | 结论 |
|
||||
|------|------|
|
||||
| 基准 | yongxu |
|
||||
| dev 价值 | 管理端 UI 增强(ContentPage、FindPartnerPage、UsersPage) |
|
||||
| dev 问题 | 未配套 soul-api 的 persons、link-tags、ckb-leads 等,导致功能不全 |
|
||||
| 迁移路径 | 先补 soul-api → 再迁入 soul-admin 新页面 |
|
||||
| 小程序 | 不改动,保留 yongxu |
|
||||
|
||||
---
|
||||
|
||||
*文档生成:2026-03-09 | 供开发执行参考*
|
||||
@@ -66,3 +66,5 @@ YYYY-MM-DD_会议主题.md
|
||||
| 2026-03-05 | 超级个体解锁眼睛需求分析 | 产品、小程序 | [2026-03-05_超级个体解锁眼睛需求分析.md](2026-03-05_超级个体解锁眼睛需求分析.md) |
|
||||
| 2026-03-05 | 文章详情 @某人 高亮与一键加好友方案讨论 | 产品、后端、管理端、小程序、测试 | [2026-03-05_文章详情@某人加好友方案讨论.md](2026-03-05_文章详情@某人加好友方案讨论.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) |
|
||||
|
||||
1
Mycontent-temp
Submodule
1
Mycontent-temp
Submodule
Submodule Mycontent-temp added at f00315d785
@@ -8,9 +8,9 @@ const { parseScene } = require('./utils/scene.js')
|
||||
App({
|
||||
globalData: {
|
||||
// API基础地址 - 连接真实后端
|
||||
baseUrl: 'https://soulapi.quwanzhi.com',
|
||||
// baseUrl: 'https://soulapi.quwanzhi.com',
|
||||
// baseUrl: 'https://souldev.quwanzhi.com',
|
||||
// baseUrl: 'http://localhost:8080',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
|
||||
|
||||
// 小程序配置 - 真实AppID
|
||||
@@ -34,6 +34,9 @@ App({
|
||||
// 购买记录
|
||||
purchasedSections: [],
|
||||
hasFullBook: false,
|
||||
// VIP 会员(365天,包含增值版免费;与 hasFullBook=9.9 买断不同)
|
||||
isVip: false,
|
||||
vipExpireDate: '',
|
||||
|
||||
// 已读章节(仅统计有权限打开过的章节,用于首页「已读/待读」)
|
||||
readSectionIds: [],
|
||||
@@ -227,6 +230,8 @@ App({
|
||||
this.globalData.isLoggedIn = true
|
||||
this.globalData.purchasedSections = userInfo.purchasedSections || []
|
||||
this.globalData.hasFullBook = userInfo.hasFullBook || false
|
||||
this.globalData.isVip = userInfo.isVip || false
|
||||
this.globalData.vipExpireDate = userInfo.vipExpireDate || ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查登录状态失败:', e)
|
||||
|
||||
@@ -16,6 +16,7 @@ Page({
|
||||
// 用户状态
|
||||
isLoggedIn: false,
|
||||
hasFullBook: false,
|
||||
isVip: false,
|
||||
purchasedSections: [],
|
||||
|
||||
// 书籍数据 - 完整真实标题
|
||||
@@ -212,6 +213,7 @@ Page({
|
||||
navBarHeight: app.globalData.navBarHeight
|
||||
})
|
||||
this.updateUserStatus()
|
||||
this.loadVipStatus()
|
||||
this.loadChaptersOnce()
|
||||
},
|
||||
|
||||
@@ -258,13 +260,19 @@ Page({
|
||||
})
|
||||
}
|
||||
const ch = part.chapters.get(cid)
|
||||
const isPremium =
|
||||
r.editionPremium === true ||
|
||||
r.edition_premium === true ||
|
||||
r.edition_premium === 1 ||
|
||||
r.edition_premium === '1'
|
||||
ch.sections.push({
|
||||
id: r.id,
|
||||
mid: r.mid ?? r.MID ?? 0,
|
||||
title: r.sectionTitle || r.section_title || r.title || '',
|
||||
isFree: r.isFree === true || (r.price !== undefined && r.price === 0),
|
||||
price: r.price ?? 1,
|
||||
isNew: r.isNew === true || r.is_new === true
|
||||
isNew: r.isNew === true || r.is_new === true,
|
||||
isPremium
|
||||
})
|
||||
})
|
||||
const bookData = Array.from(partMap.values()).map(p => ({
|
||||
@@ -310,12 +318,33 @@ Page({
|
||||
}
|
||||
}
|
||||
this.updateUserStatus()
|
||||
this.loadVipStatus()
|
||||
},
|
||||
|
||||
// 拉取 VIP 状态(isVip=会员,hasFullBook=9.9 买断)
|
||||
async loadVipStatus() {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) return
|
||||
try {
|
||||
const res = await app.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true, timeout: 3000 })
|
||||
if (res?.success) {
|
||||
app.globalData.isVip = !!res.data?.isVip
|
||||
app.globalData.vipExpireDate = res.data?.expireDate || ''
|
||||
this.setData({ isVip: app.globalData.isVip })
|
||||
const userInfo = app.globalData.userInfo || {}
|
||||
userInfo.isVip = app.globalData.isVip
|
||||
userInfo.vipExpireDate = app.globalData.vipExpireDate
|
||||
wx.setStorageSync('userInfo', userInfo)
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败不影响目录展示
|
||||
}
|
||||
},
|
||||
|
||||
// 更新用户状态
|
||||
updateUserStatus() {
|
||||
const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData
|
||||
this.setData({ isLoggedIn, hasFullBook, purchasedSections })
|
||||
const { isLoggedIn, hasFullBook, purchasedSections, isVip } = app.globalData
|
||||
this.setData({ isLoggedIn, hasFullBook, purchasedSections, isVip })
|
||||
},
|
||||
|
||||
// 切换展开状态
|
||||
@@ -335,8 +364,9 @@ Page({
|
||||
},
|
||||
|
||||
// 检查是否已购买
|
||||
hasPurchased(sectionId) {
|
||||
if (this.data.hasFullBook) return true
|
||||
hasPurchased(sectionId, isPremium) {
|
||||
if (this.data.isVip) return true
|
||||
if (!isPremium && this.data.hasFullBook) return true
|
||||
return this.data.purchasedSections.includes(sectionId)
|
||||
},
|
||||
|
||||
|
||||
@@ -73,13 +73,14 @@
|
||||
<block wx:for="{{chapter.sections}}" wx:key="id" wx:for-item="section">
|
||||
<view class="section-item" bindtap="goToRead" data-id="{{section.id}}" data-mid="{{section.mid}}">
|
||||
<view class="section-left">
|
||||
<text class="section-lock {{section.isFree || hasFullBook || purchasedSections.indexOf(section.id) > -1 ? 'lock-open' : 'lock-closed'}}">{{section.isFree || hasFullBook || purchasedSections.indexOf(section.id) > -1 ? '○' : '●'}}</text>
|
||||
<text class="section-title {{section.isFree || hasFullBook || purchasedSections.indexOf(section.id) > -1 ? '' : 'text-muted'}}">{{section.id}} {{section.title}}</text>
|
||||
<text class="section-lock {{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1 ? 'lock-open' : 'lock-closed'}}">{{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1 ? '○' : '●'}}</text>
|
||||
<text class="section-title {{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1 ? '' : 'text-muted'}}">{{section.id}} {{section.title}}</text>
|
||||
<text wx:if="{{section.isNew}}" class="tag tag-new">NEW</text>
|
||||
<text wx:if="{{section.isPremium}}" class="tag tag-vip">增值</text>
|
||||
</view>
|
||||
<view class="section-right">
|
||||
<text wx:if="{{section.isFree}}" class="tag tag-free">免费</text>
|
||||
<text wx:elif="{{hasFullBook || purchasedSections.indexOf(section.id) > -1}}" class="tag tag-purchased">已购</text>
|
||||
<text wx:elif="{{isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1}}" class="tag tag-purchased">已解锁</text>
|
||||
<text wx:else class="section-price">¥{{section.price}}</text>
|
||||
<text class="section-arrow">›</text>
|
||||
</view>
|
||||
|
||||
@@ -232,6 +232,14 @@
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.tag-vip {
|
||||
background: rgba(255, 215, 0, 0.12);
|
||||
color: #FFD700;
|
||||
font-size: 20rpx;
|
||||
padding: 2rpx 8rpx;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.text-brand {
|
||||
color: #00CED1;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ Page({
|
||||
}
|
||||
const d = this._getUnlockData(member.id)
|
||||
if (d[field]) return
|
||||
const isVip = app.globalData.hasFullBook
|
||||
const isVip = app.globalData.isVip
|
||||
const usedFree = this._hasUsedFreeForMember(member.id)
|
||||
if (isVip || !usedFree) {
|
||||
this._addUnlock(member.id, field)
|
||||
|
||||
@@ -742,20 +742,25 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// VIP状态查询(hasFullBook 优先,兼容模拟支付等本地已置 VIP 的情况)
|
||||
// VIP状态查询(注意:hasFullBook=9.9 买断,不等同 VIP)
|
||||
async loadVipStatus() {
|
||||
if (app.globalData.hasFullBook) {
|
||||
this.setData({ isVip: true, vipExpireDate: this.data.vipExpireDate || '' })
|
||||
}
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) return
|
||||
try {
|
||||
const res = await app.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true })
|
||||
if (res?.success) {
|
||||
const isVip = !!res.data?.isVip
|
||||
app.globalData.isVip = isVip
|
||||
app.globalData.vipExpireDate = res.data?.expireDate || ''
|
||||
this.setData({
|
||||
isVip: res.data?.isVip || app.globalData.hasFullBook,
|
||||
isVip,
|
||||
vipExpireDate: res.data?.expireDate || this.data.vipExpireDate || ''
|
||||
})
|
||||
// 同步到 storage,便于其他页面复用(注意:hasFullBook=买断,isVip=会员)
|
||||
const userInfo = app.globalData.userInfo || {}
|
||||
userInfo.isVip = isVip
|
||||
userInfo.vipExpireDate = res.data?.expireDate || ''
|
||||
wx.setStorageSync('userInfo', userInfo)
|
||||
}
|
||||
} catch (e) { console.log('[My] VIP查询失败', e) }
|
||||
},
|
||||
|
||||
1
soul-admin/dist/assets/index-CXOwdtCt.css
vendored
Normal file
1
soul-admin/dist/assets/index-CXOwdtCt.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-DP984f3K.css
vendored
1
soul-admin/dist/assets/index-DP984f3K.css
vendored
File diff suppressed because one or more lines are too long
774
soul-admin/dist/assets/index-DsLRWYbk.js
vendored
Normal file
774
soul-admin/dist/assets/index-DsLRWYbk.js
vendored
Normal file
File diff suppressed because one or more lines are too long
470
soul-admin/dist/assets/index-iq3PeMuM.js
vendored
470
soul-admin/dist/assets/index-iq3PeMuM.js
vendored
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-iq3PeMuM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DP984f3K.css">
|
||||
<script type="module" crossorigin src="/assets/index-DsLRWYbk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CXOwdtCt.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@tiptap/extension-image": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-mention": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-table": "^3.20.1",
|
||||
"@tiptap/extension-table-cell": "^3.20.1",
|
||||
"@tiptap/extension-table-header": "^3.20.1",
|
||||
"@tiptap/extension-table-row": "^3.20.1",
|
||||
"@tiptap/pm": "^3.20.1",
|
||||
"@tiptap/react": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"lucide-react": "0.562.0",
|
||||
|
||||
715
soul-admin/pnpm-lock.yaml
generated
715
soul-admin/pnpm-lock.yaml
generated
@@ -32,6 +32,39 @@ importers:
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: 1.1.13
|
||||
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tiptap/extension-image':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
'@tiptap/extension-mention':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(@tiptap/suggestion@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-table':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
'@tiptap/extension-table-cell':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-table-header':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-table-row':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1
|
||||
'@tiptap/react':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1
|
||||
class-variance-authority:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
@@ -55,7 +88,7 @@ importers:
|
||||
version: 3.4.0
|
||||
zustand:
|
||||
specifier: 5.0.9
|
||||
version: 5.0.9(@types/react@18.3.28)(react@18.3.1)
|
||||
version: 5.0.9(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.15.0
|
||||
@@ -814,6 +847,9 @@ packages:
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@remirror/core-constants@3.0.0':
|
||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||
|
||||
'@remix-run/router@1.23.2':
|
||||
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -1051,6 +1087,199 @@ packages:
|
||||
'@tailwindcss/postcss@4.1.18':
|
||||
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
|
||||
|
||||
'@tiptap/core@3.20.1':
|
||||
resolution: {integrity: sha512-SwkPEWIfaDEZjC8SEIi4kZjqIYUbRgLUHUuQezo5GbphUNC8kM1pi3C3EtoOPtxXrEbY6e4pWEzW54Pcrd+rVA==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@tiptap/extension-blockquote@3.20.1':
|
||||
resolution: {integrity: sha512-WzNXk/63PQI2fav4Ta6P0GmYRyu8Gap1pV3VUqaVK829iJ6Zt1T21xayATHEHWMK27VT1GLPJkx9Ycr2jfDyQw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-bold@3.20.1':
|
||||
resolution: {integrity: sha512-fz++Qv6Rk/Hov0IYG/r7TJ1Y4zWkuGONe0UN5g0KY32NIMg3HeOHicbi4xsNWTm9uAOl3eawWDkezEMrleObMw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.20.1':
|
||||
resolution: {integrity: sha512-XaPvO6aCoWdFnCBus0s88lnj17NR/OopV79i8Qhgz3WMR0vrsL5zsd45l0lZuu9pSvm5VW47SoxakkJiZC1suw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@tiptap/extension-bullet-list@3.20.1':
|
||||
resolution: {integrity: sha512-mbrlvOZo5OF3vLhp+3fk9KuL/6J/wsN0QxF6ZFRAHzQ9NkJdtdfARcBeBnkWXGN8inB6YxbTGY1/E4lmBkOpOw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.1
|
||||
|
||||
'@tiptap/extension-code-block@3.20.1':
|
||||
resolution: {integrity: sha512-vKejwBq+Nlj4Ybd3qOyDxIQKzYymdNH+8eXkKwGShk2nfLJIxq69DCyGvmuHgipIO1qcYPJ149UNpGN+YGcdmA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@tiptap/extension-code@3.20.1':
|
||||
resolution: {integrity: sha512-509DHINIA/Gg+eTG7TEkfsS8RUiPLH5xZNyLRT0A1oaoaJmECKfrV6aAm05IdfTyqDqz6LW5pbnX6DdUC4keug==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-document@3.20.1':
|
||||
resolution: {integrity: sha512-9vrqdGmRV7bQCSY3NLgu7UhIwgOCDp4sKqMNsoNRX0aZ021QQMTvBQDPkiRkCf7MNsnWrNNnr52PVnULEn3vFQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-dropcursor@3.20.1':
|
||||
resolution: {integrity: sha512-K18L9FX4znn+ViPSIbTLOGcIaXMx/gLNwAPE8wPLwswbHhQqdiY1zzdBw6drgOc1Hicvebo2dIoUlSXOZsOEcw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.1
|
||||
|
||||
'@tiptap/extension-floating-menu@3.20.1':
|
||||
resolution: {integrity: sha512-BeDC6nfOesIMn5pFuUnkEjOxGv80sOJ8uk1mdt9/3Fkvra8cB9NIYYCVtd6PU8oQFmJ8vFqPrRkUWrG5tbqnOg==}
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.0.0
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@tiptap/extension-gapcursor@3.20.1':
|
||||
resolution: {integrity: sha512-kZOtttV6Ai8VUAgEng3h4WKFbtdSNJ6ps7r0cRPY+FctWhVmgNb/JJwwyC+vSilR7nRENAhrA/Cv/RxVlvLw+g==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.1
|
||||
|
||||
'@tiptap/extension-hard-break@3.20.1':
|
||||
resolution: {integrity: sha512-9sKpmg/IIdlLXimYWUZ3PplIRcehv4Oc7V1miTqlnAthMzjMqigDkjjgte4JZV67RdnDJTQkRw8TklCAU28Emg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-heading@3.20.1':
|
||||
resolution: {integrity: sha512-unudyfQP6FxnyWinxvPqe/51DG91J6AaJm666RnAubgYMCgym+33kBftx4j4A6qf+ddWYbD00thMNKOnVLjAEQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.20.1':
|
||||
resolution: {integrity: sha512-rjFKFXNntdl0jay8oIGFvvykHlpyQTLmrH3Ag2fj3i8yh6MVvqhtaDomYQbw5sxECd5hBkL+T4n2d2DRuVw/QQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@tiptap/extension-image@3.20.1':
|
||||
resolution: {integrity: sha512-/GPFSLNdYSZQ0E6VBXSAk0UFtDdn98HT1Aa2tO+STELqc5jAdFB42dfFnTC6KQzTvcUOUYkE2S1Q22YC5H2XNQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-italic@3.20.1':
|
||||
resolution: {integrity: sha512-ZYRX13Kt8tR8JOzSXirH3pRpi8x30o7LHxZY58uXBdUvr3tFzOkh03qbN523+diidSVeHP/aMd/+IrplHRkQug==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-link@3.20.1':
|
||||
resolution: {integrity: sha512-oYTTIgsQMqpkSnJAuAc+UtIKMuI4lv9e1y4LfI1iYm6NkEUHhONppU59smhxHLzb3Ww7YpDffbp5IgDTAiJztA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@tiptap/extension-list-item@3.20.1':
|
||||
resolution: {integrity: sha512-tzgnyTW82lYJkUnadYbatwkI9dLz/OWRSWuFpQPRje/ItmFMWuQ9c9NDD8qLbXPdEYnvrgSAA+ipCD/1G0qA0Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.1
|
||||
|
||||
'@tiptap/extension-list-keymap@3.20.1':
|
||||
resolution: {integrity: sha512-Dr0xsQKx0XPOgDg7xqoWwfv7FFwZ3WeF3eOjqh3rDXlNHMj1v+UW5cj1HLphrsAZHTrVTn2C+VWPJkMZrSbpvQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.1
|
||||
|
||||
'@tiptap/extension-list@3.20.1':
|
||||
resolution: {integrity: sha512-euBRAn0mkV7R2VEE+AuOt3R0j9RHEMFXamPFmtvTo8IInxDClusrm6mJoDjS8gCGAXsQCRiAe1SCQBPgGbOOwg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@tiptap/extension-mention@3.20.1':
|
||||
resolution: {integrity: sha512-KOGokj7oH1QpcM8P02V+o6wHsVE0g7XEtdIy2vtq2vlFE3npNNNFkMa8F8VWX6qyC+VeVrNU6SIzS5MFY2TORA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
'@tiptap/suggestion': ^3.20.1
|
||||
|
||||
'@tiptap/extension-ordered-list@3.20.1':
|
||||
resolution: {integrity: sha512-Y+3Ad7OwAdagqdYwCnbqf7/to5ypD4NnUNHA0TXRCs7cAHRA8AdgPoIcGFpaaSpV86oosNU3yfeJouYeroffog==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.1
|
||||
|
||||
'@tiptap/extension-paragraph@3.20.1':
|
||||
resolution: {integrity: sha512-QFrAtXNyv7JSnomMQc1nx5AnG9mMznfbYJAbdOQYVdbLtAzTfiTuNPNbQrufy5ZGtGaHxDCoaygu2QEfzaKG+Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-placeholder@3.20.1':
|
||||
resolution: {integrity: sha512-k+jfbCugYGuIFBdojukgEopGazIMOgHrw46FnyN2X/6ICOIjQP2rh2ObslrsUOsJYoEevxCsNF9hZl1HvWX66g==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.1
|
||||
|
||||
'@tiptap/extension-strike@3.20.1':
|
||||
resolution: {integrity: sha512-EYgyma10lpsY+rwbVQL9u+gA7hBlKLSMFH7Zgd37FSxukOjr+HE8iKPQQ+SwbGejyDsPlLT8Z5Jnuxo5Ng90Pg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-table-cell@3.20.1':
|
||||
resolution: {integrity: sha512-nSeboFey+MJqZuCQSbVUajQVaGiypeoUPiGd0CJdyfc0oPsBGGqp7XFJtw5HHEbDqEvSHvGNDoZDFsUTAxUyIg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-table': ^3.20.1
|
||||
|
||||
'@tiptap/extension-table-header@3.20.1':
|
||||
resolution: {integrity: sha512-Xsdgik3Bh5xWSkLaPR/6NDLZR5lgUDIN/yiZ7bLtfTsyGxqEvuW2Y9/j/XP3AfsZzGy2/SuA9bM9zxPcFQxwFA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-table': ^3.20.1
|
||||
|
||||
'@tiptap/extension-table-row@3.20.1':
|
||||
resolution: {integrity: sha512-uYE0SdY4J81wn/fg8Gum0TVSTTBekQhRGeSwDnRfBBd9JILBsLotBTt4ucM7JOUjrP9BjS+QFCT9EstAchPHfQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-table': ^3.20.1
|
||||
|
||||
'@tiptap/extension-table@3.20.1':
|
||||
resolution: {integrity: sha512-y2o1De3P/FH80nst1D9pYSwtRvxF87WkNAYGurqCkZ6SjwFq1Ifeh9eY+o79R6SCwcfpvQRytCYd+Yw9o5XAtA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@tiptap/extension-text@3.20.1':
|
||||
resolution: {integrity: sha512-7PlIbYW8UenV6NPOXHmv8IcmPGlGx6HFq66RmkJAOJRPXPkTLAiX0N8rQtzUJ6jDEHqoJpaHFEHJw0xzW1yF+A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-underline@3.20.1':
|
||||
resolution: {integrity: sha512-fmHvDKzwCgnZUwRreq8tYkb1YyEwgzZ6QQkAQ0CsCRtvRMqzerr3Duz0Als4i8voZTuGDEL3VR6nAJbLAb/wPg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extensions@3.20.1':
|
||||
resolution: {integrity: sha512-JRc/v+OBH0qLTdvQ7HvHWTxGJH73QOf1MC0R8NhOX2QnAbg2mPFv1h+FjGa2gfLGuCXBdWQomjekWkUKbC4e5A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@tiptap/pm@3.20.1':
|
||||
resolution: {integrity: sha512-6kCiGLvpES4AxcEuOhb7HR7/xIeJWMjZlb6J7e8zpiIh5BoQc7NoRdctsnmFEjZvC19bIasccshHQ7H2zchWqw==}
|
||||
|
||||
'@tiptap/react@3.20.1':
|
||||
resolution: {integrity: sha512-UH1NpVpCaZBGB3Yr5N6aTS+rsCMDl9wHfrt/w+6+Gz4KHFZ2OILA82hELxZzhNc1Lmjz8vgCArKcsYql9gbzJA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tiptap/starter-kit@3.20.1':
|
||||
resolution: {integrity: sha512-opqWxL/4OTEiqmVC0wsU4o3JhAf6LycJ2G/gRIZVAIFLljI9uHfpPMTFGxZ5w9IVVJaP5PJysfwW/635kKqkrw==}
|
||||
|
||||
'@tiptap/suggestion@3.20.1':
|
||||
resolution: {integrity: sha512-ng7olbzgZhWvPJVJygNQK5153CjquR2eJXpkLq7bRjHlahvt4TH4tGFYvGdYZcXuzbe2g9RoqT7NaPGL9CUq9w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
@@ -1069,6 +1298,15 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||
|
||||
'@types/mdurl@2.0.0':
|
||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
@@ -1080,6 +1318,9 @@ packages:
|
||||
'@types/react@18.3.28':
|
||||
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.54.0':
|
||||
resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1225,6 +1466,9 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1258,6 +1502,10 @@ packages:
|
||||
resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
esbuild@0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1327,6 +1575,10 @@ packages:
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-equals@5.4.0:
|
||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-json-stable-stringify@2.1.0:
|
||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||
|
||||
@@ -1530,6 +1782,12 @@ packages:
|
||||
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
linkifyjs@4.3.2:
|
||||
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1552,6 +1810,13 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
hasBin: true
|
||||
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
@@ -1577,6 +1842,9 @@ packages:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1619,6 +1887,68 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prosemirror-changeset@2.4.0:
|
||||
resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==}
|
||||
|
||||
prosemirror-collab@1.3.1:
|
||||
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
|
||||
|
||||
prosemirror-gapcursor@1.4.0:
|
||||
resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==}
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||
|
||||
prosemirror-markdown@1.13.4:
|
||||
resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
|
||||
|
||||
prosemirror-menu@1.3.0:
|
||||
resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==}
|
||||
|
||||
prosemirror-model@1.25.4:
|
||||
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
|
||||
|
||||
prosemirror-schema-basic@1.2.4:
|
||||
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||
|
||||
prosemirror-trailing-node@3.0.0:
|
||||
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
|
||||
peerDependencies:
|
||||
prosemirror-model: ^1.22.1
|
||||
prosemirror-state: ^1.4.2
|
||||
prosemirror-view: ^1.33.8
|
||||
|
||||
prosemirror-transform@1.11.0:
|
||||
resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==}
|
||||
|
||||
prosemirror-view@1.41.6:
|
||||
resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==}
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1688,6 +2018,9 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
@@ -1759,6 +2092,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
@@ -1788,6 +2124,11 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
vite@6.4.1:
|
||||
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -1828,6 +2169,9 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2500,6 +2844,8 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@remirror/core-constants@3.0.0': {}
|
||||
|
||||
'@remix-run/router@1.23.2': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
@@ -2648,6 +2994,219 @@ snapshots:
|
||||
postcss: 8.5.6
|
||||
tailwindcss: 4.1.18
|
||||
|
||||
'@tiptap/core@3.20.1(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.20.1
|
||||
|
||||
'@tiptap/extension-blockquote@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-bold@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-bullet-list@3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-code-block@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
|
||||
'@tiptap/extension-code@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-document@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-dropcursor@3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-floating-menu@3.20.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-gapcursor@3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-hard-break@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-heading@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
|
||||
'@tiptap/extension-image@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-italic@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-link@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
linkifyjs: 4.3.2
|
||||
|
||||
'@tiptap/extension-list-item@3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-list-keymap@3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
|
||||
'@tiptap/extension-mention@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(@tiptap/suggestion@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
'@tiptap/suggestion': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-ordered-list@3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-paragraph@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-placeholder@3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-strike@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-table-cell@3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-table': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-table-header@3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-table': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-table-row@3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-table': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
|
||||
'@tiptap/extension-text@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-underline@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
|
||||
'@tiptap/pm@3.20.1':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.4.0
|
||||
prosemirror-collab: 1.3.1
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-dropcursor: 1.8.2
|
||||
prosemirror-gapcursor: 1.4.0
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-inputrules: 1.5.1
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-markdown: 1.13.4
|
||||
prosemirror-menu: 1.3.0
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-schema-basic: 1.2.4
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
|
||||
'@tiptap/react@3.20.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
'@types/react': 18.3.28
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.28)
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
fast-equals: 5.4.0
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@tiptap/extension-bubble-menu': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
'@tiptap/extension-floating-menu': 3.20.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
transitivePeerDependencies:
|
||||
- '@floating-ui/dom'
|
||||
|
||||
'@tiptap/starter-kit@3.20.1':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/extension-blockquote': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-bold': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-bullet-list': 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-code': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-code-block': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
'@tiptap/extension-document': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-dropcursor': 3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-gapcursor': 3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-hard-break': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-heading': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-horizontal-rule': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
'@tiptap/extension-italic': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-link': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
'@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
'@tiptap/extension-list-item': 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-list-keymap': 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-ordered-list': 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-paragraph': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-strike': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-text': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-underline': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extensions': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
|
||||
'@tiptap/suggestion@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
@@ -2673,6 +3232,15 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
'@types/mdurl': 2.0.0
|
||||
|
||||
'@types/mdurl@2.0.0': {}
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/react-dom@18.3.7(@types/react@18.3.28)':
|
||||
@@ -2684,6 +3252,8 @@ snapshots:
|
||||
'@types/prop-types': 15.7.15
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -2865,6 +3435,8 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -2890,6 +3462,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
@@ -3001,6 +3575,8 @@ snapshots:
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-equals@5.4.0: {}
|
||||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
@@ -3145,6 +3721,12 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.30.2
|
||||
lightningcss-win32-x64-msvc: 1.30.2
|
||||
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
linkifyjs@4.3.2: {}
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -3167,6 +3749,17 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
markdown-it@14.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.0
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
minimatch@3.1.2:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
@@ -3192,6 +3785,8 @@ snapshots:
|
||||
type-check: 0.4.0
|
||||
word-wrap: 1.2.5
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
@@ -3224,6 +3819,111 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prosemirror-changeset@2.4.0:
|
||||
dependencies:
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-collab@1.3.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
|
||||
prosemirror-gapcursor@1.4.0:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.6
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
rope-sequence: 1.3.4
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
prosemirror-markdown@1.13.4:
|
||||
dependencies:
|
||||
'@types/markdown-it': 14.1.2
|
||||
markdown-it: 14.1.1
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-menu@1.3.0:
|
||||
dependencies:
|
||||
crelt: 1.0.6
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-state: 1.4.4
|
||||
|
||||
prosemirror-model@1.25.4:
|
||||
dependencies:
|
||||
orderedmap: 2.1.1
|
||||
|
||||
prosemirror-schema-basic@1.2.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
|
||||
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6):
|
||||
dependencies:
|
||||
'@remirror/core-constants': 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.6
|
||||
|
||||
prosemirror-transform@1.11.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-view@1.41.6:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
react-dom@18.3.1(react@18.3.1):
|
||||
@@ -3310,6 +4010,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.57.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -3366,6 +4068,8 @@ snapshots:
|
||||
|
||||
typescript@5.6.3: {}
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
@@ -3391,6 +4095,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.28
|
||||
|
||||
use-sync-external-store@1.6.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
@@ -3404,6 +4112,8 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.30.2
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -3414,7 +4124,8 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zustand@5.0.9(@types/react@18.3.28)(react@18.3.1):
|
||||
zustand@5.0.9(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)):
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.28
|
||||
react: 18.3.1
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
|
||||
@@ -19,6 +19,7 @@ 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'
|
||||
|
||||
@@ -44,6 +45,7 @@ function App() {
|
||||
<Route path="payment" element={<PaymentPage />} />
|
||||
<Route path="site" element={<SitePage />} />
|
||||
<Route path="qrcodes" element={<QRCodesPage />} />
|
||||
<Route path="find-partner" element={<FindPartnerPage />} />
|
||||
<Route path="match" element={<MatchPage />} />
|
||||
<Route path="match-records" element={<MatchRecordsPage />} />
|
||||
<Route path="api-doc" element={<ApiDocPage />} />
|
||||
|
||||
196
soul-admin/src/components/RichEditor.css
Normal file
196
soul-admin/src/components/RichEditor.css
Normal file
@@ -0,0 +1,196 @@
|
||||
.rich-editor-wrapper {
|
||||
border: 1px solid #374151;
|
||||
border-radius: 0.5rem;
|
||||
background: #0a1628;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rich-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #374151;
|
||||
background: #0f1d32;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #374151;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.rich-editor-toolbar button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.rich-editor-toolbar button:hover { background: #1f2937; color: #d1d5db; }
|
||||
.rich-editor-toolbar button.is-active { background: rgba(56, 189, 172, 0.2); color: #38bdac; }
|
||||
.rich-editor-toolbar button:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.link-tag-select {
|
||||
background: #0a1628;
|
||||
border: 1px solid #374151;
|
||||
color: #d1d5db;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.link-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #374151;
|
||||
background: #0f1d32;
|
||||
}
|
||||
.link-input {
|
||||
flex: 1;
|
||||
background: #0a1628;
|
||||
border: 1px solid #374151;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.link-confirm, .link-remove {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link-confirm { background: #38bdac; color: white; }
|
||||
.link-remove { background: #374151; color: #9ca3af; }
|
||||
|
||||
.rich-editor-content {
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
color: #e5e7eb;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.rich-editor-content:focus { outline: none; }
|
||||
|
||||
.rich-editor-content h1 { font-size: 1.5em; font-weight: 700; margin: 0.8em 0 0.4em; color: white; }
|
||||
.rich-editor-content h2 { font-size: 1.3em; font-weight: 600; margin: 0.7em 0 0.3em; color: white; }
|
||||
.rich-editor-content h3 { font-size: 1.15em; font-weight: 600; margin: 0.6em 0 0.3em; color: white; }
|
||||
.rich-editor-content p { margin: 0.4em 0; }
|
||||
.rich-editor-content strong { color: white; }
|
||||
.rich-editor-content code { background: #1f2937; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; color: #38bdac; }
|
||||
.rich-editor-content pre { background: #1f2937; padding: 12px; border-radius: 6px; overflow-x: auto; margin: 0.6em 0; }
|
||||
.rich-editor-content blockquote {
|
||||
border-left: 3px solid #38bdac;
|
||||
padding-left: 12px;
|
||||
margin: 0.6em 0;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.rich-editor-content ul, .rich-editor-content ol { padding-left: 1.5em; margin: 0.4em 0; }
|
||||
.rich-editor-content li { margin: 0.2em 0; }
|
||||
.rich-editor-content hr { border: none; border-top: 1px solid #374151; margin: 1em 0; }
|
||||
.rich-editor-content img { max-width: 100%; border-radius: 6px; margin: 0.5em 0; }
|
||||
.rich-editor-content a, .rich-link { color: #38bdac; text-decoration: underline; cursor: pointer; }
|
||||
|
||||
.rich-editor-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.rich-editor-content th, .rich-editor-content td {
|
||||
border: 1px solid #374151;
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.rich-editor-content th { background: #1f2937; font-weight: 600; }
|
||||
|
||||
.rich-editor-content .ProseMirror-placeholder::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #6b7280;
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mention-tag {
|
||||
background: rgba(56, 189, 172, 0.15);
|
||||
color: #38bdac;
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mention-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: #1a2638;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
min-width: 180px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
}
|
||||
.mention-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #d1d5db;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mention-item:hover, .mention-item.is-selected {
|
||||
background: rgba(56, 189, 172, 0.15);
|
||||
color: #38bdac;
|
||||
}
|
||||
.mention-name { font-weight: 500; }
|
||||
.mention-id { font-size: 11px; color: #6b7280; }
|
||||
|
||||
.bubble-menu {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: #1a2638;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.bubble-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bubble-menu button:hover { background: #1f2937; color: #d1d5db; }
|
||||
.bubble-menu button.is-active { color: #38bdac; }
|
||||
335
soul-admin/src/components/RichEditor.tsx
Normal file
335
soul-admin/src/components/RichEditor.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
|
||||
import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import {
|
||||
Bold, Italic, Strikethrough, Code, List, ListOrdered, Quote,
|
||||
Heading1, Heading2, Heading3, Image as ImageIcon, Link as LinkIcon,
|
||||
Table as TableIcon, Undo, Redo, Minus,
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface PersonItem {
|
||||
id: string
|
||||
name: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface LinkTagItem {
|
||||
id: string
|
||||
label: string
|
||||
url: string
|
||||
type: 'url' | 'miniprogram' | 'ckb'
|
||||
appId?: string
|
||||
pagePath?: string
|
||||
}
|
||||
|
||||
export interface RichEditorRef {
|
||||
getHTML: () => string
|
||||
getMarkdown: () => string
|
||||
}
|
||||
|
||||
interface RichEditorProps {
|
||||
content: string
|
||||
onChange: (html: string) => void
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
persons?: PersonItem[]
|
||||
linkTags?: LinkTagItem[]
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function htmlToMarkdown(html: string): string {
|
||||
if (!html) return ''
|
||||
let md = html
|
||||
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n')
|
||||
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n')
|
||||
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n')
|
||||
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**')
|
||||
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**')
|
||||
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*')
|
||||
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*')
|
||||
md = md.replace(/<s[^>]*>(.*?)<\/s>/gi, '~~$1~~')
|
||||
md = md.replace(/<del[^>]*>(.*?)<\/del>/gi, '~~$1~~')
|
||||
md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
|
||||
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n')
|
||||
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi, '')
|
||||
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '')
|
||||
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)')
|
||||
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n')
|
||||
md = md.replace(/<\/?[uo]l[^>]*>/gi, '\n')
|
||||
md = md.replace(/<br\s*\/?>/gi, '\n')
|
||||
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n')
|
||||
md = md.replace(/<hr\s*\/?>/gi, '---\n\n')
|
||||
md = md.replace(/<span[^>]*data-type="mention"[^>]*data-id="([^"]*)"[^>]*>@([^<]*)<\/span>/gi, '@$2')
|
||||
md = md.replace(/<span[^>]*data-type="linkTag"[^>]*data-url="([^"]*)"[^>]*>#([^<]*)<\/span>/gi, '#[$2]($1)')
|
||||
md = md.replace(/<[^>]+>/g, '')
|
||||
md = md.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||
md = md.replace(/\n{3,}/g, '\n\n')
|
||||
return md.trim()
|
||||
}
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
if (!md) return ''
|
||||
if (md.startsWith('<') && md.includes('</')) return md
|
||||
let html = md
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
||||
html = html.replace(/^> (.+)$/gm, '<blockquote><p>$1</p></blockquote>')
|
||||
html = html.replace(/^---$/gm, '<hr />')
|
||||
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
const lines = html.split('\n')
|
||||
const result: string[] = []
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
if (/^<(?:h[1-6]|blockquote|hr|li|ul|ol|table|img)/.test(trimmed)) {
|
||||
result.push(trimmed)
|
||||
} else {
|
||||
result.push(`<p>${trimmed}</p>`)
|
||||
}
|
||||
}
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const MentionSuggestion = (persons: PersonItem[]): any => ({
|
||||
items: ({ query }: { query: string }) =>
|
||||
persons.filter(p => p.name.toLowerCase().includes(query.toLowerCase()) || p.id.includes(query)).slice(0, 8),
|
||||
render: () => {
|
||||
let popup: HTMLDivElement | null = null
|
||||
let selectedIndex = 0
|
||||
let items: PersonItem[] = []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let command: ((p: { id: string; label: string }) => void) | null = null
|
||||
|
||||
const update = () => {
|
||||
if (!popup) return
|
||||
popup.innerHTML = items.map((item, i) =>
|
||||
`<div class="mention-item ${i === selectedIndex ? 'is-selected' : ''}" data-index="${i}">
|
||||
<span class="mention-name">@${item.name}</span>
|
||||
<span class="mention-id">${item.label || item.id}</span>
|
||||
</div>`
|
||||
).join('')
|
||||
popup.querySelectorAll('.mention-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const idx = parseInt(el.getAttribute('data-index') || '0')
|
||||
if (command && items[idx]) command({ id: items[idx].id, label: items[idx].name })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onStart: (props: any) => {
|
||||
popup = document.createElement('div')
|
||||
popup.className = 'mention-popup'
|
||||
document.body.appendChild(popup)
|
||||
items = props.items
|
||||
command = props.command
|
||||
selectedIndex = 0
|
||||
update()
|
||||
if (props.clientRect) {
|
||||
const rect = props.clientRect()
|
||||
if (rect) {
|
||||
popup.style.top = `${rect.bottom + 4}px`
|
||||
popup.style.left = `${rect.left}px`
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onUpdate: (props: any) => {
|
||||
items = props.items
|
||||
command = props.command
|
||||
selectedIndex = 0
|
||||
update()
|
||||
if (props.clientRect && popup) {
|
||||
const rect = props.clientRect()
|
||||
if (rect) {
|
||||
popup.style.top = `${rect.bottom + 4}px`
|
||||
popup.style.left = `${rect.left}px`
|
||||
}
|
||||
}
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === 'ArrowUp') { selectedIndex = Math.max(0, selectedIndex - 1); update(); return true }
|
||||
if (props.event.key === 'ArrowDown') { selectedIndex = Math.min(items.length - 1, selectedIndex + 1); update(); return true }
|
||||
if (props.event.key === 'Enter') { if (command && items[selectedIndex]) command({ id: items[selectedIndex].id, label: items[selectedIndex].name }); return true }
|
||||
if (props.event.key === 'Escape') { popup?.remove(); popup = null; return true }
|
||||
return false
|
||||
},
|
||||
onExit: () => { popup?.remove(); popup = null },
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
|
||||
content,
|
||||
onChange,
|
||||
onImageUpload,
|
||||
persons = [],
|
||||
linkTags = [],
|
||||
placeholder = '开始编辑内容...',
|
||||
className,
|
||||
}, ref) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [showLinkInput, setShowLinkInput] = useState(false)
|
||||
const initialContent = useRef(markdownToHtml(content))
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Image.configure({ inline: true, allowBase64: true }),
|
||||
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'rich-link' } }),
|
||||
Mention.configure({
|
||||
HTMLAttributes: { class: 'mention-tag' },
|
||||
suggestion: MentionSuggestion(persons),
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: true }),
|
||||
TableRow, TableCell, TableHeader,
|
||||
],
|
||||
content: initialContent.current,
|
||||
onUpdate: ({ editor: ed }: { editor: Editor }) => {
|
||||
onChange(ed.getHTML())
|
||||
},
|
||||
editorProps: {
|
||||
attributes: { class: 'rich-editor-content' },
|
||||
},
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getHTML: () => editor?.getHTML() || '',
|
||||
getMarkdown: () => htmlToMarkdown(editor?.getHTML() || ''),
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
const html = markdownToHtml(content)
|
||||
if (html !== editor.getHTML()) {
|
||||
editor.commands.setContent(html)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [content])
|
||||
|
||||
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !editor) return
|
||||
if (onImageUpload) {
|
||||
const url = await onImageUpload(file)
|
||||
if (url) editor.chain().focus().setImage({ src: url }).run()
|
||||
} else {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') editor.chain().focus().setImage({ src: reader.result }).run()
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
e.target.value = ''
|
||||
}, [editor, onImageUpload])
|
||||
|
||||
const insertLinkTag = useCallback((tag: LinkTagItem) => {
|
||||
if (!editor) return
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'text',
|
||||
marks: [{ type: 'link', attrs: { href: tag.url, target: '_blank' } }],
|
||||
text: `#${tag.label}`,
|
||||
}).run()
|
||||
}, [editor])
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor || !linkUrl) return
|
||||
editor.chain().focus().setLink({ href: linkUrl }).run()
|
||||
setLinkUrl('')
|
||||
setShowLinkInput(false)
|
||||
}, [editor, linkUrl])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<div className={`rich-editor-wrapper ${className || ''}`}>
|
||||
<div className="rich-editor-toolbar">
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => editor.chain().focus().toggleBold().run()} className={editor.isActive('bold') ? 'is-active' : ''} type="button"><Bold className="w-4 h-4" /></button>
|
||||
<button onClick={() => editor.chain().focus().toggleItalic().run()} className={editor.isActive('italic') ? 'is-active' : ''} type="button"><Italic className="w-4 h-4" /></button>
|
||||
<button onClick={() => editor.chain().focus().toggleStrike().run()} className={editor.isActive('strike') ? 'is-active' : ''} type="button"><Strikethrough className="w-4 h-4" /></button>
|
||||
<button onClick={() => editor.chain().focus().toggleCode().run()} className={editor.isActive('code') ? 'is-active' : ''} type="button"><Code className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="toolbar-divider" />
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''} type="button"><Heading1 className="w-4 h-4" /></button>
|
||||
<button onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} type="button"><Heading2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''} type="button"><Heading3 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="toolbar-divider" />
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => editor.chain().focus().toggleBulletList().run()} className={editor.isActive('bulletList') ? 'is-active' : ''} type="button"><List className="w-4 h-4" /></button>
|
||||
<button onClick={() => editor.chain().focus().toggleOrderedList().run()} className={editor.isActive('orderedList') ? 'is-active' : ''} type="button"><ListOrdered className="w-4 h-4" /></button>
|
||||
<button onClick={() => editor.chain().focus().toggleBlockquote().run()} className={editor.isActive('blockquote') ? 'is-active' : ''} type="button"><Quote className="w-4 h-4" /></button>
|
||||
<button onClick={() => editor.chain().focus().setHorizontalRule().run()} type="button"><Minus className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="toolbar-divider" />
|
||||
<div className="toolbar-group">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleImageUpload} className="hidden" />
|
||||
<button onClick={() => fileInputRef.current?.click()} type="button"><ImageIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => setShowLinkInput(!showLinkInput)} className={editor.isActive('link') ? 'is-active' : ''} type="button"><LinkIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} type="button"><TableIcon className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="toolbar-divider" />
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} type="button"><Undo className="w-4 h-4" /></button>
|
||||
<button onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} type="button"><Redo className="w-4 h-4" /></button>
|
||||
</div>
|
||||
{linkTags.length > 0 && (
|
||||
<>
|
||||
<div className="toolbar-divider" />
|
||||
<div className="toolbar-group">
|
||||
<select
|
||||
className="link-tag-select"
|
||||
onChange={(e) => {
|
||||
const tag = linkTags.find(t => t.id === e.target.value)
|
||||
if (tag) insertLinkTag(tag)
|
||||
e.target.value = ''
|
||||
}}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled># 插入链接标签</option>
|
||||
{linkTags.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showLinkInput && (
|
||||
<div className="link-input-bar">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="输入链接地址..."
|
||||
value={linkUrl}
|
||||
onChange={e => setLinkUrl(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addLink()}
|
||||
className="link-input"
|
||||
/>
|
||||
<button onClick={addLink} className="link-confirm" type="button">确定</button>
|
||||
<button onClick={() => { editor.chain().focus().unsetLink().run(); setShowLinkInput(false) }} className="link-remove" type="button">移除</button>
|
||||
</div>
|
||||
)}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
RichEditor.displayName = 'RichEditor'
|
||||
export default RichEditor
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,10 +10,11 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
User,
|
||||
Phone,
|
||||
History,
|
||||
MapPin,
|
||||
RefreshCw,
|
||||
Link2,
|
||||
BookOpen,
|
||||
@@ -24,6 +25,13 @@ import {
|
||||
Save,
|
||||
X,
|
||||
Tag,
|
||||
Zap,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
Crown,
|
||||
Key,
|
||||
Navigation,
|
||||
Smartphone,
|
||||
} from 'lucide-react'
|
||||
import { get, put, post } from '@/api/client'
|
||||
|
||||
@@ -60,6 +68,12 @@ interface UserDetail {
|
||||
vipProject?: string | null
|
||||
vipContact?: string | null
|
||||
vipBio?: string | null
|
||||
vipRole?: string | null
|
||||
// 扩展字段
|
||||
mbti?: string
|
||||
region?: string
|
||||
industry?: string
|
||||
position?: string
|
||||
}
|
||||
|
||||
interface UserTrack {
|
||||
@@ -72,6 +86,14 @@ interface UserTrack {
|
||||
timeAgo: string
|
||||
}
|
||||
|
||||
interface ShensheShouData {
|
||||
rfm_score?: number
|
||||
user_level?: string
|
||||
tags?: string[]
|
||||
last_active?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export function UserDetailModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -90,8 +112,41 @@ export function UserDetailModal({
|
||||
const [editTags, setEditTags] = useState<string[]>([])
|
||||
const [newTag, setNewTag] = useState('')
|
||||
|
||||
// 修改密码
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||
|
||||
// 设成超级个体(VIP)
|
||||
const [vipForm, setVipForm] = useState({ isVip: false, vipExpireDate: '', vipRole: '', vipName: '', vipProject: '', vipContact: '', vipBio: '' })
|
||||
const [vipRoles, setVipRoles] = useState<{ id: number; name: string }[]>([])
|
||||
const [vipSaving, setVipSaving] = useState(false)
|
||||
|
||||
// 用户资料完善(神射手)
|
||||
const [sssLoading, setSssLoading] = useState(false)
|
||||
const [sssData, setSssData] = useState<ShensheShouData | null>(null)
|
||||
const [sssError, setSssError] = useState<string | null>(null)
|
||||
const [sssQueryPhone, setSssQueryPhone] = useState('')
|
||||
const [sssQueryWechatId, setSssQueryWechatId] = useState('')
|
||||
const [sssQueryOpenId, setSssQueryOpenId] = useState('')
|
||||
const [batchIngestLoading, setBatchIngestLoading] = useState(false)
|
||||
const [batchIngestResult, setBatchIngestResult] = useState<Record<string, unknown> | null>(null)
|
||||
const [ckbWechatOwner, setCkbWechatOwner] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open && userId) loadUserDetail()
|
||||
if (open && userId) {
|
||||
setActiveTab('info')
|
||||
setSssData(null)
|
||||
setSssError(null)
|
||||
setBatchIngestResult(null)
|
||||
setCkbWechatOwner('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
loadUserDetail()
|
||||
get<{ success?: boolean; data?: { id: number; name: string }[] }>('/api/db/vip-roles').then((r) => {
|
||||
if (r?.success && (r as { data?: { id: number; name: string }[] }).data) setVipRoles((r as { data: { id: number; name: string }[] }).data)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [open, userId])
|
||||
|
||||
async function loadUserDetail() {
|
||||
@@ -106,24 +161,38 @@ export function UserDetailModal({
|
||||
setUser(u)
|
||||
setEditPhone(u.phone || '')
|
||||
setEditNickname(u.nickname || '')
|
||||
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
|
||||
setSssQueryPhone(u.phone || '')
|
||||
setSssQueryWechatId(u.wechatId || '')
|
||||
setSssQueryOpenId(u.openId || '')
|
||||
try {
|
||||
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
|
||||
} catch {
|
||||
setEditTags([])
|
||||
}
|
||||
setVipForm({
|
||||
isVip: !!(u.isVip ?? false),
|
||||
vipExpireDate: u.vipExpireDate ? String(u.vipExpireDate).slice(0, 10) : '',
|
||||
vipRole: String(u.vipRole ?? ''),
|
||||
vipName: String(u.vipName ?? ''),
|
||||
vipProject: String(u.vipProject ?? ''),
|
||||
vipContact: String(u.vipContact ?? ''),
|
||||
vipBio: String(u.vipBio ?? ''),
|
||||
})
|
||||
}
|
||||
// 行为轨迹(用户旅程)
|
||||
try {
|
||||
const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>(
|
||||
`/api/user/track?userId=${encodeURIComponent(userId)}&limit=50`,
|
||||
)
|
||||
if (trackData?.success && trackData.tracks) setTracks(trackData.tracks)
|
||||
} catch {
|
||||
setTracks([])
|
||||
}
|
||||
} catch { setTracks([]) }
|
||||
// 关系链路
|
||||
try {
|
||||
const refData = await get<{ success?: boolean; referrals?: unknown[] }>(
|
||||
`/api/db/users/referrals?userId=${encodeURIComponent(userId)}`,
|
||||
)
|
||||
if (refData?.success && refData.referrals) setReferrals(refData.referrals)
|
||||
} catch {
|
||||
setReferrals([])
|
||||
}
|
||||
} catch { setReferrals([]) }
|
||||
} catch (e) {
|
||||
console.error('Load user detail error:', e)
|
||||
} finally {
|
||||
@@ -132,10 +201,7 @@ export function UserDetailModal({
|
||||
}
|
||||
|
||||
async function handleSyncCKB() {
|
||||
if (!user?.phone) {
|
||||
alert('用户未绑定手机号,无法同步')
|
||||
return
|
||||
}
|
||||
if (!user?.phone) { alert('用户未绑定手机号,无法同步'); return }
|
||||
setSyncing(true)
|
||||
try {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/ckb/sync', {
|
||||
@@ -143,12 +209,8 @@ export function UserDetailModal({
|
||||
phone: user.phone,
|
||||
userId: user.id,
|
||||
})
|
||||
if (data?.success) {
|
||||
alert('同步成功')
|
||||
loadUserDetail()
|
||||
} else {
|
||||
alert('同步失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
if (data?.success) { alert('同步成功'); loadUserDetail() }
|
||||
else alert('同步失败: ' + (data as { error?: string })?.error)
|
||||
} catch (e) {
|
||||
console.error('Sync CKB error:', e)
|
||||
alert('同步失败')
|
||||
@@ -190,8 +252,115 @@ export function UserDetailModal({
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setEditTags(editTags.filter((t) => t !== tag))
|
||||
const removeTag = (tag: string) => setEditTags(editTags.filter((t) => t !== tag))
|
||||
|
||||
async function handleSavePassword() {
|
||||
if (!user) return
|
||||
if (!newPassword) { alert('请输入新密码'); return }
|
||||
if (newPassword !== confirmPassword) { alert('两次密码不一致'); return }
|
||||
if (newPassword.length < 6) { alert('密码至少 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) }
|
||||
}
|
||||
|
||||
async function handleSaveVip() {
|
||||
if (!user) return
|
||||
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) { alert('开启 VIP 请填写有效到期日'); return }
|
||||
setVipSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
id: user.id,
|
||||
isVip: vipForm.isVip,
|
||||
vipExpireDate: vipForm.isVip ? vipForm.vipExpireDate : undefined,
|
||||
vipRole: vipForm.vipRole || undefined,
|
||||
vipName: vipForm.vipName || undefined,
|
||||
vipProject: vipForm.vipProject || undefined,
|
||||
vipContact: vipForm.vipContact || undefined,
|
||||
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) }
|
||||
}
|
||||
|
||||
// 用户资料完善查询(支持多维度)
|
||||
async function handleSSSQuery() {
|
||||
if (!sssQueryPhone && !sssQueryOpenId && !sssQueryWechatId) {
|
||||
setSssError('请至少输入手机号、微信号或 OpenID 中的一项')
|
||||
return
|
||||
}
|
||||
setSssLoading(true)
|
||||
setSssError(null)
|
||||
setSssData(null)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (sssQueryPhone) params.set('phone', sssQueryPhone)
|
||||
if (sssQueryOpenId) params.set('openId', sssQueryOpenId)
|
||||
if (sssQueryWechatId) params.set('wechatId', sssQueryWechatId)
|
||||
const data = await get<{ success?: boolean; data?: ShensheShouData; error?: string }>(
|
||||
`/api/admin/shensheshou/query?${params}`,
|
||||
)
|
||||
if (data?.success && data.data) {
|
||||
setSssData(data.data)
|
||||
// 自动回填到用户信息
|
||||
if (user) await handleSSSEnrich(data.data)
|
||||
}
|
||||
else setSssError(data?.error || '未查询到数据,该用户可能未在神射手收录')
|
||||
} catch (e) {
|
||||
console.error('SSS query error:', e)
|
||||
setSssError('请求失败,请检查神射手接口配置')
|
||||
} finally {
|
||||
setSssLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询后自动回填用户基础信息
|
||||
async function handleSSSEnrich(_sssResult?: ShensheShouData) {
|
||||
if (!user) return
|
||||
try {
|
||||
await post('/api/admin/shensheshou/enrich', {
|
||||
userId: user.id,
|
||||
phone: sssQueryPhone || user.phone || '',
|
||||
openId: sssQueryOpenId || user.openId || '',
|
||||
wechatId: sssQueryWechatId || user.wechatId || '',
|
||||
})
|
||||
loadUserDetail()
|
||||
} catch (e) {
|
||||
console.error('SSS enrich error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 神射手 - 将当前用户信息推送/同步到神射手
|
||||
async function handleSSSIngest() {
|
||||
if (!user) return
|
||||
setBatchIngestLoading(true)
|
||||
setBatchIngestResult(null)
|
||||
try {
|
||||
const payload = {
|
||||
users: [{
|
||||
phone: user.phone || '',
|
||||
name: user.nickname || '',
|
||||
openId: user.openId || '',
|
||||
tags: editTags,
|
||||
}]
|
||||
}
|
||||
const data = await post<{ success?: boolean; data?: Record<string, unknown>; error?: string }>(
|
||||
'/api/admin/shensheshou/ingest',
|
||||
payload,
|
||||
)
|
||||
if (data?.success && data.data) setBatchIngestResult(data.data)
|
||||
else setBatchIngestResult({ error: data?.error || '推送失败' })
|
||||
} catch (e) {
|
||||
console.error('SSS ingest error:', e)
|
||||
setBatchIngestResult({ error: '请求失败' })
|
||||
} finally {
|
||||
setBatchIngestLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
@@ -204,8 +373,10 @@ export function UserDetailModal({
|
||||
share: Link2,
|
||||
bind_phone: Phone,
|
||||
bind_wechat: MessageCircle,
|
||||
fill_profile: Tag,
|
||||
visit_page: Navigation,
|
||||
}
|
||||
const Icon = icons[action] || History
|
||||
const Icon = icons[action] || Clock
|
||||
return <Icon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
@@ -218,9 +389,8 @@ export function UserDetailModal({
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-[#38bdac]" />
|
||||
用户详情
|
||||
{user?.phone && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 ml-2">已绑定手机</Badge>
|
||||
)}
|
||||
{user?.phone && <Badge className="bg-green-500/20 text-green-400 border-0 ml-2">已绑定手机</Badge>}
|
||||
{user?.isVip && <Badge className="bg-amber-500/20 text-amber-400 border-0">VIP</Badge>}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -230,55 +400,66 @@ export function UserDetailModal({
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : user ? (
|
||||
<div className="flex flex-col h-[70vh]">
|
||||
<div className="flex items-center gap-4 p-4 bg-[#0a1628] rounded-lg mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-2xl text-[#38bdac]">
|
||||
<div className="flex flex-col h-[75vh]">
|
||||
{/* 用户头部信息 */}
|
||||
<div className="flex items-center gap-4 p-4 bg-[#0a1628] rounded-lg mb-3">
|
||||
<div className="w-16 h-16 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-2xl text-[#38bdac] shrink-0">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
user.nickname?.charAt(0) || '?'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-lg font-bold text-white">{user.nickname}</h3>
|
||||
{user.isAdmin && (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-0">管理员</Badge>
|
||||
)}
|
||||
{user.hasFullBook && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">全书已购</Badge>
|
||||
)}
|
||||
{user.isAdmin && <Badge className="bg-purple-500/20 text-purple-400 border-0">管理员</Badge>}
|
||||
{user.hasFullBook && <Badge className="bg-green-500/20 text-green-400 border-0">全书已购</Badge>}
|
||||
{user.vipRole && <Badge className="bg-amber-500/20 text-amber-400 border-0">{user.vipRole}</Badge>}
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{user.phone ? `📱 ${user.phone}` : '未绑定手机'}
|
||||
{user.wechatId && ` · 💬 ${user.wechatId}`}
|
||||
{user.mbti && ` · ${user.mbti}`}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
ID: {user.id} · 推广码: {user.referralCode ?? '-'}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<p className="text-gray-600 text-xs">ID: {user.id.slice(0, 16)}…</p>
|
||||
{user.referralCode && (
|
||||
<p className="text-xs">
|
||||
<span className="text-gray-500">推广码:</span>
|
||||
<code className="text-[#38bdac] bg-[#38bdac]/10 px-1.5 py-0.5 rounded">{user.referralCode}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[#38bdac] font-bold">¥{(user.earnings || 0).toFixed(2)}</p>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-[#38bdac] font-bold text-lg">¥{(user.earnings || 0).toFixed(2)}</p>
|
||||
<p className="text-gray-500 text-xs">累计收益</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
|
||||
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-1 mb-4">
|
||||
<TabsTrigger value="info" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-1 mb-3 flex-wrap h-auto gap-1">
|
||||
<TabsTrigger value="info" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
|
||||
基础信息
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
<TabsTrigger value="tags" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
|
||||
标签体系
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tracks" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
行为轨迹
|
||||
<TabsTrigger value="journey" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
|
||||
<Navigation className="w-3 h-3 mr-1" />
|
||||
用户旅程
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="relations" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
<TabsTrigger value="relations" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
|
||||
关系链路
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="shensheshou" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
用户资料完善
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ===== 基础信息 ===== */}
|
||||
<TabsContent value="info" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -300,6 +481,36 @@ export function UserDetailModal({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 详细信息展示 */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{user.openId && (
|
||||
<div className="p-3 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-1">微信 OpenID</p>
|
||||
<p className="text-gray-300 font-mono text-xs break-all">{user.openId}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.region && (
|
||||
<div className="p-3 bg-[#0a1628] rounded-lg flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs">地区</p>
|
||||
<p className="text-white">{user.region}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user.industry && (
|
||||
<div className="p-3 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-1">行业</p>
|
||||
<p className="text-white">{user.industry}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.position && (
|
||||
<div className="p-3 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-1">职位</p>
|
||||
<p className="text-white">{user.position}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-400 text-sm">推荐人数</p>
|
||||
@@ -318,6 +529,149 @@ export function UserDetailModal({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷操作:修改密码 & 设成超级个体 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg border border-gray-700/50">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Key className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-white font-medium">修改密码</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#162840] border-gray-700 text-white"
|
||||
placeholder="新密码(至少6位)"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#162840] border-gray-700 text-white"
|
||||
placeholder="确认密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSavePassword}
|
||||
disabled={passwordSaving || !newPassword || !confirmPassword}
|
||||
className="bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/40"
|
||||
>
|
||||
{passwordSaving ? '保存中...' : '确认修改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg border border-amber-500/20">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Crown className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-white font-medium">设成超级个体</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-400 text-sm">VIP 会员</Label>
|
||||
<Switch
|
||||
checked={vipForm.isVip}
|
||||
onCheckedChange={(c) => setVipForm((f) => ({ ...f, isVip: c }))}
|
||||
/>
|
||||
</div>
|
||||
{vipForm.isVip && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-400 text-xs">到期日</Label>
|
||||
<Input
|
||||
type="date"
|
||||
className="bg-[#162840] border-gray-700 text-white text-sm"
|
||||
value={vipForm.vipExpireDate}
|
||||
onChange={(e) => setVipForm((f) => ({ ...f, vipExpireDate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-gray-400 text-xs">角色</Label>
|
||||
<select
|
||||
className="w-full bg-[#162840] border border-gray-700 text-white rounded px-2 py-1.5 text-sm"
|
||||
value={vipForm.vipRole}
|
||||
onChange={(e) => setVipForm((f) => ({ ...f, vipRole: e.target.value }))}
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{vipRoles.map((r) => (
|
||||
<option key={r.id} value={r.name}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-gray-400 text-xs">展示名</Label>
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white text-sm"
|
||||
placeholder="创业老板排行展示名"
|
||||
value={vipForm.vipName}
|
||||
onChange={(e) => setVipForm((f) => ({ ...f, vipName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveVip}
|
||||
disabled={vipSaving}
|
||||
className="bg-amber-500/20 hover:bg-amber-500/30 text-amber-400 border border-amber-500/40"
|
||||
>
|
||||
{vipSaving ? '保存中...' : '保存 VIP'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIP 信息(只读展示) */}
|
||||
{user.isVip && (
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg border border-amber-500/20">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Crown className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-white font-medium">VIP 信息</span>
|
||||
<Badge className="bg-amber-500/20 text-amber-400 border-0 text-xs">{user.vipRole || 'VIP'}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{user.vipName && <div><span className="text-gray-500">展示名:</span><span className="text-white">{user.vipName}</span></div>}
|
||||
{user.vipProject && <div><span className="text-gray-500">项目:</span><span className="text-white">{user.vipProject}</span></div>}
|
||||
{user.vipContact && <div><span className="text-gray-500">联系方式:</span><span className="text-white">{user.vipContact}</span></div>}
|
||||
{user.vipExpireDate && <div><span className="text-gray-500">到期时间:</span><span className="text-white">{new Date(user.vipExpireDate).toLocaleDateString()}</span></div>}
|
||||
</div>
|
||||
{user.vipBio && <p className="text-gray-400 text-sm mt-2">{user.vipBio}</p>}
|
||||
</div>
|
||||
)}
|
||||
{/* 微信归属(存客宝) */}
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg border border-purple-500/20">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Smartphone className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white font-medium">微信归属</span>
|
||||
<span className="text-gray-500 text-xs">该用户归属在哪个微信号下</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white flex-1"
|
||||
placeholder="输入归属微信号(如 wxid_xxxx)"
|
||||
value={ckbWechatOwner}
|
||||
onChange={(e) => setCkbWechatOwner(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!ckbWechatOwner || !user) return
|
||||
try {
|
||||
await put('/api/db/users', { id: user.id, wechatId: ckbWechatOwner })
|
||||
alert('已保存微信归属')
|
||||
loadUserDetail()
|
||||
} catch { alert('保存失败') }
|
||||
}}
|
||||
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-400 border border-purple-500/30 shrink-0"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" /> 保存
|
||||
</Button>
|
||||
</div>
|
||||
{user.wechatId && (
|
||||
<p className="text-gray-500 text-xs mt-2">当前归属:<span className="text-purple-400">{user.wechatId}</span></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 存客宝同步 */}
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -331,13 +685,9 @@ export function UserDetailModal({
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 同步中...
|
||||
</>
|
||||
<><RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 同步中...</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1" /> 同步数据
|
||||
</>
|
||||
<><RefreshCw className="w-4 h-4 mr-1" /> 同步数据</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -360,54 +710,134 @@ export function UserDetailModal({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 标签体系 ===== */}
|
||||
<TabsContent value="tags" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Tag className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">系统标签</span>
|
||||
<span className="text-white font-medium">用户标签</span>
|
||||
<span className="text-gray-500 text-xs">基于《一场 Soul 的创业实验》维度打标</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{editTags.map((tag, i) => (
|
||||
<Badge key={i} className="bg-[#38bdac]/20 text-[#38bdac] border-0 pr-1">
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} className="ml-1 hover:text-red-400">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
{/* 自动打标说明 */}
|
||||
<div className="mb-3 p-2.5 bg-[#38bdac]/5 border border-[#38bdac]/20 rounded-lg flex items-center gap-2 text-xs text-gray-400">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
|
||||
命中的标签自动高亮 · 系统根据行为轨迹和填写资料自动打标 · 手动点击补充或取消
|
||||
</div>
|
||||
{/* 预设标签分类 */}
|
||||
<div className="mb-4 space-y-3">
|
||||
{[
|
||||
{
|
||||
category: '身份类型',
|
||||
tags: ['创业者', '打工人', '自由职业', '学生', '投资人', '合伙人'],
|
||||
},
|
||||
{
|
||||
category: '行业背景',
|
||||
tags: ['电商', '内容', '传统行业', '科技/AI', '金融', '教育', '餐饮'],
|
||||
},
|
||||
{
|
||||
category: '痛点标签',
|
||||
tags: ['找资源', '找方向', '找合伙人', '想赚钱', '想学习', '找情感出口'],
|
||||
},
|
||||
{
|
||||
category: '付费意愿',
|
||||
tags: ['高意向', '已付费', '观望中', '薅羊毛'],
|
||||
},
|
||||
{
|
||||
category: 'MBTI',
|
||||
tags: ['ENTJ', 'INTJ', 'ENFP', 'INFP', 'ENTP', 'INTP', 'ESTJ', 'ISFJ'],
|
||||
},
|
||||
].map((group) => (
|
||||
<div key={group.category}>
|
||||
<p className="text-gray-500 text-xs mb-1.5">{group.category}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.tags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (editTags.includes(tag)) removeTag(tag)
|
||||
else setEditTags([...editTags, tag])
|
||||
}}
|
||||
className={`px-2 py-0.5 rounded text-xs border transition-all ${
|
||||
editTags.includes(tag)
|
||||
? 'bg-[#38bdac]/20 border-[#38bdac]/50 text-[#38bdac]'
|
||||
: 'bg-transparent border-gray-700 text-gray-500 hover:border-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{editTags.includes(tag) ? '✓ ' : ''}{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{editTags.length === 0 && <span className="text-gray-500 text-sm">暂无标签</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white flex-1"
|
||||
placeholder="添加新标签"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag()}
|
||||
/>
|
||||
<Button onClick={addTag} className="bg-[#38bdac] hover:bg-[#2da396]">
|
||||
添加
|
||||
</Button>
|
||||
<div className="border-t border-gray-700/50 pt-3">
|
||||
<p className="text-gray-500 text-xs mb-2">已选标签</p>
|
||||
<div className="flex flex-wrap gap-2 mb-3 min-h-[32px]">
|
||||
{editTags.map((tag, i) => (
|
||||
<Badge key={i} className="bg-[#38bdac]/20 text-[#38bdac] border-0 pr-1">
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} className="ml-1 hover:text-red-400">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{editTags.length === 0 && <span className="text-gray-600 text-sm">暂未选择标签</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white flex-1"
|
||||
placeholder="自定义标签(回车添加)"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag()}
|
||||
/>
|
||||
<Button onClick={addTag} className="bg-[#38bdac] hover:bg-[#2da396]">添加</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 存客宝标签 */}
|
||||
{user.ckbTags && (
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Tag className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white font-medium">存客宝标签</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(typeof user.ckbTags === 'string' ? user.ckbTags.split(',') : []).map((tag, i) => (
|
||||
<Badge key={i} className="bg-purple-500/20 text-purple-400 border-0">{tag.trim()}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tracks" className="flex-1 overflow-auto">
|
||||
{/* ===== 用户旅程(原行为轨迹)===== */}
|
||||
<TabsContent value="journey" className="flex-1 overflow-auto">
|
||||
<div className="mb-3 p-3 bg-[#0a1628] rounded-lg flex items-center gap-2">
|
||||
<Navigation className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-gray-400 text-sm">记录用户从注册到付费的完整行动路径,共 {tracks.length} 条记录</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tracks.length > 0 ? (
|
||||
tracks.map((track) => (
|
||||
tracks.map((track, idx) => (
|
||||
<div key={track.id} className="flex items-start gap-3 p-3 bg-[#0a1628] rounded-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-[#38bdac]">
|
||||
{getActionIcon(track.action)}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-[#38bdac]">
|
||||
{getActionIcon(track.action)}
|
||||
</div>
|
||||
{idx < tracks.length - 1 && (
|
||||
<div className="w-0.5 h-4 bg-gray-700/50 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium">{track.actionLabel}</span>
|
||||
{track.chapterTitle && (
|
||||
<span className="text-gray-400 text-sm">- {track.chapterTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
<p className="text-gray-500 text-xs mt-0.5">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
{track.timeAgo} · {new Date(track.createdAt).toLocaleString()}
|
||||
</p>
|
||||
@@ -416,13 +846,15 @@ export function UserDetailModal({
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<History className="w-10 h-10 text-[#38bdac]/40 mx-auto mb-4" />
|
||||
<p className="text-gray-400">暂无行为轨迹</p>
|
||||
<Navigation className="w-10 h-10 text-[#38bdac]/40 mx-auto mb-4" />
|
||||
<p className="text-gray-400">暂无用户旅程记录</p>
|
||||
<p className="text-gray-600 text-sm mt-1">当用户浏览章节、购买或完善信息时会自动记录</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 关系链路 ===== */}
|
||||
<TabsContent value="relations" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -432,7 +864,7 @@ export function UserDetailModal({
|
||||
</div>
|
||||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">共 {referrals.length} 人</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
<div className="space-y-2 max-h-[250px] overflow-y-auto">
|
||||
{referrals.length > 0 ? (
|
||||
referrals.map((ref: unknown, i: number) => {
|
||||
const r = ref as { id?: string; nickname?: string; status?: string; createdAt?: string }
|
||||
@@ -461,9 +893,142 @@ export function UserDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 用户资料完善 ===== */}
|
||||
<TabsContent value="shensheshou" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Zap className="w-5 h-5 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">用户资料完善</span>
|
||||
<span className="text-gray-500 text-xs">通过多维度查询神射手数据,自动回填用户基础信息</span>
|
||||
</div>
|
||||
{/* 多维度查询输入 */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<div>
|
||||
<Label className="text-gray-500 text-xs mb-1 block">手机号</Label>
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white"
|
||||
placeholder="11位手机号"
|
||||
value={sssQueryPhone}
|
||||
onChange={(e) => setSssQueryPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-500 text-xs mb-1 block">微信号</Label>
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white"
|
||||
placeholder="微信 ID"
|
||||
value={sssQueryWechatId}
|
||||
onChange={(e) => setSssQueryWechatId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-gray-500 text-xs mb-1 block">微信 OpenID</Label>
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white"
|
||||
placeholder="openid_xxxx(自动填入)"
|
||||
value={sssQueryOpenId}
|
||||
onChange={(e) => setSssQueryOpenId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSSSQuery}
|
||||
disabled={sssLoading}
|
||||
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{sssLoading ? (
|
||||
<><RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 查询并自动回填中...</>
|
||||
) : (
|
||||
<><Search className="w-4 h-4 mr-1" /> 查询并自动完善用户资料</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-gray-600 text-xs mt-2">查询成功后,神射手返回的标签将自动同步到该用户</p>
|
||||
{sssError && (
|
||||
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{sssError}
|
||||
</div>
|
||||
)}
|
||||
{sssData && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-[#162840] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-1">神射手 RFM 分</p>
|
||||
<p className="text-2xl font-bold text-[#38bdac]">{sssData.rfm_score ?? '-'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[#162840] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-1">用户等级</p>
|
||||
<p className="text-2xl font-bold text-white">{sssData.user_level ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{sssData.tags && sssData.tags.length > 0 && (
|
||||
<div className="p-3 bg-[#162840] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-2">神射手标签</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sssData.tags.map((tag, i) => (
|
||||
<Badge key={i} className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/20">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sssData.last_active && (
|
||||
<div className="text-sm text-gray-500">
|
||||
最近活跃:{sssData.last_active}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 推送到神射手 */}
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Zap className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white font-medium">推送用户数据到神射手</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs">将本用户信息(手机号、昵称、标签等)同步至神射手,自动完善用户画像</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSSSIngest}
|
||||
disabled={batchIngestLoading || !user.phone}
|
||||
variant="outline"
|
||||
className="border-purple-500/40 text-purple-400 hover:bg-purple-500/10 bg-transparent shrink-0 ml-4"
|
||||
>
|
||||
{batchIngestLoading ? (
|
||||
<><RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 推送中</>
|
||||
) : (
|
||||
<><Zap className="w-4 h-4 mr-1" /> 推送</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{!user.phone && (
|
||||
<p className="text-yellow-500/70 text-xs">⚠ 用户未绑定手机号,无法推送</p>
|
||||
)}
|
||||
{batchIngestResult && (
|
||||
<div className="mt-3 p-3 bg-[#162840] rounded-lg text-sm">
|
||||
{batchIngestResult.error ? (
|
||||
<p className="text-red-400">{String(batchIngestResult.error)}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-green-400 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-4 h-4" /> 推送成功
|
||||
</p>
|
||||
{batchIngestResult.enriched !== undefined && (
|
||||
<p className="text-gray-400">自动补全标签数:{String(batchIngestResult.new_tags_added ?? 0)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-gray-700 mt-4">
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-gray-700 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ShieldCheck,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Handshake,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
import { clearAdminToken } from '@/api/auth'
|
||||
@@ -34,6 +35,7 @@ const moreMenuItems = [
|
||||
{ icon: GraduationCap, label: '导师管理', href: '/mentors' },
|
||||
{ icon: Calendar, label: '导师预约', href: '/mentor-consultations' },
|
||||
{ icon: Wallet, label: '推广中心', href: '/distribution' },
|
||||
{ icon: Handshake, label: '找伙伴', href: '/find-partner' },
|
||||
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
|
||||
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
|
||||
]
|
||||
|
||||
337
soul-admin/src/pages/chapters/ChaptersPage.tsx
Normal file
337
soul-admin/src/pages/chapters/ChaptersPage.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
interface Section {
|
||||
id: string
|
||||
title: string
|
||||
price: number
|
||||
isFree: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Chapter {
|
||||
id: string
|
||||
title: string
|
||||
sections?: Section[]
|
||||
price?: number
|
||||
isFree?: boolean
|
||||
status?: string
|
||||
}
|
||||
|
||||
interface Part {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
chapters: Chapter[]
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
totalSections: number
|
||||
freeSections: number
|
||||
paidSections: number
|
||||
totalParts: number
|
||||
}
|
||||
|
||||
export function ChaptersPage() {
|
||||
const [structure, setStructure] = useState<Part[]>([])
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedParts, setExpandedParts] = useState<string[]>([])
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null)
|
||||
const [editPrice, setEditPrice] = useState<number>(1)
|
||||
|
||||
async function loadChapters() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; data?: { structure?: Part[]; stats?: Stats } }>(
|
||||
'/api/admin/chapters',
|
||||
)
|
||||
if (data?.success && data.data) {
|
||||
setStructure(data.data.structure ?? [])
|
||||
setStats(data.data.stats ?? null)
|
||||
} else {
|
||||
setError('加载章节失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载章节失败:', e)
|
||||
setError('加载失败,请检查网络后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadChapters()
|
||||
}, [])
|
||||
|
||||
const togglePart = (partId: string) => {
|
||||
setExpandedParts((prev) =>
|
||||
prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId],
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpdatePrice = async (sectionId: string) => {
|
||||
try {
|
||||
const result = await post<{ success?: boolean }>('/api/admin/chapters', {
|
||||
action: 'updatePrice',
|
||||
chapterId: sectionId,
|
||||
data: { price: editPrice },
|
||||
})
|
||||
if (result?.success) {
|
||||
alert('价格更新成功')
|
||||
setEditingSection(null)
|
||||
loadChapters()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('更新价格失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleFree = async (sectionId: string, currentFree: boolean) => {
|
||||
try {
|
||||
const result = await post<{ success?: boolean }>('/api/admin/chapters', {
|
||||
action: 'toggleFree',
|
||||
chapterId: sectionId,
|
||||
data: { isFree: !currentFree },
|
||||
})
|
||||
if (result?.success) {
|
||||
alert('状态更新成功')
|
||||
loadChapters()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('更新状态失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center">
|
||||
<div className="text-xl text-gray-400">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
{/* 导航栏 */}
|
||||
<div className="sticky top-0 bg-black/90 backdrop-blur border-b border-white/10 z-50">
|
||||
<div className="w-full min-w-[1024px] px-4 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">章节管理</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadChapters}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white disabled:opacity-50"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedParts(structure.map((p) => p.id))}
|
||||
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white"
|
||||
>
|
||||
展开全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedParts([])}
|
||||
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white"
|
||||
>
|
||||
收起全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full min-w-[1024px] px-4 py-8">
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-gradient-to-br from-cyan-500/20 to-cyan-500/5 border border-cyan-500/30 rounded-xl p-4">
|
||||
<div className="text-3xl font-bold text-cyan-400">{stats.totalSections}</div>
|
||||
<div className="text-white/60 text-sm mt-1">总章节数</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-500/20 to-green-500/5 border border-green-500/30 rounded-xl p-4">
|
||||
<div className="text-3xl font-bold text-green-400">{stats.freeSections}</div>
|
||||
<div className="text-white/60 text-sm mt-1">免费章节</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-yellow-500/20 to-yellow-500/5 border border-yellow-500/30 rounded-xl p-4">
|
||||
<div className="text-3xl font-bold text-yellow-400">{stats.paidSections}</div>
|
||||
<div className="text-white/60 text-sm mt-1">付费章节</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/30 rounded-xl p-4">
|
||||
<div className="text-3xl font-bold text-purple-400">{stats.totalParts}</div>
|
||||
<div className="text-white/60 text-sm mt-1">篇章数</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 章节列表 */}
|
||||
<div className="space-y-4">
|
||||
{structure.map((part) => (
|
||||
<div
|
||||
key={part.id}
|
||||
className="bg-white/5 border border-white/10 rounded-xl overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5"
|
||||
onClick={() => togglePart(part.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && togglePart(part.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">
|
||||
{part.type === 'preface'
|
||||
? '📖'
|
||||
: part.type === 'epilogue'
|
||||
? '🎬'
|
||||
: part.type === 'appendix'
|
||||
? '📎'
|
||||
: '📚'}
|
||||
</span>
|
||||
<span className="font-semibold text-white">{part.title}</span>
|
||||
<span className="text-white/40 text-sm">
|
||||
({part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} 节)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-white/40">{expandedParts.includes(part.id) ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{expandedParts.includes(part.id) && (
|
||||
<div className="border-t border-white/10">
|
||||
{part.chapters.map((chapter) => (
|
||||
<div
|
||||
key={chapter.id}
|
||||
className="border-b border-white/5 last:border-b-0"
|
||||
>
|
||||
{chapter.sections ? (
|
||||
<>
|
||||
<div className="px-6 py-3 bg-white/5 text-white/70 font-medium">
|
||||
{chapter.title}
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{chapter.sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="flex items-center justify-between px-6 py-3 hover:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
section.isFree ? 'text-green-400' : 'text-yellow-400'
|
||||
}
|
||||
>
|
||||
{section.isFree ? '🔓' : '🔒'}
|
||||
</span>
|
||||
<span className="text-white/80">{section.id}</span>
|
||||
<span className="text-white/60">{section.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{editingSection === section.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editPrice}
|
||||
onChange={(e) => setEditPrice(Number(e.target.value))}
|
||||
className="w-20 px-2 py-1 bg-white/10 border border-white/20 rounded text-white"
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdatePrice(section.id)}
|
||||
className="px-3 py-1 bg-cyan-500 text-black rounded text-sm"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingSection(null)}
|
||||
className="px-3 py-1 bg-white/20 rounded text-sm text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
section.isFree
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{section.isFree ? '免费' : `¥${section.price}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingSection(section.id)
|
||||
setEditPrice(section.price)
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20 text-white"
|
||||
>
|
||||
编辑价格
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleToggleFree(section.id, section.isFree)
|
||||
}
|
||||
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20 text-white"
|
||||
>
|
||||
{section.isFree ? '设为付费' : '设为免费'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-between px-6 py-3 hover:bg-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
chapter.isFree ? 'text-green-400' : 'text-yellow-400'
|
||||
}
|
||||
>
|
||||
{chapter.isFree ? '🔓' : '🔒'}
|
||||
</span>
|
||||
<span className="text-white/80">{chapter.title}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
chapter.isFree
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{chapter.isFree ? '免费' : `¥${chapter.price ?? 1}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,23 @@
|
||||
/**
|
||||
/**
|
||||
* 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序
|
||||
* 整行可拖拽;节和章可跨篇
|
||||
*/
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ChevronRight, ChevronDown, BookOpen, Eye, Edit3, Trash2, GripVertical, Plus } from 'lucide-react'
|
||||
import { ChevronRight, ChevronDown, BookOpen, Edit3, Trash2, GripVertical, Plus, Star } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const PART_LABELS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
|
||||
// 固定模块:序言首位、附录/尾声末位,不可拖拽
|
||||
const FIXED_PART_KEYS = ['序言', '尾声', '附录']
|
||||
function isFixedPart(title: string): boolean {
|
||||
if (!title) return false
|
||||
return FIXED_PART_KEYS.some((k) => title.includes(k))
|
||||
}
|
||||
|
||||
export interface SectionItem {
|
||||
id: string
|
||||
title: string
|
||||
price: number
|
||||
isFree?: boolean
|
||||
isNew?: boolean
|
||||
clickCount?: number
|
||||
payCount?: number
|
||||
hotScore?: number
|
||||
hotRank?: number
|
||||
}
|
||||
|
||||
export interface ChapterItem {
|
||||
@@ -52,8 +49,18 @@ interface ChapterTreeProps {
|
||||
onReadSection: (s: SectionItem) => void
|
||||
onDeleteSection: (s: SectionItem) => void
|
||||
onAddSectionInPart?: (part: PartItem) => void
|
||||
onAddChapterInPart?: (part: PartItem) => void
|
||||
onDeleteChapter?: (part: PartItem, chapter: ChapterItem) => void
|
||||
onEditPart?: (part: PartItem) => void
|
||||
onDeletePart?: (part: PartItem) => void
|
||||
onEditChapter?: (part: PartItem, chapter: ChapterItem) => void
|
||||
/** 批量移动:勾选章节 */
|
||||
selectedSectionIds?: string[]
|
||||
onToggleSectionSelect?: (sectionId: string) => void
|
||||
/** 查看某节的付款记录 */
|
||||
onShowSectionOrders?: (s: SectionItem) => void
|
||||
/** 置顶章节ID列表 */
|
||||
pinnedSectionIds?: string[]
|
||||
}
|
||||
|
||||
export function ChapterTree({
|
||||
@@ -64,8 +71,15 @@ export function ChapterTree({
|
||||
onReadSection,
|
||||
onDeleteSection,
|
||||
onAddSectionInPart,
|
||||
onAddChapterInPart,
|
||||
onDeleteChapter,
|
||||
onEditPart,
|
||||
onDeletePart,
|
||||
onEditChapter,
|
||||
selectedSectionIds = [],
|
||||
onToggleSectionSelect,
|
||||
onShowSectionOrders,
|
||||
pinnedSectionIds = [],
|
||||
}: ChapterTreeProps) {
|
||||
const [draggingItem, setDraggingItem] = useState<{ type: DragType; id: string } | null>(null)
|
||||
const [dragOverTarget, setDragOverTarget] = useState<{ type: DragType; id: string } | null>(null)
|
||||
@@ -106,20 +120,7 @@ export function ChapterTree({
|
||||
const sections = buildSectionsList()
|
||||
const sectionMap = new Map(sections.map((x) => [x.id, x]))
|
||||
|
||||
// 固定模块(序言、附录、尾声)不可拖拽,也不可作为落点
|
||||
if (from.type === 'section') {
|
||||
const sec = sectionMap.get(from.id)
|
||||
if (sec && isFixedPart(sec.partTitle)) return
|
||||
} else {
|
||||
const part = from.type === 'part' ? parts.find((p) => p.id === from.id) : parts.find((p) => p.chapters.some((c) => c.id === from.id))
|
||||
if (part && isFixedPart(part.title)) return
|
||||
}
|
||||
if (toContext && isFixedPart(toContext.partTitle)) return
|
||||
if (toType === 'part') {
|
||||
const toPart = parts.find((p) => p.id === toId)
|
||||
if (toPart && isFixedPart(toPart.title)) return
|
||||
}
|
||||
|
||||
// 所有篇/章/节均可拖拽与作为落点(与后台顺序一致)
|
||||
if (from.type === 'part' && toType === 'part') {
|
||||
const partOrder = parts.map((p) => p.id)
|
||||
const fromIdx = partOrder.indexOf(from.id)
|
||||
@@ -272,25 +273,49 @@ export function ChapterTree({
|
||||
const isXuYan = part.title === '序言' || part.title.includes('序言')
|
||||
const isWeiSheng = part.title === '尾声' || part.title.includes('尾声')
|
||||
const isFuLu = part.title === '附录' || part.title.includes('附录')
|
||||
const partDragOver = isDragOver('part', part.id)
|
||||
const isExpanded = expandedParts.includes(part.id)
|
||||
const chapterCount = part.chapters.length
|
||||
const sectionCount = part.chapters.reduce((s, ch) => s + ch.sections.length, 0)
|
||||
|
||||
// 序言:单行卡片样式,固定首位不可拖拽
|
||||
// 序言:单行卡片,带六点拖拽、可拖可放
|
||||
if (isXuYan && part.chapters.length === 1 && part.chapters[0].sections.length === 1) {
|
||||
const sec = part.chapters[0].sections[0]
|
||||
const secDragOver = isDragOver('section', sec.id)
|
||||
const ctx = { partId: part.id, partTitle: part.title, chapterId: part.chapters[0].id, chapterTitle: part.chapters[0].title }
|
||||
return (
|
||||
<div
|
||||
key={part.id}
|
||||
className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingItem({ type: 'section', id: sec.id })
|
||||
}}
|
||||
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
|
||||
className={`rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors cursor-grab active:cursor-grabbing select-none min-h-[40px] ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : ''} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
|
||||
{...droppableHandlers('section', sec.id, ctx)}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0 select-none">
|
||||
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
|
||||
{onToggleSectionSelect && (
|
||||
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSectionIds.includes(sec.id)}
|
||||
onChange={() => onToggleSectionSelect(sec.id)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-600/50 flex items-center justify-center shrink-0">
|
||||
<BookOpen className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<span className="font-medium text-gray-200 truncate">
|
||||
{part.chapters[0].title} | {sec.title}
|
||||
</span>
|
||||
{pinnedSectionIds.includes(sec.id) && <span title="已置顶"><Star className="w-3.5 h-3.5 text-amber-400 fill-amber-400 shrink-0" /></span>}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 shrink-0"
|
||||
@@ -302,52 +327,287 @@ export function ChapterTree({
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">¥{sec.price}</span>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] text-gray-500">点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)}</span>
|
||||
<span className="text-[10px] text-amber-400/90" title="热度积分与排名">热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名</span>
|
||||
{onShowSectionOrders && (
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
|
||||
付款记录
|
||||
</Button>
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 附录:平铺章节列表,固定末位不可拖拽
|
||||
// 2026每日派对干货:独立篇章,带六点拖拽、可拖可放
|
||||
const is2026Daily = part.title === '2026每日派对干货' || part.title.includes('2026每日派对干货')
|
||||
if (is2026Daily) {
|
||||
const partDragOver = isDragOver('part', part.id)
|
||||
return (
|
||||
<div
|
||||
key={part.id}
|
||||
className={`rounded-xl border overflow-hidden transition-all duration-200 ${partDragOver ? 'border-[#38bdac] ring-2 ring-[#38bdac]/40 bg-[#38bdac]/5' : 'border-gray-700/50 bg-[#1C1C1E]'}`}
|
||||
{...droppableHandlers('part', part.id, {
|
||||
partId: part.id,
|
||||
partTitle: part.title,
|
||||
chapterId: part.chapters[0]?.id ?? '',
|
||||
chapterTitle: part.chapters[0]?.title ?? '',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.setData('text/plain', 'part:' + part.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingItem({ type: 'part', id: part.id })
|
||||
}}
|
||||
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
|
||||
className={`flex items-center justify-between p-4 cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${isDragging('part', part.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : 'hover:bg-[#162840]/50'}`}
|
||||
onClick={() => onTogglePart(part.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
|
||||
<div className="w-10 h-10 rounded-xl bg-[#38bdac]/80 flex items-center justify-center text-white font-bold shrink-0">
|
||||
派
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-base">{part.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">共 {sectionCount} 节</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 shrink-0"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onAddSectionInPart && (
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onAddSectionInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="在本篇下新增章节">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onEditPart && (
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onEditPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑篇名">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onDeletePart && (
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeletePart(part)} className="text-gray-500 hover:text-red-400 h-7 px-2" title="删除本篇">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{chapterCount}章</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && part.chapters.length > 0 && (
|
||||
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
|
||||
{part.chapters.map((chapter) => (
|
||||
<div key={chapter.id} className="space-y-2">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<p className="text-xs text-gray-500 pb-1 flex-1">{chapter.title}</p>
|
||||
<div className="flex gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{onEditChapter && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onEditChapter(part, chapter)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑章节名称">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onAddChapterInPart && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onAddChapterInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="新增第X章">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onDeleteChapter && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onDeleteChapter(part, chapter)} className="text-gray-500 hover:text-red-400 h-7 px-1.5" title="删除本章">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 pl-2">
|
||||
{chapter.sections.map((section) => {
|
||||
const secDragOver = isDragOver('section', section.id)
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.setData('text/plain', 'section:' + section.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingItem({ type: 'section', id: section.id })
|
||||
}}
|
||||
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
|
||||
className={`flex items-center justify-between py-2 px-3 rounded-lg min-h-[40px] cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', section.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
|
||||
{...droppableHandlers('section', section.id, {
|
||||
partId: part.id,
|
||||
partTitle: part.title,
|
||||
chapterId: chapter.id,
|
||||
chapterTitle: chapter.title,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
|
||||
{onToggleSectionSelect && (
|
||||
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSectionIds.includes(section.id)}
|
||||
onChange={() => onToggleSectionSelect(section.id)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<span className="text-sm text-gray-200 truncate">{section.id} {section.title}</span>
|
||||
{pinnedSectionIds.includes(section.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] text-gray-500">点击 {(section.clickCount ?? 0)} · 付款 {(section.payCount ?? 0)}</span>
|
||||
<span className="text-[10px] text-amber-400/90" title="热度积分与排名">热度 {(section.hotScore ?? 0).toFixed(1)} · 第{(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}名</span>
|
||||
{onShowSectionOrders && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(section)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
|
||||
付款记录
|
||||
</Button>
|
||||
)}
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(section)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 附录:平铺章节列表,每节带六点拖拽、可拖可放
|
||||
if (isFuLu) {
|
||||
return (
|
||||
<div key={part.id} className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-5">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-4">附录</h3>
|
||||
<div className="space-y-3">
|
||||
{part.chapters.map((ch, chIdx) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="flex justify-between items-center py-2 select-none hover:bg-[#162840]/50 rounded px-2 -mx-2"
|
||||
>
|
||||
<span className="text-sm text-gray-300">附录{chIdx + 1} | {ch.title}</span>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
{part.chapters.map((ch, chIdx) =>
|
||||
ch.sections.length > 0
|
||||
? ch.sections.map((sec) => {
|
||||
const secDragOver = isDragOver('section', sec.id)
|
||||
return (
|
||||
<div
|
||||
key={sec.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingItem({ type: 'section', id: sec.id })
|
||||
}}
|
||||
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
|
||||
className={`flex justify-between items-center py-2 select-none rounded px-2 -mx-2 group cursor-grab active:cursor-grabbing min-h-[40px] transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
|
||||
{...droppableHandlers('section', sec.id, {
|
||||
partId: part.id,
|
||||
partTitle: part.title,
|
||||
chapterId: ch.id,
|
||||
chapterTitle: ch.title,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
|
||||
{onToggleSectionSelect && (
|
||||
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSectionIds.includes(sec.id)}
|
||||
onChange={() => onToggleSectionSelect(sec.id)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<span className="text-sm text-gray-300 truncate">附录{chIdx + 1} | {ch.title} | {sec.title}</span>
|
||||
{pinnedSectionIds.includes(sec.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] text-gray-500">点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)}</span>
|
||||
<span className="text-[10px] text-amber-400/90" title="热度积分与排名">热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名</span>
|
||||
{onShowSectionOrders && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
|
||||
付款记录
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
: (
|
||||
<div key={ch.id} className="flex justify-between items-center py-2 select-none hover:bg-[#162840]/50 rounded px-2 -mx-2">
|
||||
<span className="text-sm text-gray-500">附录{chIdx + 1} | {ch.title}(空)</span>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 尾声:固定末位不可拖拽
|
||||
// 尾声:单节,带六点拖拽、可拖可放
|
||||
if (isWeiSheng && part.chapters.length === 1 && part.chapters[0].sections.length === 1) {
|
||||
const sec = part.chapters[0].sections[0]
|
||||
const secDragOver = isDragOver('section', sec.id)
|
||||
const ctx = { partId: part.id, partTitle: part.title, chapterId: part.chapters[0].id, chapterTitle: part.chapters[0].title }
|
||||
return (
|
||||
<div
|
||||
key={part.id}
|
||||
className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingItem({ type: 'section', id: sec.id })
|
||||
}}
|
||||
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
|
||||
className={`rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors cursor-grab active:cursor-grabbing select-none min-h-[40px] ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : ''} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
|
||||
{...droppableHandlers('section', sec.id, ctx)}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0 select-none">
|
||||
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
|
||||
{onToggleSectionSelect && (
|
||||
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSectionIds.includes(sec.id)}
|
||||
onChange={() => onToggleSectionSelect(sec.id)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-600/50 flex items-center justify-center shrink-0">
|
||||
<BookOpen className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
@@ -365,45 +625,87 @@ export function ChapterTree({
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">¥{sec.price}</span>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] text-gray-500">点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)}</span>
|
||||
<span className="text-[10px] text-amber-400/90" title="热度积分与排名">热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名</span>
|
||||
{onShowSectionOrders && (
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
|
||||
付款记录
|
||||
</Button>
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isWeiSheng) {
|
||||
// 尾声多章节:平铺展示,不可拖拽
|
||||
// 尾声多章节:平铺展示,每节带六点拖拽、可拖可放
|
||||
return (
|
||||
<div key={part.id} className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-5">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-4">尾声</h3>
|
||||
<div className="space-y-3">
|
||||
{part.chapters.map((ch) =>
|
||||
ch.sections.map((sec) => (
|
||||
<div key={sec.id} className="flex justify-between items-center py-2 select-none hover:bg-[#162840]/50 rounded px-2 -mx-2">
|
||||
<span className="text-sm text-gray-300">{ch.title} | {sec.title}</span>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
ch.sections.map((sec) => {
|
||||
const secDragOver = isDragOver('section', sec.id)
|
||||
return (
|
||||
<div
|
||||
key={sec.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingItem({ type: 'section', id: sec.id })
|
||||
}}
|
||||
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
|
||||
className={`flex justify-between items-center py-2 select-none rounded px-2 -mx-2 cursor-grab active:cursor-grabbing min-h-[40px] transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
|
||||
{...droppableHandlers('section', sec.id, {
|
||||
partId: part.id,
|
||||
partTitle: part.title,
|
||||
chapterId: ch.id,
|
||||
chapterTitle: ch.title,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
|
||||
{onToggleSectionSelect && (
|
||||
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSectionIds.includes(sec.id)}
|
||||
onChange={() => onToggleSectionSelect(sec.id)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<span className="text-sm text-gray-300">{ch.title} | {sec.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] text-gray-500">点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)}</span>
|
||||
<span className="text-[10px] text-amber-400/90" title="热度积分与排名">热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名</span>
|
||||
{onShowSectionOrders && (
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
|
||||
付款记录
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)),
|
||||
)
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,7 +713,6 @@ export function ChapterTree({
|
||||
}
|
||||
|
||||
// 普通篇:卡片 + 章/节
|
||||
const partDragOver = isDragOver('part', part.id)
|
||||
return (
|
||||
<div
|
||||
key={part.id}
|
||||
@@ -476,10 +777,11 @@ export function ChapterTree({
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
|
||||
{part.chapters.map((chapter, chIndex) => {
|
||||
{part.chapters.map((chapter) => {
|
||||
const chDragOver = isDragOver('chapter', chapter.id)
|
||||
return (
|
||||
<div key={chapter.id} className="space-y-2">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
@@ -510,10 +812,28 @@ export function ChapterTree({
|
||||
if (from.type === 'section') handleDrop(e, 'chapter', chapter.id, ctx)
|
||||
else if (from.type === 'chapter') handleDrop(e, 'chapter', chapter.id, ctx)
|
||||
}}
|
||||
className={`py-2 px-2 rounded cursor-grab active:cursor-grabbing select-none -mx-2 transition-all duration-200 flex items-center gap-2 ${chDragOver ? 'bg-[#38bdac]/15 ring-1 ring-[#38bdac]/50' : ''} ${isDragging('chapter', chapter.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : 'hover:bg-[#162840]/30'}`}
|
||||
className={`flex-1 min-w-0 py-2 px-2 rounded cursor-grab active:cursor-grabbing select-none -mx-2 transition-all duration-200 flex items-center gap-2 ${chDragOver ? 'bg-[#38bdac]/15 ring-1 ring-[#38bdac]/50' : ''} ${isDragging('chapter', chapter.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : 'hover:bg-[#162840]/30'}`}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
|
||||
<p className="text-xs text-gray-500 pb-1">第{chIndex + 1}章 | {chapter.title}</p>
|
||||
<p className="text-xs text-gray-500 pb-1 flex-1">{chapter.title}</p>
|
||||
</div>
|
||||
<div className="flex gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{onEditChapter && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onEditChapter(part, chapter)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑章节名称">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onAddChapterInPart && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onAddChapterInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="新增第X章">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onDeleteChapter && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onDeleteChapter(part, chapter)} className="text-gray-500 hover:text-red-400 h-7 px-1.5" title="删除本章">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 pl-2">
|
||||
{chapter.sections.map((section) => {
|
||||
@@ -538,6 +858,16 @@ export function ChapterTree({
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{onToggleSectionSelect && (
|
||||
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSectionIds.includes(section.id)}
|
||||
onChange={() => onToggleSectionSelect(section.id)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${section.price === 0 || section.isFree ? 'border-2 border-[#38bdac] bg-transparent' : 'bg-gray-500'}`}
|
||||
@@ -545,6 +875,7 @@ export function ChapterTree({
|
||||
<span className="text-sm text-gray-200 truncate">
|
||||
{section.id} {section.title}
|
||||
</span>
|
||||
{pinnedSectionIds.includes(section.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 shrink-0"
|
||||
@@ -559,18 +890,21 @@ export function ChapterTree({
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">¥{section.price}</span>
|
||||
)}
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] text-gray-500" title="点击次数 · 付款笔数">点击 {(section.clickCount ?? 0)} · 付款 {(section.payCount ?? 0)}</span>
|
||||
<span className="text-[10px] text-amber-400/90" title="热度积分与排名">热度 {(section.hotScore ?? 0).toFixed(1)} · 第{(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}名</span>
|
||||
{onShowSectionOrders && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(section)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5 shrink-0">
|
||||
付款记录
|
||||
</Button>
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
|
||||
)}
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(section)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
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'
|
||||
import { get } from '@/api/client'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
|
||||
interface OrderRow {
|
||||
id: string
|
||||
@@ -28,14 +29,29 @@ interface UserRow {
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
interface DashboardOverviewRes {
|
||||
success?: boolean
|
||||
totalUsers?: number
|
||||
paidOrderCount?: number
|
||||
paidUserCount?: number
|
||||
totalRevenue?: number
|
||||
conversionRate?: number
|
||||
totalMatches?: number
|
||||
matchRevenue?: number
|
||||
recentOrders?: OrderRow[]
|
||||
newUsers?: UserRow[]
|
||||
}
|
||||
|
||||
interface UsersRes {
|
||||
success?: boolean
|
||||
users?: UserRow[]
|
||||
total?: number
|
||||
}
|
||||
|
||||
interface OrdersRes {
|
||||
success?: boolean
|
||||
orders?: OrderRow[]
|
||||
total?: number
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
@@ -43,18 +59,60 @@ export function DashboardPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [users, setUsers] = useState<UserRow[]>([])
|
||||
const [purchases, setPurchases] = useState<OrderRow[]>([])
|
||||
const [totalUsersCount, setTotalUsersCount] = useState(0)
|
||||
const [paidOrderCount, setPaidOrderCount] = useState(0)
|
||||
const [totalRevenue, setTotalRevenue] = useState(0)
|
||||
const [conversionRate, setConversionRate] = useState(0)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [detailUserId, setDetailUserId] = useState<string | null>(null)
|
||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||
|
||||
async function loadData() {
|
||||
setIsLoading(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 [usersData, ordersData] = await Promise.all([
|
||||
get<UsersRes>('/api/db/users'),
|
||||
get<OrdersRes>('/api/orders'),
|
||||
get<UsersRes>('/api/db/users?page=1&pageSize=10'),
|
||||
get<OrdersRes>('/api/orders?page=1&pageSize=20&status=paid'),
|
||||
])
|
||||
if (usersData?.success && usersData.users) setUsers(usersData.users)
|
||||
if (ordersData?.success && ordersData.orders) setPurchases(ordersData.orders)
|
||||
} catch (e) {
|
||||
console.error('加载数据失败', e)
|
||||
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('加载失败,请检查网络或联系管理员')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -62,6 +120,8 @@ export function DashboardPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
const timer = setInterval(loadData, 30000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
@@ -76,10 +136,7 @@ export function DashboardPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const paidOrders = purchases.filter(p => p.status === 'paid' || p.status === 'completed' || p.status === 'success')
|
||||
const totalRevenue = paidOrders.reduce((sum, p) => sum + Number(p.amount || 0), 0)
|
||||
const totalUsers = users.length
|
||||
const totalPurchases = purchases.length
|
||||
const totalUsers = totalUsersCount
|
||||
|
||||
const formatOrderProduct = (p: OrderRow) => {
|
||||
const type = p.productType || ''
|
||||
@@ -125,7 +182,7 @@ export function DashboardPage() {
|
||||
},
|
||||
{
|
||||
title: '总收入',
|
||||
value: `¥${Number(totalRevenue).toFixed(2)}`,
|
||||
value: `¥${(totalRevenue ?? 0).toFixed(2)}`,
|
||||
icon: TrendingUp,
|
||||
color: 'text-[#38bdac]',
|
||||
bg: 'bg-[#38bdac]/20',
|
||||
@@ -133,7 +190,7 @@ export function DashboardPage() {
|
||||
},
|
||||
{
|
||||
title: '订单数',
|
||||
value: totalPurchases,
|
||||
value: paidOrderCount,
|
||||
icon: ShoppingBag,
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-500/20',
|
||||
@@ -141,7 +198,7 @@ export function DashboardPage() {
|
||||
},
|
||||
{
|
||||
title: '转化率',
|
||||
value: `${totalUsers > 0 ? ((totalPurchases / totalUsers) * 100).toFixed(1) : 0}%`,
|
||||
value: `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
|
||||
icon: BookOpen,
|
||||
color: 'text-orange-400',
|
||||
bg: 'bg-orange-500/20',
|
||||
@@ -152,7 +209,18 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div className="p-8 w-full">
|
||||
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
||||
|
||||
{loadError && (
|
||||
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-500/20 border border-amber-500/50 text-amber-200 text-sm flex items-center justify-between">
|
||||
<span>{loadError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadData()}
|
||||
className="text-amber-400 hover:text-amber-300 underline"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<Card
|
||||
@@ -178,14 +246,22 @@ export function DashboardPage() {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white">最近订单</CardTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadData()}
|
||||
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
刷新(每 30 秒自动更新)
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{purchases
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
.slice(0, 5)
|
||||
.map((p) => {
|
||||
const referrer: UserRow | undefined = p.referrerId
|
||||
? users.find((u) => u.id === p.referrerId)
|
||||
@@ -227,16 +303,24 @@ export function DashboardPage() {
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm text-gray-300">{buyer}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (p.userId) { setDetailUserId(p.userId); setShowDetailModal(true) } }}
|
||||
className="text-sm text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
|
||||
>
|
||||
{buyer}
|
||||
</button>
|
||||
<span className="text-gray-600">·</span>
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{product.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="px-1.5 py-0.5 bg-gray-700/50 rounded">
|
||||
{product.subtitle}
|
||||
</span>
|
||||
{product.subtitle && product.subtitle !== '章节购买' && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-700/50 rounded">
|
||||
{product.subtitle}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{new Date(p.createdAt || 0).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
@@ -280,8 +364,7 @@ export function DashboardPage() {
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{users
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
.slice(0, 5)
|
||||
.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
@@ -292,9 +375,13 @@ export function DashboardPage() {
|
||||
{u.nickname?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDetailUserId(u.id); setShowDetailModal(true) }}
|
||||
className="text-sm font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
|
||||
>
|
||||
{u.nickname || '匿名用户'}
|
||||
</p>
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">{u.phone || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,6 +399,13 @@ export function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<UserDetailModal
|
||||
open={showDetailModal}
|
||||
onClose={() => { setShowDetailModal(false); setDetailUserId(null) }}
|
||||
userId={detailUserId}
|
||||
onUserUpdated={loadData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Users,
|
||||
TrendingUp,
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
Link2,
|
||||
Eye,
|
||||
Undo2,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import { ReferralSettingsPage } from '@/pages/referral-settings/ReferralSettingsPage'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -28,6 +30,11 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { get, put } from '@/api/client'
|
||||
|
||||
interface TodayClicksByPageItem {
|
||||
page: string
|
||||
clicks: number
|
||||
}
|
||||
|
||||
interface DistributionOverview {
|
||||
todayClicks: number
|
||||
todayBindings: number
|
||||
@@ -47,6 +54,9 @@ interface DistributionOverview {
|
||||
conversionRate: string
|
||||
totalDistributors: number
|
||||
activeDistributors: number
|
||||
todayUniqueVisitors?: number
|
||||
todayClickRate?: number
|
||||
todayClicksByPage?: TodayClicksByPageItem[]
|
||||
}
|
||||
|
||||
interface Binding {
|
||||
@@ -111,7 +121,7 @@ interface Order {
|
||||
}
|
||||
|
||||
export function DistributionPage() {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals'>(
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals' | 'settings'>(
|
||||
'overview',
|
||||
)
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
@@ -412,7 +422,7 @@ export function DistributionPage() {
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">交易中心</h1>
|
||||
<h1 className="text-2xl font-bold text-white">推广中心</h1>
|
||||
<p className="text-gray-400 mt-1">统一管理:订单、分销绑定、提现审核</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -426,12 +436,13 @@ export function DistributionPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6 border-b border-gray-700 pb-4">
|
||||
<div className="flex gap-2 mb-6 border-b border-gray-700 pb-4 flex-wrap">
|
||||
{[
|
||||
{ key: 'overview', label: '数据概览', icon: TrendingUp },
|
||||
{ key: 'orders', label: '订单管理', icon: DollarSign },
|
||||
{ key: 'bindings', label: '绑定管理', icon: Link2 },
|
||||
{ key: 'withdrawals', label: '提现审核', icon: Wallet },
|
||||
{ key: 'settings', label: '推广设置', icon: Settings },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -469,6 +480,7 @@ export function DistributionPage() {
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日点击</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{overview.todayClicks}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">总点击次数(实时)</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||
<Eye className="w-6 h-6 text-blue-400" />
|
||||
@@ -476,6 +488,36 @@ export function DistributionPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日独立用户</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{overview.todayUniqueVisitors ?? 0}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">去重访客数(实时)</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-cyan-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日总文章点击率</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">
|
||||
{(overview.todayClickRate ?? 0).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">人均点击(总点击/独立用户)</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500/20 flex items-center justify-center">
|
||||
<TrendingUp className="w-6 h-6 text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -519,6 +561,48 @@ export function DistributionPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 每篇文章今日点击 */}
|
||||
{(overview.todayClicksByPage?.length ?? 0) > 0 && (
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Eye className="w-5 h-5 text-[#38bdac]" />
|
||||
每篇文章今日点击(按来源页/文章统计)
|
||||
</CardTitle>
|
||||
<p className="text-gray-400 text-sm mt-1">实际用户与实际文章的点击均计入;今日总点击与上表一致</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700 text-left text-gray-400">
|
||||
<th className="pb-3 pr-4">来源页/文章</th>
|
||||
<th className="pb-3 pr-4 text-right">今日点击</th>
|
||||
<th className="pb-3 text-right">占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...(overview.todayClicksByPage ?? [])]
|
||||
.sort((a, b) => b.clicks - a.clicks)
|
||||
.map((row, i) => (
|
||||
<tr key={i} className="border-b border-gray-700/50">
|
||||
<td className="py-2 pr-4 text-white font-mono">{row.page || '(未区分)'}</td>
|
||||
<td className="py-2 pr-4 text-right text-white">{row.clicks}</td>
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{overview.todayClicks > 0
|
||||
? ((row.clicks / overview.todayClicks) * 100).toFixed(1)
|
||||
: 0}
|
||||
%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="bg-orange-500/10 border-orange-500/30">
|
||||
<CardContent className="p-6">
|
||||
@@ -1137,6 +1221,13 @@ export function DistributionPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 推广设置 Tab */}
|
||||
{activeTab === 'settings' && (
|
||||
<div className="-mx-8 -mt-6">
|
||||
<ReferralSettingsPage embedded />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
87
soul-admin/src/pages/find-partner/FindPartnerPage.tsx
Normal file
87
soul-admin/src/pages/find-partner/FindPartnerPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Users, Handshake, GraduationCap, UserPlus, BarChart3, Link2 } from 'lucide-react'
|
||||
import { FindPartnerTab } from './tabs/FindPartnerTab'
|
||||
import { ResourceDockingTab } from './tabs/ResourceDockingTab'
|
||||
import { MentorTab } from './tabs/MentorTab'
|
||||
import { TeamRecruitTab } from './tabs/TeamRecruitTab'
|
||||
import { CKBStatsTab } from './tabs/CKBStatsTab'
|
||||
import { CKBConfigPanel } from './tabs/CKBConfigPanel'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'stats', label: '数据统计', icon: BarChart3 },
|
||||
{ id: 'partner', label: '找伙伴', icon: Users },
|
||||
{ id: 'resource', label: '资源对接', icon: Handshake },
|
||||
{ id: 'mentor', label: '导师预约', icon: GraduationCap },
|
||||
{ id: 'team', label: '团队招募', icon: UserPlus },
|
||||
] as const
|
||||
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
export function FindPartnerPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('stats')
|
||||
const [showCKBPanel, setShowCKBPanel] = useState(false)
|
||||
const [ckbPanelTab, setCkbPanelTab] = useState<'overview' | 'submitted' | 'contact' | 'config' | 'test' | 'doc'>('overview')
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Users className="w-6 h-6 text-[#38bdac]" />
|
||||
找伙伴
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
数据统计、匹配池与记录、资源对接、导师预约、团队招募
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCKBPanel((v) => !v)}
|
||||
className="border-orange-500/40 text-orange-300 hover:bg-orange-500/10 bg-transparent"
|
||||
>
|
||||
<Link2 className="w-4 h-4 mr-2" />
|
||||
存客宝
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCKBPanel && <CKBConfigPanel initialTab={ckbPanelTab} />}
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-6 bg-[#0f2137] rounded-lg p-1 border border-gray-700/50">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-md text-sm font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-[#38bdac] text-white shadow-lg'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeTab === 'stats' && (
|
||||
<CKBStatsTab
|
||||
onSwitchTab={(id) => setActiveTab(id as TabId)}
|
||||
onOpenCKB={(tab) => {
|
||||
setCkbPanelTab((tab as typeof ckbPanelTab) || 'overview')
|
||||
setShowCKBPanel(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'partner' && <FindPartnerTab />}
|
||||
{activeTab === 'resource' && <ResourceDockingTab />}
|
||||
{activeTab === 'mentor' && <MentorTab />}
|
||||
{activeTab === 'team' && <TeamRecruitTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
380
soul-admin/src/pages/find-partner/tabs/CKBConfigPanel.tsx
Normal file
380
soul-admin/src/pages/find-partner/tabs/CKBConfigPanel.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
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'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { RefreshCw, Zap, CheckCircle2, XCircle, Smartphone, ExternalLink, Save } from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
type WorkspaceTab = 'overview' | 'submitted' | 'contact' | 'config' | 'test' | 'doc'
|
||||
|
||||
interface TestResult {
|
||||
endpoint: string
|
||||
label: string
|
||||
method: 'GET' | 'POST'
|
||||
status: 'idle' | 'testing' | 'success' | 'error'
|
||||
message?: string
|
||||
responseTime?: number
|
||||
}
|
||||
|
||||
interface LeadRow {
|
||||
id: string
|
||||
userId: string
|
||||
userNickname?: string
|
||||
matchType: string
|
||||
phone?: string
|
||||
wechatId?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface RouteConfig {
|
||||
apiUrl: string
|
||||
apiKey: string
|
||||
source: string
|
||||
tags: string
|
||||
siteTags: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
const typeMap = ['partner', 'investor', 'mentor', 'team']
|
||||
const routeDefs = [
|
||||
{ key: 'join_partner', label: '找伙伴场景' },
|
||||
{ key: 'join_investor', label: '资源对接场景' },
|
||||
{ key: 'join_mentor', label: '导师顾问场景' },
|
||||
{ key: 'join_team', label: '团队招募场景' },
|
||||
{ key: 'match', label: '匹配上报' },
|
||||
{ key: 'lead', label: '链接卡若' },
|
||||
] as const
|
||||
|
||||
const defaultDoc = `# 场景获客接口摘要
|
||||
- 地址:POST /v1/api/scenarios
|
||||
- 必填:apiKey、sign、timestamp
|
||||
- 主标识:phone 或 wechatId 至少一项
|
||||
- 可选:name、source、remark、tags、siteTags、portrait
|
||||
- 签名:排除 sign/apiKey/portrait,键名升序拼接值后双重 MD5
|
||||
- 成功:code=200,message=新增成功 或 已存在`
|
||||
|
||||
export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: WorkspaceTab }) {
|
||||
const [activeTab, setActiveTab] = useState<WorkspaceTab>(initialTab)
|
||||
const [testPhone, setTestPhone] = useState('13800000000')
|
||||
const [testWechat, setTestWechat] = useState('')
|
||||
const [docNotes, setDocNotes] = useState('')
|
||||
const [docContent, setDocContent] = useState(defaultDoc)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submittedLeads, setSubmittedLeads] = useState<LeadRow[]>([])
|
||||
const [contactLeads, setContactLeads] = useState<LeadRow[]>([])
|
||||
const [routes, setRoutes] = useState<Record<string, RouteConfig>>({})
|
||||
const [tests, setTests] = useState<TestResult[]>([
|
||||
{ endpoint: '/api/ckb/join', label: '找伙伴', method: 'POST', status: 'idle' },
|
||||
{ endpoint: '/api/ckb/join', label: '资源对接', method: 'POST', status: 'idle' },
|
||||
{ endpoint: '/api/ckb/join', label: '导师顾问', method: 'POST', status: 'idle' },
|
||||
{ endpoint: '/api/ckb/join', label: '团队招募', method: 'POST', status: 'idle' },
|
||||
{ endpoint: '/api/ckb/match', label: '匹配上报', method: 'POST', status: 'idle' },
|
||||
{ endpoint: '/api/miniprogram/ckb/lead', label: '链接卡若', method: 'POST', status: 'idle' },
|
||||
{ endpoint: '/api/match/config', label: '匹配配置', method: 'GET', status: 'idle' },
|
||||
])
|
||||
|
||||
const routeMap = useMemo(() => {
|
||||
const m: Record<string, RouteConfig> = {}
|
||||
routeDefs.forEach((item) => {
|
||||
m[item.key] = routes[item.key] || {
|
||||
apiUrl: 'https://ckbapi.quwanzhi.com/v1/api/scenarios',
|
||||
apiKey: 'fyngh-ecy9h-qkdae-epwd5-rz6kd',
|
||||
source: '',
|
||||
tags: '',
|
||||
siteTags: '创业实验APP',
|
||||
notes: '',
|
||||
}
|
||||
})
|
||||
return m
|
||||
}, [routes])
|
||||
|
||||
const getBody = (idx: number) => {
|
||||
const phone = testPhone.trim()
|
||||
const wechat = testWechat.trim()
|
||||
if (idx <= 3) return { type: typeMap[idx], phone: phone || undefined, wechat: wechat || undefined, userId: 'admin_test', name: '后台测试' }
|
||||
if (idx === 4) return { matchType: 'partner', phone: phone || undefined, wechat: wechat || undefined, userId: 'admin_test', nickname: '后台测试', matchedUser: { id: 'test', nickname: '测试', matchScore: 88 } }
|
||||
if (idx === 5) return { phone: phone || undefined, wechatId: wechat || undefined, userId: 'admin_test', name: '后台测试' }
|
||||
return {}
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [cfgRes, submittedRes, contactRes] = await Promise.all([
|
||||
get<{ data?: { routes?: Record<string, RouteConfig>; docNotes?: string; docContent?: string } }>('/api/db/config/full?key=ckb_config'),
|
||||
get<{ success?: boolean; records?: LeadRow[] }>('/api/db/ckb-leads?mode=submitted&page=1&pageSize=50'),
|
||||
get<{ success?: boolean; records?: LeadRow[] }>('/api/db/ckb-leads?mode=contact&page=1&pageSize=50'),
|
||||
])
|
||||
const c = cfgRes?.data
|
||||
if (c?.routes) setRoutes(c.routes)
|
||||
if (c?.docNotes) setDocNotes(c.docNotes)
|
||||
if (c?.docContent) setDocContent(c.docContent)
|
||||
if (submittedRes?.success) setSubmittedLeads(submittedRes.records || [])
|
||||
if (contactRes?.success) setContactLeads(contactRes.records || [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { setActiveTab(initialTab) }, [initialTab])
|
||||
useEffect(() => { void loadWorkspace() }, [])
|
||||
|
||||
async function saveConfig() {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
|
||||
key: 'ckb_config',
|
||||
value: { routes: routeMap, docNotes, docContent },
|
||||
description: '存客宝接口配置',
|
||||
})
|
||||
alert(res?.success !== false ? '存客宝配置已保存' : `保存失败: ${res?.error || '未知错误'}`)
|
||||
} catch (e) {
|
||||
alert(`保存失败: ${e instanceof Error ? e.message : '网络错误'}`)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateRoute = (key: string, patch: Partial<RouteConfig>) => {
|
||||
setRoutes((prev) => ({ ...prev, [key]: { ...routeMap[key], ...patch } }))
|
||||
}
|
||||
|
||||
const testOne = async (idx: number) => {
|
||||
const t = tests[idx]
|
||||
if (t.method === 'POST' && !testPhone.trim() && !testWechat.trim()) { alert('请填写测试手机号'); return }
|
||||
const next = [...tests]
|
||||
next[idx] = { ...t, status: 'testing', message: undefined, responseTime: undefined }
|
||||
setTests(next)
|
||||
const start = performance.now()
|
||||
try {
|
||||
const res = t.method === 'GET'
|
||||
? await get<{ success?: boolean; message?: string }>(t.endpoint)
|
||||
: await post<{ success?: boolean; message?: string }>(t.endpoint, getBody(idx))
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
const msg = res?.message || ''
|
||||
const ok = res?.success === true || msg.includes('已存在') || msg.includes('已加入') || msg.includes('已提交')
|
||||
const out = [...tests]
|
||||
out[idx] = { ...t, status: ok ? 'success' : 'error', message: msg || (ok ? '正常' : '异常'), responseTime: elapsed }
|
||||
setTests(out)
|
||||
await loadWorkspace()
|
||||
} catch (e: unknown) {
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
const out = [...tests]
|
||||
out[idx] = { ...t, status: 'error', message: e instanceof Error ? e.message : '失败', responseTime: elapsed }
|
||||
setTests(out)
|
||||
}
|
||||
}
|
||||
|
||||
const testAll = async () => {
|
||||
if (!testPhone.trim() && !testWechat.trim()) { alert('请填写测试手机号'); return }
|
||||
for (let i = 0; i < tests.length; i++) await testOne(i)
|
||||
}
|
||||
|
||||
const renderLeadTable = (rows: LeadRow[], emptyText: string) => (
|
||||
<div className="overflow-auto rounded-lg border border-gray-700/30">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#0a1628] text-gray-400">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">发起人</th>
|
||||
<th className="text-left px-4 py-3">类型</th>
|
||||
<th className="text-left px-4 py-3">手机号</th>
|
||||
<th className="text-left px-4 py-3">微信号</th>
|
||||
<th className="text-left px-4 py-3">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-center py-10 text-gray-500">{emptyText}</td></tr>
|
||||
) : rows.map((row) => (
|
||||
<tr key={row.id} className="border-t border-gray-700/30">
|
||||
<td className="px-4 py-3 text-white">{row.userNickname || row.userId}</td>
|
||||
<td className="px-4 py-3 text-gray-300">{row.matchType}</td>
|
||||
<td className="px-4 py-3 text-green-400">{row.phone || '—'}</td>
|
||||
<td className="px-4 py-3 text-blue-400">{row.wechatId || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{row.createdAt ? new Date(row.createdAt).toLocaleString() : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className="bg-[#0f2137] border-orange-500/30 mb-6">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-white font-semibold">存客宝工作台</h3>
|
||||
<Badge className="bg-orange-500/20 text-orange-400 border-0 text-xs">CKB</Badge>
|
||||
<button type="button" onClick={() => setActiveTab('doc')} className="text-orange-400/60 text-xs hover:text-orange-400 flex items-center gap-1">
|
||||
<ExternalLink className="w-3 h-3" /> API 文档
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => loadWorkspace()} variant="outline" size="sm" className="border-gray-700 text-gray-300 hover:bg-gray-700/50 bg-transparent">
|
||||
<RefreshCw className={`w-3.5 h-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} /> 刷新
|
||||
</Button>
|
||||
<Button onClick={saveConfig} disabled={isSaving} size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-3.5 h-3.5 mr-1" /> {isSaving ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-5">
|
||||
{[
|
||||
['overview', '概览'],
|
||||
['submitted', '已提交线索'],
|
||||
['contact', '有联系方式'],
|
||||
['config', '场景配置'],
|
||||
['test', '接口测试'],
|
||||
['doc', 'API 文档'],
|
||||
].map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(id as WorkspaceTab)}
|
||||
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeTab === id ? 'bg-orange-500 text-white' : 'bg-[#0a1628] text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
|
||||
<p className="text-gray-400 text-xs mb-2">已提交线索</p>
|
||||
<p className="text-3xl font-bold text-white">{submittedLeads.length}</p>
|
||||
</div>
|
||||
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
|
||||
<p className="text-gray-400 text-xs mb-2">有联系方式</p>
|
||||
<p className="text-3xl font-bold text-white">{contactLeads.length}</p>
|
||||
</div>
|
||||
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
|
||||
<p className="text-gray-400 text-xs mb-2">场景配置数</p>
|
||||
<p className="text-3xl font-bold text-white">{routeDefs.length}</p>
|
||||
</div>
|
||||
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
|
||||
<p className="text-gray-400 text-xs mb-2">文档备注</p>
|
||||
<p className="text-sm text-gray-300 line-clamp-3">{docNotes || '未填写'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'submitted' && renderLeadTable(submittedLeads, '暂无已提交线索')}
|
||||
{activeTab === 'contact' && renderLeadTable(contactLeads, '暂无有联系方式线索')}
|
||||
|
||||
{activeTab === 'config' && (
|
||||
<div className="space-y-4">
|
||||
{routeDefs.map((item) => (
|
||||
<div key={item.key} className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-white font-medium">{item.label}</h4>
|
||||
<Badge className="bg-orange-500/20 text-orange-300 border-0 text-xs">{item.key}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-gray-500 text-xs">API 地址</Label>
|
||||
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].apiUrl} onChange={(e) => updateRoute(item.key, { apiUrl: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-gray-500 text-xs">API Key</Label>
|
||||
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].apiKey} onChange={(e) => updateRoute(item.key, { apiKey: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-gray-500 text-xs">Source</Label>
|
||||
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].source} onChange={(e) => updateRoute(item.key, { source: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-gray-500 text-xs">Tags</Label>
|
||||
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].tags} onChange={(e) => updateRoute(item.key, { tags: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-gray-500 text-xs">SiteTags</Label>
|
||||
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].siteTags} onChange={(e) => updateRoute(item.key, { siteTags: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-gray-500 text-xs">说明备注</Label>
|
||||
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].notes} onChange={(e) => updateRoute(item.key, { notes: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'test' && (
|
||||
<>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Smartphone className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<Label className="text-gray-500 text-xs">测试手机号</Label>
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm mt-0.5" value={testPhone} onChange={(e) => setTestPhone(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-gray-500 text-sm shrink-0">💬</span>
|
||||
<div className="flex-1">
|
||||
<Label className="text-gray-500 text-xs">微信号(可选)</Label>
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm mt-0.5" value={testWechat} onChange={(e) => setTestWechat(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={testAll} className="bg-orange-500 hover:bg-orange-600 text-white">
|
||||
<Zap className="w-3.5 h-3.5 mr-1" /> 全部测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{tests.map((t, idx) => (
|
||||
<div key={`${t.endpoint}-${idx}`} className="flex items-center justify-between bg-[#0a1628] rounded-lg px-3 py-2 border border-gray-700/30">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{t.status === 'idle' && <div className="w-2 h-2 rounded-full bg-gray-600 shrink-0" />}
|
||||
{t.status === 'testing' && <RefreshCw className="w-3 h-3 text-yellow-400 animate-spin shrink-0" />}
|
||||
{t.status === 'success' && <CheckCircle2 className="w-3 h-3 text-green-400 shrink-0" />}
|
||||
{t.status === 'error' && <XCircle className="w-3 h-3 text-red-400 shrink-0" />}
|
||||
<span className="text-white text-xs truncate">{t.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{t.responseTime !== undefined && <span className="text-gray-600 text-[10px]">{t.responseTime}ms</span>}
|
||||
<button type="button" onClick={() => testOne(idx)} disabled={t.status === 'testing'} className="text-orange-400/60 hover:text-orange-400 text-[10px] disabled:opacity-50">测试</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'doc' && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-white text-sm font-medium">场景获客 API 摘要</h4>
|
||||
<a href="https://ckbapi.quwanzhi.com/v1/api/scenarios" target="_blank" rel="noreferrer" className="text-orange-400/70 hover:text-orange-400 text-xs flex items-center gap-1">
|
||||
<ExternalLink className="w-3 h-3" /> 打开外链
|
||||
</a>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap text-xs text-gray-400 leading-6">{docContent || defaultDoc}</pre>
|
||||
</div>
|
||||
<div className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
|
||||
<h4 className="text-white text-sm font-medium mb-3">说明备注(可编辑)</h4>
|
||||
<textarea
|
||||
className="w-full min-h-[260px] bg-[#0f2137] border border-gray-700 rounded-md text-sm text-gray-300 p-3 outline-none focus:border-orange-500/50 resize-y"
|
||||
value={docNotes}
|
||||
onChange={(e) => setDocNotes(e.target.value)}
|
||||
placeholder="记录 Token、入口差异、回复率统计规则、对接约定等。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
197
soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx
Normal file
197
soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Users, Zap, Link2, ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface MatchStats {
|
||||
totalMatches: number
|
||||
todayMatches: number
|
||||
byType: { matchType: string; count: number }[]
|
||||
uniqueUsers: number
|
||||
matchRevenue?: number
|
||||
paidMatchCount?: number
|
||||
}
|
||||
|
||||
interface CKBPlanStats {
|
||||
ckbTotal: number
|
||||
withContact: number
|
||||
byType: { matchType: string; total: number }[]
|
||||
ckbApiKey: string
|
||||
ckbApiUrl: string
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = { partner: '找伙伴', investor: '资源对接', mentor: '导师顾问', team: '团队招募' }
|
||||
const typeIcons: Record<string, string> = { partner: '⭐', investor: '👥', mentor: '❤️', team: '🎮' }
|
||||
|
||||
interface Props {
|
||||
onSwitchTab?: (tabId: string) => void
|
||||
onOpenCKB?: (tab?: string) => void
|
||||
}
|
||||
|
||||
export function CKBStatsTab({ onSwitchTab, onOpenCKB }: Props = {}) {
|
||||
const navigate = useNavigate()
|
||||
const [stats, setStats] = useState<MatchStats | null>(null)
|
||||
const [ckbStats, setCkbStats] = useState<CKBPlanStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [statsRes, ckbRes] = await Promise.allSettled([
|
||||
get<{ success?: boolean; data?: MatchStats }>('/api/db/match-records?stats=true'),
|
||||
get<{ success?: boolean; data?: CKBPlanStats }>('/api/db/ckb-plan-stats'),
|
||||
])
|
||||
|
||||
if (statsRes.status === 'fulfilled' && statsRes.value?.success && statsRes.value.data) {
|
||||
let result = statsRes.value.data
|
||||
if (result.totalMatches > 0 && (!result.uniqueUsers || result.uniqueUsers === 0)) {
|
||||
try {
|
||||
const allRec = await get<{ success?: boolean; records?: { userId: string }[]; total?: number }>('/api/db/match-records?page=1&pageSize=200')
|
||||
if (allRec?.success && allRec.records) {
|
||||
const userSet = new Set(allRec.records.map(r => r.userId).filter(Boolean))
|
||||
result = { ...result, uniqueUsers: userSet.size }
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
}
|
||||
setStats(result)
|
||||
}
|
||||
|
||||
if (ckbRes.status === 'fulfilled' && ckbRes.value?.success && ckbRes.value.data) {
|
||||
setCkbStats(ckbRes.value.data)
|
||||
}
|
||||
} catch (e) { console.error('加载统计失败:', e) }
|
||||
finally { setIsLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadStats() }, [loadStats])
|
||||
|
||||
const v = (n: number | undefined) => isLoading ? '—' : String(n ?? 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* ===== 区块一:找伙伴核心数据 ===== */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#38bdac]" /> 找伙伴数据
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<Card className="bg-gradient-to-br from-[#0f2137] to-[#162d4a] border-gray-700/40 cursor-pointer hover:border-[#38bdac]/60 transition-all" onClick={() => onSwitchTab?.('partner')}>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">总匹配次数</p>
|
||||
<p className="text-4xl font-bold text-white">{v(stats?.totalMatches)}</p>
|
||||
<p className="text-[#38bdac] text-xs mt-3 flex items-center gap-1"><ExternalLink className="w-3 h-3" /> 查看匹配记录</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-[#0f2137] to-[#162d4a] border-gray-700/40 cursor-pointer hover:border-yellow-500/60 transition-all" onClick={() => onSwitchTab?.('partner')}>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">今日匹配</p>
|
||||
<p className="text-4xl font-bold text-white">{v(stats?.todayMatches)}</p>
|
||||
<p className="text-yellow-400/60 text-xs mt-3 flex items-center gap-1"><Zap className="w-3 h-3" /> 今日实时</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-[#0f2137] to-[#162d4a] border-gray-700/40 cursor-pointer hover:border-blue-500/60 transition-all" onClick={() => navigate('/users')}>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">匹配用户数</p>
|
||||
<p className="text-4xl font-bold text-white">{v(stats?.uniqueUsers)}</p>
|
||||
<p className="text-blue-400/60 text-xs mt-3 flex items-center gap-1"><ExternalLink className="w-3 h-3" /> 查看用户管理</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/40">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">人均匹配</p>
|
||||
<p className="text-3xl font-bold text-white">{isLoading ? '—' : (stats?.uniqueUsers ? (stats.totalMatches / stats.uniqueUsers).toFixed(1) : '0')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/40">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">付费匹配次数</p>
|
||||
<p className="text-3xl font-bold text-white">{v(stats?.paidMatchCount)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型分布 */}
|
||||
{stats?.byType && stats.byType.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">各类型匹配分布</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.byType.map(item => {
|
||||
const pct = stats.totalMatches > 0 ? ((item.count / stats.totalMatches) * 100) : 0
|
||||
return (
|
||||
<div key={item.matchType} className="bg-[#0f2137] border border-gray-700/40 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-2xl">{typeIcons[item.matchType] || '📊'}</span>
|
||||
<span className="text-gray-300 font-medium">{typeLabels[item.matchType] || item.matchType}</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white mb-2">{item.count}</p>
|
||||
<div className="w-full h-2 bg-gray-700/50 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#38bdac] rounded-full transition-all" style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mt-1.5">{pct.toFixed(1)}%</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 区块二:AI 获客数据 ===== */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5 text-orange-400" /> AI 获客数据
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5 mb-6">
|
||||
<Card className="bg-[#0f2137] border-orange-500/20 cursor-pointer hover:border-orange-500/50 transition-colors" onClick={() => onOpenCKB?.('submitted')}>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">已提交线索</p>
|
||||
<p className="text-3xl font-bold text-white">{isLoading ? '—' : (ckbStats?.ckbTotal ?? 0)}</p>
|
||||
<p className="text-orange-400/60 text-xs mt-2">点击查看明细 →</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-orange-500/20 cursor-pointer hover:border-orange-500/50 transition-colors" onClick={() => onOpenCKB?.('contact')}>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">有联系方式</p>
|
||||
<p className="text-3xl font-bold text-white">{isLoading ? '—' : (ckbStats?.withContact ?? 0)}</p>
|
||||
<p className="text-orange-400/60 text-xs mt-2">点击查看明细 →</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-orange-500/20 cursor-pointer hover:border-orange-500/50 transition-colors" onClick={() => onOpenCKB?.('test')}>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">AI 添加进度</p>
|
||||
<p className="text-xl font-bold text-orange-400">查看详情 →</p>
|
||||
<p className="text-gray-500 text-xs mt-2">添加成功率 · 回复率 · API 文档</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* CKB 各类型提交统计 */}
|
||||
{ckbStats?.byType && ckbStats.byType.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
{ckbStats.byType.map(item => (
|
||||
<div key={item.matchType} className="bg-[#0a1628] border border-gray-700/30 rounded-lg p-4 flex items-center gap-3">
|
||||
<span className="text-xl">{typeIcons[item.matchType] || '📋'}</span>
|
||||
<div>
|
||||
<p className="text-gray-400 text-xs">{typeLabels[item.matchType] || item.matchType}</p>
|
||||
<p className="text-xl font-bold text-white">{item.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 接口联通测试已移到右上角「存客宝」按钮面板 */}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
soul-admin/src/pages/find-partner/tabs/FindPartnerTab.tsx
Normal file
24
soul-admin/src/pages/find-partner/tabs/FindPartnerTab.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { MatchPoolTab } from './MatchPoolTab'
|
||||
import { MatchRecordsTab } from './MatchRecordsTab'
|
||||
|
||||
export function FindPartnerTab() {
|
||||
const [subTab, setSubTab] = useState<'records' | 'pool'>('records')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setSubTab('records')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${subTab === 'records' ? 'bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/50' : 'bg-[#0a1628] text-gray-400 border border-gray-700 hover:text-white'}`}>
|
||||
匹配记录
|
||||
</button>
|
||||
<button type="button" onClick={() => setSubTab('pool')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${subTab === 'pool' ? 'bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/50' : 'bg-[#0a1628] text-gray-400 border border-gray-700 hover:text-white'}`}>
|
||||
匹配池设置
|
||||
</button>
|
||||
</div>
|
||||
{subTab === 'records' && <MatchRecordsTab />}
|
||||
{subTab === 'pool' && <MatchPoolTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
358
soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx
Normal file
358
soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Save, RefreshCw, Edit3, Plus, Trash2, Users, Zap, Filter } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
interface MatchType {
|
||||
id: string; label: string; matchLabel: string; icon: string
|
||||
matchFromDB: boolean; showJoinAfterMatch: boolean; price: number; enabled: boolean
|
||||
}
|
||||
interface PoolSettings {
|
||||
poolSource: string[]
|
||||
requirePhone: boolean
|
||||
requireNickname: boolean
|
||||
requireAvatar: boolean
|
||||
requireBusiness: boolean
|
||||
}
|
||||
interface MatchConfig {
|
||||
matchTypes: MatchType[]; freeMatchLimit: number; matchPrice: number
|
||||
settings: { enableFreeMatches: boolean; enablePaidMatches: boolean; maxMatchesPerDay: number }
|
||||
poolSettings?: PoolSettings
|
||||
}
|
||||
|
||||
const DEFAULT_POOL: PoolSettings = {
|
||||
poolSource: ['vip'], requirePhone: true, requireNickname: true, requireAvatar: false, requireBusiness: false,
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: MatchConfig = {
|
||||
matchTypes: [
|
||||
{ id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, price: 1, enabled: true },
|
||||
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
|
||||
{ id: 'mentor', label: '导师顾问', matchLabel: '导师顾问', icon: '❤️', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
|
||||
{ id: 'team', label: '团队招募', matchLabel: '加入项目', icon: '🎮', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
|
||||
],
|
||||
freeMatchLimit: 3, matchPrice: 1,
|
||||
settings: { enableFreeMatches: true, enablePaidMatches: true, maxMatchesPerDay: 10 },
|
||||
poolSettings: DEFAULT_POOL,
|
||||
}
|
||||
const ICONS = ['⭐', '👥', '❤️', '🎮', '💼', '🚀', '💡', '🎯', '🔥', '✨']
|
||||
|
||||
interface PoolCounts {
|
||||
vip: number; complete: number; all: number
|
||||
}
|
||||
|
||||
export function MatchPoolTab() {
|
||||
const navigate = useNavigate()
|
||||
const [config, setConfig] = useState<MatchConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showTypeModal, setShowTypeModal] = useState(false)
|
||||
const [editingType, setEditingType] = useState<MatchType | null>(null)
|
||||
const [formData, setFormData] = useState({ id: '', label: '', matchLabel: '', icon: '⭐', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true })
|
||||
const [poolCounts, setPoolCounts] = useState<PoolCounts | null>(null)
|
||||
const [poolCountsLoading, setPoolCountsLoading] = useState(false)
|
||||
|
||||
const loadPoolCounts = async () => {
|
||||
setPoolCountsLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; data?: PoolCounts }>('/api/db/match-pool-counts')
|
||||
if (data?.success && data.data) setPoolCounts(data.data)
|
||||
} catch (e) { console.error('加载池子人数失败:', e) }
|
||||
finally { setPoolCountsLoading(false) }
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; data?: MatchConfig; config?: MatchConfig }>('/api/db/config/full?key=match_config')
|
||||
const c = (data as { data?: MatchConfig })?.data ?? (data as { config?: MatchConfig })?.config
|
||||
if (c) {
|
||||
let ps = c.poolSettings ?? DEFAULT_POOL
|
||||
if (ps.poolSource && !Array.isArray(ps.poolSource)) {
|
||||
ps = { ...ps, poolSource: [ps.poolSource as unknown as string] }
|
||||
}
|
||||
setConfig({ ...DEFAULT_CONFIG, ...c, poolSettings: ps })
|
||||
}
|
||||
} catch (e) { console.error('加载匹配配置失败:', e) }
|
||||
finally { setIsLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { loadConfig(); loadPoolCounts() }, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
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('保存失败') }
|
||||
finally { setIsSaving(false) }
|
||||
}
|
||||
|
||||
const handleEditType = (type: MatchType) => {
|
||||
setEditingType(type)
|
||||
setFormData({ ...type })
|
||||
setShowTypeModal(true)
|
||||
}
|
||||
const handleAddType = () => {
|
||||
setEditingType(null)
|
||||
setFormData({ id: '', label: '', matchLabel: '', icon: '⭐', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true })
|
||||
setShowTypeModal(true)
|
||||
}
|
||||
const handleSaveType = () => {
|
||||
if (!formData.id || !formData.label) { alert('请填写类型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 }
|
||||
newTypes.push({ ...formData })
|
||||
}
|
||||
setConfig({ ...config, matchTypes: newTypes })
|
||||
setShowTypeModal(false)
|
||||
}
|
||||
const handleDeleteType = (typeId: string) => {
|
||||
if (!confirm('确定要删除这个匹配类型吗?')) return
|
||||
setConfig({ ...config, matchTypes: config.matchTypes.filter(t => t.id !== typeId) })
|
||||
}
|
||||
const handleToggleType = (typeId: string) => {
|
||||
setConfig({ ...config, matchTypes: config.matchTypes.map(t => t.id === typeId ? { ...t, enabled: !t.enabled } : t) })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={loadConfig} disabled={isLoading} 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' : ''}`} /> 刷新
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" /> {isSaving ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 匹配池选择 */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2"><Filter className="w-5 h-5 text-blue-400" /> 匹配池选择</CardTitle>
|
||||
<CardDescription className="text-gray-400">选择匹配的用户池和完善程度要求,只有满足条件的用户才可被匹配到</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-gray-300">匹配来源池</Label>
|
||||
<p className="text-gray-500 text-xs">可同时勾选多个池子(取并集匹配)</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{([
|
||||
{ value: 'vip', label: '超级个体(VIP会员)', desc: '付费 ¥1980 的VIP会员', icon: '👑', countKey: 'vip' as const },
|
||||
{ value: 'complete', label: '完善资料用户', desc: '符合下方完善度要求的用户', icon: '✅', countKey: 'complete' as const },
|
||||
{ value: 'all', label: '全部用户', desc: '所有已注册用户', icon: '👥', countKey: 'all' as const },
|
||||
]).map(opt => {
|
||||
const ps = config.poolSettings ?? DEFAULT_POOL
|
||||
const sources = Array.isArray(ps.poolSource) ? ps.poolSource : [ps.poolSource]
|
||||
const selected = sources.includes(opt.value)
|
||||
const count = poolCounts?.[opt.countKey]
|
||||
const toggleSource = () => {
|
||||
const cur = Array.isArray(ps.poolSource) ? [...ps.poolSource] : [ps.poolSource as string]
|
||||
const next = selected ? cur.filter(v => v !== opt.value) : [...cur, opt.value]
|
||||
if (next.length === 0) next.push(opt.value)
|
||||
setConfig({ ...config, poolSettings: { ...ps, poolSource: next } })
|
||||
}
|
||||
return (
|
||||
<button key={opt.value} type="button" onClick={toggleSource}
|
||||
className={`p-4 rounded-lg border text-left transition-all ${selected ? 'border-[#38bdac] bg-[#38bdac]/10' : 'border-gray-700 bg-[#0a1628] hover:border-gray-600'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center text-xs ${selected ? 'border-[#38bdac] bg-[#38bdac] text-white' : 'border-gray-600'}`}>
|
||||
{selected && '✓'}
|
||||
</div>
|
||||
<span className="text-xl">{opt.icon}</span>
|
||||
<span className={`text-sm font-medium ${selected ? 'text-[#38bdac]' : 'text-gray-300'}`}>{opt.label}</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">
|
||||
{poolCountsLoading ? '...' : (count ?? '-')}
|
||||
<span className="text-xs text-gray-500 font-normal ml-1">人</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mt-2">{opt.desc}</p>
|
||||
<span role="link" tabIndex={0}
|
||||
onClick={e => { e.stopPropagation(); navigate(`/users?pool=${opt.value}`) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.stopPropagation(); navigate(`/users?pool=${opt.value}`) } }}
|
||||
className="text-[#38bdac] text-xs mt-2 inline-block hover:underline cursor-pointer">
|
||||
查看用户列表 →
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 pt-4 border-t border-gray-700/50">
|
||||
<Label className="text-gray-300">用户资料完善要求(被匹配用户必须满足以下条件)</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{([
|
||||
{ key: 'requirePhone' as const, label: '有手机号', icon: '📱' },
|
||||
{ key: 'requireNickname' as const, label: '有昵称', icon: '👤' },
|
||||
{ key: 'requireAvatar' as const, label: '有头像', icon: '🖼️' },
|
||||
{ key: 'requireBusiness' as const, label: '有业务需求', icon: '💼' },
|
||||
]).map(item => {
|
||||
const ps = config.poolSettings ?? DEFAULT_POOL
|
||||
const checked = ps[item.key]
|
||||
return (
|
||||
<div key={item.key} className="flex items-center gap-3 bg-[#0a1628] rounded-lg p-3">
|
||||
<Switch checked={checked} onCheckedChange={v => setConfig({ ...config, poolSettings: { ...(config.poolSettings ?? DEFAULT_POOL), [item.key]: v } })} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{item.icon}</span>
|
||||
<Label className="text-gray-300 text-sm">{item.label}</Label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2"><Zap className="w-5 h-5 text-yellow-400" /> 基础设置</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置免费匹配次数和付费规则</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">每日免费匹配次数</Label>
|
||||
<Input type="number" min={0} max={100} className="bg-[#0a1628] border-gray-700 text-white" value={config.freeMatchLimit} onChange={e => setConfig({ ...config, freeMatchLimit: parseInt(e.target.value, 10) || 0 })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">付费匹配价格(元)</Label>
|
||||
<Input type="number" min={0.01} step={0.01} className="bg-[#0a1628] border-gray-700 text-white" value={config.matchPrice} onChange={e => setConfig({ ...config, matchPrice: parseFloat(e.target.value) || 1 })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">每日最大匹配次数</Label>
|
||||
<Input type="number" min={1} max={100} className="bg-[#0a1628] border-gray-700 text-white" value={config.settings.maxMatchesPerDay} onChange={e => setConfig({ ...config, settings: { ...config.settings, maxMatchesPerDay: parseInt(e.target.value, 10) || 10 } })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8 pt-4 border-t border-gray-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={config.settings.enableFreeMatches} onCheckedChange={checked => setConfig({ ...config, settings: { ...config.settings, enableFreeMatches: checked } })} />
|
||||
<Label className="text-gray-300">启用免费匹配</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={config.settings.enablePaidMatches} onCheckedChange={checked => setConfig({ ...config, settings: { ...config.settings, enablePaidMatches: checked } })} />
|
||||
<Label className="text-gray-300">启用付费匹配</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-white flex items-center gap-2"><Users className="w-5 h-5 text-[#38bdac]" /> 匹配类型管理</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置不同的匹配类型及其价格</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleAddType} size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加类型
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">图标</TableHead>
|
||||
<TableHead className="text-gray-400">类型ID</TableHead>
|
||||
<TableHead className="text-gray-400">显示名称</TableHead>
|
||||
<TableHead className="text-gray-400">匹配标签</TableHead>
|
||||
<TableHead className="text-gray-400">价格</TableHead>
|
||||
<TableHead className="text-gray-400">数据库匹配</TableHead>
|
||||
<TableHead className="text-gray-400">状态</TableHead>
|
||||
<TableHead className="text-right text-gray-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{config.matchTypes.map(type => (
|
||||
<TableRow key={type.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell><span className="text-2xl">{type.icon}</span></TableCell>
|
||||
<TableCell className="font-mono text-gray-300">{type.id}</TableCell>
|
||||
<TableCell className="text-white font-medium">{type.label}</TableCell>
|
||||
<TableCell className="text-gray-300">{type.matchLabel}</TableCell>
|
||||
<TableCell><Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">¥{type.price}</Badge></TableCell>
|
||||
<TableCell>{type.matchFromDB ? <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">是</Badge> : <Badge variant="outline" className="text-gray-500 border-gray-600">否</Badge>}</TableCell>
|
||||
<TableCell><Switch checked={type.enabled} onCheckedChange={() => handleToggleType(type.id)} /></TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditType(type)} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"><Edit3 className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteType(type.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10"><Trash2 className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showTypeModal} onOpenChange={setShowTypeModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg" showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
{editingType ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <Plus className="w-5 h-5 text-[#38bdac]" />}
|
||||
{editingType ? '编辑匹配类型' : '添加匹配类型'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">类型ID(英文)</Label>
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="如: partner" value={formData.id} onChange={e => setFormData({ ...formData, id: e.target.value })} disabled={!!editingType} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">图标</Label>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{ICONS.map(icon => (
|
||||
<button key={icon} type="button" className={`w-8 h-8 text-lg rounded ${formData.icon === icon ? 'bg-[#38bdac]/30 ring-1 ring-[#38bdac]' : 'bg-[#0a1628]'}`} onClick={() => setFormData({ ...formData, icon })}>{icon}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">显示名称</Label>
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="如: 超级个体" value={formData.label} onChange={e => setFormData({ ...formData, label: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">匹配标签</Label>
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="如: 超级个体" value={formData.matchLabel} onChange={e => setFormData({ ...formData, matchLabel: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">单次匹配价格(元)</Label>
|
||||
<Input type="number" min={0.01} step={0.01} className="bg-[#0a1628] border-gray-700 text-white" value={formData.price} onChange={e => setFormData({ ...formData, price: parseFloat(e.target.value) || 1 })} />
|
||||
</div>
|
||||
<div className="flex gap-6 pt-2">
|
||||
<div className="flex items-center gap-3"><Switch checked={formData.matchFromDB} onCheckedChange={checked => setFormData({ ...formData, matchFromDB: checked })} /><Label className="text-gray-300 text-sm">从数据库匹配</Label></div>
|
||||
<div className="flex items-center gap-3"><Switch checked={formData.showJoinAfterMatch} onCheckedChange={checked => setFormData({ ...formData, showJoinAfterMatch: checked })} /><Label className="text-gray-300 text-sm">匹配后显示加入</Label></div>
|
||||
<div className="flex items-center gap-3"><Switch checked={formData.enabled} onCheckedChange={checked => setFormData({ ...formData, enabled: checked })} /><Label className="text-gray-300 text-sm">启用</Label></div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowTypeModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">取消</Button>
|
||||
<Button onClick={handleSaveType} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Save className="w-4 h-4 mr-2" /> 保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
soul-admin/src/pages/find-partner/tabs/MatchRecordsTab.tsx
Normal file
140
soul-admin/src/pages/find-partner/tabs/MatchRecordsTab.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface MatchRecord {
|
||||
id: string; userId: string; matchedUserId: string; matchType: string
|
||||
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
|
||||
userAvatar?: string; matchedUserAvatar?: string; matchScore?: number; createdAt: string
|
||||
}
|
||||
|
||||
const matchTypeLabels: Record<string, string> = {
|
||||
partner: '找伙伴', investor: '资源对接', mentor: '导师顾问', team: '团队招募',
|
||||
}
|
||||
|
||||
export function MatchRecordsTab() {
|
||||
const [records, setRecords] = useState<MatchRecord[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [matchTypeFilter, setMatchTypeFilter] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [detailUserId, setDetailUserId] = useState<string | null>(null)
|
||||
|
||||
async function loadRecords() {
|
||||
setIsLoading(true); setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (matchTypeFilter) params.set('matchType', matchTypeFilter)
|
||||
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
|
||||
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
|
||||
else setError('加载匹配记录失败')
|
||||
} catch { setError('加载失败,请检查网络后重试') }
|
||||
finally { setIsLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { loadRecords() }, [page, matchTypeFilter])
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
|
||||
const UserCell = ({ userId, nickname, avatar }: { userId: string; nickname?: string; avatar?: string }) => (
|
||||
<div className="flex items-center gap-3 cursor-pointer group" onClick={() => setDetailUserId(userId)}>
|
||||
<div className="w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 overflow-hidden">
|
||||
{avatar ? <img src={avatar} alt="" className="w-full h-full object-cover" onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }} /> : null}
|
||||
<span className={avatar ? 'hidden' : ''}>{(nickname || userId || '?').charAt(0)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white group-hover:text-[#38bdac] transition-colors">{nickname || userId}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">{userId?.slice(0, 16)}{userId?.length > 16 ? '...' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">×</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-gray-400">共 {total} 条匹配记录 · 点击用户名查看详情</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<select value={matchTypeFilter} onChange={e => { setMatchTypeFilter(e.target.value); setPage(1) }}
|
||||
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(matchTypeLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={loadRecords} disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">加载中...</span></div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">发起人</TableHead>
|
||||
<TableHead className="text-gray-400">匹配到</TableHead>
|
||||
<TableHead className="text-gray-400">类型</TableHead>
|
||||
<TableHead className="text-gray-400">联系方式</TableHead>
|
||||
<TableHead className="text-gray-400">匹配时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map(r => (
|
||||
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell>
|
||||
<UserCell userId={r.userId} nickname={r.userNickname} avatar={r.userAvatar} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.matchedUserId ? (
|
||||
<UserCell userId={r.matchedUserId} nickname={r.matchedNickname} avatar={r.matchedUserAvatar} />
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell><Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">{matchTypeLabels[r.matchType] || r.matchType}</Badge></TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{r.phone && <div className="text-green-400">📱 {r.phone}</div>}
|
||||
{r.wechatId && <div className="text-blue-400">💬 {r.wechatId}</div>}
|
||||
{!r.phone && !r.wechatId && <span className="text-gray-600">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{records.length === 0 && <TableRow><TableCell colSpan={5} className="text-center py-12 text-gray-500">暂无匹配记录</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize}
|
||||
onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<UserDetailModal
|
||||
open={!!detailUserId}
|
||||
onClose={() => setDetailUserId(null)}
|
||||
userId={detailUserId}
|
||||
onUserUpdated={loadRecords}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx
Normal file
86
soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
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'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface Consultation {
|
||||
id: number; userId: number; mentorId: number; consultationType: string
|
||||
amount: number; status: string; createdAt: string
|
||||
}
|
||||
|
||||
const statusMap: Record<string, string> = { created: '已创建', pending_pay: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消' }
|
||||
const typeMap: Record<string, string> = { single: '单次', half_year: '半年', year: '年度' }
|
||||
|
||||
export function MentorBookingTab() {
|
||||
const [list, setList] = useState<Consultation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = statusFilter ? `/api/db/mentor-consultations?status=${statusFilter}` : '/api/db/mentor-consultations'
|
||||
const data = await get<{ success?: boolean; data?: Consultation[] }>(url)
|
||||
if (data?.success && data.data) setList(data.data)
|
||||
} catch (e) { console.error(e) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [statusFilter])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-gray-400">导师咨询预约记录</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
|
||||
className="bg-[#0f2137] border border-gray-700 rounded-lg px-3 py-2 text-gray-300 text-sm">
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<Button onClick={load} disabled={loading} variant="outline" className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> 刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-gray-400">加载中...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">ID</TableHead>
|
||||
<TableHead className="text-gray-400">用户ID</TableHead>
|
||||
<TableHead className="text-gray-400">导师ID</TableHead>
|
||||
<TableHead className="text-gray-400">类型</TableHead>
|
||||
<TableHead className="text-gray-400">金额</TableHead>
|
||||
<TableHead className="text-gray-400">状态</TableHead>
|
||||
<TableHead className="text-gray-400">创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list.map(r => (
|
||||
<TableRow key={r.id} className="border-gray-700/50">
|
||||
<TableCell className="text-gray-300">{r.id}</TableCell>
|
||||
<TableCell className="text-gray-400">{r.userId}</TableCell>
|
||||
<TableCell className="text-gray-400">{r.mentorId}</TableCell>
|
||||
<TableCell className="text-gray-400">{typeMap[r.consultationType] || r.consultationType}</TableCell>
|
||||
<TableCell className="text-white">¥{r.amount}</TableCell>
|
||||
<TableCell className="text-gray-400">{statusMap[r.status] || r.status}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center py-12 text-gray-500">暂无预约记录</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
soul-admin/src/pages/find-partner/tabs/MentorTab.tsx
Normal file
28
soul-admin/src/pages/find-partner/tabs/MentorTab.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState } from 'react'
|
||||
import { MentorBookingTab } from './MentorBookingTab'
|
||||
import { MentorsPage } from '@/pages/mentors/MentorsPage'
|
||||
|
||||
export function MentorTab() {
|
||||
const [subTab, setSubTab] = useState<'booking' | 'manage'>('booking')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setSubTab('booking')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${subTab === 'booking' ? 'bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/50' : 'bg-[#0a1628] text-gray-400 border border-gray-700 hover:text-white'}`}>
|
||||
预约记录
|
||||
</button>
|
||||
<button type="button" onClick={() => setSubTab('manage')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${subTab === 'manage' ? 'bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/50' : 'bg-[#0a1628] text-gray-400 border border-gray-700 hover:text-white'}`}>
|
||||
导师管理
|
||||
</button>
|
||||
</div>
|
||||
{subTab === 'booking' && <MentorBookingTab />}
|
||||
{subTab === 'manage' && (
|
||||
<div className="-mx-8">
|
||||
<MentorsPage embedded />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
soul-admin/src/pages/find-partner/tabs/ResourceDockingTab.tsx
Normal file
139
soul-admin/src/pages/find-partner/tabs/ResourceDockingTab.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
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'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { RefreshCw, Send } from 'lucide-react'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
interface MatchRecord {
|
||||
id: string; userId: string; matchedUserId: string; matchType: string
|
||||
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
|
||||
createdAt: string; ckbStatus?: string
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
investor: '资源对接', mentor: '导师顾问', team: '团队招募',
|
||||
}
|
||||
|
||||
export function ResourceDockingTab() {
|
||||
const [records, setRecords] = useState<MatchRecord[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [typeFilter, setTypeFilter] = useState('investor')
|
||||
const [pushingId, setPushingId] = useState<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), matchType: typeFilter })
|
||||
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
|
||||
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
|
||||
} catch (e) { console.error(e) }
|
||||
finally { setIsLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [page, typeFilter])
|
||||
|
||||
const pushToCKB = async (record: MatchRecord) => {
|
||||
if (!record.phone && !record.wechatId) {
|
||||
alert('该记录无联系方式,无法推送到存客宝')
|
||||
return
|
||||
}
|
||||
setPushingId(record.id)
|
||||
try {
|
||||
const res = await post<{ success?: boolean; message?: string }>('/api/ckb/join', {
|
||||
type: record.matchType || 'investor',
|
||||
phone: record.phone || '',
|
||||
wechat: record.wechatId || '',
|
||||
userId: record.userId,
|
||||
name: record.userNickname || '',
|
||||
})
|
||||
alert(res?.message || (res?.success ? '推送成功' : '推送失败'))
|
||||
} catch (e) {
|
||||
alert('推送失败: ' + (e instanceof Error ? e.message : '网络错误'))
|
||||
} finally {
|
||||
setPushingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
const hasContact = (r: MatchRecord) => !!(r.phone || r.wechatId)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<p className="text-gray-400">点击获客:有人填写手机号/微信号的直接显示,可一键推送到存客宝</p>
|
||||
<p className="text-gray-500 text-xs mt-1">共 {total} 条记录 — 有联系方式的可触发存客宝添加好友</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={typeFilter} onChange={e => { setTypeFilter(e.target.value); setPage(1) }}
|
||||
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm">
|
||||
{Object.entries(typeLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<Button onClick={load} disabled={isLoading} variant="outline" 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' : ''}`} /> 刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">加载中...</span></div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">发起人</TableHead>
|
||||
<TableHead className="text-gray-400">匹配到</TableHead>
|
||||
<TableHead className="text-gray-400">类型</TableHead>
|
||||
<TableHead className="text-gray-400">联系方式</TableHead>
|
||||
<TableHead className="text-gray-400">时间</TableHead>
|
||||
<TableHead className="text-gray-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map(r => (
|
||||
<TableRow key={r.id} className={`border-gray-700/50 ${hasContact(r) ? 'hover:bg-[#0a1628]' : 'opacity-60'}`}>
|
||||
<TableCell className="text-white">{r.userNickname || r.userId?.slice(0, 12)}</TableCell>
|
||||
<TableCell className="text-white">{r.matchedNickname || r.matchedUserId?.slice(0, 12)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">{typeLabels[r.matchType] || r.matchType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{r.phone && <div className="text-green-400">📱 {r.phone}</div>}
|
||||
{r.wechatId && <div className="text-blue-400">💬 {r.wechatId}</div>}
|
||||
{!r.phone && !r.wechatId && <span className="text-gray-600">无联系方式</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400 text-sm">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{hasContact(r) ? (
|
||||
<Button size="sm" onClick={() => pushToCKB(r)} disabled={pushingId === r.id}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white text-xs h-7 px-3">
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
{pushingId === r.id ? '推送中...' : '推送CKB'}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{records.length === 0 && <TableRow><TableCell colSpan={6} className="text-center py-12 text-gray-500">暂无记录</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize} onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx
Normal file
86
soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface MatchRecord {
|
||||
id: string; userId: string; matchedUserId: string; matchType: string
|
||||
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function TeamRecruitTab() {
|
||||
const [records, setRecords] = useState<MatchRecord[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), matchType: 'team' })
|
||||
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
|
||||
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
|
||||
} catch (e) { console.error(e) }
|
||||
finally { setIsLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [page])
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<p className="text-gray-400">团队招募匹配记录,共 {total} 条</p>
|
||||
<p className="text-gray-500 text-xs mt-1">用户通过「团队招募」提交联系方式到存客宝</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">加载中...</span></div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">发起人</TableHead>
|
||||
<TableHead className="text-gray-400">匹配到</TableHead>
|
||||
<TableHead className="text-gray-400">联系方式</TableHead>
|
||||
<TableHead className="text-gray-400">时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map(r => (
|
||||
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell className="text-white">{r.userNickname || r.userId}</TableCell>
|
||||
<TableCell className="text-white">{r.matchedNickname || r.matchedUserId}</TableCell>
|
||||
<TableCell className="text-gray-400 text-sm">
|
||||
{r.phone && <div>📱 {r.phone}</div>}
|
||||
{r.wechatId && <div>💬 {r.wechatId}</div>}
|
||||
{!r.phone && !r.wechatId && '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{records.length === 0 && <TableRow><TableCell colSpan={4} className="text-center py-12 text-gray-500">暂无团队招募记录</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize} onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import { Users, Plus, Edit3, Trash2, X, Save, Upload } from 'lucide-react'
|
||||
import { get, post, put, del, apiUrl } from '@/api/client'
|
||||
import { getAdminToken } from '@/api/auth'
|
||||
|
||||
interface MentorsPageProps {
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
interface Mentor {
|
||||
id: number
|
||||
name: string
|
||||
@@ -39,7 +43,7 @@ interface Mentor {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function MentorsPage() {
|
||||
export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean }) {
|
||||
const [mentors, setMentors] = useState<Mentor[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
@@ -29,7 +29,11 @@ const DEFAULT: ReferralConfig = {
|
||||
vipOrderShareNonVip: 10,
|
||||
}
|
||||
|
||||
export function ReferralSettingsPage() {
|
||||
interface ReferralSettingsPageProps {
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embedded?: boolean }) {
|
||||
const [config, setConfig] = useState<ReferralConfig>(DEFAULT)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -30,8 +32,11 @@ import {
|
||||
BookOpen,
|
||||
Gift,
|
||||
Smartphone,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
|
||||
import { AdminUsersPage } from '@/pages/admin-users/AdminUsersPage'
|
||||
|
||||
interface AuthorInfo {
|
||||
name?: string
|
||||
@@ -93,7 +98,14 @@ const defaultFeatures: FeatureConfig = {
|
||||
aboutEnabled: true,
|
||||
}
|
||||
|
||||
const TAB_KEYS = ['system', 'author', 'admin'] as const
|
||||
type TabKey = (typeof TAB_KEYS)[number]
|
||||
|
||||
export function SettingsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const tabParam = searchParams.get('tab') ?? 'system'
|
||||
const activeTab = TAB_KEYS.includes(tabParam as TabKey) ? (tabParam as TabKey) : 'system'
|
||||
|
||||
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
|
||||
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
|
||||
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
|
||||
@@ -208,26 +220,58 @@ export function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = (v: string) => {
|
||||
setSearchParams(v === 'system' ? {} : { tab: v })
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-gray-500">加载中...</div>
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">系统设置</h2>
|
||||
<p className="text-gray-400 mt-1">配置全站基础参数与开关</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? '保存中...' : '保存设置'}
|
||||
</Button>
|
||||
{activeTab === 'system' && (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? '保存中...' : '保存设置'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="mb-6 bg-[#0f2137] border border-gray-700/50 p-1">
|
||||
<TabsTrigger
|
||||
value="system"
|
||||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
系统设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="author"
|
||||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
|
||||
>
|
||||
<UserCircle className="w-4 h-4 mr-2" />
|
||||
作者详情
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="admin"
|
||||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4 mr-2" />
|
||||
管理员
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="system" className="mt-0">
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
@@ -561,7 +605,17 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="author" className="mt-0">
|
||||
<AuthorSettingsPage />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="admin" className="mt-0">
|
||||
<AdminUsersPage />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/richeditor.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
|
||||
@@ -54,6 +54,12 @@ func Init(dsn string) error {
|
||||
if err := db.AutoMigrate(&model.CkbLeadRecord{}); err != nil {
|
||||
log.Printf("database: ckb_lead_records migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Person{}); err != nil {
|
||||
log.Printf("database: persons migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.LinkTag{}); err != nil {
|
||||
log.Printf("database: link_tags migrate warning: %v", err)
|
||||
}
|
||||
log.Println("database: connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -86,18 +86,34 @@ func DBBookAction(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"section": gin.H{
|
||||
"id": ch.ID,
|
||||
"title": ch.SectionTitle,
|
||||
"price": price,
|
||||
"content": ch.Content,
|
||||
"isNew": ch.IsNew,
|
||||
"partId": ch.PartID,
|
||||
"partTitle": ch.PartTitle,
|
||||
"chapterId": ch.ChapterID,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"id": ch.ID,
|
||||
"title": ch.SectionTitle,
|
||||
"price": price,
|
||||
"content": ch.Content,
|
||||
"isNew": ch.IsNew,
|
||||
"partId": ch.PartID,
|
||||
"partTitle": ch.PartTitle,
|
||||
"chapterId": ch.ChapterID,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"editionStandard": ch.EditionStandard,
|
||||
"editionPremium": ch.EditionPremium,
|
||||
},
|
||||
})
|
||||
return
|
||||
case "section-orders":
|
||||
// 某章节的付款记录(管理端展示)
|
||||
if id == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
var orders []model.Order
|
||||
if err := db.Where("product_type = ? AND product_id = ? AND status IN ?", "section", id, []string{"paid", "completed", "success"}).
|
||||
Order("pay_time DESC, created_at DESC").Limit(200).Find(&orders).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "orders": []model.Order{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "orders": orders})
|
||||
return
|
||||
case "export":
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
@@ -190,6 +206,12 @@ func DBBookAction(c *gin.Context) {
|
||||
// reorder:新顺序,支持跨篇跨章时附带 partId/chapterId
|
||||
IDs []string `json:"ids"`
|
||||
Items []reorderItem `json:"items"`
|
||||
// move-sections:批量移动节到目标篇/章
|
||||
SectionIds []string `json:"sectionIds"`
|
||||
TargetPartID string `json:"targetPartId"`
|
||||
TargetChapterID string `json:"targetChapterId"`
|
||||
TargetPartTitle string `json:"targetPartTitle"`
|
||||
TargetChapterTitle string `json:"targetChapterTitle"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
@@ -248,6 +270,28 @@ func DBBookAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Action == "move-sections" {
|
||||
if len(body.SectionIds) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "批量移动缺少 sectionIds(请先勾选要移动的节)"})
|
||||
return
|
||||
}
|
||||
if body.TargetPartID == "" || body.TargetChapterID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "批量移动缺少目标篇或目标章(targetPartId、targetChapterId)"})
|
||||
return
|
||||
}
|
||||
up := map[string]interface{}{
|
||||
"part_id": body.TargetPartID,
|
||||
"chapter_id": body.TargetChapterID,
|
||||
"part_title": body.TargetPartTitle,
|
||||
"chapter_title": body.TargetChapterTitle,
|
||||
}
|
||||
if err := db.Model(&model.Chapter{}).Where("id IN ?", body.SectionIds).Updates(up).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
|
||||
return
|
||||
}
|
||||
if body.ID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
|
||||
138
soul-api/internal/handler/db_ckb_leads.go
Normal file
138
soul-api/internal/handler/db_ckb_leads.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBCKBLeadList GET /api/db/ckb-leads 管理端-CKB线索明细
|
||||
// mode=submitted: ckb_submit_records(join/match 提交)
|
||||
// mode=contact: ckb_lead_records(链接卡若留资,有 phone/wechat)
|
||||
func DBCKBLeadList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
mode := c.DefaultQuery("mode", "submitted")
|
||||
matchType := c.Query("matchType")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
if mode == "contact" {
|
||||
// ckb_lead_records:链接卡若留资
|
||||
q := db.Model(&model.CkbLeadRecord{})
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var records []model.CkbLeadRecord
|
||||
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"userId": r.UserID,
|
||||
"userNickname": r.Nickname,
|
||||
"matchType": "lead",
|
||||
"phone": r.Phone,
|
||||
"wechatId": r.WechatID,
|
||||
"name": r.Name,
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
|
||||
return
|
||||
}
|
||||
|
||||
// mode=submitted: ckb_submit_records
|
||||
q := db.Model(&model.CkbSubmitRecord{})
|
||||
if matchType != "" {
|
||||
// matchType 对应 action: join 时 type 在 params 中,match 时 matchType 在 params 中
|
||||
// 简化:仅按 action 过滤,join 时 params 含 type
|
||||
if matchType == "join" || matchType == "match" {
|
||||
q = q.Where("action = ?", matchType)
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var records []model.CkbSubmitRecord
|
||||
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
userIDs := make(map[string]bool)
|
||||
for _, r := range records {
|
||||
if r.UserID != "" {
|
||||
userIDs[r.UserID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
if len(ids) > 0 {
|
||||
db.Where("id IN ?", ids).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
safeNickname := func(u *model.User) string {
|
||||
if u == nil || u.Nickname == nil {
|
||||
return ""
|
||||
}
|
||||
return *u.Nickname
|
||||
}
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"userId": r.UserID,
|
||||
"userNickname": safeNickname(userMap[r.UserID]),
|
||||
"matchType": r.Action,
|
||||
"nickname": r.Nickname,
|
||||
"params": r.Params,
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
|
||||
}
|
||||
|
||||
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(基于 ckb_submit_records + ckb_lead_records)
|
||||
func CKBPlanStats(c *gin.Context) {
|
||||
db := database.DB()
|
||||
type TypeStat struct {
|
||||
Action string `gorm:"column:action" json:"matchType"`
|
||||
Total int64 `gorm:"column:total" json:"total"`
|
||||
}
|
||||
var submitStats []TypeStat
|
||||
db.Raw("SELECT action, COUNT(*) as total FROM ckb_submit_records GROUP BY action").Scan(&submitStats)
|
||||
var submitTotal int64
|
||||
db.Model(&model.CkbSubmitRecord{}).Count(&submitTotal)
|
||||
var leadTotal int64
|
||||
db.Model(&model.CkbLeadRecord{}).Count(&leadTotal)
|
||||
withContact := leadTotal // ckb_lead_records 均有 phone 或 wechat
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"ckbTotal": submitTotal + leadTotal,
|
||||
"withContact": withContact,
|
||||
"byType": submitStats,
|
||||
"ckbApiKey": "***",
|
||||
"ckbApiUrl": "https://ckbapi.quwanzhi.com/v1/api/scenarios",
|
||||
"docNotes": "",
|
||||
"docContent": "",
|
||||
"routes": gin.H{},
|
||||
},
|
||||
})
|
||||
}
|
||||
75
soul-api/internal/handler/db_link_tag.go
Normal file
75
soul-api/internal/handler/db_link_tag.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBLinkTagList GET /api/db/link-tags 管理端-链接标签列表
|
||||
func DBLinkTagList(c *gin.Context) {
|
||||
var rows []model.LinkTag
|
||||
if err := database.DB().Order("label ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
|
||||
}
|
||||
|
||||
// DBLinkTagSave POST /api/db/link-tags 管理端-新增或更新链接标签
|
||||
func DBLinkTagSave(c *gin.Context) {
|
||||
var body struct {
|
||||
TagID string `json:"tagId"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
AppID string `json:"appId"`
|
||||
PagePath string `json:"pagePath"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.TagID == "" || body.Label == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 和 label 必填"})
|
||||
return
|
||||
}
|
||||
if body.Type == "" {
|
||||
body.Type = "url"
|
||||
}
|
||||
db := database.DB()
|
||||
var existing model.LinkTag
|
||||
if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil {
|
||||
existing.Label = body.Label
|
||||
existing.URL = body.URL
|
||||
existing.Type = body.Type
|
||||
existing.AppID = body.AppID
|
||||
existing.PagePath = body.PagePath
|
||||
db.Save(&existing)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
||||
return
|
||||
}
|
||||
t := model.LinkTag{TagID: body.TagID, Label: body.Label, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
|
||||
}
|
||||
|
||||
// DBLinkTagDelete DELETE /api/db/link-tags?tagId=xxx 管理端-删除链接标签
|
||||
func DBLinkTagDelete(c *gin.Context) {
|
||||
tid := c.Query("tagId")
|
||||
if tid == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 tagId"})
|
||||
return
|
||||
}
|
||||
if err := database.DB().Where("tag_id = ?", tid).Delete(&model.LinkTag{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
71
soul-api/internal/handler/db_person.go
Normal file
71
soul-api/internal/handler/db_person.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBPersonList GET /api/db/persons 管理端-@提及人物列表
|
||||
func DBPersonList(c *gin.Context) {
|
||||
var rows []model.Person
|
||||
if err := database.DB().Order("name ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "persons": rows})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.Name == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 必填"})
|
||||
return
|
||||
}
|
||||
if body.PersonID == "" {
|
||||
body.PersonID = fmt.Sprintf("%s_%d", body.Name, time.Now().UnixMilli())
|
||||
}
|
||||
db := database.DB()
|
||||
var existing model.Person
|
||||
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
|
||||
existing.Name = body.Name
|
||||
existing.Label = body.Label
|
||||
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}
|
||||
if err := db.Create(&p).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "person": p})
|
||||
}
|
||||
|
||||
// DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物
|
||||
func DBPersonDelete(c *gin.Context) {
|
||||
pid := c.Query("personId")
|
||||
if pid == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 personId"})
|
||||
return
|
||||
}
|
||||
if err := database.DB().Where("person_id = ?", pid).Delete(&model.Person{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -11,8 +11,40 @@ import (
|
||||
)
|
||||
|
||||
// DBMatchRecordsList GET /api/db/match-records 管理端-匹配记录列表(分页、按类型筛选)
|
||||
// 当 ?stats=true 时返回汇总统计
|
||||
func DBMatchRecordsList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
if c.Query("stats") == "true" {
|
||||
var totalMatches int64
|
||||
db.Raw("SELECT COUNT(*) FROM match_records").Scan(&totalMatches)
|
||||
var todayMatches int64
|
||||
db.Raw("SELECT COUNT(*) FROM match_records WHERE created_at >= CURDATE()").Scan(&todayMatches)
|
||||
type TypeCount struct {
|
||||
MatchType string `json:"matchType" gorm:"column:match_type"`
|
||||
Count int64 `json:"count" gorm:"column:count"`
|
||||
}
|
||||
var byType []TypeCount
|
||||
db.Raw("SELECT match_type, COUNT(*) as count FROM match_records GROUP BY match_type").Scan(&byType)
|
||||
var uniqueUsers int64
|
||||
db.Raw("SELECT COUNT(DISTINCT user_id) FROM match_records WHERE user_id IS NOT NULL AND user_id != ''").Scan(&uniqueUsers)
|
||||
var matchRevenue float64
|
||||
db.Model(&model.Order{}).Where("product_type = ? AND status IN ?", "match", []string{"paid", "completed", "success"}).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&matchRevenue)
|
||||
var paidMatchCount int64
|
||||
db.Model(&model.Order{}).Where("product_type = ? AND status IN ?", "match", []string{"paid", "completed", "success"}).Count(&paidMatchCount)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"totalMatches": totalMatches,
|
||||
"todayMatches": todayMatches,
|
||||
"byType": byType,
|
||||
"uniqueUsers": uniqueUsers,
|
||||
"matchRevenue": matchRevenue,
|
||||
"paidMatchCount": paidMatchCount,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
matchType := c.Query("matchType")
|
||||
@@ -94,3 +126,26 @@ func DBMatchRecordsList(c *gin.Context) {
|
||||
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// DBMatchPoolCounts GET /api/db/match-pool-counts 返回各匹配池的用户人数
|
||||
func DBMatchPoolCounts(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var vipCount int64
|
||||
db.Model(&model.User{}).Where("is_vip = 1 AND vip_expire_date > NOW()").Count(&vipCount)
|
||||
var completeCount int64
|
||||
db.Model(&model.User{}).Where(
|
||||
"(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')",
|
||||
).Count(&completeCount)
|
||||
var allCount int64
|
||||
db.Model(&model.User{}).Where(
|
||||
"((wechat_id IS NOT NULL AND wechat_id != '') OR (phone IS NOT NULL AND phone != ''))",
|
||||
).Count(&allCount)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"vip": vipCount,
|
||||
"complete": completeCount,
|
||||
"all": allCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,18 +171,43 @@ func UserCheckPurchased(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_vip"}})
|
||||
return
|
||||
}
|
||||
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
|
||||
if hasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
return
|
||||
}
|
||||
if type_ == "fullbook" {
|
||||
// 9.9 买断:永久权益,写入 users.has_full_book;兜底再查订单
|
||||
if user.HasFullBook != nil && *user.HasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
return
|
||||
}
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userId, "fullbook", "paid").Count(&count)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "fullbook_order_exists"}[count > 0]}})
|
||||
return
|
||||
}
|
||||
if type_ == "section" && productId != "" {
|
||||
// 章节:需要区分普通版/增值版
|
||||
var ch model.Chapter
|
||||
// 不加载 content,避免大字段
|
||||
_ = db.Select("id", "is_free", "price", "edition_standard", "edition_premium").Where("id = ?", productId).First(&ch).Error
|
||||
|
||||
// 免费章节:直接可读
|
||||
if ch.ID != "" {
|
||||
if (ch.IsFree != nil && *ch.IsFree) || (ch.Price != nil && *ch.Price == 0) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "free_section"}})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isPremium := ch.ID != "" && ch.EditionPremium != nil && *ch.EditionPremium
|
||||
// 默认普通版:未明确标记增值版时,按普通版处理
|
||||
isStandard := !isPremium
|
||||
|
||||
// 普通版:买断可读;增值版:买断不包含
|
||||
if isStandard {
|
||||
if user.HasFullBook != nil && *user.HasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status = ?", userId, "section", productId, "paid").Count(&count)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "section_order_exists"}[count > 0]}})
|
||||
@@ -382,11 +407,8 @@ func UserPurchaseStatus(c *gin.Context) {
|
||||
if user.PendingEarnings != nil {
|
||||
pendingEarnings = *user.PendingEarnings
|
||||
}
|
||||
// 超级VIP(管理端开通):与 check-purchased 一致,视为全章可读
|
||||
// 9.9 买断:仅表示“普通版买断”,不等同 VIP(增值版仍需 VIP 或单章购买)
|
||||
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
|
||||
if !hasFullBook && user.IsVip != nil && *user.IsVip && user.VipExpireDate != nil && user.VipExpireDate.After(time.Now()) {
|
||||
hasFullBook = true
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"hasFullBook": hasFullBook,
|
||||
"purchasedSections": purchasedSections,
|
||||
|
||||
@@ -45,8 +45,10 @@ func isVipFromUsers(db *gorm.DB, userID string) (bool, *time.Time) {
|
||||
// isVipFromOrders 从 orders 表判断是否 VIP(兜底)
|
||||
func isVipFromOrders(db *gorm.DB, userID string) (bool, *time.Time) {
|
||||
var order model.Order
|
||||
err := db.Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)",
|
||||
userID, "paid", "completed", "fullbook", "vip").
|
||||
// 注意:fullbook=9.9 买断(永久权益),不等同于 VIP(365天)。
|
||||
// VIP 仅认 product_type=vip。
|
||||
err := db.Where("user_id = ? AND (status = ? OR status = ?) AND product_type = ?",
|
||||
userID, "paid", "completed", "vip").
|
||||
Order("pay_time DESC").First(&order).Error
|
||||
if err != nil || order.PayTime == nil {
|
||||
return false, nil
|
||||
@@ -97,7 +99,7 @@ func VipStatus(c *gin.Context) {
|
||||
daysRemaining := 0
|
||||
expStr := ""
|
||||
if expireDate != nil {
|
||||
daysRemaining = int(expireDate.Sub(time.Now()).Hours()/24) + 1
|
||||
daysRemaining = int(time.Until(*expireDate).Hours()/24) + 1
|
||||
if daysRemaining < 0 {
|
||||
daysRemaining = 0
|
||||
}
|
||||
|
||||
18
soul-api/internal/model/link_tag.go
Normal file
18
soul-api/internal/model/link_tag.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// LinkTag 链接标签配置(ContentPage 用)
|
||||
type LinkTag struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TagID string `gorm:"column:tag_id;size:50;uniqueIndex" json:"tagId"`
|
||||
Label string `gorm:"column:label;size:200" json:"label"`
|
||||
URL string `gorm:"column:url;size:500" json:"url"`
|
||||
Type string `gorm:"column:type;size:20" json:"type"`
|
||||
AppID string `gorm:"column:app_id;size:100" json:"appId,omitempty"`
|
||||
PagePath string `gorm:"column:page_path;size:500" json:"pagePath,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (LinkTag) TableName() string { return "link_tags" }
|
||||
15
soul-api/internal/model/person.go
Normal file
15
soul-api/internal/model/person.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func (Person) TableName() string { return "persons" }
|
||||
@@ -143,11 +143,20 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
db.PUT("/vip-roles", handler.DBVipRolesAction)
|
||||
db.DELETE("/vip-roles", handler.DBVipRolesAction)
|
||||
db.GET("/match-records", handler.DBMatchRecordsList)
|
||||
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
|
||||
db.GET("/mentors", handler.DBMentorsList)
|
||||
db.POST("/mentors", handler.DBMentorsAction)
|
||||
db.PUT("/mentors", handler.DBMentorsAction)
|
||||
db.DELETE("/mentors", handler.DBMentorsAction)
|
||||
db.GET("/mentor-consultations", handler.DBMentorConsultationsList)
|
||||
db.GET("/persons", handler.DBPersonList)
|
||||
db.POST("/persons", handler.DBPersonSave)
|
||||
db.DELETE("/persons", handler.DBPersonDelete)
|
||||
db.GET("/link-tags", handler.DBLinkTagList)
|
||||
db.POST("/link-tags", handler.DBLinkTagSave)
|
||||
db.DELETE("/link-tags", handler.DBLinkTagDelete)
|
||||
db.GET("/ckb-leads", handler.DBCKBLeadList)
|
||||
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
|
||||
}
|
||||
|
||||
// ----- 分销 -----
|
||||
|
||||
23
soul-api/scripts/add-persons-link-tags.sql
Normal file
23
soul-api/scripts/add-persons-link-tags.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- persons、link_tags 表,供 ContentPage @提及人物与链接标签配置
|
||||
-- 执行:mysql -u user -p db < soul-api/scripts/add-persons-link-tags.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persons (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
person_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
label VARCHAR(200) NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS link_tags (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
tag_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
label VARCHAR(200) NOT NULL DEFAULT '',
|
||||
url VARCHAR(500) NOT NULL DEFAULT '',
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'url',
|
||||
app_id VARCHAR(100) NOT NULL DEFAULT '',
|
||||
page_path VARCHAR(500) NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
157
开发文档/服务器管理/Windows-macOS虚拟机安装指南.md
Normal file
157
开发文档/服务器管理/Windows-macOS虚拟机安装指南.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Windows 上运行 macOS 虚拟机 - GitHub 方案汇总
|
||||
|
||||
> **重要说明**:**macOS 无法在 Docker 中运行**。Docker 只能运行 Linux 容器。本方案使用 **WSL2 + QEMU/KVM 虚拟机**,在 Windows 上虚拟出一台「Mac」。
|
||||
>
|
||||
> **用途**:仅用于演示、测试、学习。请遵守 Apple 软件许可协议。
|
||||
>
|
||||
> **环境**:Windows 10/11 + WSL2 + Ubuntu,通过 QEMU/KVM 运行 macOS 虚拟机。
|
||||
|
||||
---
|
||||
|
||||
## 一、GitHub 方案概览
|
||||
|
||||
| 项目 | Stars | 适用场景 | Windows 支持 |
|
||||
|------|-------|----------|-------------|
|
||||
| [OneClick-macOS-Simple-KVM](https://github.com/notAperson535/OneClick-macOS-Simple-KVM) | 877 | **推荐** 一键安装,适合 Windows | ✅ 支持 |
|
||||
| [OSX-KVM](https://github.com/kholia/OSX-KVM) | 23k | 成熟方案,支持 Monterey/Ventura/Sonoma | ⚠️ 需 WSL2 |
|
||||
| [dortania/OpenCore-Install-Guide](https://github.com/dortania/OpenCore-Install-Guide) | 4.1k | 官方 OpenCore 安装指南 | 通用 |
|
||||
| [Elymac/Hackintosh](https://github.com/Elymac/Hackintosh) | 3 | Windows 下制作 macOS 安装盘 | 物理机安装 |
|
||||
|
||||
**结论**:在 Windows 上做演示/测试,优先用 **OneClick-macOS-Simple-KVM**(WSL2 + QEMU/KVM)。
|
||||
|
||||
---
|
||||
|
||||
## 二、前置要求
|
||||
|
||||
- Windows 10 或更高
|
||||
- 8GB+ 内存(建议 16GB)
|
||||
- CPU 支持虚拟化(BIOS 中开启 VT-x/AMD-V)
|
||||
- **AMD Ryzen/EPYC**:需 Windows 11 才支持嵌套虚拟化
|
||||
|
||||
---
|
||||
|
||||
## 三、全自动安装步骤(PowerShell + WSL2)
|
||||
|
||||
> 也可使用项目提供的脚本:`开发文档/服务器管理/scripts/macOS-VM-安装-步骤1-WSL.ps1` 和 `macOS-VM-安装-步骤2-克隆与检查.ps1`(步骤 1 需管理员权限)。
|
||||
|
||||
### 步骤 1:安装 WSL2(管理员 PowerShell)
|
||||
|
||||
```powershell
|
||||
wsl --install
|
||||
```
|
||||
|
||||
安装完成后**重启电脑**。若未自动安装 Ubuntu,再执行:
|
||||
|
||||
```powershell
|
||||
wsl --install Ubuntu
|
||||
```
|
||||
|
||||
### 步骤 2:进入 WSL 并克隆项目
|
||||
|
||||
重启后打开 **Ubuntu** 终端,执行(将 `WINDOWS_USER_NAME` 换成你的 Windows 用户名):
|
||||
|
||||
```bash
|
||||
cd /mnt/c/users/WINDOWS_USER_NAME/Documents
|
||||
sudo apt update && sudo apt install -y git cpu-checker
|
||||
git clone https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git
|
||||
cd OneClick-macOS-Simple-KVM
|
||||
```
|
||||
|
||||
### 步骤 3:检查虚拟化
|
||||
|
||||
```bash
|
||||
kvm-ok
|
||||
```
|
||||
|
||||
应输出 `KVM acceleration can be used`。
|
||||
|
||||
- **Intel**:`cat /sys/module/kvm_intel/parameters/nested` 应为 `Y`
|
||||
- **AMD**:`cat /sys/module/kvm_amd/parameters/nested` 应为 `Y` 或 `1`
|
||||
|
||||
若为 `N`,见文末「故障排查」。
|
||||
|
||||
### 步骤 4:首次设置并启动
|
||||
|
||||
```bash
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
出现 QEMU 窗口后按 `Ctrl+C` 停止,再执行:
|
||||
|
||||
```bash
|
||||
sudo HEADLESS=1 ./basic.sh
|
||||
```
|
||||
|
||||
### 步骤 5:用 VNC 连接
|
||||
|
||||
1. 下载 [VNC Viewer](https://www.realvnc.com/en/connect/download/viewer/)
|
||||
2. 连接地址:`localhost:5900`
|
||||
3. 在虚拟机内完成 macOS 安装向导
|
||||
|
||||
---
|
||||
|
||||
## 四、日常启动(非首次)
|
||||
|
||||
每次启动虚拟机只需:
|
||||
|
||||
```bash
|
||||
cd /mnt/c/users/WINDOWS_USER_NAME/Documents/OneClick-macOS-Simple-KVM
|
||||
sudo HEADLESS=1 ./basic.sh
|
||||
```
|
||||
|
||||
然后用 VNC 连接 `localhost:5900`。
|
||||
|
||||
**注意**:不要关闭运行 `basic.sh` 的终端,否则虚拟机会关闭。
|
||||
|
||||
---
|
||||
|
||||
## 五、故障排查
|
||||
|
||||
### 1. nested 返回 N,但 kvm-ok 正常
|
||||
|
||||
在 Windows 用户目录创建/编辑 `.wslconfig`(路径:`C:\Users\WINDOWS_USER_NAME\.wslconfig`):
|
||||
|
||||
```ini
|
||||
[wsl2]
|
||||
nestedVirtualization=true
|
||||
kernel=C:\\Users\\WINDOWS_USER_NAME\\bzImage
|
||||
debugConsole=true
|
||||
pageReporting=true
|
||||
kernelCommandLine=intel_iommu=on iommu=pt kvm.ignore_msrs=1 kvm-intel.nested=1 kvm-intel.ept=1 kvm-intel.emulate_invalid_guest_state=0 kvm-intel.enable_shadow_vmcs=1 kvm-intel.enable_apicv=1
|
||||
```
|
||||
|
||||
保存后执行 `wsl --shutdown`,再重新打开 WSL。
|
||||
|
||||
### 2. AMD 处理器提示 "Your CPU does not support KVM extensions"
|
||||
|
||||
Windows 10 对 AMD Ryzen/EPYC 的嵌套虚拟化支持有限,需升级到 **Windows 11**。
|
||||
|
||||
### 3. 获取 Windows 用户名
|
||||
|
||||
PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
$env:USERNAME
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、相关链接
|
||||
|
||||
- [OneClick 官方文档 - Windows 安装](https://oneclick-macos-simple-kvm.notaperson535.is-a.dev/docs/windows-install)
|
||||
- [Dortania OpenCore 安装指南](https://dortania.github.io/OpenCore-Install-Guide/)
|
||||
- [OSX-KVM 项目](https://github.com/kholia/OSX-KVM)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 七、一键脚本
|
||||
|
||||
- **macOS-VM-安装-简化版.bat**:双击运行,自动完成克隆、安装依赖、执行 setup
|
||||
- 若 setup.sh 提示选择 macOS 版本,输入 `7` 选 Sonoma 或 `6` 选 Ventura
|
||||
- 出现 QEMU 窗口后按 Ctrl+C,再执行 `sudo HEADLESS=1 ./basic.sh`,用 VNC 连接 localhost:5900
|
||||
|
||||
---
|
||||
|
||||
*文档生成时间:2026-03-09*
|
||||
24
开发文档/服务器管理/scripts/macOS-VM-setup-auto.sh
Normal file
24
开发文档/服务器管理/scripts/macOS-VM-setup-auto.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# 全自动 macOS 虚拟机安装脚本 - 无交互
|
||||
# 在 WSL Ubuntu 中运行
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== 安装依赖 ==="
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y qemu-system qemu-utils python3 python3-pip
|
||||
|
||||
echo "=== 创建虚拟磁盘 ==="
|
||||
[ -f macOS.qcow2 ] || qemu-img create -f qcow2 macOS.qcow2 64G
|
||||
|
||||
echo "=== 下载 macOS Ventura 恢复镜像(约 6GB,请耐心等待)==="
|
||||
python3 fetch-macOS-v2.py -s ventura 2>/dev/null || python3 fetch-macOS-v2.py -s catalina
|
||||
|
||||
echo "=== 转换镜像 ==="
|
||||
[ -f RecoveryImage.dmg ] && mv RecoveryImage.dmg BaseSystem.dmg
|
||||
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
|
||||
|
||||
echo "=== 完成。启动虚拟机请执行: sudo HEADLESS=1 ./basic.sh ==="
|
||||
echo "=== 然后用 VNC Viewer 连接 localhost:5900 ==="
|
||||
51
开发文档/服务器管理/scripts/macOS-VM-一键安装.ps1
Normal file
51
开发文档/服务器管理/scripts/macOS-VM-一键安装.ps1
Normal file
@@ -0,0 +1,51 @@
|
||||
# macOS 虚拟机 - 一键安装脚本(全自动,无交互)
|
||||
# 说明:macOS 无法在 Docker 中运行,本脚本使用 WSL2 + QEMU/KVM 虚拟机方案
|
||||
# 运行方式:PowerShell 中执行 .\macOS-VM-一键安装.ps1
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$docPath = "C:\Users\$env:USERNAME\Documents"
|
||||
$repoPath = "$docPath\OneClick-macOS-Simple-KVM"
|
||||
$wslPath = "/mnt/c/Users/$env:USERNAME/Documents/OneClick-macOS-Simple-KVM" -replace '\\','/'
|
||||
|
||||
Write-Host "`n=== macOS 虚拟机一键安装(全自动)===" -ForegroundColor Cyan
|
||||
Write-Host "目标: $repoPath`n" -ForegroundColor Gray
|
||||
|
||||
# 1. 克隆项目
|
||||
if (-not (Test-Path "$repoPath\.git")) {
|
||||
Write-Host "[1/5] 克隆 OneClick-macOS-Simple-KVM..." -ForegroundColor Yellow
|
||||
wsl -d Ubuntu-24.04 -e bash -c "cd /mnt/c/Users/$env:USERNAME/Documents; git clone https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git 2>&1"
|
||||
} else {
|
||||
Write-Host "[1/5] 项目已存在" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# 2. 安装依赖
|
||||
Write-Host "`n[2/5] 安装 QEMU、Python 等依赖..." -ForegroundColor Yellow
|
||||
wsl -d Ubuntu-24.04 -e bash -c "cd $wslPath; export DEBIAN_FRONTEND=noninteractive; sudo apt-get update -qq; sudo apt-get install -y qemu-system qemu-utils python3 python3-pip 2>&1 | tail -5"
|
||||
|
||||
# 3. 检查 KVM
|
||||
Write-Host "`n[3/5] 检查虚拟化..." -ForegroundColor Yellow
|
||||
wsl -d Ubuntu-24.04 -e bash -c "kvm-ok 2>&1"
|
||||
|
||||
# 4. 生成并执行 bash 脚本
|
||||
$shContent = @'
|
||||
cd PLACEHOLDER_WSLPATH
|
||||
chmod +x *.sh *.py 2>/dev/null
|
||||
test -f macOS.qcow2 || qemu-img create -f qcow2 macOS.qcow2 64G
|
||||
python3 fetch-macOS-v2.py -s ventura 2>/dev/null || python3 fetch-macOS-v2.py -s catalina
|
||||
test -f RecoveryImage.dmg && mv RecoveryImage.dmg BaseSystem.dmg
|
||||
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
|
||||
echo done
|
||||
'@ -replace 'PLACEHOLDER_WSLPATH', $wslPath
|
||||
$shFile = Join-Path $env:TEMP "macos-vm-setup-$PID.sh"
|
||||
$shContent | Out-File -FilePath $shFile -Encoding ASCII
|
||||
$wslShPath = "/mnt/" + $shFile.Substring(0,1).ToLower() + ($shFile.Substring(2) -replace '\\','/')
|
||||
|
||||
Write-Host "`n[4/5] 下载 macOS Ventura 恢复镜像(约 6GB,需 10-30 分钟)..." -ForegroundColor Yellow
|
||||
wsl -d Ubuntu-24.04 -e bash $wslShPath
|
||||
Remove-Item $shFile -ErrorAction SilentlyContinue
|
||||
|
||||
# 5. 启动虚拟机
|
||||
Write-Host "`n[5/5] 启动 macOS 虚拟机..." -ForegroundColor Yellow
|
||||
Write-Host "VNC: localhost:5900" -ForegroundColor Cyan
|
||||
$vmCmd = 'cd ' + $wslPath + '; sudo HEADLESS=1 ./basic.sh'
|
||||
wsl -d Ubuntu-24.04 -e bash -c $vmCmd
|
||||
13
开发文档/服务器管理/scripts/macOS-VM-安装-步骤1-WSL.ps1
Normal file
13
开发文档/服务器管理/scripts/macOS-VM-安装-步骤1-WSL.ps1
Normal file
@@ -0,0 +1,13 @@
|
||||
# macOS 虚拟机安装 - 步骤 1:安装 WSL2
|
||||
# 需以管理员身份运行 PowerShell
|
||||
# 执行后需重启电脑,再运行 步骤2
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== macOS 虚拟机安装 - 步骤 1/2 ===" -ForegroundColor Cyan
|
||||
Write-Host "正在安装 WSL2..." -ForegroundColor Yellow
|
||||
|
||||
wsl --install
|
||||
|
||||
Write-Host "`nWSL 安装完成。请重启电脑,然后运行 步骤2 脚本。" -ForegroundColor Green
|
||||
Write-Host "若未自动安装 Ubuntu,重启后可执行: wsl --install Ubuntu" -ForegroundColor Gray
|
||||
44
开发文档/服务器管理/scripts/macOS-VM-安装-步骤2-克隆与检查.ps1
Normal file
44
开发文档/服务器管理/scripts/macOS-VM-安装-步骤2-克隆与检查.ps1
Normal file
@@ -0,0 +1,44 @@
|
||||
# macOS 虚拟机安装 - 步骤 2:克隆项目并检查环境
|
||||
# 在重启后运行,会启动 WSL 并执行克隆与检查
|
||||
# 需先完成步骤 1 并重启
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$winUser = $env:USERNAME
|
||||
$targetDir = "C:\Users\$winUser\Documents\OneClick-macOS-Simple-KVM"
|
||||
|
||||
Write-Host "=== macOS 虚拟机安装 - 步骤 2/2 ===" -ForegroundColor Cyan
|
||||
Write-Host "Windows 用户: $winUser" -ForegroundColor Gray
|
||||
Write-Host "目标目录: $targetDir`n" -ForegroundColor Gray
|
||||
|
||||
# 生成临时脚本并执行
|
||||
$shPath = "$env:TEMP\macos-vm-setup.sh"
|
||||
$shContent = @"
|
||||
cd /mnt/c/users/$winUser/Documents
|
||||
sudo apt update -qq && sudo apt install -y git cpu-checker 2>/dev/null
|
||||
if [ -d OneClick-macOS-Simple-KVM ]; then
|
||||
echo '项目已存在,进入目录...'
|
||||
cd OneClick-macOS-Simple-KVM
|
||||
else
|
||||
git clone https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git
|
||||
cd OneClick-macOS-Simple-KVM
|
||||
fi
|
||||
echo ''
|
||||
echo '=== 虚拟化检查 ==='
|
||||
kvm-ok 2>/dev/null || echo 'kvm-ok 未通过,请检查 BIOS 虚拟化设置'
|
||||
echo ''
|
||||
echo '=== 下一步 ==='
|
||||
echo '在 WSL Ubuntu 终端中执行:'
|
||||
echo ' cd /mnt/c/users/$winUser/Documents/OneClick-macOS-Simple-KVM'
|
||||
echo ' ./setup.sh'
|
||||
echo ''
|
||||
echo '出现 QEMU 窗口后按 Ctrl+C,再执行:'
|
||||
echo ' sudo HEADLESS=1 ./basic.sh'
|
||||
echo ''
|
||||
echo '然后下载 VNC Viewer,连接 localhost:5900'
|
||||
"@
|
||||
$shContent | Out-File -FilePath $shPath -Encoding ASCII
|
||||
$wslPath = '/mnt/' + $shPath.Substring(0,1).ToLower() + $shPath.Substring(2) -replace '\\', '/'
|
||||
wsl bash $wslPath
|
||||
Remove-Item $shPath -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "`n完成。请按上述提示在 WSL 中继续操作。" -ForegroundColor Green
|
||||
27
开发文档/服务器管理/scripts/macOS-VM-安装-简化版.bat
Normal file
27
开发文档/服务器管理/scripts/macOS-VM-安装-简化版.bat
Normal file
@@ -0,0 +1,27 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo === macOS 虚拟机安装(WSL2+QEMU,非 Docker)===
|
||||
echo.
|
||||
echo [1] 克隆项目...
|
||||
wsl -d Ubuntu-24.04 -e bash -c "cd /mnt/c/Users/29195/Documents && (test -d OneClick-macOS-Simple-KVM && echo already exists || git clone https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git) 2>&1"
|
||||
if not exist "C:\Users\29195\Documents\OneClick-macOS-Simple-KVM\.git" (
|
||||
echo 克隆失败,请检查网络
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2] 安装依赖...
|
||||
wsl -d Ubuntu-24.04 -e bash -c "cd /mnt/c/Users/29195/Documents/OneClick-macOS-Simple-KVM && sudo apt-get update -qq && sudo apt-get install -y qemu-system qemu-utils python3 python3-pip"
|
||||
|
||||
echo.
|
||||
echo [3] 检查 KVM...
|
||||
wsl -d Ubuntu-24.04 -e bash -c "kvm-ok"
|
||||
|
||||
echo.
|
||||
echo [4] 下载 macOS 并配置(约 6GB,需 10-30 分钟)...
|
||||
wsl -d Ubuntu-24.04 -e bash -c "cd /mnt/c/Users/29195/Documents/OneClick-macOS-Simple-KVM && chmod +x *.sh *.py && ./setup.sh"
|
||||
|
||||
echo.
|
||||
echo 完成后请用 VNC Viewer 连接 localhost:5900
|
||||
pause
|
||||
Reference in New Issue
Block a user