更新小程序,新增VIP会员状态管理功能,优化章节解锁逻辑,支持VIP用户访问增值内容。调整用户详情页面,增加VIP相关字段和功能,提升用户体验。更新会议记录,反映最新讨论内容。

This commit is contained in:
Alex-larget
2026-03-10 11:04:34 +08:00
parent 30ebdb5ac7
commit 05ac60dc7e
60 changed files with 8387 additions and 1583 deletions

View File

@@ -0,0 +1,157 @@
# 会议纪要 - 2026-03-09 | devlop 与 yongxu 分支差异分析
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-09
- **议题**:分析 devlopdev 分支 / 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` 及多 TabCKBConfigPanel、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 | 用户 | (待补充) |
---
## 两分支差异速查表
| 维度 | devlopdev 分支) | 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`*

View 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 + 多 TabCKBConfigPanel、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 增量**:更丰富的 ContentPagepersons、link-tags、RichEditor @提及、FindPartnerPage 整页、UsersPage 扩展
- **策略**:在 soul-api 补全接口后,再选择性迁入 dev 的页面组件
---
## 三、迁移原则
1. **基准分支**yongxu功能完善、三端协调良好
2. **不引入**dev 对 miniprogram 的改动(避免覆盖 yongxu 的 @提及、推荐码等)
3. **分阶段**:先补 soul-api 缺口 → 再迁入 soul-admin 新页面/组件
4. **三端协调**:每迁入一个管理端功能,必须确保 soul-api 已有对应接口,且小程序若依赖该配置则已对齐
---
## 四、执行方案(按优先级)
### 阶段 1soul-api 补全(必须)
| 序号 | 任务 | 说明 |
|------|------|------|
| 1.1 | 新增 `db/persons` | model.Person、表 personsGET/POST/DELETE供 ContentPage @提及人物配置 |
| 1.2 | 新增 `db/link-tags` | model.LinkTag、表 link_tagsGET/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 分页;否则先建表再实现 |
### 阶段 2soul-admin 迁入(在阶段 1 完成后)
| 序号 | 任务 | 说明 |
|------|------|------|
| 2.1 | ContentPage 增强 | 从 dev 迁入 persons、link-tags 配置区RichEditor @提及(需 persons 接口) |
| 2.2 | FindPartnerPage | 从 dev 迁入整页 + TabCKBConfigPanel、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. ContentPagepersons 配置区、link-tags 配置区
[ ] 7. ContentPageRichEditor @提及(调用 persons
[ ] 8. FindPartnerPage + TabsCKBConfigPanel、CKBStatsTab、FindPartnerTab 等)
[ ] 9. AdminLayout增加「找伙伴」菜单项若尚无
[ ] 10. UsersPageUserDetailModal 扩展(按需)
```
### 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 | 供开发执行参考*

View File

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

Submodule Mycontent-temp added at f00315d785

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

774
soul-admin/dist/assets/index-DsLRWYbk.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-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>

View File

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

View File

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

View File

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

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

View 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, '![$2]($1)')
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '![]($1)')
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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

View File

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

View File

@@ -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' },
]

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

View File

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

View File

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

View File

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

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

View 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=200message=新增成功 或 已存在`
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>
)
}

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/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"}

View File

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

View File

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

View 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_recordsjoin/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{},
},
})
}

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

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

View File

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

View File

@@ -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,

View File

@@ -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 买断(永久权益),不等同于 VIP365天
// 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
}

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

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

View File

@@ -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)
}
// ----- 分销 -----

View 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;

View 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*

View 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 ==="

View 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

View 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

View 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

View 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