Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
991e17698c | ||
|
|
8778a42429 | ||
|
|
db4b4b8b87 | ||
|
|
41ebc70a50 | ||
|
|
d3b67681d7 | ||
|
|
da6d2c0852 | ||
|
|
3d8873fe24 | ||
|
|
68c0bb1588 | ||
|
|
90edabfca2 | ||
|
|
f2af615087 | ||
|
|
08dd0703ec | ||
|
|
a8c7dc9306 | ||
|
|
dc3597c906 | ||
|
|
3b942fd7a4 | ||
|
|
aebb533507 | ||
|
|
e23eba5d3e | ||
|
|
3e22e54f75 | ||
|
|
f1afeee5e0 | ||
|
|
05ac60dc7e | ||
|
|
f00315d785 | ||
|
|
30ebdb5ac7 | ||
|
|
07e8a43bff | ||
|
|
c3de123ef8 |
13
.cursor/agent/产品经理/evolution/2026-03-10.md
Normal file
13
.cursor/agent/产品经理/evolution/2026-03-10.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 产品经理 经验记录 - 2026-03-10
|
||||
|
||||
## 管理端迁移 Mycontent-temp:信息架构与验收口径
|
||||
|
||||
- **主导航收敛**:侧栏只保留运营主链路 5 项(概览/内容/用户/找伙伴/推广),系统设置固定在底部;其余能力不删除但不占主导航入口。
|
||||
- **入口承载策略**:非主菜单页面(订单/提现/推广设置/VIP角色/导师等)通过“概览卡片/页面内按钮/系统设置 Tab”进入,确保可达且路径更短。
|
||||
- **验收标准**:
|
||||
- 菜单与布局一致(新规范)
|
||||
- 隐藏页面路由仍可访问(功能不丢)
|
||||
- author/admin 设置统一在 `/settings?tab=...` 承载,旧路径可兼容跳转
|
||||
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md`
|
||||
|
||||
7
.cursor/agent/产品经理/evolution/2026-03-11.md
Normal file
7
.cursor/agent/产品经理/evolution/2026-03-11.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 产品经理 经验记录 - 2026-03-11
|
||||
|
||||
## 需求基准:以界面定需求
|
||||
|
||||
- 需求与验收以《开发文档/1、需求/以界面定需求》为准;新增/变更功能时先对齐界面再更新《需求汇总》需求清单。
|
||||
- 小程序与管理端界面清单、主要接口、业务逻辑对齐(用户/VIP 资料展示、三端 API 边界等)已落档,作为验收基准。
|
||||
- 详见团队共享:`agent/团队/evolution/2026-03-11.md`。
|
||||
@@ -4,3 +4,4 @@
|
||||
|------|------|------|
|
||||
| 2026-03-05 | 分支冲突后需求文档与实现一致性核对 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-05 | 文章详情@某人高亮与一键加好友验收标准与待确认 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp:主导航收敛与隐藏页面入口承载策略 | [2026-03-10.md](./2026-03-10.md) |
|
||||
|
||||
67
.cursor/agent/后端工程师/evolution/2026-03-10.md
Normal file
67
.cursor/agent/后端工程师/evolution/2026-03-10.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 后端工程师 经验记录 - 2026-03-10
|
||||
|
||||
## 管理端迁移 Mycontent-temp:后端视角注意点
|
||||
|
||||
- **接口边界不变**:管理端迁移/重构只允许调用 `/api/admin/*`、`/api/db/*`、`/api/orders`,严禁引入 `/api/miniprogram/*`。
|
||||
- **概览聚合接口可选**:`/api/admin/dashboard/overview` 可作为“优化项”提供更轻量的统计聚合,但必须保留**降级策略**(用 `/api/db/users` + `/api/orders` 拼)以免阻塞前端迁移与部署节奏。
|
||||
- **鉴权一致性**:页面入口/菜单变化不影响鉴权口径,仍以 `GET /api/admin` 作为 session/token 校验;401 统一跳登录并清 token。
|
||||
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md`
|
||||
|
||||
---
|
||||
|
||||
## 新增聚合接口 UserDashboardStats
|
||||
|
||||
**场景**:小程序「我的」页需要一个聚合接口返回阅读统计,避免多次请求。
|
||||
|
||||
**接口**:`GET /api/miniprogram/user/dashboard-stats?userId=xxx`
|
||||
|
||||
**数据来源**:
|
||||
- `readSectionIds` / `readCount` → `reading_progress` WHERE `user_id = userId`
|
||||
- `totalReadMinutes` → `SUM(duration) / 60`(秒转分,最小值 1 分钟)
|
||||
- `recentChapters` → `reading_progress` ORDER BY `last_open_at DESC` JOIN `chapters`(最近 5 条**去重**)
|
||||
- `matchHistory` → `match_records` COUNT WHERE `user_id = userId`
|
||||
|
||||
**三处 bug 修复点**(对比 Mycontent-temp 参考版发现):
|
||||
|
||||
| Bug | 错误做法 | 正确做法 |
|
||||
|-----|---------|---------|
|
||||
| 最近阅读重复 | 直接取前 5 条(同章节可重复) | `seenRecent` map 去重,保证 5 条不重复 |
|
||||
| 阅读时长最小值 | 不足 60 秒返回 0 | `if totalReadSeconds > 0 && totalReadMinutes == 0 { totalReadMinutes = 1 }` |
|
||||
| DB 错误状态码 | 返回 200 + `success:false` | 返回 HTTP 500 `InternalServerError` |
|
||||
|
||||
**规则沉淀**:新增聚合接口时,先参考已有版本实现,对比 diff 后修复潜在 bug,再提交。
|
||||
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_小程序新旧版对比与dashboard接口新增.md`
|
||||
|
||||
---
|
||||
|
||||
## chapters 表新增 hot_score 字段
|
||||
|
||||
### 问题
|
||||
|
||||
前端 `ContentPage.tsx` 保存章节时传递 `hotScore` 字段,但后端 model 和数据库均缺少该列,导致:
|
||||
```
|
||||
Error 1054 (42S22): Unknown column 'hot_score' in 'field list'
|
||||
```
|
||||
|
||||
### 修复步骤
|
||||
|
||||
1. 执行迁移 SQL(`soul-api/scripts/add-hot-score.sql`):
|
||||
```sql
|
||||
ALTER TABLE chapters ADD COLUMN hot_score INT NOT NULL DEFAULT 0;
|
||||
```
|
||||
2. 同步 model(`internal/model/chapter.go`):
|
||||
```go
|
||||
HotScore int `gorm:"column:hot_score;default:0" json:"hotScore"`
|
||||
```
|
||||
3. 重启后端服务生效
|
||||
|
||||
### 规则沉淀
|
||||
|
||||
- **model 与 DB 必须同步**:前端传入新字段时,必须先确认 DB 列存在,再确认 model struct 中有对应字段,缺一不可
|
||||
- **变更流程**:前端加字段 → ALTER TABLE → 更新 model struct → 重启服务
|
||||
- 迁移 SQL 统一放 `soul-api/scripts/` 目录,文件名格式 `add-{描述}.sql`
|
||||
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_Toast通知系统全局落地.md`
|
||||
|
||||
8
.cursor/agent/后端工程师/evolution/2026-03-11.md
Normal file
8
.cursor/agent/后端工程师/evolution/2026-03-11.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 后端工程师 经验记录 - 2026-03-11
|
||||
|
||||
## 数据库迁移:users 仅 VIP 身份/状态,chapters 补 hot_score
|
||||
|
||||
- users 表:迁移脚本只添加 is_vip、vip_expire_date、vip_activated_at、vip_sort、vip_role;不再添加 vip_name、vip_avatar、vip_project、vip_contact、vip_bio(小程序已改为直接读用户资料)。
|
||||
- chapters 表:SQL 导出仅有 hot_score_override,Model 使用 hot_score;迁移脚本增加 hot_score 列。
|
||||
- 脚本:`soul-api/scripts/sync-users-vip-and-schema.sql`;说明:`soul-api/scripts/README-schema-sync.md`。
|
||||
- 详见团队共享:`agent/团队/evolution/2026-03-11.md`。
|
||||
61
.cursor/agent/后端工程师/evolution/2026-03-12.md
Normal file
61
.cursor/agent/后端工程师/evolution/2026-03-12.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 后端工程师 经验记录 - 2026-03-12
|
||||
|
||||
## 1. persons 表 token 字段与 DB 迁移
|
||||
|
||||
### 问题
|
||||
|
||||
新增 @ 人物时报错:`Unknown column 'token' in 'field list'`。GORM model 已加 `Token` 字段,但数据库未执行迁移。
|
||||
|
||||
### 解决方案
|
||||
|
||||
- **迁移脚本**:`soul-api/scripts/add-persons-token.sql`
|
||||
- **执行**:`node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-persons-token.sql`
|
||||
- **内容**:`ALTER TABLE persons ADD COLUMN token VARCHAR(36) NOT NULL DEFAULT '' AFTER person_id` + 唯一索引
|
||||
|
||||
### 规则
|
||||
|
||||
- **Model 新增字段后**:需编写并执行 ALTER 脚本,GORM AutoMigrate 不一定自动生效(取决于启动时机与连接)
|
||||
- **迁移脚本位置**:`soul-api/scripts/`,命名 `add-xxx.sql`
|
||||
- **执行方式**:db-exec 脚本读取 soul-api/.env 的 DB_DSN
|
||||
|
||||
---
|
||||
|
||||
## 2. CKBLead 用 token 兑换真实密钥
|
||||
|
||||
- `targetUserId` 现为 persons.token(非 person_id)
|
||||
- 查询:`db.Where("token = ?", body.TargetUserID).First(&p)`
|
||||
- 取 `p.CkbApiKey` 调用存客宝
|
||||
|
||||
---
|
||||
|
||||
## 3. 9.9 买断与后端开关(hasFullBook)
|
||||
|
||||
### 场景
|
||||
|
||||
- 小程序已通过 `hasFullBook` 标识「买断全书」,权限判断和文案都依赖该字段(由 `/api/miniprogram/user/purchase-status` 与 `/api/miniprogram/user/check-purchased` 返回)。
|
||||
- 现在需要在用户资料里增加一个布尔开关:运维/客服手动打开后,相当于该用户已经买过 9.9,全书可看,后续不再需要支付。
|
||||
|
||||
### 设计要点
|
||||
|
||||
- **统一事实来源**:9.9 买断是否生效完全由后端计算,前端只认:
|
||||
- `purchase-status` 返回的 `hasFullBook = true`;
|
||||
- 或 `check-purchased` 返回 `isPurchased = true` 且 `reason = "has_full_book"`。
|
||||
- **用户资料开关**:
|
||||
- 在 `users` 表或 user profile 中新增布尔字段(例如 `manual_fullbook`,具体命名按现有规范调整)。
|
||||
- 仅管理端/运维修改该字段,小程序不直接写入。
|
||||
- **接口契约调整**:
|
||||
- `/api/miniprogram/user/purchase-status`:
|
||||
- 计算 `hasFullBook` 时,将订单表中的全书订单结果与 `manual_fullbook` 做 **OR**,只要任一为真就返回 `hasFullBook = true`。
|
||||
- `/api/miniprogram/user/check-purchased`:
|
||||
- 对章节做权限判断时,如果由 `manual_fullbook` 推导出可看,应返回:
|
||||
- `isPurchased = true`
|
||||
- `reason = "has_full_book"`
|
||||
- 前端的 `chapterAccessManager.syncLocalCache` 会据此把 `app.globalData.hasFullBook = true` 并同步到 `userInfo.hasFullBook`。
|
||||
- **与 VIP 的边界**:
|
||||
- `hasFullBook`(9.9 买断)与 `isVip`(会员)继续解耦:手动开 fullbook 开关不会自动授予 VIP。
|
||||
- VIP 相关逻辑只看 `isVip` / `vipExpireDate`,不受 `manual_fullbook` 影响。
|
||||
|
||||
### 规则
|
||||
|
||||
- 不在前端增加「跳过支付」开关,所有免 9.9 行为都通过后端折叠到 `hasFullBook/has_full_book` 暴露给小程序。
|
||||
- 满足以上约定后,小程序现有代码无需修改即可支持「后台手动赠送 9.9 买断」。
|
||||
45
.cursor/agent/后端工程师/evolution/2026-03-13.md
Normal file
45
.cursor/agent/后端工程师/evolution/2026-03-13.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 2026-03-13 - 文章详情预览统一与内容安全
|
||||
|
||||
## 问题 / 场景
|
||||
|
||||
- 文章详情目前小程序侧只展示约 20% 内容作为预览,再引导用户付费解锁。
|
||||
- 历史实现中前端本地按 20% 计算预览,后端曾同时返回外层 `content`(预览)和 `data.content`(全文),存在「接口约定不统一」和「误用 data.content 泄露全文」的风险。
|
||||
- 需求:统一由后端按业务规则截取预览(改为 50%),小程序只按「是否已付费」选择用预览还是全文;未付费时,无论字段层级都不能拿到全文。
|
||||
|
||||
## 解决方案
|
||||
|
||||
- 在 `internal/handler/book.go` 中调整章节预览逻辑:
|
||||
- `previewContent` 改为按字符数取正文前 50%(`total/2`),同时保证预览不少于 100 个字符;
|
||||
- 预览结尾统一追加 `……(购买后阅读完整内容)` 作为提示文案。
|
||||
- 在 `findChapterAndRespond` 中统一内容返回策略:
|
||||
- 先根据 system_config.free_chapters / chapter_config.freeChapters / chapters.is_free / price 判断章节是否免费;
|
||||
- 免费章节:`returnContent = ch.Content`(全文);
|
||||
- 付费章节:
|
||||
- 若 `checkUserChapterAccess` 判断用户已购买 / VIP / 全书:`returnContent = ch.Content`(全文);
|
||||
- 否则:`returnContent = previewContent(ch.Content)`(仅预览);
|
||||
- 构造响应时,将 `chForResponse.Content = returnContent`,并通过:
|
||||
- 外层 `content: returnContent`,
|
||||
- 内层 `data: chForResponse`(其中 `content` 也为 `returnContent`),
|
||||
- 确保未授权用户在任意字段上都拿不到完整正文。
|
||||
|
||||
## 与前端的接口约定
|
||||
|
||||
- 小程序阅读页通过 `userId` 查询章节详情,`accessManager` 基于返回的章节信息与用户购买状态计算 `accessState`:
|
||||
- 当 `accessState` 为 `free` 或 `unlocked_purchased` 时,前端使用 `res.data.content ?? res.content` 渲染全文;
|
||||
- 当 `accessState` 为未登录 / 未购买时,前端只使用 `res.content` 渲染预览。
|
||||
- 预览比例完全由后端控制(当前为 50%),小程序不再自行用 20% 做二次截断,只是把后端提供的预览完整展示出来。
|
||||
|
||||
## 代码位置
|
||||
|
||||
- 后端:
|
||||
- `soul-api/internal/handler/book.go`
|
||||
- 小程序(前端配合):
|
||||
- `miniprogram/pages/read/read.js`
|
||||
- `miniprogram/pages/read/read.wxml`
|
||||
|
||||
## 对后续开发的约定
|
||||
|
||||
- 预览长度(包括比例、最小字符数、提示文案)统一由后端控制;如需调整比例,只需修改 `previewContent`,保持接口字段含义不变。
|
||||
- 任何需要「只返回部分内容预览」的场景,应优先复用「外层 `content` + 内层 `data.content` 保持一致」的安全模式,避免在不同字段中混放全文与预览内容。
|
||||
- 涉及付费内容时,优先在后端用「权限判断 + 统一内容裁剪」实现安全边界,前端只根据状态选择展示预览还是全文。
|
||||
|
||||
@@ -4,3 +4,5 @@
|
||||
|------|------|------|
|
||||
| 2026-03-05 | soul-api 合并状态确认;orders、distribution 接口核对 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-05 | 文章详情@某人:content 内嵌 @ 标记、miniprogram 添加好友接口 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp:接口边界不变;overview 聚合接口可选但需降级 | [2026-03-10.md](./2026-03-10.md) |
|
||||
| 2026-03-12 | persons token 字段与 DB 迁移;CKBLead 用 token 兑换 ckb_api_key | [2026-03-12.md](./2026-03-12.md) |
|
||||
|
||||
20
.cursor/agent/团队/evolution/2026-03-08.md
Normal file
20
.cursor/agent/团队/evolution/2026-03-08.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 团队共享 经验记录 - 2026-03-08
|
||||
|
||||
## 文章阅读付费规则澄清与后端修复
|
||||
|
||||
### 业务规则(全团队共识)
|
||||
|
||||
1. **非会员专属文章**:免费,无需登录/付费;以管理端「系统设置 → 免费章节」配置为准
|
||||
2. **VIP 会员**:开通 VIP 后,所有文章免费阅读;`check-purchased` 按 `is_vip=1` 且 `vip_expire_date>NOW` 返回 `isPurchased: true`
|
||||
|
||||
### 技术实现
|
||||
|
||||
- **免费章节**:soul-api `book.go` 从 `system_config.free_chapters` 或 `chapter_config.freeChapters` 读取,优先于 chapters 表
|
||||
- **VIP 全章免费**:`user.go` 的 `UserCheckPurchased` 已实现,无需改动
|
||||
|
||||
### 影响角色
|
||||
|
||||
- 后端:book.go 变更,部署后需重启
|
||||
- 管理端:确保免费章节配置正确
|
||||
- 产品:作为验收规则
|
||||
- 小程序:无变更
|
||||
163
.cursor/agent/团队/evolution/2026-03-10.md
Normal file
163
.cursor/agent/团队/evolution/2026-03-10.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 团队共享 经验记录 - 2026-03-10
|
||||
|
||||
## 在 Windows 上一键启动 macOS 虚拟机(“龙虾”智能体经验)
|
||||
|
||||
### 场景 / 问题
|
||||
|
||||
- 成员希望在 **Windows 10/11** 上通过 **Docker** 一键安装 macOS,用于演示 / 测试。
|
||||
- 实际上:
|
||||
- **macOS 不能在 Docker 中运行**(Docker 只跑 Linux 容器,没有合法的 macOS 镜像)。
|
||||
- 唯一可行路径是:**WSL2 + Ubuntu + QEMU/KVM + OneClick-macOS-Simple-KVM**。
|
||||
|
||||
### 关键决策
|
||||
|
||||
1. **明确技术与法律边界**
|
||||
- 不支持也不承诺在 Docker 中直接跑 macOS。
|
||||
- 统一采用「**WSL2 + QEMU/KVM + OneClick**」这个方案,仅用于演示 / 测试。
|
||||
|
||||
2. **固定目录与流程约定**
|
||||
- Windows 侧统一放在:`C:\Users\{USERNAME}\Mycontent\macos-vm`
|
||||
- WSL 侧路径:`/mnt/c/Users/{USERNAME}/Mycontent/macos-vm`
|
||||
- 内部结构:
|
||||
- `OneClick-macOS-Simple-KVM/`
|
||||
- `BaseSystem.dmg` / `BaseSystem.img`
|
||||
- `macOS.qcow2`
|
||||
|
||||
3. **获取 OneClick 源码时优先走 zip,而不是 git clone**
|
||||
- 直接 `git clone` 很容易在国内网络环境下触发 `GnuTLS recv error (-110)` 等 TLS 超时。
|
||||
- 统一约定使用:
|
||||
- `https://codeload.github.com/notAperson535/OneClick-macOS-Simple-KVM/zip/refs/heads/master`
|
||||
- 然后在 WSL 中:
|
||||
- `curl + unzip` → 解压 → 重命名为 `OneClick-macOS-Simple-KVM`。
|
||||
|
||||
4. **依赖安装与 KVM 检查**
|
||||
- 在 Ubuntu-24.04 内安装:
|
||||
- `qemu-system qemu-utils python3 python3-pip cpu-checker`
|
||||
- 使用 `kvm-ok` 检查:
|
||||
- 期望输出:`/dev/kvm exists` + `KVM acceleration can be used`。
|
||||
- 若 `nested` 为 `N` 且需要嵌套虚拟化,使用 `.wslconfig` 打开 nested。
|
||||
|
||||
5. **下载 macOS Ventura 恢复镜像并生成 BaseSystem.img**
|
||||
- 通过 `python3 fetch-macOS-v2.py -s ventura` 下载官方 Recovery 镜像。
|
||||
- 将 `RecoveryImage.dmg` 重命名为 `BaseSystem.dmg`,再用 `qemu-img convert` 生成 `BaseSystem.img`。
|
||||
- 验收标准:
|
||||
- `BaseSystem.dmg` ≈ 678 MB
|
||||
- `BaseSystem.img` ≈ 3.0 GB
|
||||
|
||||
6. **以 HEADLESS + VNC 方式启动 VM**
|
||||
- 使用 `sudo HEADLESS=1 ./basic.sh` 启动 QEMU。
|
||||
- 在 Windows 中确认 `127.0.0.1:5900` 端口监听。
|
||||
- 统一告知使用 VNC 客户端连接 `localhost:5900`,再在图形界面内完成 macOS 安装向导。**用户环境已采用 TightVNC**,与 RealVNC Viewer 等方式等效。
|
||||
|
||||
7. **WSL 卡死与多 wsl 进程清理策略**
|
||||
- 当 `wsl --shutdown` 卡住、或者有大量 `wsl` 进程残留时:
|
||||
- 使用 PowerShell `Get-Process wsl | Stop-Process -Force` 清理。
|
||||
- 再执行 `wsl --shutdown` + `wsl -l -v` 验证状态恢复。
|
||||
|
||||
### 对应 Skill / 智能体
|
||||
|
||||
- 新建 Skill:`.cursor/skills/lobster-macos-vm/SKILL.md`
|
||||
- 技能名:`lobster-macos-vm`
|
||||
- 智能体名(对外):**“龙虾”**
|
||||
- 职责:当用户在 Windows 上提出安装 / 维护 macOS 虚拟机的需求时,统一按该 Skill 流程执行:
|
||||
- 解释 Docker 不可用 → 切换到 WSL2 + QEMU 方案。
|
||||
- 固定目录 → 下载 OneClick → 安装依赖 → 下载 Ventura → 生成 `BaseSystem.img` → HEADLESS 启动 → 引导 VNC 安装。
|
||||
|
||||
- 计划脚本化:
|
||||
- 在 `开发文档/服务器管理/scripts/lobster_macos_vm.py` 中实现一键部署脚本,封装上述流程,供“龙虾”及人类成员复用。
|
||||
|
||||
### 用户环境补充
|
||||
|
||||
- **VNC 客户端**:当前环境使用 **TightVNC** 连接 `localhost:5900`,已写入龙虾 Skill,后续回复可一并推荐 TightVNC / RealVNC / TigerVNC 等。
|
||||
|
||||
### 安装完成与使用规范快照
|
||||
|
||||
- 当前虚拟机参数:
|
||||
- 内存:8G(`-m 8G`)
|
||||
- CPU:1 颗 CPU × 4 核 × 2 线程(共 8 线程)
|
||||
- 系统盘:`macOS.qcow2`,逻辑容量 64G,可后续通过 `qemu-img resize` 扩容。
|
||||
- 启动方式快照:
|
||||
- 优先使用 `C:\Users\{USERNAME}\Mycontent\macos-vm\一键启动-macOS虚拟机.bat`
|
||||
- bat 内部做两件事:
|
||||
1. 启动 WSL → 进入 `OneClick-macOS-Simple-KVM` → `sudo HEADLESS=1 ./basic.sh`
|
||||
2. 等待约 10 秒后自动启动 TightVNC Viewer 连接 `localhost:5900`
|
||||
- 关闭规则:不要关名为「macOS 虚拟机 - 勿关此窗口」的终端窗口,否则虚拟机会被强制关闭。
|
||||
- 安装阶段经验:
|
||||
- 安装过程中多次重启属正常,出现 `X86PlatformPlugin::systemWillShutdown!` / `IOPlatformHaltRestartAction -> AppleSMC` 说明在正常关机/重启。
|
||||
- OpenCore 菜单的选择顺序:
|
||||
- 安装阶段:多次选择 `macOS Installer` 直至不再出现安装向导。
|
||||
- 安装完成:只选系统盘(如 `Macintosh HD`),不要选 `mac - Data`。
|
||||
- 安装完成后,为避免反复从恢复盘启动,`basic.sh` 默认不再挂载 `BaseSystem.img`;需要重装时可通过 `INSTALL_MEDIA=1 HEADLESS=1 ./basic.sh` 临时挂载。
|
||||
|
||||
### 适用角色
|
||||
|
||||
- 后端 / 运维:需要在本地或服务器上快速拉起 macOS VM 做兼容性验证或演示。
|
||||
- 团队:对外说明 **“我们不支持 Docker macOS,统一用龙虾方案”**。
|
||||
|
||||
---
|
||||
|
||||
## 管理端迁移 Mycontent-temp:菜单/布局新规范基线
|
||||
|
||||
### 决议(团队共享)
|
||||
|
||||
- **目标态基线**:以 `Mycontent-temp/soul-admin` 的 `AdminLayout` + `SettingsPage` 作为“新规范基线”,后续管理端所有菜单/布局调整按该基线执行,避免两套后台并行发散。
|
||||
- **主导航收敛**:侧栏只保留 5 个主入口(概览/内容/用户/找伙伴/推广),系统设置固定底部,取消“更多”折叠入口。
|
||||
- **功能不丢但入口收敛**:订单/提现/推广设置/VIP角色/导师等页面保留路由可达,入口通过概览卡片或页面内跳转承载;作者/管理员设置并入 `/settings?tab=author|admin`。
|
||||
|
||||
### 实施建议
|
||||
|
||||
- 迁移时优先保证:**鉴权一致(GET /api/admin)**、**路由可达性**、**菜单一致性**,再逐步优化概览聚合接口与快捷入口。
|
||||
|
||||
---
|
||||
|
||||
## 新旧版代码对比方法论(Mycontent-temp vs miniprogram)
|
||||
|
||||
### 场景
|
||||
|
||||
存在两个并行代码库(主线 + 预览版),需要判断哪个版本更可靠,以及如何安全地吸收另一版的优点。
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. **批量 diff 优先于逐文件比较**:用 PowerShell 批量对比 WXSS/JS/WXML 文件,精确列出「相同/有差异」的文件清单,再聚焦差异文件逐一分析。
|
||||
2. **以功能完整性为基准**:不以「新/旧」日期判断优劣,而以**功能是否完整**为主要依据;本次判断旧版(miniprogram)才是功能更完整的版本。
|
||||
3. **差异归类**:
|
||||
- **旧版有、新版没有** → 旧版是主线,保留旧版
|
||||
- **新版有、旧版没有** → 评估是否需要移植(如 dashboard-stats 调用)
|
||||
- **样式差异** → 对比具体行数,判断是改进还是遗漏
|
||||
4. **接口对比时对照新版参考修复 bug**:新版的接口实现即使存在,也可能有遗漏;参考后自行修复(去重、最小值、错误码等)再提交。
|
||||
|
||||
### 适用场景
|
||||
|
||||
- 分支合并前的功能完整性分析
|
||||
- 迁移预览版到主线时的取舍决策
|
||||
- 跨版本 bug 溯源
|
||||
|
||||
> 同时影响:小程序开发工程师、后端工程师
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_小程序新旧版对比与dashboard接口新增.md`
|
||||
|
||||
---
|
||||
|
||||
## Toast 通知系统 & DB 变更 SOP(团队共享)
|
||||
|
||||
### Toast 批量替换方法论
|
||||
|
||||
使用 PowerShell 正则脚本批量替换 alert → toast,**替换后必须人工复查 `toast.info()`**:
|
||||
- 验证提示类("请输入/请填写/密码至少/ID已存在")被脚本误判为 info,应改为 error
|
||||
- 核查方式:`grep -r "toast.info(" src/` 逐条确认语义
|
||||
|
||||
### DB 变更 SOP(前后端联动)
|
||||
|
||||
当前端新增字段时,完整变更流程:
|
||||
|
||||
| 步骤 | 执行方 | 操作 |
|
||||
|------|--------|------|
|
||||
| 1 | 后端 | 执行 `ALTER TABLE` 或用 `db-exec` 脚本 |
|
||||
| 2 | 后端 | 更新 `internal/model/*.go` struct |
|
||||
| 3 | 后端 | 重启服务验证 |
|
||||
| 4 | 管理端 | 确认保存请求不再报 1054 |
|
||||
|
||||
若跳过任一步骤,GORM 写入时必报 `Unknown column`。
|
||||
|
||||
> 同时影响:管理端开发工程师、后端工程师
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_Toast通知系统全局落地.md`
|
||||
|
||||
30
.cursor/agent/团队/evolution/2026-03-11.md
Normal file
30
.cursor/agent/团队/evolution/2026-03-11.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 团队共享 经验记录 - 2026-03-11
|
||||
|
||||
## 以界面定需求与业务逻辑对齐(全团队)
|
||||
|
||||
### 背景
|
||||
|
||||
用户要求开发团队对齐业务逻辑,并更新开发文档和需求文档,**以界面来定需求**。
|
||||
|
||||
### 决议与产出
|
||||
|
||||
1. **新增《以界面定需求》**
|
||||
- 路径:`开发文档/1、需求/以界面定需求.md`
|
||||
- 内容:原则(界面即需求、三端路由隔离、用户/VIP 展示以用户资料为准);小程序界面清单(每页功能要点与主要 /api/miniprogram/* 接口);管理端界面清单(每页功能要点与主要 /api/admin/*、/api/db/*、/api/orders 等);业务逻辑对齐(用户与 VIP 资料展示、三端 API 边界、免费章与 VIP、分销提现)。
|
||||
|
||||
2. **需求基准**
|
||||
- 需求以《以界面定需求》为准;《需求汇总》增加「需求基准(必读)」节,新增/变更功能先对齐界面再更新需求清单。
|
||||
- 开发文档 README 增加《以界面定需求》链接;运营与变更第九部分记录本次对齐。
|
||||
|
||||
3. **用户/VIP 资料规则(与界面一致)**
|
||||
- 展示以**用户资料**为准(nickname、avatar、projectIntro、phone 等);不再单独存 vip_name、vip_avatar、vip_project、vip_contact、vip_bio;数据库迁移脚本不再新增上述五列;VIP 身份/状态(is_vip、vip_expire_date、vip_activated_at、vip_sort、vip_role)仍保留。
|
||||
|
||||
### 适用角色
|
||||
|
||||
- 产品经理:需求与验收以《以界面定需求》为准。
|
||||
- 小程序 / 管理端 / 后端:开发与联调以界面清单中的功能要点与主要接口为准;业务规则以《以界面定需求》第四节为准。
|
||||
- 测试:功能与回归以界面清单与业务逻辑对齐节为验收范围。
|
||||
|
||||
### 会议纪要
|
||||
|
||||
- `.cursor/meeting/2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md`
|
||||
47
.cursor/agent/团队/evolution/2026-03-12.md
Normal file
47
.cursor/agent/团队/evolution/2026-03-12.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 团队共享 经验记录 - 2026-03-12
|
||||
|
||||
## 密钥/token 设计:关联小程序与 @ 人物
|
||||
|
||||
### 背景
|
||||
|
||||
- **关联小程序**:添加时生成 32 位英文+数字密钥,链接标签存 key,小程序点击 # 时用 key 查 appId 再跳转
|
||||
- **@ 人物**:添加时生成 32 位 token,文章 @ 时存 token,小程序点击 @ 时用 token 兑换真实 ckb_api_key 后加好友
|
||||
|
||||
### 设计原则
|
||||
|
||||
1. **不暴露真实密钥**:管理端配置的真实密钥(appId、ckb_api_key)不写入文章内容,仅存可对外传递的 token/key
|
||||
2. **服务端兑换**:小程序端只传 token/key,后端用其查表得到真实密钥再调用第三方
|
||||
3. **不兼容旧数据**:项目未上架,无需兼容 person_id、appId 等旧格式
|
||||
|
||||
### 数据流
|
||||
|
||||
| 场景 | 添加时 | 内容存储 | 小程序点击 | 后端兑换 |
|
||||
|------|--------|----------|------------|----------|
|
||||
| 关联小程序 | 生成 key | data-mp-key | 传 mpKey | 查 linked_miniprograms 得 appId |
|
||||
| @ 人物 | 生成 token | data-id | 传 targetUserId | 查 persons 得 ckb_api_key |
|
||||
|
||||
### 适用角色
|
||||
|
||||
- 后端:persons.token、linked_miniprograms.key、CKBLead 兑换逻辑
|
||||
- 管理端:链接标签选 key、@ 人物 id=token、列表展示
|
||||
- 小程序:contentParser 解析 mpKey、onLinkTagTap/onMentionTap 传 key/token
|
||||
|
||||
---
|
||||
|
||||
## 9.9 买断与团队级约定
|
||||
|
||||
### 结论
|
||||
|
||||
- 「9.9 买断全书」在三端的唯一来源是后端的 `hasFullBook`/`has_full_book` 信号:
|
||||
- 小程序:只看 `/user/purchase-status` 的 `hasFullBook` 和 `/user/check-purchased` 的 `reason = "has_full_book"`。
|
||||
- 后端:可以通过订单或用户资料开关(如 `manual_fullbook`)计算该状态。
|
||||
- 管理端:如需赠送 9.9 买断,应只改后端用户资料中的开关,不直接改前端逻辑。
|
||||
|
||||
### 约定
|
||||
|
||||
- 任意「免 9.9」或「赠送全书」能力都必须:
|
||||
- 由后端在 purchase-status/check-purchased 中折叠为统一的 `hasFullBook/has_full_book`。
|
||||
- 不允许在小程序或管理端各自新增本地开关绕过后端逻辑。
|
||||
- VIP 与 9.9 买断继续分离:
|
||||
- `hasFullBook` → 只代表书的权限;
|
||||
- `isVip` → 代表会员权益(案例库、增值版等),两者可以独立打开。
|
||||
42
.cursor/agent/团队/evolution/2026-03-13.md
Normal file
42
.cursor/agent/团队/evolution/2026-03-13.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 2026-03-13 - 文章详情预览规则统一(小程序 + 后端)
|
||||
|
||||
## 场景
|
||||
|
||||
- 文章详情页采用「部分内容预览 + 付费解锁全文」的收费模式。
|
||||
- 历史实现中,预览比例由小程序本地按 20% 行数截取,后端可能同时返回预览与全文两个 content 字段,存在:
|
||||
- 三端对「预览长度」的理解不一致;
|
||||
- 前端误用 `data.content` 导致未付费用户拿到全文的潜在风险。
|
||||
- 本次目标:将「预览长度 + 安全边界」下沉到后端统一控制,小程序只按「是否已解锁」选择展示预览或全文。
|
||||
|
||||
## 团队级决策
|
||||
|
||||
- 预览长度统一由 **soul-api** 控制:
|
||||
- 当前规则:对付费章节,未解锁用户默认看到正文前 **50%**,且不少于 100 个字符,末尾追加「购买后阅读完整内容」提示;
|
||||
- 未来如需改为 40% / 30% 等,只需调整后端 `previewContent`,前端逻辑保持不变。
|
||||
- 接口约定统一:
|
||||
- 章节详情接口(含 `/api/miniprogram/book/chapter/:id`、`/api/miniprogram/book/chapter/by-mid/:mid`)返回的:
|
||||
- 外层 `content` 字段,
|
||||
- 内层 `data.content`(Chapter.Content)字段,
|
||||
- **在同一次响应中必须保持一致**:
|
||||
- 免费或已解锁:两处都是全文;
|
||||
- 未解锁:两处都是预览(50%)。
|
||||
- 不允许出现「外层是预览、`data.content` 是全文」这类混合返回,避免前端误用泄露付费内容。
|
||||
- 小程序阅读页约定:
|
||||
- 只根据 `accessState` 判断是否有权看全文;
|
||||
- 有权限时使用 `data.content` / `content` 渲染全文;
|
||||
- 无权限时仅使用后端返回的预览内容,不再在前端重新按 20% 行数切割。
|
||||
|
||||
## 影响角色
|
||||
|
||||
- **后端工程师(soul-api)**:
|
||||
- 负责实现和维护 `previewContent` 预览算法及权限判断逻辑;
|
||||
- 在新增/修改与付费内容相关的接口时,必须遵守「外层 content 与 data.content 一致」的安全约定。
|
||||
- **小程序开发工程师**:
|
||||
- 阅读页不自行决定预览比例,只展示后端返回的预览内容;
|
||||
- 通过 `accessManager` 与章节详情响应判断权限,严格按照 `accessState` 切换「预览 / 全文」视图。
|
||||
|
||||
## 对后续迭代的提示
|
||||
|
||||
- 若未来引入「增值版内容」「章节试读长度差异化」等新收费形态,优先在后端扩展 `previewContent` 与权限判断逻辑,对前端暴露统一、稳定的字段语义。
|
||||
- 若管理端需要控制预览比例或提示文案,可在配置中心增加相关配置项,由后端读取后影响 `previewContent` 行为,保持三端一致。
|
||||
|
||||
@@ -5,3 +5,5 @@
|
||||
| 日期 | 摘要 | 文件 |
|
||||
|------|------|------|
|
||||
| 2026-03-05 | 分支冲突后各端完整性自查流程 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp:菜单/布局新规范基线与入口收敛规则 | [2026-03-10.md](./2026-03-10.md) |
|
||||
| 2026-03-12 | 密钥/token 设计:关联小程序 key、@ 人物 token,不暴露真实密钥、服务端兑换 | [2026-03-12.md](./2026-03-12.md) |
|
||||
|
||||
46
.cursor/agent/小程序开发工程师/evolution/2026-03-10.md
Normal file
46
.cursor/agent/小程序开发工程师/evolution/2026-03-10.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 小程序开发工程师 经验记录 - 2026-03-10
|
||||
|
||||
## 管理端迁移 Mycontent-temp:小程序侧关注点
|
||||
|
||||
- **菜单/布局迁移不应影响小程序接口**:管理端仅重排入口与页面承载,禁止因此改动小程序端接口路径或混用 `/api/miniprogram/*`。
|
||||
- **内容编辑产物稳定性**:如果管理端迁移导致内容从 Markdown → HTML(TipTap)或 mention/tag 的序列化结构变化,小程序阅读页解析必须同步升级并回归兼容。
|
||||
- **验收建议**:迁移期间抽样验证“新编辑器保存的内容”在小程序阅读页可正常渲染与交互(@ 点击加好友、# 标签等)。
|
||||
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md`
|
||||
|
||||
---
|
||||
|
||||
## my.js 阅读统计改为后端接口(loadDashboardStats 移植)
|
||||
|
||||
**场景**:旧版 `my.js` 的 `initUserStatus()` 用本地缓存随机数时间(`Math.floor(Math.random() * 200) + 50`)和标题占位(`章节 ${id}`)展示统计,不准确且不一致。
|
||||
|
||||
**解决方案**:
|
||||
1. 移植 Mycontent-temp 中的 `loadDashboardStats()` 方法
|
||||
2. 调用 `/api/miniprogram/user/dashboard-stats?userId=xxx`
|
||||
3. 同步 `readSectionIds` 到 `app.globalData` 和 Storage
|
||||
4. 返回真实 `recentChapters`(含标题/mid)、`readCount`、`totalReadMinutes`、`matchHistory`
|
||||
|
||||
**initUserStatus 改造要点**:
|
||||
- `readCount` 初始化为 `0`(不再读本地缓存)
|
||||
- `recentChapters` 初始化为 `[]`
|
||||
- `totalReadTime` 初始化为 `0`(不再用随机数)
|
||||
- 新增 `matchHistory: 0` 字段
|
||||
- 登录状态下额外调用 `this.loadDashboardStats()`
|
||||
|
||||
**规则沉淀**:`my.js` 阅读统计必须走后端接口,禁止用本地缓存 + 随机数占位展示统计数据。
|
||||
|
||||
---
|
||||
|
||||
## 富文本渲染现状(技术债,待实施)
|
||||
|
||||
**现状**:`contentParser.js` 将 TipTap HTML 剥成纯文本,`<strong>`/`<h2>`/`<ul>` 等格式全部丢失,只保留 `@mention` 高亮。
|
||||
|
||||
**建议方案**(待实施):
|
||||
- 有权限的完整内容改用 `<rich-text nodes="{{richNodes}}">` 渲染
|
||||
- 预处理时将 TipTap mention span 替换为 `<span style="color:#00CED1">@昵称</span>`
|
||||
- 付费墙预览段落保留纯文本
|
||||
|
||||
**验收前置问题**:确认 DB 中文章内容格式(纯文本 vs TipTap HTML)是否真的用了格式化标记。
|
||||
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_小程序新旧版对比与dashboard接口新增.md`
|
||||
|
||||
7
.cursor/agent/小程序开发工程师/evolution/2026-03-11.md
Normal file
7
.cursor/agent/小程序开发工程师/evolution/2026-03-11.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 小程序开发工程师 经验记录 - 2026-03-11
|
||||
|
||||
## 以界面定需求:小程序界面清单与展示以用户资料为准
|
||||
|
||||
- 《以界面定需求》已纳入小程序全部页面(首页、目录、阅读、找伙伴、我的、推广中心、设置、VIP、购买记录、提现记录、会员详情、资料展示/编辑、导师、关于、地址、搜索、协议与隐私)及每页功能要点与主要接口(均为 /api/miniprogram/*)。
|
||||
- 用户/VIP 展示以**用户资料**为准(nickname、avatar、projectIntro、phone 等),不再依赖单独 VIP 资料列;与现有代码(name: u.nickname || u.vipName || '会员' 等)一致。
|
||||
- 详见团队共享:`agent/团队/evolution/2026-03-11.md`。
|
||||
15
.cursor/agent/小程序开发工程师/evolution/2026-03-12.md
Normal file
15
.cursor/agent/小程序开发工程师/evolution/2026-03-12.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 小程序开发工程师 经验记录 - 2026-03-12
|
||||
|
||||
## 链接标签与 @ 人物:key/token 兑换
|
||||
|
||||
### 链接标签(# 跳转小程序)
|
||||
|
||||
- **contentParser**:解析 `data-mp-key`,seg.mpKey 传给 wxml
|
||||
- **onLinkTagTap**:`mpKey` 从 dataset;用 `linkedMiniprograms.find(m => m.key === mpKey)` 查 appId
|
||||
- **config 预加载**:read.js onLoad 时拉取 `linkTags`、`linkedMiniprograms` 存 globalData
|
||||
|
||||
### @ 人物(加好友)
|
||||
|
||||
- **contentParser**:mention 的 `data-id` 为 token,seg.userId = token
|
||||
- **onMentionTap**:`targetUserId` 传 token,CKBLead 接口用 token 兑换 ckb_api_key
|
||||
- 无需在 config 中拉取 persons,token 已嵌入 content
|
||||
@@ -6,3 +6,5 @@
|
||||
| 2026-03-03 | 我的页面卡片区边距优化,16rpx 推荐值 | [2026-03-03.md](./2026-03-03.md) |
|
||||
| 2026-03-05 | 分支合并后核心流程自测;app.json 拆行;orders 接口确认 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-05 | 文章详情@某人高亮与一键加好友(解析@、调添加好友接口) | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp:关注内容产物格式与阅读页解析兼容回归 | [2026-03-10.md](./2026-03-10.md) |
|
||||
| 2026-03-12 | 链接标签 mpKey、@ 人物 token 兑换:contentParser、onLinkTagTap、onMentionTap | [2026-03-12.md](./2026-03-12.md) |
|
||||
|
||||
7
.cursor/agent/开发助理/evolution/2026-03-11.md
Normal file
7
.cursor/agent/开发助理/evolution/2026-03-11.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 开发助理 经验记录 - 2026-03-11
|
||||
|
||||
## 会议收尾:开发团队对齐业务逻辑与以界面定需求
|
||||
|
||||
- 用户提出「结束会议」后执行会议收尾:生成会议纪要、各角色经验入库、更新项目索引与会议记录索引。
|
||||
- 纪要:`.cursor/meeting/2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md`。
|
||||
- 各角色经验已写入:团队、产品经理、后端工程师、管理端开发工程师、小程序开发工程师 evolution/2026-03-11.md;项目索引(团队、产品、后端、管理端、小程序、助理橙子)开发进度表已追加 2026-03-11。
|
||||
@@ -25,6 +25,21 @@
|
||||
| 2026-02-27 | 小程序、团队 | 最佳实践 | SKILL-小程序开发 §6、SKILL-管理端开发 §4.1 | 输入框 padding 用 view/div 包裹 |
|
||||
| 2026-02-28 | 小程序、管理端 | 最佳实践 | miniprogram §6、admin §4.1 | input 边距口诀「外边包 view、内部 width 100%」;match 弹窗已修正 |
|
||||
| 2026-03-03 | 小程序 | 最佳实践 | miniprogram §8 | 我的页面卡片区边距 16rpx,个人中心类页面布局规范 |
|
||||
| 2026-03-10 | 团队 | 架构/运维约定 | lobster-macos-vm Skill | Windows 上统一使用 WSL2+QEMU+OneClick 的"龙虾"方案安装 macOS 虚拟机,禁止 Docker 直跑 macOS |
|
||||
| 2026-03-10 | 小程序 | 最佳实践 | miniprogram-dev SKILL §my | my.js 阅读统计改为后端接口(loadDashboardStats),禁止用随机数时间/标题占位 |
|
||||
| 2026-03-10 | 后端 | bug 修复 | api-dev SKILL | 聚合接口三处修复:recentChapters 去重、totalReadMinutes 最小1分钟、DB 错误返回 500 |
|
||||
| 2026-03-10 | 团队 | 方法论 | - | 新旧版代码对比:以功能完整性为基准,批量 diff + 分类取舍,不以日期判优劣 |
|
||||
| 2026-03-10 | 管理端 | 最佳实践 | admin-dev SKILL §toast | Toast 通知系统落地:utils/toast.ts 纯原生实现,全系统 18 文件 alert → toast 批量替换 |
|
||||
| 2026-03-12 | 管理端 | bug 修复 | admin-dev SKILL §ts | ContentPage TypeScript 严格类型:可选字段 ?? 兜底、接口补字段、API 映射、setState 传参 |
|
||||
| 2026-03-12 | 团队 | 架构/设计 | - | 密钥/token 设计:关联小程序 key、@ 人物 token;不暴露真实密钥、服务端兑换 |
|
||||
| 2026-03-12 | 后端 | 最佳实践 | mysql-direct SKILL | persons token 字段;Model 新增字段后需执行 ALTER 迁移脚本 |
|
||||
| 2026-03-12 | 管理端 | 设计落地 | - | 关联小程序存 key、@ 人物 id=token;链接标签与 PersonItem 约定 |
|
||||
| 2026-03-12 | 小程序 | 设计落地 | - | contentParser mpKey/token;onLinkTagTap、onMentionTap 传 key/token 兑换 |
|
||||
| 2026-03-10 | 后端 | bug 修复 | api-dev SKILL | chapters 表新增 hot_score 列,修复 1054 Unknown column 错误;DB 变更 SOP:ALTER→model→重启 |
|
||||
| 2026-03-10 | 团队 | 方法论 | - | DB 变更 SOP + Toast 批量替换方法论(脚本替换后必须人工复查 toast.info 语义) |
|
||||
|
||||
| 2026-03-12 | 后端、团队 | 业务规则 | api-dev SKILL、team three-tier-arch | 9.9 买断统一由后端折叠为 hasFullBook/has_full_book,小程序和管理端只认该信号;可通过用户资料开关 OR 订单实现赠送全书 |
|
||||
| 2026-03-13 | 小程序、后端、团队 | 业务规则 | api-dev SKILL、miniprogram-dev SKILL、three-tier-arch SKILL | 文章详情预览统一由后端按 50% 截取,小程序按 accessState 使用预览/全文,外层 content 与 data.content 始终一致以避免泄露全文 |
|
||||
|
||||
---
|
||||
|
||||
@@ -35,4 +50,4 @@
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-03
|
||||
**最后更新**:2026-03-13(文章预览规则统一、小程序与后端对齐)
|
||||
|
||||
@@ -19,9 +19,11 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。
|
||||
| 2026-02-28 | stitch_soul 需求评审:内容→会员→导师变现路径,待产品补充正式需求文档 | 待续 |
|
||||
| 2026-03-05 | 分支冲突后功能完整性分析会议:核对需求文档与实现一致性 | 待续 |
|
||||
| 2026-03-05 | 文章详情@某人加好友方案讨论:验收标准、添加好友接口 path 待确认 | 待续 |
|
||||
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp,新菜单/布局与入口收敛验收口径确定 | 待续 |
|
||||
| 2026-03-11 | 以界面定需求文档建立;需求基准以《以界面定需求》为准 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-05
|
||||
**最后更新**:2026-03-11
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
|------|------|------|
|
||||
| 2026-02-26 | 项目索引初始化;经验库五角色目录结构搭建;SKILL 补充角色映射表与跨端写入规则 | 已完成 |
|
||||
| 2026-02-28 | .cursor 按 cursor标准模板 重构:agent 目录、config、evolution.py、meeting | 已完成 |
|
||||
| 2026-03-11 | 会议收尾:开发团队对齐业务逻辑与以界面定需求;纪要生成、各角色经验入库、项目索引与会议索引更新 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-02-28
|
||||
**最后更新**:2026-03-11
|
||||
|
||||
@@ -20,9 +20,16 @@ soul-api(Go + Gin + GORM + MySQL)提供三组路由:`/api/miniprogram/*`
|
||||
| 2026-02-28 | stitch_soul 需求评审:需梳理 chapter/book/vip,设计导师/预约/会员权益模型与接口 | 待续 |
|
||||
| 2026-03-05 | 分支冲突后功能完整性分析会议:在 soul-api 确认合并状态,核对 orders、distribution 接口 | 待续 |
|
||||
| 2026-03-05 | 文章详情@某人加好友方案讨论:content 内嵌 @ 标记、miniprogram 添加好友接口 | 待续 |
|
||||
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp;后端接口边界不变,overview 聚合接口可选但需降级 | 待续 |
|
||||
| 2026-03-10 | 新增 GET /api/miniprogram/user/dashboard-stats:聚合阅读统计接口,含去重、min1分钟、500错误码修复 | 已完成 |
|
||||
| 2026-03-10 | chapters 表新增 hot_score 列(ALTER TABLE + model 同步),修复前端保存章节报 1054 错误 | 已完成 |
|
||||
| 2026-03-11 | users 迁移仅 VIP 身份/状态五字段,不再新增 VIP 资料列;chapters 补 hot_score;sync 脚本与 README-schema-sync 更新 | 已完成 |
|
||||
| 2026-03-12 | persons 表新增 token 字段(add-persons-token.sql);CKBLead 用 token 兑换 ckb_api_key | 已完成 |
|
||||
| 2026-03-12 | 9.9 买断后端开关方案:users 增手动 fullbook 开关,purchase-status/check-purchased 折叠为统一 hasFullBook/has_full_book,小程序免改即支持赠送全书 | 已完成 |
|
||||
| 2026-03-13 | 文章详情预览统一与安全:previewContent 改为截取正文前 50%,findChapterAndRespond 保证外层 content 与 data.content 一致,未授权只返回预览 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-05
|
||||
**最后更新**:2026-03-13
|
||||
|
||||
@@ -16,9 +16,17 @@ Soul 创业派对全项目架构与约定:路由隔离(miniprogram/admin/db
|
||||
|------|------|------|
|
||||
| 2026-02-27 | 项目索引初始化;团队经验库目录建立 | 已完成 |
|
||||
| 2026-02-28 | stitch_soul 需求评审:内容→会员→导师变现路径,需与现有三端架构协同 | 已完成 |
|
||||
| 2026-03-08 | 文章阅读付费规则澄清:免费章节以 free_chapters 为准;VIP 全章免费;后端 book.go 合并配置修复 | 已完成 |
|
||||
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp,新菜单/布局作为团队规范基线,避免两套后台并行发散 | 待续 |
|
||||
| 2026-03-10 | 新旧版代码对比方法论:以功能完整性而非日期判断优劣,批量 diff + 分类取舍 + 移植修复 | 已完成 |
|
||||
|
||||
| 2026-03-11 | 以界面定需求文档建立;需求基准与业务逻辑对齐(用户/VIP 资料、三端路由);会议收尾纪要与各角色经验入库 | 已完成 |
|
||||
| 2026-03-12 | 密钥/token 设计:关联小程序 key、@ 人物 token;不暴露真实密钥、服务端兑换 | 已完成 |
|
||||
| 2026-03-12 | 9.9 买断团队约定:后端统一输出 hasFullBook/has_full_book,小程序和管理端只认该信号;支持通过用户资料开关 OR 订单赠送全书且不影响 VIP 逻辑 | 已完成 |
|
||||
| 2026-03-13 | 文章详情预览规则统一:预览长度由后端统一按 50% 计算,小程序按 accessState 切换预览/全文,接口约定 content 与 data.content 始终一致 | 已完成 |
|
||||
|
||||
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-02-28
|
||||
**最后更新**:2026-03-13
|
||||
|
||||
@@ -23,9 +23,15 @@
|
||||
| 2026-03-03 | 吸收经验:我的页面卡片区边距优化,16rpx 为个人中心类页面推荐值,已升级 SKILL §8 | 已完成 |
|
||||
| 2026-03-05 | 分支冲突后功能完整性分析会议:修正 app.json 拆行、核心流程自测、确认 orders 接口 | 待续 |
|
||||
| 2026-03-05 | 文章详情@某人加好友方案讨论:阅读页解析 @、高亮可点击、调添加好友接口 | 待续 |
|
||||
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp;小程序侧关注内容产物格式变化与阅读页兼容回归 | 待续 |
|
||||
| 2026-03-10 | 移植 loadDashboardStats():my.js 阅读统计改为后端接口,去除随机数时间/标题占位 | 已完成 |
|
||||
| 2026-03-10 | 富文本渲染技术债分析:contentParser.js 剥除 HTML 格式,建议改 rich-text 组件(待实施) | 待续 |
|
||||
| 2026-03-11 | 以界面定需求:小程序界面清单纳入《以界面定需求》;展示以用户资料为准,与现有实现一致 | 已完成 |
|
||||
| 2026-03-12 | 链接标签 mpKey 兑换 appId;@ 人物 token 兑换 ckb_api_key;contentParser、onLinkTagTap、onMentionTap | 已完成 |
|
||||
| 2026-03-13 | 阅读页文章预览与付费解锁对齐:预览长度改由后端统一计算,前端按 accessState 显示预览/全文,避免 data.content 泄露全文 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-05
|
||||
**最后更新**:2026-03-13
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
| 2026-02-28 | stitch_soul 需求评审:关键场景为阅读/付费/会员/导师预约/资料;待需求确定后补充联调用例 | 待续 |
|
||||
| 2026-03-05 | 分支冲突后功能完整性分析会议:制定「分支合并后回归清单」 | 待续 |
|
||||
| 2026-03-05 | 文章详情@某人加好友方案讨论:@ 展示与添加好友用例、联调与回归 | 待续 |
|
||||
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp;回归重点为菜单一致性、隐藏路由可达性、鉴权跳转 | 待续 |
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-05
|
||||
**最后更新**:2026-03-10
|
||||
|
||||
@@ -20,9 +20,13 @@
|
||||
| 2026-02-28 | stitch_soul 需求评审:待后端方案确定后规划章节/导师/会员/预约管理页面 | 待续 |
|
||||
| 2026-03-05 | 分支冲突后功能完整性分析会议:全功能自测,记录 404/异常接口 | 待续 |
|
||||
| 2026-03-05 | 文章详情@某人加好友方案讨论:编辑页插入 @用户、保存约定 content 格式 | 待续 |
|
||||
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp 新菜单/布局;主导航收敛、author/admin 并入 Settings Tab | 待续 |
|
||||
| 2026-03-10 | Toast 通知系统落地:创建 utils/toast.ts(纯原生),全系统 18 文件约 90 处 alert 全部替换为 toast.success/error/info | 已完成 |
|
||||
| 2026-03-11 | 以界面定需求:管理端界面清单纳入《以界面定需求》,作为验收基准 | 已完成 |
|
||||
| 2026-03-12 | ContentPage TypeScript 严格类型修复;关联小程序 key、@ 人物 token 设计(链接标签存 key、PersonItem.id=token) | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-05
|
||||
**最后更新**:2026-03-12
|
||||
|
||||
56
.cursor/agent/管理端开发工程师/evolution/2026-03-10.md
Normal file
56
.cursor/agent/管理端开发工程师/evolution/2026-03-10.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 管理端开发工程师 经验记录 - 2026-03-10
|
||||
|
||||
## 会议结论:管理端迁移 Mycontent-temp 菜单/布局
|
||||
|
||||
- **目标态基线**:以 `Mycontent-temp/soul-admin` 为“新规范基线”,旧 `soul-admin` 若继续演进则对齐其实现,避免两套后台信息架构发散。
|
||||
- **菜单信息架构**:
|
||||
- 侧栏主菜单固定 5 项:数据概览 / 内容管理 / 用户管理 / 找伙伴 / 推广中心
|
||||
- 系统设置固定在侧栏底部
|
||||
- 取消「更多」折叠入口
|
||||
- **路由与入口策略**:
|
||||
- `author-settings`、`admin-users` 不再作为独立菜单/页面入口,统一并入 `/settings?tab=author|admin`
|
||||
- 订单/提现/推广设置/VIP角色/导师等页面**保留路由可达**,但不进入侧栏主菜单;入口通过概览卡片/页面内跳转承载
|
||||
- **实现抓手**:
|
||||
- `AdminLayout.tsx`:用 `primaryMenuItems` 平铺主菜单;Settings 单独固定
|
||||
- `App.tsx`:对旧路径用 `Navigate` 做兼容跳转(减少断链风险)
|
||||
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md`
|
||||
|
||||
---
|
||||
|
||||
## Toast 通知系统全局落地
|
||||
|
||||
### 背景
|
||||
|
||||
管理端全部操作反馈使用原生 `alert()`,体验差、阻断操作流程、需点 OK 才能继续。
|
||||
|
||||
### 解决方案
|
||||
|
||||
创建 `soul-admin/src/utils/toast.ts`(**纯原生 DOM 实现**,无第三方依赖):
|
||||
|
||||
```typescript
|
||||
// 用法
|
||||
import toast from '@/utils/toast'
|
||||
toast.success('已保存:标题') // 绿色,3s 消失
|
||||
toast.error('保存失败: ...') // 红色,3s 消失
|
||||
toast.info('暂无数据') // 蓝色,3s 消失
|
||||
```
|
||||
|
||||
### 全系统替换
|
||||
|
||||
使用 PowerShell 批量脚本处理 **18 个文件、约 90 处 alert**,替换规则:
|
||||
- 含"失败/错误/请填/不一致/必填" → `toast.error()`
|
||||
- 含"成功/已保存/已删除/已创建" → `toast.success()`
|
||||
- 其余 → `toast.info()`
|
||||
|
||||
替换后人工复查 `toast.info()` 调用,修正 5 处语义误判(验证提示类应为 error)。
|
||||
|
||||
### 规则沉淀
|
||||
|
||||
1. **管理端禁用 `alert()`**,统一使用 `@/utils/toast`
|
||||
2. 新增页面/组件时,操作反馈一律用 toast
|
||||
3. 批量脚本替换后,**必须**人工复查 `toast.info()` 是否有应为 `toast.error()` 的验证提示
|
||||
4. toast 自动消失(3s),不阻断流程;若需用户确认,仍使用 `confirm()`
|
||||
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_Toast通知系统全局落地.md`
|
||||
|
||||
7
.cursor/agent/管理端开发工程师/evolution/2026-03-11.md
Normal file
7
.cursor/agent/管理端开发工程师/evolution/2026-03-11.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 管理端开发工程师 经验记录 - 2026-03-11
|
||||
|
||||
## 以界面定需求:管理端界面清单作为验收基准
|
||||
|
||||
- 《以界面定需求》已纳入管理端全部路由(dashboard、content、users、find-partner、distribution、orders、withdrawals、settings、vip-roles、mentors、mentor-consultations、payment、site、qrcodes、match、match-records、api-doc)及每页功能要点与主要接口(/api/admin/*、/api/db/*、/api/orders 等)。
|
||||
- 开发与联调以该清单为准;业务规则(用户/VIP 资料展示、三端 API 边界等)以《以界面定需求》第四节为准。
|
||||
- 详见团队共享:`agent/团队/evolution/2026-03-11.md`。
|
||||
43
.cursor/agent/管理端开发工程师/evolution/2026-03-12.md
Normal file
43
.cursor/agent/管理端开发工程师/evolution/2026-03-12.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 管理端开发工程师 经验记录 - 2026-03-12
|
||||
|
||||
## ContentPage TypeScript 严格类型修复
|
||||
|
||||
### 问题
|
||||
|
||||
`soul-admin` 构建时 `ContentPage.tsx` 出现多处 TS2322 类型错误:
|
||||
|
||||
1. **可选字段赋给必填字段**:`LinkTagItem` 的 `appId`、`pagePath` 为可选(`string | undefined`),`setNewLinkTag` 期望 `string`
|
||||
2. **接口缺字段**:`SectionListItem` 无 `isPinned`,但 ranking API 返回该字段
|
||||
3. **API 映射类型**:`loadPersons` 中 `p.token`、`p.label`、`p.ckbApiKey` 可能为 `undefined`,映射后 `PersonItem.id` 等需为 `string`
|
||||
4. **可选参数传 setState**:`setEditingPersonKey(p.personId)` 中 `personId` 可选,setter 期望 `string | null`
|
||||
|
||||
### 解决方案
|
||||
|
||||
| 场景 | 写法 |
|
||||
|------|------|
|
||||
| 可选字段 → 必填 string | `t.appId ?? ''`、`t.pagePath ?? ''` |
|
||||
| 接口补字段 | 在 `SectionListItem` 添加 `isPinned?: boolean` |
|
||||
| API 映射兜底 | `id: p.token ?? p.personId ?? ''`,`label: p.label ?? ''`,`ckbApiKey: p.ckbApiKey ?? ''` |
|
||||
| 可选 → setState(string\|null) | `setEditingPersonKey(p.personId ?? null)` |
|
||||
|
||||
### 规则提炼
|
||||
|
||||
- 从可选类型(`T | undefined`)赋给必填类型(`T`)时,用 `?? defaultValue` 兜底
|
||||
- 接口类型需与 API 返回字段对齐,缺字段时补 `field?: Type`
|
||||
- `useState<string | null>` 的 setter 传参时,`undefined` 需显式转为 `null`
|
||||
|
||||
---
|
||||
|
||||
## 关联小程序与 @ 人物:密钥/token 设计
|
||||
|
||||
### 关联小程序
|
||||
|
||||
- 添加时生成 32 位 key,链接标签选择小程序时存 key(非 appId)
|
||||
- 列表展示:名称、密钥、AppID、路径;编辑/删除用 key
|
||||
- 链接标签下拉:选项显示 name + key,选中后 `appId` 字段存 key
|
||||
|
||||
### @ 人物
|
||||
|
||||
- 添加时生成 32 位 token,PersonItem.id = token(RichEditor 插入用)
|
||||
- 列表展示 token;编辑/删除用 personId(API 仍用 personId)
|
||||
- 文章 @ 时 data-id 存 token
|
||||
@@ -2,5 +2,7 @@
|
||||
|
||||
| 日期 | 摘要 | 文件 |
|
||||
|------|------|------|
|
||||
| 2026-03-12 | ContentPage TypeScript 严格类型修复;关联小程序 key、@ 人物 token 设计 | [2026-03-12.md](./2026-03-12.md) |
|
||||
| 2026-03-05 | 分支合并后全功能自测,404/异常接口记录 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-05 | 文章详情@某人:编辑页插入 @用户、保存约定 content 格式 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp 菜单/布局:主导航收敛、Settings Tab 承载 author/admin | [2026-03-10.md](./2026-03-10.md) |
|
||||
|
||||
12
.cursor/agent/软件测试/evolution/2026-03-10.md
Normal file
12
.cursor/agent/软件测试/evolution/2026-03-10.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 软件测试 经验记录 - 2026-03-10
|
||||
|
||||
## 管理端迁移 Mycontent-temp:测试与回归关注点
|
||||
|
||||
- **菜单一致性**:侧栏必须是 5 个主入口 + 底部系统设置;不再出现“更多”折叠。
|
||||
- **路由可达性回归**:菜单入口减少不等于功能减少,需要覆盖“隐藏路由仍可访问”的用例:
|
||||
- 订单、提现、推广设置、VIP角色、导师、导师预约、二维码、站点、支付、API 文档、匹配记录等。
|
||||
- **鉴权回归**:任意页面刷新都必须先 `GET /api/admin` 校验;失效则跳登录并清 token。
|
||||
- **导航兼容**:旧路径(如 `/author-settings`、`/admin-users`)应跳转到 `/settings?tab=author|admin`。
|
||||
|
||||
> 详见会议纪要:`.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md`
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
|------|------|------|
|
||||
| 2026-03-05 | 分支合并后回归清单制定;三端联调验证 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-05 | 文章详情@某人:@ 展示与添加好友用例、联调与回归点 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp:菜单一致性、隐藏路由可达性、鉴权与跳转回归 | [2026-03-10.md](./2026-03-10.md) |
|
||||
|
||||
116
.cursor/apifox-mcp-setup.md
Normal file
116
.cursor/apifox-mcp-setup.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Apifox MCP 配置说明
|
||||
|
||||
已在 Cursor 的 `mcp.json` 中添加 Apifox MCP 配置,你需要根据使用场景填写对应参数。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Node.js >= 18
|
||||
- Apifox 版本 >= 2.7.2(若使用项目/文档站)
|
||||
|
||||
---
|
||||
|
||||
## 方式一:项目 ID + 访问令牌(推荐,团队私有文档)
|
||||
|
||||
适用于:读取自己团队 Apifox 项目内的 API 文档。
|
||||
|
||||
### 1. 获取项目 ID
|
||||
|
||||
- 打开 Apifox 项目
|
||||
- 左侧边栏 → **项目设置** → **基本设置**
|
||||
- 复制「项目 ID」
|
||||
|
||||
### 2. 获取访问令牌
|
||||
|
||||
- Apifox 右上角头像 → **账号设置** → **API 访问令牌**
|
||||
- 创建新的 API 访问令牌并复制
|
||||
|
||||
### 3. 修改 mcp.json
|
||||
|
||||
编辑 `C:\Users\29195\.cursor\mcp.json`,将 Apifox 配置中的占位符替换为实际值:
|
||||
|
||||
```json
|
||||
"Apifox": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"apifox-mcp-server@latest",
|
||||
"--project-id=你的项目ID"
|
||||
],
|
||||
"env": {
|
||||
"APIFOX_ACCESS_TOKEN": "你的访问令牌"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式二:Site ID(公开发布的文档)
|
||||
|
||||
适用于:读取已公开发布的 API 文档站,无需令牌。
|
||||
|
||||
### 1. 获取 Site ID
|
||||
|
||||
- Apifox 项目内:**分享文档** → **发布文档站** → **AI 功能**
|
||||
- 开启 MCP 服务,复制生成的 `site-id`
|
||||
|
||||
### 2. 修改 mcp.json
|
||||
|
||||
将 Apifox 配置改为使用 `--site-id`,并移除 `env`:
|
||||
|
||||
```json
|
||||
"Apifox": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"apifox-mcp-server@latest",
|
||||
"--site-id=你的SiteID"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式三:OpenAPI/Swagger 文件
|
||||
|
||||
适用于:本地或线上的 OpenAPI/Swagger 文档,不依赖 Apifox 项目。
|
||||
|
||||
```json
|
||||
"Apifox": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"apifox-mcp-server@latest",
|
||||
"--oas=https://example.com/openapi.json"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
|
||||
本地文件示例:`--oas=E:/path/to/openapi.json`
|
||||
|
||||
---
|
||||
|
||||
## 私有化部署
|
||||
|
||||
若使用 Apifox 私有化部署,需在 `args` 中增加:
|
||||
|
||||
```
|
||||
"--apifox-api-base-url=https://你的私有化服务器地址"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 生效方式
|
||||
|
||||
修改 `mcp.json` 后,重启 Cursor 或重新加载 MCP 服务即可。
|
||||
|
||||
## 使用示例
|
||||
|
||||
配置完成后,可对 AI 说:
|
||||
|
||||
- 「通过 MCP 获取 API 文档,然后生成 Product 及其相关模型的定义代码」
|
||||
- 「根据 API 文档,在 Product DTO 里添加 API 文档新增的几个字段」
|
||||
- 「根据 API 文档,生成接口 /users 相关的所有 MVC 代码」
|
||||
|
||||
文档有更新时,可提示 AI「刷新接口文档数据」。
|
||||
157
.cursor/meeting/2026-03-09_devlop与yongxu分支差异分析会议.md
Normal file
157
.cursor/meeting/2026-03-09_devlop与yongxu分支差异分析会议.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 会议纪要 - 2026-03-09 | devlop 与 yongxu 分支差异分析
|
||||
|
||||
> 本文件由**助理橙子**在会议结束后自动生成。
|
||||
|
||||
---
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **时间**:2026-03-09
|
||||
- **议题**:分析 devlop(dev 分支 / Mycontent-temp)与 yongxu(当前分支)两个项目的区别
|
||||
- **触发方式**:开个会议
|
||||
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
|
||||
|
||||
---
|
||||
|
||||
## 各角色发言
|
||||
|
||||
### 【产品经理】
|
||||
|
||||
**分支定位**:
|
||||
- **devlop**:老板的老板主改,侧重内容管理、用户管理、找伙伴等管理端能力,开发文档已删除(迁移至他处)
|
||||
- **yongxu**:你主改,侧重 C 端体验:@提及、一键收款、个人资料页、找伙伴、推荐码绑定、分享带 ref、退款等
|
||||
|
||||
**差异要点**:
|
||||
- devlop 删除了整个 `开发文档/` 目录(约 100+ 文件),yongxu 保留
|
||||
- 需求文档(20260308 内容管理、用户管理、找伙伴)在 devlop 侧有更新,yongxu 侧沿用旧版
|
||||
- 合并后需确保:C 端功能(@提及、推荐码、一键收款)不丢,管理端能力(ContentPage、FindPartnerPage、UsersPage)不丢
|
||||
|
||||
### 【后端开发】
|
||||
|
||||
**devlop 独有**(yongxu 没有):
|
||||
- `admin_dashboard.go`、`admin_rfm.go`、`admin_shensheshou.go`、`admin_user_rules.go`
|
||||
- `db_book.go`、`db_person.go`(db 路由组扩展)
|
||||
- `ckb.go` 大改(存客宝扩展)
|
||||
- `match.go`、`match_records.go` 扩展
|
||||
- `book.go`、`user.go`、`cron.go` 等逻辑更新
|
||||
- `person.go`、`user_rule.go` 等 model 新增
|
||||
- 路由、数据库、配置等变更
|
||||
|
||||
**yongxu 独有**(devlop 没有):
|
||||
- @提及相关接口、免费章节判断、存客宝限频、退款逻辑等(在共同祖先 90d32a51 之后)
|
||||
|
||||
**共同修改文件(易冲突)**:
|
||||
- `soul-api/internal/handler/miniprogram.go`
|
||||
- `soul-api/internal/config/config.go`
|
||||
- `soul-api/internal/database/database.go`
|
||||
- `soul-api/internal/router/router.go`
|
||||
|
||||
### 【管理端开发工程师】
|
||||
|
||||
**devlop 独有**(yongxu 没有):
|
||||
- `ContentPage.tsx` 大改(约 1395 行变更)
|
||||
- `ChapterTree.tsx`、`ChaptersPage.tsx` 新增/重构
|
||||
- `FindPartnerPage.tsx` 及多 Tab:CKBConfigPanel、CKBStatsTab、FindPartnerTab、MatchPoolTab、MatchRecordsTab、MentorBookingTab、MentorTab、ResourceDockingTab、TeamRecruitTab
|
||||
- `RichEditor.tsx`、`UserDetailModal.tsx` 扩展
|
||||
- `UsersPage.tsx` 大改(约 1267 行)
|
||||
- `DashboardPage.tsx`、`DistributionPage.tsx`、`SettingsPage.tsx` 等更新
|
||||
- `client.ts`、`AdminLayout.tsx`、`App.tsx` 配置调整
|
||||
|
||||
**yongxu**:管理端改动较少,主要在小程序侧
|
||||
|
||||
**合并策略**:以 devlop 管理端为主,yongxu 若有管理端改动需手工合入
|
||||
|
||||
### 【小程序开发工程师】
|
||||
|
||||
**yongxu 独有**(相对共同祖先 90d32a51):
|
||||
- `app.js`:baseUrl 真实后端、goBackOrToHome、推荐码/访问记录、checkUpdate
|
||||
- `read/*`:@提及解析与高亮、mid 优先跳转
|
||||
- `chapters/*`:章节列表、分享
|
||||
- `index/*`:首页、已读/待读
|
||||
- `my/*`:个人中心、导航栏
|
||||
|
||||
**devlop 也改了同一批文件**:
|
||||
- `miniprogram/app.js`、`app.json`
|
||||
- `miniprogram/pages/chapters/chapters.js`、`chapters.json`
|
||||
- `miniprogram/pages/index/index.js`、`index.wxml`
|
||||
- `miniprogram/pages/my/my.js`
|
||||
- `miniprogram/pages/read/read.js`、`read.wxml`、`read.wxss`
|
||||
- `miniprogram/project.private.config.json`
|
||||
- `miniprogram/utils/readingTracker.js`
|
||||
|
||||
**合并重点**:上述文件两分支均有修改,合并时需保留 yongxu 的 @提及、推荐码、baseUrl、goBackOrToHome 等业务逻辑,同时接纳 devlop 的其它改动(若有)
|
||||
|
||||
### 【测试人员】
|
||||
|
||||
合并后需做:
|
||||
- 三端联调:小程序↔API、管理端↔API
|
||||
- 回归测试:@提及、推荐码、找伙伴、内容管理、用户管理、存客宝、一键收款、退款
|
||||
- 建议合并完成后拉一份回归清单,逐项验证
|
||||
|
||||
---
|
||||
|
||||
## 讨论过程
|
||||
|
||||
- 用户明确:Mycontent-temp 对应 dev 分支(devlop),当前打开的是 yongxu 分支
|
||||
- 基于 `git diff`、`git log` 分析两分支自共同祖先 90d32a51 以来的差异
|
||||
- 共识:devlop 改动量大(248 文件、约 6 万行变更),yongxu 改动小(7 文件、约 366 行),合并时需分模块处理
|
||||
|
||||
---
|
||||
|
||||
## 会议决议
|
||||
|
||||
1. **差异总结**:devlop 侧重管理端与脚本(内容管理、找伙伴、飞书导出、开发文档删除);yongxu 侧重 C 端(@提及、一键收款、推荐码、baseUrl 真实后端)
|
||||
2. **合并策略**:
|
||||
- 管理端、soul-api 新增能力:以 devlop 为主
|
||||
- 小程序:保留 yongxu 的 @提及、推荐码、goBackOrToHome、baseUrl 等,与 devlop 改动手工合并
|
||||
- 开发文档:若需保留,从 yongxu 恢复;若已迁移他处,可沿用 devlop 的删除
|
||||
3. **待确认项**:开发文档最终保留在仓库内还是迁移到外部?合并冲突时以哪边为准(按模块已约定)
|
||||
|
||||
---
|
||||
|
||||
## 待办事项
|
||||
|
||||
| 责任角色 | 任务 | 优先级 | 截止建议 |
|
||||
|---------|------|--------|---------|
|
||||
| 用户 | 确认开发文档保留策略 | 中 | 合并前 |
|
||||
| 用户 | 执行分支合并(如 git merge devlop 或 git merge yongxu) | 高 | 待用户操作 |
|
||||
| 助理橙子 | 合并时协助逐文件解决冲突 | 高 | 用户合并时 |
|
||||
| 测试人员 | 合并后回归测试 | 中 | 合并完成 |
|
||||
|
||||
---
|
||||
|
||||
## 问题与作答区
|
||||
|
||||
| # | 问题 | 责任角色 | 作答 |
|
||||
|---|------|---------|------|
|
||||
| 1 | 开发文档最终保留在仓库内还是迁移到外部? | 用户 | (待补充) |
|
||||
| 2 | 合并时以 devlop 为基准合并 yongxu,还是以 yongxu 为基准合并 devlop? | 用户 | (待补充) |
|
||||
|
||||
---
|
||||
|
||||
## 两分支差异速查表
|
||||
|
||||
| 维度 | devlop(dev 分支) | yongxu(当前分支) |
|
||||
|------|-------------------|---------------------|
|
||||
| 共同祖先 | 90d32a51 | 90d32a51 |
|
||||
| 独有提交数 | 约 200+ | 2 |
|
||||
| 变更文件数 | 248 | 7 |
|
||||
| 开发文档 | 已删除 | 保留 |
|
||||
| 小程序 | 有改动(与 yongxu 重叠) | @提及、推荐码、baseUrl、goBackOrToHome 等 |
|
||||
| 管理端 | ContentPage、FindPartnerPage、UsersPage 等大改 | 改动少 |
|
||||
| soul-api | admin_*、db_*、ckb、match 等扩展 | 免费章节、存客宝限频、退款等 |
|
||||
| 脚本 | 飞书导出、content_upload、Gitea 推送等 | 无 |
|
||||
| 会议纪要 | 合并策略、管理端与 API 分析等 | 代码完整性分析、各成员功能检测 |
|
||||
|
||||
---
|
||||
|
||||
## 各角色经验与业务理解更新
|
||||
|
||||
### 团队共享
|
||||
|
||||
- 分支差异分析会议:先确定共同祖先,再用 `git diff --stat`、`git log` 分模块梳理,便于制定合并策略
|
||||
- 多分支合并时按模块约定「以谁为主」:管理端/soul-api 以 devlop 为主,小程序保留 yongxu 业务逻辑
|
||||
|
||||
---
|
||||
|
||||
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-09.md`*
|
||||
173
.cursor/meeting/2026-03-09_devlop与yongxu比较及各角色边界分析.md
Normal file
173
.cursor/meeting/2026-03-09_devlop与yongxu比较及各角色边界分析.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# devlop 与 yongxu 比较及各角色边界分析 - 2026-03-09
|
||||
|
||||
> 用户已切换至 devlop 分支。本文档对比老板(devlop)与用户(yongxu)的改动,各角色分析自身边界代码。
|
||||
|
||||
---
|
||||
|
||||
## 一、分支对比概览
|
||||
|
||||
| 项目 | yongxu(你的) | devlop(当前/老板的) |
|
||||
|------|----------------|----------------------|
|
||||
| 最新 commit | c3de123e | 07e8a43b |
|
||||
| 主要改动 | @提及、一键收款、goBackOrToHome、推荐码、mid 优先跳转 | 内容管理深度优化、FindPartnerPage、神射手、RFM、dashboard-stats、推荐码自绑拦截 |
|
||||
| 开发文档 | 保留 | **已删除**(07e8a43b chore) |
|
||||
|
||||
---
|
||||
|
||||
## 二、【小程序开发工程师】边界分析
|
||||
|
||||
### 2.1 API 路径合规性 ✅
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| 是否仅调用 `/api/miniprogram/*` | ✅ 是 |
|
||||
| 是否调用 `/api/admin/*` 或 `/api/db/*` | ⚠️ **read.js.backup** 调用了 `/api/db/config`(备份文件,非运行代码) |
|
||||
|
||||
**结论**:当前运行代码(read.js、app.js、my.js 等)全部使用 `/api/miniprogram/*`,符合边界。
|
||||
|
||||
### 2.2 devlop 中老板的改动(miniprogram)
|
||||
|
||||
| 文件 | 改动摘要 |
|
||||
|------|----------|
|
||||
| app.js | 推荐码绑定优化:不能绑定自己的推荐码;新增 `_normalizeReferralCode`;错误处理优化 |
|
||||
| app.json | 配置调整 |
|
||||
| chapters.js | 章节列表逻辑调整(约 227 行变更) |
|
||||
| index.js | 首页逻辑调整(约 31 行) |
|
||||
| my.js | 新增 `loadDashboardStats`,调用 `/api/miniprogram/user/dashboard-stats` |
|
||||
| read.js | 阅读页逻辑调整(约 270 行变更) |
|
||||
| read.wxss | 样式调整(-7 行) |
|
||||
| readingTracker.js | 3 行变更 |
|
||||
| project.private.config.json | 配置调整 |
|
||||
|
||||
### 2.3 yongxu 独有功能(可能被 devlop 覆盖)
|
||||
|
||||
| 功能 | 说明 | 当前 devlop 是否保留 |
|
||||
|------|------|---------------------|
|
||||
| goBackOrToHome | 集中返回逻辑 | ✅ 保留(app.js 有) |
|
||||
| baseUrl 真实后端 | soulapi.quwanzhi.com | ✅ 保留 |
|
||||
| @提及解析与高亮 | 阅读页 contentSegments、点击添加好友 | ❌ **未保留**(read.js 无 contentSegments/mention) |
|
||||
| 一键收款 | 待确认收款、confirm-received | ✅ 保留(my.js 有) |
|
||||
| mid 优先跳转 | 分享带 mid、by-mid 接口 | ⚠️ 需核对 read.js onLoad 与 getChapterUrl |
|
||||
| 推荐码 visit/bind | 访问记录、绑定 | ✅ 保留并增强(自绑拦截) |
|
||||
|
||||
### 2.4 待办
|
||||
|
||||
- [ ] **@提及功能缺失**:devlop 的 read.js 无 contentSegments、ckb/lead 点击逻辑,需从 yongxu 合并或重新实现
|
||||
- [ ] 核对 read.js 的 mid 支持(onLoad、getChapterUrl、by-mid)
|
||||
- [ ] 删除或归档 read.js.backup(避免误用 /api/db/config)
|
||||
|
||||
---
|
||||
|
||||
## 三、【管理端开发工程师】边界分析
|
||||
|
||||
### 3.1 API 路径合规性 ✅
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| 是否仅调用 `/api/admin/*`、`/api/db/*`、`/api/orders` | ✅ 是 |
|
||||
| 是否调用 `/api/miniprogram/*` | ✅ 否(SettingsPage 注释中提及为文档说明,非调用) |
|
||||
|
||||
### 3.2 devlop 新增/变更的接口调用
|
||||
|
||||
| 接口 | 页面 | 后端是否注册 |
|
||||
|------|------|-------------|
|
||||
| /api/admin/dashboard/overview | DashboardPage | ✅ |
|
||||
| /api/admin/shensheshou/query | UserDetailModal | ✅ |
|
||||
| /api/admin/shensheshou/enrich | UserDetailModal | ✅ |
|
||||
| /api/admin/shensheshou/ingest | UserDetailModal | ✅ |
|
||||
| /api/db/match-records?stats=true | CKBStatsTab | ✅ |
|
||||
| /api/db/ckb-plan-stats | CKBStatsTab | ✅ |
|
||||
| /api/db/match-pool-counts | MatchPoolTab | ✅ |
|
||||
| /api/db/users/rfm | UsersPage | ✅ |
|
||||
| /api/db/users/journey-stats | UsersPage | ✅ |
|
||||
| /api/db/user-rules | UsersPage | ✅ |
|
||||
| /api/db/persons | ContentPage | ✅ |
|
||||
| /api/db/link-tags | ContentPage | ✅ |
|
||||
| /api/db/book?action=section-orders | ContentPage | ✅ |
|
||||
| /api/db/book?action=read | ContentPage | ✅ |
|
||||
| /api/db/config/full?key=article_ranking_weights 等 | ContentPage | ✅ |
|
||||
|
||||
**结论**:管理端新增接口均在后端 router 中注册,无 404 风险。
|
||||
|
||||
### 3.3 devlop 新增页面
|
||||
|
||||
| 页面 | 路由 | 说明 |
|
||||
|------|------|------|
|
||||
| FindPartnerPage | /find-partner | 找伙伴管理(含 CKB 配置、匹配池、资源对接等 Tab) |
|
||||
| ChaptersPage | /chapters | 章节管理(若为新增) |
|
||||
| RichEditor | 组件 | 富文本编辑 |
|
||||
|
||||
### 3.4 待办
|
||||
|
||||
- [ ] 联调验证:Dashboard、FindPartner、ContentPage、UsersPage(RFM、user-rules、journey-stats)是否正常
|
||||
|
||||
---
|
||||
|
||||
## 四、【后端开发】边界分析
|
||||
|
||||
### 4.1 路由分组合规性 ✅
|
||||
|
||||
| 路由组 | 前缀 | 使用方 | 状态 |
|
||||
|--------|------|--------|------|
|
||||
| miniprogram | /api/miniprogram/* | 小程序 | ✅ |
|
||||
| admin | /api/admin/* | 管理端 | ✅ |
|
||||
| db | /api/db/* | 管理端 | ✅ |
|
||||
|
||||
### 4.2 devlop 新增/变更的 handler
|
||||
|
||||
| Handler | 路由 | 用途 |
|
||||
|---------|------|------|
|
||||
| AdminDashboardOverview | GET /api/admin/dashboard/overview | 仪表盘概览 |
|
||||
| AdminShensheShouQuery | GET /api/admin/shensheshou/query | 神射手查询 |
|
||||
| AdminShensheShouIngest | POST /api/admin/shensheshou/ingest | 神射手入库 |
|
||||
| AdminShensheShouEnrich | POST /api/admin/shensheshou/enrich | 神射手 enrich |
|
||||
| UserDashboardStatsGet | GET /api/miniprogram/user/dashboard-stats | 小程序「我的」阅读统计 |
|
||||
| DBBookAction | 扩展 | action=section-orders、read 等 |
|
||||
| DBPersonList/Save/Delete | /api/db/persons | 人物管理 |
|
||||
| DBLinkTagList/Save/Delete | /api/db/link-tags | 链接标签 |
|
||||
| DBUserRulesList/Action | /api/db/user-rules | 用户规则 |
|
||||
| DBUsersRFM | GET /api/db/users/rfm | RFM 估值 |
|
||||
| DBUsersJourneyStats | GET /api/db/users/journey-stats | 用户旅程统计 |
|
||||
| DBMatchPoolCounts | GET /api/db/match-pool-counts | 匹配池统计 |
|
||||
| CKBPlanStats | GET /api/db/ckb-plan-stats | CKB 计划统计 |
|
||||
|
||||
### 4.3 待确认
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| /api/orders 鉴权 | 仍在 api 根下,未经过 AdminAuth,存在未授权访问风险 |
|
||||
| 开发文档删除 | 07e8a43b 删除了开发文档目录,若需保留需从 yongxu 或历史恢复 |
|
||||
|
||||
---
|
||||
|
||||
## 五、合并建议
|
||||
|
||||
### 5.1 若需将 yongxu 的改动合入 devlop
|
||||
|
||||
1. **小程序**:`git cherry-pick` 或手工合并关键提交:
|
||||
- @提及(若 devlop 已覆盖需确认)
|
||||
- 一键收款(若 devlop 已覆盖需确认)
|
||||
- 其他你独有的优化
|
||||
|
||||
2. **管理端**:devlop 已大幅领先,无需从 yongxu 合并管理端代码
|
||||
|
||||
3. **后端**:devlop 已包含更多 handler,yongxu 若有 soul-api 独有改动需手工核对
|
||||
|
||||
4. **开发文档**:若需保留,可从 yongxu 或历史 commit 恢复 `开发文档/` 目录
|
||||
|
||||
### 5.2 冲突处理优先级
|
||||
|
||||
- 冲突时以 **devlop 为主**,再手工补回 yongxu 中你确认必须保留的功能
|
||||
|
||||
---
|
||||
|
||||
## 六、会议决议
|
||||
|
||||
1. **小程序**:边界合规;需核对 @提及、一键收款、mid 在 devlop 中是否完整
|
||||
2. **管理端**:边界合规;新增接口均有对应后端
|
||||
3. **后端**:路由分组正确;/api/orders 鉴权待补
|
||||
4. **开发文档**:若需保留,可从 yongxu 恢复
|
||||
|
||||
---
|
||||
|
||||
*报告生成时间:2026-03-09 | 基于 devlop 分支与 yongxu 比较*
|
||||
165
.cursor/meeting/2026-03-09_dev分支需求分析与yongxu迁移方案.md
Normal file
165
.cursor/meeting/2026-03-09_dev分支需求分析与yongxu迁移方案.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# dev 分支需求分析与 yongxu 迁移方案
|
||||
|
||||
> 基于用户反馈:dev 分支只改了样式,未考虑后端/API/小程序三端协调,导致 bug 或功能不全;yongxu 分支功能完善。本文档分析 dev 需求、识别三端缺口,并给出「新功能迁入 yongxu + 补全功能」的执行方案。
|
||||
|
||||
---
|
||||
|
||||
## 一、dev 分支变更概览
|
||||
|
||||
### 1.1 变更性质(用户结论)
|
||||
|
||||
- **dev 侧重**:管理端 UI/样式、新页面布局
|
||||
- **问题**:未同步考虑 soul-api、miniprogram 的接口与逻辑,导致:
|
||||
- 管理端新页面调用的接口在 yongxu 的 soul-api 中不存在或不全
|
||||
- 小程序端与 dev 管理端配置/数据模型不一致
|
||||
- 三端联调时出现 bug 或功能不全
|
||||
|
||||
### 1.2 dev 相对 yongxu 的增量(按模块)
|
||||
|
||||
| 模块 | dev 新增/改动 | 依赖的 API/能力 |
|
||||
|------|---------------|-----------------|
|
||||
| **soul-admin** | ContentPage 大改、ChapterTree、RichEditor、@提及、persons、link-tags 配置 | `/api/db/book`(含 section-orders)、`/api/db/persons`、`/api/db/link-tags`、`/api/db/config/full?key=xxx` |
|
||||
| **soul-admin** | FindPartnerPage + 多 Tab(CKBConfigPanel、CKBStatsTab、MatchPoolTab 等) | `/api/db/config/full?key=ckb_config`、`/api/db/ckb-leads` |
|
||||
| **soul-admin** | UsersPage 大改、UserDetailModal 扩展 | `/api/db/users` 已有,可能需扩展字段 |
|
||||
| **soul-admin** | ChaptersPage 新增、DashboardPage 调整 | 依赖 db/book、db/users 等 |
|
||||
| **soul-admin** | DistributionPage、SettingsPage 等样式调整 | 现有 admin 接口 |
|
||||
| **soul-api** | admin_dashboard、admin_rfm、admin_shensheshou、admin_user_rules | 新增 handler |
|
||||
| **soul-api** | db_person、db_book 扩展(section-orders、move-sections) | 扩展 db_book action |
|
||||
| **soul-api** | ckb 扩展、match/match_records 扩展 | 扩展 ckb、match handler |
|
||||
| **miniprogram** | 部分页面简化/样式调整 | 与 yongxu 的 @提及、推荐码等可能冲突 |
|
||||
|
||||
---
|
||||
|
||||
## 二、三端缺口分析(yongxu 当前缺失)
|
||||
|
||||
### 2.1 soul-api 缺口(管理端新页面依赖)
|
||||
|
||||
| 接口/能力 | dev 管理端调用 | yongxu 是否已有 | 缺口说明 |
|
||||
|-----------|---------------|-----------------|----------|
|
||||
| `GET/POST/DELETE /api/db/persons` | ContentPage @提及人物配置 | ❌ 无 | 需新增 persons 表、model、handler |
|
||||
| `GET/POST/DELETE /api/db/link-tags` | ContentPage 链接标签配置 | ❌ 无 | 需新增 link_tags 表、model、handler |
|
||||
| `GET /api/db/book?action=section-orders&id=xxx` | ContentPage 章节内排序 | ❌ 无 | 需在 db_book 中扩展 action |
|
||||
| `PUT /api/db/book` action=move-sections | ContentPage 跨章移动 | ❌ 无 | 需在 db_book 中扩展 |
|
||||
| `GET /api/db/config/full?key=article_ranking_weights` | ContentPage | ✅ 有 | DBConfigGet 已支持 |
|
||||
| `GET /api/db/config/full?key=pinned_section_ids` | ContentPage | ✅ 有 | 同上 |
|
||||
| `GET /api/db/config/full?key=unpaid_preview_percent` | ContentPage | ✅ 有 | 同上 |
|
||||
| `GET /api/db/config/full?key=ckb_config` | FindPartnerPage CKBConfigPanel | ✅ 有 | 同上 |
|
||||
| `GET /api/db/ckb-leads?mode=submitted&page=1&pageSize=50` | FindPartnerPage 存客宝线索 | ❌ 无 | 需新增 ckb_leads 相关接口 |
|
||||
| admin_dashboard、admin_rfm、admin_shensheshou、admin_user_rules | Dashboard、神奢手等 | ❌ 无 | 可选,按业务需要 |
|
||||
|
||||
### 2.2 小程序端
|
||||
|
||||
- **yongxu 已完善**:@提及、推荐码、baseUrl、goBackOrToHome、一键收款、免费章节、存客宝限频、退款等
|
||||
- **策略**:**保留 yongxu 小程序全部逻辑**,不引入 dev 对 miniprogram 的简化(dev 可能删减了功能)
|
||||
|
||||
### 2.3 管理端(soul-admin)
|
||||
|
||||
- **yongxu 已有**:ContentPage、ChapterTree、UsersPage、DashboardPage 等基础版本
|
||||
- **dev 增量**:更丰富的 ContentPage(persons、link-tags、RichEditor @提及)、FindPartnerPage 整页、UsersPage 扩展
|
||||
- **策略**:在 soul-api 补全接口后,再选择性迁入 dev 的页面组件
|
||||
|
||||
---
|
||||
|
||||
## 三、迁移原则
|
||||
|
||||
1. **基准分支**:yongxu(功能完善、三端协调良好)
|
||||
2. **不引入**:dev 对 miniprogram 的改动(避免覆盖 yongxu 的 @提及、推荐码等)
|
||||
3. **分阶段**:先补 soul-api 缺口 → 再迁入 soul-admin 新页面/组件
|
||||
4. **三端协调**:每迁入一个管理端功能,必须确保 soul-api 已有对应接口,且小程序若依赖该配置则已对齐
|
||||
|
||||
---
|
||||
|
||||
## 四、执行方案(按优先级)
|
||||
|
||||
### 阶段 1:soul-api 补全(必须)
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| 1.1 | 新增 `db/persons` | model.Person、表 persons,GET/POST/DELETE,供 ContentPage @提及人物配置 |
|
||||
| 1.2 | 新增 `db/link-tags` | model.LinkTag、表 link_tags,GET/POST/DELETE,供 ContentPage 链接标签 |
|
||||
| 1.3 | 扩展 `db/book` | 增加 action=section-orders、action=move-sections(若 ContentPage 需要) |
|
||||
| 1.4 | 新增 `db/ckb-leads` | 若存在 ckb_leads 表,GET 支持 mode=submitted/contact 分页;否则先建表再实现 |
|
||||
|
||||
### 阶段 2:soul-admin 迁入(在阶段 1 完成后)
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| 2.1 | ContentPage 增强 | 从 dev 迁入 persons、link-tags 配置区,RichEditor @提及(需 persons 接口) |
|
||||
| 2.2 | FindPartnerPage | 从 dev 迁入整页 + Tab(CKBConfigPanel、CKBStatsTab 等),需 ckb_config、ckb-leads 接口 |
|
||||
| 2.3 | UsersPage 扩展 | 从 dev 迁入 UserDetailModal 等扩展(若字段与 yongxu 的 db/users 一致) |
|
||||
| 2.4 | 菜单与路由 | 在 AdminLayout、App.tsx 中增加 FindPartner 入口(若尚未有) |
|
||||
|
||||
### 阶段 3:可选(按业务需要)
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| 3.1 | admin_dashboard、admin_rfm 等 | 若 Dashboard 需要新统计维度,再补充 |
|
||||
| 3.2 | dev 的 scripts、飞书导出等 | 与三端功能无直接关系,可单独评估 |
|
||||
|
||||
---
|
||||
|
||||
## 五、补全功能清单(可直接用于开发)
|
||||
|
||||
### 5.1 soul-api 必须补全
|
||||
|
||||
```
|
||||
[ ] 1. persons 表 + model + GET/POST/DELETE /api/db/persons
|
||||
[ ] 2. link_tags 表 + model + GET/POST/DELETE /api/db/link-tags
|
||||
[ ] 3. db_book 扩展:GET ?action=section-orders&id=xxx
|
||||
[ ] 4. db_book 扩展:PUT action=move-sections(若 ContentPage 需要)
|
||||
[ ] 5. ckb_leads 表(若无)+ GET /api/db/ckb-leads?mode=xxx&page=1&pageSize=50
|
||||
```
|
||||
|
||||
### 5.2 soul-admin 迁入(依赖 5.1)
|
||||
|
||||
```
|
||||
[ ] 6. ContentPage:persons 配置区、link-tags 配置区
|
||||
[ ] 7. ContentPage:RichEditor @提及(调用 persons)
|
||||
[ ] 8. FindPartnerPage + Tabs(CKBConfigPanel、CKBStatsTab、FindPartnerTab 等)
|
||||
[ ] 9. AdminLayout:增加「找伙伴」菜单项(若尚无)
|
||||
[ ] 10. UsersPage:UserDetailModal 扩展(按需)
|
||||
```
|
||||
|
||||
### 5.3 小程序
|
||||
|
||||
```
|
||||
[ ] 无需变更,保留 yongxu 全部逻辑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、迁移操作建议
|
||||
|
||||
### 6.1 不推荐直接 merge devlop
|
||||
|
||||
- dev 与 yongxu 在 miniprogram、部分 soul-api 上存在冲突
|
||||
- 直接 merge 会覆盖 yongxu 的完善功能
|
||||
|
||||
### 6.2 推荐方式
|
||||
|
||||
1. **保持当前在 yongxu 分支**
|
||||
2. **按阶段 1**:在 yongxu 上直接开发 soul-api 补全(persons、link-tags、db_book 扩展、ckb-leads)
|
||||
3. **按阶段 2**:用 `git show devlop:path/to/file` 取出 dev 的 soul-admin 文件,手工合并到 yongxu,并确认调用的接口已在 soul-api 中存在
|
||||
4. **每完成一个功能**:过 soul-change-checklist,做三端联调验证
|
||||
|
||||
### 6.3 冲突文件处理(若必须 diff 参考)
|
||||
|
||||
- `miniprogram/*`:**以 yongxu 为准**,不采纳 dev 的改动
|
||||
- `soul-admin/*`:选择性采纳 dev 的 UI/组件,确保调用的 API 已在 yongxu 的 soul-api 中实现
|
||||
- `soul-api/*`:以 yongxu 为基础,缺什么补什么(persons、link-tags、ckb-leads 等)
|
||||
|
||||
---
|
||||
|
||||
## 七、总结
|
||||
|
||||
| 项目 | 结论 |
|
||||
|------|------|
|
||||
| 基准 | yongxu |
|
||||
| dev 价值 | 管理端 UI 增强(ContentPage、FindPartnerPage、UsersPage) |
|
||||
| dev 问题 | 未配套 soul-api 的 persons、link-tags、ckb-leads 等,导致功能不全 |
|
||||
| 迁移路径 | 先补 soul-api → 再迁入 soul-admin 新页面 |
|
||||
| 小程序 | 不改动,保留 yongxu |
|
||||
|
||||
---
|
||||
|
||||
*文档生成:2026-03-09 | 供开发执行参考*
|
||||
137
.cursor/meeting/2026-03-09_代码完整性分析与分支合并准备.md
Normal file
137
.cursor/meeting/2026-03-09_代码完整性分析与分支合并准备.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 会议纪要 - 2026-03-09 | 代码完整性分析与分支合并准备
|
||||
|
||||
> 本文件由**助理橙子**在会议结束后自动生成。
|
||||
|
||||
---
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **时间**:2026-03-09
|
||||
- **议题**:分析当前代码完整性;记录你(yongxu 分支)的改动进度;待切换分支后做比较与合并
|
||||
- **触发方式**:开会
|
||||
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
|
||||
|
||||
---
|
||||
|
||||
## 各角色发言
|
||||
|
||||
### 【产品经理】
|
||||
|
||||
当前 yongxu 分支已实现的功能(从提交记录推断):@提及、一键收款、个人资料页、找伙伴、推荐码绑定、分享带 ref、退款等。需要与 devlop 分支的需求文档(20260308 内容管理、用户管理、找伙伴等)做对照,确保合并后需求不遗漏。
|
||||
|
||||
### 【后端开发】
|
||||
|
||||
- **yongxu 独有**:@提及相关接口、免费章节判断、存客宝限频、退款逻辑等
|
||||
- **devlop 独有**:内容管理深度优化、admin_dashboard、admin_rfm、admin_shensheshou、admin_user_rules、ckb 扩展、match/match_records、db_book、db_person 等
|
||||
- **合并风险**:soul-api 多处 handler 可能冲突,需逐文件比对
|
||||
|
||||
### 【管理端开发工程师】
|
||||
|
||||
- **devlop 新增**:ContentPage 大改、ChapterTree、ChaptersPage、FindPartnerPage 及多 Tab、RichEditor、UserDetailModal 扩展、UsersPage 扩展等
|
||||
- **yongxu**:管理端改动较少
|
||||
- **合并策略**:devlop 管理端改动量大,建议以 devlop 为主,yongxu 若有管理端改动需手工合入
|
||||
|
||||
### 【小程序开发工程师】
|
||||
|
||||
- **yongxu 独有**:app.js(baseUrl 真实后端、goBackOrToHome、推荐码/访问记录)、chapters、index、my、read 等页面的 @提及、mid 优先跳转、一键收款等
|
||||
- **devlop 独有**:部分配置、脚本、文档
|
||||
- **合并重点**:miniprogram/app.js、read.js、chapters.js 等可能冲突,需保留 yongxu 的业务逻辑
|
||||
|
||||
### 【测试人员】
|
||||
|
||||
合并后需做:三端联调(小程序↔API、管理端↔API)、@提及、推荐码、找伙伴、内容管理、用户管理、存客宝等回归测试。建议合并完成后拉一份回归清单。
|
||||
|
||||
---
|
||||
|
||||
## 讨论过程
|
||||
|
||||
- 用户明确:老板的老板在 devlop 上改了代码,用户当前在 yongxu,尚未切换分支
|
||||
- 决议:先记录 yongxu 当前状态,待用户切换分支后再执行比较与合并动作
|
||||
|
||||
---
|
||||
|
||||
## 会议决议
|
||||
|
||||
1. **记录 yongxu 当前状态**:已写入本纪要下方的「yongxu 分支快照」
|
||||
2. **合并策略**:用户切换分支后,由助理执行 `git diff` 比较,并协助合并
|
||||
3. **待确认项**:用户切换到哪个分支(devlop / main)需用户明确
|
||||
|
||||
---
|
||||
|
||||
## 待办事项
|
||||
|
||||
| 责任角色 | 任务 | 优先级 | 截止建议 |
|
||||
|---------|------|--------|---------|
|
||||
| 用户 | 切换分支(如 git checkout devlop) | 高 | 待用户操作 |
|
||||
| 助理橙子 | 切换后执行 diff 比较、协助合并 | 高 | 用户切换后 |
|
||||
| 测试人员 | 合并后回归测试 | 中 | 合并完成 |
|
||||
|
||||
---
|
||||
|
||||
## 问题与作答区
|
||||
|
||||
| # | 问题 | 责任角色 | 作答 |
|
||||
|---|------|---------|------|
|
||||
| 1 | 用户将切换到 devlop 还是 main? | 用户 | (待补充) |
|
||||
| 2 | 合并冲突时以哪边为准? | 用户 | (待补充) |
|
||||
|
||||
---
|
||||
|
||||
## yongxu 分支快照(供后续比较与合并)
|
||||
|
||||
> **重要**:以下为 2026-03-09 会议时记录,供切换分支后对比使用。
|
||||
|
||||
### 分支与提交
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 当前分支 | `yongxu` |
|
||||
| 当前 commit | `c3de123ef8b5e971888739999816d13d4f78bd4d` |
|
||||
| 工作区状态 | clean(无未提交变更) |
|
||||
| 对比目标 | `origin/devlop` (`868b0a10`) |
|
||||
|
||||
### yongxu 独有提交(相对 origin/main,前 10 条)
|
||||
|
||||
```
|
||||
c3de123e 1
|
||||
90d32a51 更新小程序配置,切换API基础地址至真实后端。实现@用户提及功能...
|
||||
73ecead3 更新小程序配置,切换API基础地址至本地开发环境。优化用户提交联系方式...
|
||||
68520043 实现@提及功能,允许用户在阅读页中高亮并点击提及的用户...
|
||||
9aaffd80 更新.gitignore文件...
|
||||
2af49611 新增一键收款功能...
|
||||
04b6924a 重构跨多个页面的导航逻辑,goBackOrToHome...
|
||||
3b193fb5 优化个人中心页面,调整导航栏布局...
|
||||
...
|
||||
```
|
||||
|
||||
### 关键文件(yongxu 侧你已改动的)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| miniprogram/app.js | baseUrl 真实后端、goBackOrToHome、推荐码/访问记录、checkUpdate |
|
||||
| miniprogram/pages/read/* | @提及解析与高亮、mid 优先跳转 |
|
||||
| miniprogram/pages/chapters/* | 章节列表、分享 |
|
||||
| miniprogram/pages/index/* | 首页、已读/待读 |
|
||||
| miniprogram/pages/my/* | 个人中心、导航栏 |
|
||||
| soul-api/* | 免费章节、存客宝、退款等 |
|
||||
| soul-admin/* | 若有改动需核对 |
|
||||
|
||||
### devlop 独有(老板的老板的改动,摘要)
|
||||
|
||||
- **soul-admin**:ContentPage、ChapterTree、FindPartnerPage、RichEditor、UsersPage、UserDetailModal 等大量改动
|
||||
- **soul-api**:admin_dashboard、admin_rfm、admin_shensheshou、ckb、match、db_book、db_person 等
|
||||
- **scripts**:飞书同步、Gitea 推送、content_upload 等
|
||||
- **开发文档**:20260308 内容管理、用户管理、找伙伴需求等
|
||||
|
||||
---
|
||||
|
||||
## 各角色经验与业务理解更新
|
||||
|
||||
### 团队共享
|
||||
|
||||
- 分支合并前先记录当前分支状态(commit、关键文件列表),便于后续 diff 与合并决策
|
||||
- 多人在不同分支开发时,合并策略需提前约定(以谁为主、冲突解决规则)
|
||||
|
||||
---
|
||||
|
||||
*会议纪要由助理橙子生成 | 快照供切换分支后比较与合并使用*
|
||||
178
.cursor/meeting/2026-03-09_各成员功能检测报告.md
Normal file
178
.cursor/meeting/2026-03-09_各成员功能检测报告.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 各成员功能检测报告 - 2026-03-09
|
||||
|
||||
> 按角色检测小程序、管理端、后端的 API 调用与路由匹配、边界合规性。
|
||||
|
||||
---
|
||||
|
||||
## 一、小程序开发工程师(miniprogram/)
|
||||
|
||||
### 1.1 API 路径合规性 ✅
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| 是否仅调用 `/api/miniprogram/*` | ✅ 是(除 read.js.backup 外) |
|
||||
| 是否调用 `/api/admin/*` 或 `/api/db/*` | ❌ **read.js.backup** 调用了 `/api/db/config`(边界违规) |
|
||||
|
||||
**说明**:`read.js.backup` 为备份文件,当前运行的 `read.js` 已使用 `/api/miniprogram/*`,无违规。建议删除或重命名 `.backup` 文件,避免误用。
|
||||
|
||||
### 1.2 小程序调用的接口 vs 后端路由
|
||||
|
||||
| 接口路径 | 后端是否注册 | 说明 |
|
||||
|----------|-------------|------|
|
||||
| /api/miniprogram/config | ✅ | GetPublicDBConfig |
|
||||
| /api/miniprogram/login | ✅ | MiniprogramLogin |
|
||||
| /api/miniprogram/phone-login | ✅ | WechatPhoneLogin |
|
||||
| /api/miniprogram/book/all-chapters | ✅ | |
|
||||
| /api/miniprogram/book/chapter/:id | ✅ | |
|
||||
| /api/miniprogram/book/chapter/by-mid/:mid | ✅ | |
|
||||
| /api/miniprogram/book/hot | ✅ | |
|
||||
| /api/miniprogram/book/recommended | ✅ | |
|
||||
| /api/miniprogram/book/latest-chapters | ✅ | |
|
||||
| /api/miniprogram/book/search | ✅ | |
|
||||
| /api/miniprogram/book/stats | ✅ | |
|
||||
| /api/miniprogram/referral/visit | ✅ | |
|
||||
| /api/miniprogram/referral/bind | ✅ | |
|
||||
| /api/miniprogram/referral/data | ✅ | |
|
||||
| /api/miniprogram/earnings | ✅ | MyEarnings |
|
||||
| /api/miniprogram/match/config | ✅ | |
|
||||
| /api/miniprogram/match/users | ✅ | |
|
||||
| /api/miniprogram/ckb/join | ✅ | |
|
||||
| /api/miniprogram/ckb/match | ✅ | |
|
||||
| /api/miniprogram/ckb/lead | ✅ | |
|
||||
| /api/miniprogram/upload | ✅ | |
|
||||
| /api/miniprogram/user/* | ✅ | profile、addresses、check-purchased、purchase-status、reading-progress、update |
|
||||
| /api/miniprogram/withdraw/* | ✅ | withdraw、records、pending-confirm、confirm-received、confirm-info |
|
||||
| /api/miniprogram/vip/* | ✅ | status、profile、members |
|
||||
| /api/miniprogram/users | ✅ | MiniprogramUsers |
|
||||
| /api/miniprogram/orders | ✅ | MiniprogramOrders |
|
||||
| /api/miniprogram/mentors | ✅ | |
|
||||
| /api/miniprogram/mentors/:id | ✅ | |
|
||||
| /api/miniprogram/mentors/:id/book | ✅ | |
|
||||
| /api/miniprogram/about/author | ✅ | |
|
||||
| /api/miniprogram/pay | ✅ | |
|
||||
| /api/miniprogram/qrcode | ✅ | |
|
||||
| /api/miniprogram/phone | ✅ | |
|
||||
|
||||
**结论**:小程序调用的接口均在后端路由中注册,无 404 风险。
|
||||
|
||||
### 1.3 其他问题
|
||||
|
||||
| 问题 | 建议 |
|
||||
|------|------|
|
||||
| app.json 第 19 行多页面写同一行 | 建议拆行便于维护(2026-03-05 会议已建议) |
|
||||
| read.js.backup 调用 /api/db/config | 删除或归档该备份文件 |
|
||||
|
||||
---
|
||||
|
||||
## 二、管理端开发工程师(soul-admin/)
|
||||
|
||||
### 2.1 API 路径合规性 ✅
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| 是否仅调用 `/api/admin/*`、`/api/db/*`、`/api/orders` 等管理端接口 | ✅ 是 |
|
||||
| 是否调用 `/api/miniprogram/*` | ✅ 否 |
|
||||
|
||||
### 2.2 管理端调用的接口 vs 后端路由
|
||||
|
||||
| 接口路径 | 后端是否注册 | 页面 |
|
||||
|----------|-------------|------|
|
||||
| /api/admin/logout | ✅ | AdminLayout |
|
||||
| /api/admin/referral-settings | ✅ | ReferralSettingsPage |
|
||||
| /api/admin/withdrawals | ✅ | WithdrawalsPage、DistributionPage |
|
||||
| /api/admin/orders/refund | ✅ | OrdersPage、DistributionPage |
|
||||
| /api/admin/distribution/overview | ✅ | DistributionPage |
|
||||
| /api/admin/author-settings | ✅ | AuthorSettingsPage |
|
||||
| /api/admin/settings | ✅ | SettingsPage |
|
||||
| /api/admin/users | ✅ | AdminUsersPage |
|
||||
| /api/db/users | ✅ | UsersPage、DistributionPage、OrdersPage、UserDetailModal、SetVipModal |
|
||||
| /api/db/users/referrals | ✅ | UsersPage、UserDetailModal |
|
||||
| /api/db/book | ✅ | ContentPage |
|
||||
| /api/db/config | ✅ | PaymentPage、SitePage、QRCodesPage、MatchPage |
|
||||
| /api/db/config/full | ✅ | MatchPage |
|
||||
| /api/db/vip-roles | ✅ | VipRolesPage、SetVipModal |
|
||||
| /api/db/mentors | ✅ | MentorsPage |
|
||||
| /api/db/match-records | ✅ | MatchRecordsPage |
|
||||
| /api/db/mentor-consultations | ✅ | MentorConsultationsPage |
|
||||
| /api/orders | ✅ | OrdersPage |
|
||||
|
||||
**结论**:管理端调用的接口均在后端路由中注册,无 404 风险。
|
||||
|
||||
### 2.3 路由与页面对应
|
||||
|
||||
| 路由 | 页面 | 状态 |
|
||||
|------|------|------|
|
||||
| /dashboard | DashboardPage | ✅ |
|
||||
| /orders | OrdersPage | ✅ |
|
||||
| /users | UsersPage | ✅ |
|
||||
| /distribution | DistributionPage | ✅ |
|
||||
| /withdrawals | WithdrawalsPage | ✅ |
|
||||
| /content | ContentPage | ✅ |
|
||||
| /referral-settings | ReferralSettingsPage | ✅ |
|
||||
| /author-settings | AuthorSettingsPage | ✅ |
|
||||
| /vip-roles | VipRolesPage | ✅ |
|
||||
| /mentors | MentorsPage | ✅ |
|
||||
| /mentor-consultations | MentorConsultationsPage | ✅ |
|
||||
| /admin-users | AdminUsersPage | ✅ |
|
||||
| /settings | SettingsPage | ✅ |
|
||||
| /payment | PaymentPage | ✅ |
|
||||
| /site | SitePage | ✅ |
|
||||
| /qrcodes | QRCodesPage | ✅ |
|
||||
| /match | MatchPage | ✅ |
|
||||
| /match-records | MatchRecordsPage | ✅ |
|
||||
| /api-doc | ApiDocPage | ✅ |
|
||||
|
||||
**结论**:21 个路由与页面一一对应,无缺失。
|
||||
|
||||
---
|
||||
|
||||
## 三、后端开发(soul-api/)
|
||||
|
||||
### 3.1 路由分组
|
||||
|
||||
| 路由组 | 前缀 | 使用方 | 状态 |
|
||||
|--------|------|--------|------|
|
||||
| miniprogram | /api/miniprogram/* | 小程序 | ✅ |
|
||||
| admin | /api/admin/* | 管理端 | ✅ |
|
||||
| db | /api/db/* | 管理端 | ✅ |
|
||||
| 支付回调 | /api/payment/*、/api/miniprogram/pay/notify | 微信/支付宝 | ✅ |
|
||||
|
||||
### 3.2 待确认项
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| /api/orders 鉴权 | 该接口在 api 根下直接挂载,**未经过 AdminAuth**。OrdersList handler 未做鉴权校验,存在未授权访问风险。建议将 /api/orders 移入 admin 组或单独加 AdminAuth |
|
||||
| soul-api 版本管理 | 若 soul-api 在独立仓库或 .gitignore 排除,合并后需在 soul-api 所在位置单独确认 |
|
||||
|
||||
---
|
||||
|
||||
## 四、测试人员
|
||||
|
||||
### 4.1 建议回归清单
|
||||
|
||||
| 场景 | 验证点 |
|
||||
|------|--------|
|
||||
| 小程序登录 | 微信登录、手机号、token 持久化 |
|
||||
| 购买与支付 | 下单、微信支付、回调更新、购买状态 |
|
||||
| 推荐与分润 | 扫码/分享带 ref、绑定、分润计算 |
|
||||
| VIP 功能 | 开通、资料填写、头像上传、保存、排行展示 |
|
||||
| 管理端 CRUD | 列表、搜索、分页、新增、编辑、删除 |
|
||||
| 提现 | 申请、审核、状态流转、到账确认 |
|
||||
| 找伙伴 | match/config、ckb/join、ckb/lead |
|
||||
| @提及 | 阅读页高亮、点击添加好友 |
|
||||
|
||||
---
|
||||
|
||||
## 五、总结
|
||||
|
||||
| 角色 | 功能完整性 | 发现问题 |
|
||||
|------|-----------|----------|
|
||||
| 小程序开发工程师 | ✅ 正常 | 1. read.js.backup 边界违规(可忽略) 2. app.json 格式建议 |
|
||||
| 管理端开发工程师 | ✅ 正常 | 无 |
|
||||
| 后端开发 | ✅ 正常 | /api/orders 鉴权待确认 |
|
||||
| 产品经理 | - | 需核对需求文档与实现一致性 |
|
||||
| 测试人员 | - | 建议制定合并后回归清单 |
|
||||
|
||||
---
|
||||
|
||||
*报告生成时间:2026-03-09 | 基于 yongxu 分支*
|
||||
73
.cursor/meeting/2026-03-09_合并策略与执行清单.md
Normal file
73
.cursor/meeting/2026-03-09_合并策略与执行清单.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# devlop + yongxu 合并策略 - 2026-03-09
|
||||
|
||||
> 以 devlop 为基准,补入 yongxu 小程序侧缺失功能。管理端、后端以 devlop 为主(已领先)。
|
||||
|
||||
---
|
||||
|
||||
## 一、合并原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|------|------|
|
||||
| 以 devlop 为基准 | 老板的改动(内容管理、FindPartner、神射手、RFM、dashboard-stats 等)全部保留 |
|
||||
| 补入 yongxu 独有 | 小程序侧 devlop 缺失的功能从 yongxu 合并 |
|
||||
| 保留 devlop 优化 | app.js 的推荐码自绑拦截、_normalizeReferralCode 等保留 |
|
||||
| 不恢复开发文档 | 开发文档已删除,暂不恢复(可按需从 yongxu 单独拷贝) |
|
||||
|
||||
---
|
||||
|
||||
## 二、小程序合并清单
|
||||
|
||||
### 2.1 app.js ✅ 保留 devlop
|
||||
|
||||
- devlop 已有:推荐码自绑拦截、_normalizeReferralCode
|
||||
- yongxu 无额外独有改动
|
||||
- **操作**:不修改
|
||||
|
||||
### 2.2 read.js + read.wxml + read.wxss ⬅️ 补入 @提及
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| parseLineToSegments | 解析 `{{@userId:昵称}}` 为 segments |
|
||||
| contentSegments | 每行 `[{type:'text'\|'mention', text?, userId?, nickname?}]` |
|
||||
| onMentionTap | 点击 @ 触发确认弹窗 |
|
||||
| _doMentionAddFriend | 登录/资料校验 → POST ckb/lead |
|
||||
| read.wxml | 用 contentSegments 渲染,mention 可点击 |
|
||||
| read.wxss | 新增 `.mention` 样式 |
|
||||
|
||||
**操作**:已执行合并(见下方实施记录)
|
||||
|
||||
### 2.3 chapters.js、index.js、my.js
|
||||
|
||||
| 文件 | devlop 状态 | yongxu 独有 | 建议 |
|
||||
|------|-------------|-------------|------|
|
||||
| chapters.js | 227 行差异 | 待核对 | 若 yongxu 有重要优化可手工对比 |
|
||||
| index.js | 31 行差异 | ckb/lead 等 | devlop 已有 ckb/lead,基本一致 |
|
||||
| my.js | 56 行差异 | 一键收款、dashboard-stats | devlop 已有一键收款 + dashboard-stats |
|
||||
|
||||
**操作**:暂不合并,以 devlop 为准。若有具体问题再逐项对比。
|
||||
|
||||
### 2.4 其他
|
||||
|
||||
| 项目 | 建议 |
|
||||
|------|------|
|
||||
| read.js.backup | 删除或移出(含 /api/db/config 边界违规) |
|
||||
| app.json 拆行 | 可选,第 19 行多页面拆行便于维护 |
|
||||
|
||||
---
|
||||
|
||||
## 三、管理端、后端
|
||||
|
||||
- **管理端**:devlop 已大幅领先,不合并 yongxu。详见 [2026-03-09_管理端与API合并分析.md](2026-03-09_管理端与API合并分析.md)
|
||||
- **后端**:devlop 已包含全部新 handler,不合并 yongxu。部署需配置 DB_DSN;CkbLeadRecord 迁移移除需与老板确认
|
||||
|
||||
---
|
||||
|
||||
## 四、实施记录
|
||||
|
||||
- [x] 2026-03-09:合并 read.js 的 @提及(parseLineToSegments、contentSegments、onMentionTap、_doMentionAddFriend)
|
||||
- [x] 2026-03-09:合并 read.wxml 的 contentSegments 展示
|
||||
- [x] 2026-03-09:补充 read.wxss 的 .mention 样式
|
||||
|
||||
---
|
||||
|
||||
*策略制定:2026-03-09*
|
||||
166
.cursor/meeting/2026-03-09_管理端与API合并分析.md
Normal file
166
.cursor/meeting/2026-03-09_管理端与API合并分析.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# 管理端与 API 合并分析 - 2026-03-09
|
||||
|
||||
> devlop vs yongxu 在 soul-admin、soul-api 的差异分析,边界合规性,合并建议。
|
||||
|
||||
---
|
||||
|
||||
## 一、管理端(soul-admin)
|
||||
|
||||
### 1.1 差异概览
|
||||
|
||||
| 方向 | 说明 |
|
||||
|------|------|
|
||||
| **devlop 新增** | FindPartnerPage、ChaptersPage、RichEditor、ContentPage 深度优化、Dashboard 概览、UserDetailModal 神射手、UsersPage RFM/用户规则/旅程统计 |
|
||||
| **yongxu** | 管理端无独有改动(yongxu 主要在小程序侧) |
|
||||
|
||||
**结论**:管理端以 devlop 为准,无需从 yongxu 合并。
|
||||
|
||||
### 1.2 devlop 新增/变更的页面与接口
|
||||
|
||||
| 页面/组件 | 路由 | 主要接口 | 后端是否注册 |
|
||||
|-----------|------|----------|-------------|
|
||||
| FindPartnerPage | /find-partner | /api/db/match-records、ckb-plan-stats、match-pool-counts、ckb-leads、config/full?key=ckb_config | ✅ |
|
||||
| ChaptersPage | /chapters | /api/admin/chapters | ✅ |
|
||||
| ContentPage | /content | /api/db/book、persons、link-tags、config | ✅ |
|
||||
| DashboardPage | /dashboard | /api/admin/dashboard/overview | ✅ |
|
||||
| UserDetailModal | - | /api/admin/shensheshou/query、enrich、ingest | ✅ |
|
||||
| UsersPage | /users | /api/db/users/rfm、user-rules、users/journey-stats | ✅ |
|
||||
| RichEditor | 组件 | - | - |
|
||||
|
||||
### 1.3 管理端 API 调用清单(devlop 当前)
|
||||
|
||||
| 接口 | 使用页面 | 说明 |
|
||||
|------|----------|------|
|
||||
| /api/admin/dashboard/overview | DashboardPage | 数据概览 |
|
||||
| /api/admin/chapters | ChaptersPage | 章节树 CRUD |
|
||||
| /api/admin/shensheshou/* | UserDetailModal | 神射手查询/入库/enrich |
|
||||
| /api/admin/distribution/overview | DistributionPage | 分销概览 |
|
||||
| /api/admin/withdrawals | WithdrawalsPage、DistributionPage | 提现审核 |
|
||||
| /api/admin/orders/refund | OrdersPage、DistributionPage | 订单退款 |
|
||||
| /api/admin/referral-settings | ReferralSettingsPage | 推广设置 |
|
||||
| /api/admin/settings | SettingsPage | 系统设置 |
|
||||
| /api/admin/author-settings | AuthorSettingsPage | 作者设置 |
|
||||
| /api/admin/users | AdminUsersPage | 管理员用户 |
|
||||
| /api/admin/logout | AdminLayout | 登出 |
|
||||
| /api/db/users | 多页 | 用户 CRUD |
|
||||
| /api/db/users/rfm | UsersPage | RFM 估值 |
|
||||
| /api/db/users/referrals | UsersPage、UserDetailModal | 推荐关系 |
|
||||
| /api/db/users/journey-stats | UsersPage | 用户旅程统计 |
|
||||
| /api/db/user-rules | UsersPage | 用户规则 |
|
||||
| /api/db/book | ContentPage | 内容/章节 CRUD |
|
||||
| /api/db/persons | ContentPage | 人物管理 |
|
||||
| /api/db/link-tags | ContentPage | 链接标签 |
|
||||
| /api/db/config、config/full | 多页 | 配置 |
|
||||
| /api/db/match-records | FindPartner、MatchRecords | 匹配记录 |
|
||||
| /api/db/match-pool-counts | MatchPoolTab | 匹配池统计 |
|
||||
| /api/db/ckb-plan-stats | CKBStatsTab | CKB 计划统计 |
|
||||
| /api/db/ckb-leads | CKBConfigPanel | CKB 线索明细 |
|
||||
| /api/db/vip-roles | VipRolesPage、SetVipModal | VIP 角色 |
|
||||
| /api/db/mentors | MentorsPage | 导师 |
|
||||
| /api/db/mentor-consultations | MentorConsultationsPage | 导师预约 |
|
||||
| /api/orders | OrdersPage | 订单列表 |
|
||||
|
||||
### 1.4 边界合规性 ✅
|
||||
|
||||
- 仅调用 `/api/admin/*`、`/api/db/*`、`/api/orders`
|
||||
- 未调用 `/api/miniprogram/*`
|
||||
|
||||
### 1.5 合并建议
|
||||
|
||||
- **不合并**:管理端以 devlop 为准
|
||||
- **联调验证**:Dashboard、FindPartner、ContentPage、UsersPage(RFM、user-rules、journey-stats)与后端接口联调
|
||||
|
||||
---
|
||||
|
||||
## 二、API 后端(soul-api)
|
||||
|
||||
### 2.1 差异概览
|
||||
|
||||
| 项目 | yongxu | devlop |
|
||||
|------|--------|--------|
|
||||
| config | CkbLeadAPIKey、DB_DSN 默认本地、SyncOrdersInterval 默认 0 | 移除 CkbLeadAPIKey、DB_DSN 必填(Fatal)、SyncOrdersInterval 默认 5 |
|
||||
| database | 迁移 CkbSubmitRecord、CkbLeadRecord | 迁移 UserRule、Person、LinkTag、Chapter;移除 CkbSubmitRecord、CkbLeadRecord 迁移;seedDefaultRules |
|
||||
| handler | 基础 handler | 新增 admin_dashboard、admin_shensheshou、db_book 扩展、db_person、db_link_tag、db_user_rules、db_users_rfm、db_users_journey_stats、db_match_pool_counts、db_ckb_plan_stats、db_ckb_leads 等 |
|
||||
| router | 基础路由 | 新增上述路由 |
|
||||
|
||||
### 2.2 devlop 配置变更(需关注)
|
||||
|
||||
| 配置项 | yongxu | devlop | 影响 |
|
||||
|--------|--------|--------|------|
|
||||
| DB_DSN | 默认 `user:pass@tcp(127.0.0.1:3306)/soul?...` | 未配置则 Fatal 退出 | 部署需显式配置 DB_DSN |
|
||||
| CkbLeadAPIKey | 从环境变量读取 | 已移除 | ckb.go 使用硬编码 ckbAPIKey,不影响运行 |
|
||||
| SyncOrdersIntervalMinutes | 默认 0 | 默认 5 | 订单对账定时任务每 5 分钟执行 |
|
||||
|
||||
### 2.3 devlop 数据库迁移变更
|
||||
|
||||
| 项目 | yongxu | devlop |
|
||||
|------|--------|--------|
|
||||
| CkbSubmitRecord | 迁移 | 移除迁移 |
|
||||
| CkbLeadRecord | 迁移 | 移除迁移 |
|
||||
| UserRule | - | 新增迁移 + seedDefaultRules |
|
||||
| Person | - | 新增迁移 |
|
||||
| LinkTag | - | 新增迁移 |
|
||||
| Chapter | - | 新增迁移(hot_score_override) |
|
||||
|
||||
**说明**:CkbLeadRecord、CkbSubmitRecord 迁移被移除,但 model 与 handler 仍存在。DBCKBLeadList、CKBLead 可能依赖存客宝 API 或本地表——若本地表仍在使用,需确认迁移策略。
|
||||
|
||||
### 2.4 路由分组合规性 ✅
|
||||
|
||||
| 路由组 | 前缀 | 使用方 | 状态 |
|
||||
|--------|------|--------|------|
|
||||
| miniprogram | /api/miniprogram/* | 小程序 | ✅ |
|
||||
| admin | /api/admin/* | 管理端 | ✅ |
|
||||
| db | /api/db/* | 管理端 | ✅ |
|
||||
|
||||
### 2.5 待确认项
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| /api/orders 鉴权 | 仍在 api 根下,未经过 AdminAuth |
|
||||
| CkbLeadRecord 表 | 迁移已移除,若 DBCKBLeadList 或 CKBLead 仍写本地表,需恢复迁移或改实现 |
|
||||
| router.go 格式 | 第 51 行 admin.GET("/distribution/overview") 缩进异常,建议修正 |
|
||||
|
||||
### 2.6 合并建议
|
||||
|
||||
- **以 devlop 为准**:API 侧 devlop 已大幅领先
|
||||
- **yongxu 无独有 API 改动**:无需从 yongxu 合并
|
||||
- **部署注意**:devlop 要求 DB_DSN 必填,需在 .env 中配置
|
||||
- **可选恢复**:若 CKB 线索需落本地表,可考虑恢复 CkbLeadRecord 迁移(与老板确认)
|
||||
|
||||
---
|
||||
|
||||
## 三、管理端 ↔ API 接口对应检查
|
||||
|
||||
| 管理端调用 | soul-api 路由 | 状态 |
|
||||
|------------|---------------|------|
|
||||
| /api/admin/dashboard/overview | admin.GET("/dashboard/overview") | ✅ |
|
||||
| /api/admin/chapters | admin.GET/POST/PUT/DELETE("/chapters") | ✅ |
|
||||
| /api/admin/shensheshou/query | admin.GET("/shensheshou/query") | ✅ |
|
||||
| /api/admin/shensheshou/enrich | admin.POST("/shensheshou/enrich") | ✅ |
|
||||
| /api/admin/shensheshou/ingest | admin.POST("/shensheshou/ingest") | ✅ |
|
||||
| /api/db/ckb-leads | db.GET("/ckb-leads") | ✅ |
|
||||
| /api/db/ckb-plan-stats | db.GET("/ckb-plan-stats") | ✅ |
|
||||
| /api/db/match-pool-counts | db.GET("/match-pool-counts") | ✅ |
|
||||
| /api/db/users/rfm | db.GET("/users/rfm") | ✅ |
|
||||
| /api/db/users/journey-stats | db.GET("/users/journey-stats") | ✅ |
|
||||
| /api/db/user-rules | db.GET/POST/PUT/DELETE("/user-rules") | ✅ |
|
||||
| /api/db/persons | db.GET/POST/DELETE("/persons") | ✅ |
|
||||
| /api/db/link-tags | db.GET/POST/DELETE("/link-tags") | ✅ |
|
||||
| /api/db/book?action=section-orders | db.GET("/book") | ✅ |
|
||||
| 其他 | 见 router.go | ✅ |
|
||||
|
||||
**结论**:管理端调用的接口均在 soul-api 中注册,无 404 风险。
|
||||
|
||||
---
|
||||
|
||||
## 四、总结
|
||||
|
||||
| 端 | 合并策略 | 备注 |
|
||||
|----|----------|------|
|
||||
| 管理端 | 以 devlop 为准,不合并 | yongxu 无管理端独有改动 |
|
||||
| API | 以 devlop 为准,不合并 | 部署需配置 DB_DSN;CkbLeadRecord 迁移移除需确认 |
|
||||
| 小程序 | 已补入 @提及 | 见 2026-03-09_合并策略与执行清单.md |
|
||||
|
||||
---
|
||||
|
||||
*分析完成时间:2026-03-09*
|
||||
103
.cursor/meeting/2026-03-10_Toast通知系统全局落地.md
Normal file
103
.cursor/meeting/2026-03-10_Toast通知系统全局落地.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 会议纪要 - 2026-03-10 | Toast 通知系统全局落地 & hot_score 数据库迁移
|
||||
|
||||
> 本文件由**助理橙子**在会议结束后自动生成。
|
||||
|
||||
---
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **时间**:2026-03-10
|
||||
- **议题**:1) 文章保存后添加成功提示;2) 数据库缺失 hot_score 字段报错修复;3) 全系统 alert 替换为 toast 组件
|
||||
- **触发方式**:用户反馈 → 逐步扩展到全系统改造
|
||||
- **参与角色**:管理端开发工程师、后端开发、助理橙子
|
||||
|
||||
---
|
||||
|
||||
## 各角色发言
|
||||
|
||||
### 【管理端开发工程师】
|
||||
|
||||
当前管理端所有操作反馈都使用原生 `alert()`,体验差,阻断操作流程,用户必须手动点 OK 才能继续。需要一个统一的 toast 通知系统:非阻塞、自动消失、视觉语义化(绿色=成功、红色=错误、蓝色=信息)。
|
||||
|
||||
考虑到项目没有现成 toast 依赖(sonner 安装失败),选择纯原生 DOM 实现 `src/utils/toast.ts`,无需第三方库,全量兼容现有项目。
|
||||
|
||||
### 【后端开发】
|
||||
|
||||
保存文章时报 `Error 1054 (42S22): Unknown column 'hot_score' in 'field list'`,原因是前端 `ContentPage.tsx` 已在 `handleSaveSection` 中传递 `hotScore` 字段,但后端 `Chapter` model 缺少该字段,且数据库 `chapters` 表也未建列。
|
||||
|
||||
需要两步修复:① ALTER TABLE 新增列;② 同步 model struct。
|
||||
|
||||
### 【助理橙子】
|
||||
|
||||
全系统 18 个文件约 90 处 alert 经 PowerShell 脚本批量替换。替换规则:
|
||||
- 含"失败/错误/请填/不一致/必填"→ `toast.error()`
|
||||
- 含"成功/已保存/已删除/已创建"→ `toast.success()`
|
||||
- 其余→ `toast.info()`
|
||||
|
||||
事后人工复查,修正 5 处语义误判(info 改 error)。
|
||||
|
||||
---
|
||||
|
||||
## 讨论过程
|
||||
|
||||
1. 用户提出"文章保存了要提示保存成功"
|
||||
2. 管理端工程师创建 `src/utils/toast.ts`(纯原生,无依赖),替换 `ContentPage.tsx` 的 alert
|
||||
3. 用户截图报错 `Unknown column 'hot_score'`
|
||||
4. 后端工程师执行 `ALTER TABLE chapters ADD COLUMN hot_score INT NOT NULL DEFAULT 0`,更新 `chapter.go` model
|
||||
5. 用户提出"整个系统的 alert 都可以改为 toast"
|
||||
6. 助理橙子编写 PowerShell 批量替换脚本,处理 18 个文件
|
||||
7. 人工复查 `toast.info()` 调用,将 5 处验证提示修正为 `toast.error()`
|
||||
|
||||
---
|
||||
|
||||
## 会议决议
|
||||
|
||||
1. **Toast 系统统一规范**:管理端所有用户反馈统一使用 `@/utils/toast`,禁止使用 `alert()`
|
||||
2. **toast 类型语义**:
|
||||
- `toast.success()` → 操作成功、保存成功、删除成功
|
||||
- `toast.error()` → 操作失败、表单验证不通过、接口报错
|
||||
- `toast.info()` → 中性提示(无数据、当前状态说明)
|
||||
3. **hot_score 字段**:`chapters` 表已新增,`Chapter` model 已同步,热度分功能可正常保存
|
||||
4. **数据库变更流程**:model 字段与 DB 列必须同步维护,ALTER TABLE 后立即更新 model struct
|
||||
|
||||
---
|
||||
|
||||
## 待办事项
|
||||
|
||||
| 责任角色 | 任务 | 优先级 | 截止建议 |
|
||||
|---------|------|--------|---------|
|
||||
| 管理端开发工程师 | 新增页面/组件时使用 toast 而非 alert(已有规范) | 高 | 持续 |
|
||||
| 后端开发 | hot_score 排名算法接口(若有)按此字段排序 | 低 | 待需求 |
|
||||
|
||||
---
|
||||
|
||||
## 问题与作答区
|
||||
|
||||
| # | 问题 | 责任角色 | 作答 |
|
||||
|---|------|---------|------|
|
||||
| 1 | hot_score 热度分的计算逻辑和更新时机是什么? | 产品经理/后端开发 | (待补充) |
|
||||
| 2 | toast 是否需要支持持久化(不自动消失)的场景? | 管理端开发工程师 | (待补充) |
|
||||
|
||||
---
|
||||
|
||||
## 各角色经验与业务理解更新
|
||||
|
||||
### 后端开发
|
||||
|
||||
- `chapters` 表新增 `hot_score INT NOT NULL DEFAULT 0`,用于热度排名算法
|
||||
- model struct 字段须与 DB 列同步,否则 GORM 写入报 1054 错误
|
||||
|
||||
### 管理端开发工程师
|
||||
|
||||
- 创建 `src/utils/toast.ts`:纯原生 DOM toast,无第三方依赖,支持 success/error/info
|
||||
- 全系统 18 个文件约 90 处 `alert()` 已替换;替换按语义分类,info 类需人工复查
|
||||
- 新规范:管理端禁用 `alert()`,统一使用 `toast.*`
|
||||
|
||||
### 团队共享
|
||||
|
||||
- **Toast 替换脚本方法论**:用 PowerShell 正则 + 语义关键词批量替换,替换后人工复查 `toast.info()` 是否应为 `toast.error()`(验证提示类常被误判)
|
||||
- **DB 变更 SOP**:前端传新字段 → 后端先执行 ALTER TABLE → 再更新 model → 重启服务
|
||||
|
||||
---
|
||||
|
||||
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-10.md`*
|
||||
108
.cursor/meeting/2026-03-10_小程序新旧版对比与dashboard接口新增.md
Normal file
108
.cursor/meeting/2026-03-10_小程序新旧版对比与dashboard接口新增.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 会议纪要 - 2026-03-10 | 小程序新旧版对比分析 & dashboard-stats 接口新增
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **时间**:2026-03-10
|
||||
- **议题**:Mycontent-temp vs miniprogram 小程序新旧版功能/样式对比;loadDashboardStats 移植;后端新增聚合接口
|
||||
- **参与角色**:小程序开发工程师、后端工程师、团队(乘风)
|
||||
|
||||
---
|
||||
|
||||
## 讨论过程
|
||||
|
||||
### 一、新旧版小程序对比分析
|
||||
|
||||
用户指出 `Mycontent-temp/miniprogram` 为新版(仅供预览),`miniprogram` 为旧版(线上正确版本)。
|
||||
|
||||
**功能差异**(旧版反而功能更完整):
|
||||
|
||||
| 功能 | 新版 (Mycontent-temp) | 旧版 (miniprogram) |
|
||||
|-----|------|------|
|
||||
| 首页「我的阅读」进度卡 | ❌ 缺失 | ✅ 有(已读/待读/篇章/章节四统计) |
|
||||
| 首页章数徽章/Banner篇章名 | ❌ 缺失 | ✅ 有 |
|
||||
| 最新新增日期/描述 | ❌ 缺失 | ✅ 有 |
|
||||
| 目录页 VIP 权限 | ❌ 无 isVip | ✅ 支持 isVip + isPremium 增值章节 |
|
||||
| VIP 全局状态管理 | ❌ 不写 globalData | ✅ isVip/vipExpireDate 同步到 globalData + Storage |
|
||||
| 阅读页 contentParagraphs fallback | ❌ 无 | ✅ 有降级渲染 |
|
||||
| my.js 阅读统计 | ✅ loadDashboardStats(后端接口)| ❌ 本地缓存占位(随机时间/标题占位) |
|
||||
|
||||
**样式差异**(仅 2 处,全局 app.wxss 完全相同):
|
||||
1. `chapters.wxss`:旧版多 `.tag-vip`(金色增值标签样式)
|
||||
2. `read.wxss`:旧版 `.paragraph .mention` 多 `padding: 0 4rpx`
|
||||
|
||||
**结论**:新版的亮点仅为 `loadDashboardStats` 后端接口调用,其余功能旧版更完整。
|
||||
|
||||
### 二、my.js loadDashboardStats 移植
|
||||
|
||||
将 Mycontent-temp 中的 `loadDashboardStats()` 移植到旧版 `miniprogram/pages/my/my.js`:
|
||||
|
||||
**改动:**
|
||||
1. `initUserStatus()` 改造:去掉本地缓存占位(随机时间、标题 `章节 ${id}`),初始化清零后调 `loadDashboardStats()`
|
||||
2. 新增 `loadDashboardStats()` 方法:调用 `/api/miniprogram/user/dashboard-stats?userId=xxx`,同步 `readSectionIds` 到 globalData 和 Storage,获取真实 `recentChapters`、`readCount`、`totalReadMinutes`、`matchHistory`
|
||||
|
||||
### 三、后端 dashboard-stats 接口新增
|
||||
|
||||
在 `soul-api/internal/handler/user.go` 新增 `UserDashboardStats`,路由注册:`GET /api/miniprogram/user/dashboard-stats`。
|
||||
|
||||
参考 Mycontent-temp 实现并优化了 3 处 bug:
|
||||
1. **去重**:`seenRecent` map 防止同一章节重复出现在「最近阅读」
|
||||
2. **最小 1 分钟**:阅读不足 60 秒时显示 1 分钟而非 0
|
||||
3. **错误状态码**:DB 失败返回 500 而非 200+`success:false`
|
||||
|
||||
编译通过,数据来源:
|
||||
- `readSectionIds`/`readCount` → `reading_progress` 表
|
||||
- `totalReadMinutes` → `duration` ÷ 60(秒转分)
|
||||
- `recentChapters` → `reading_progress` JOIN `chapters`(最近 5 条去重)
|
||||
- `matchHistory` → `match_records` 计数
|
||||
|
||||
### 四、富文本渲染现状分析(未实施,待办)
|
||||
|
||||
两版均为纯文本渲染,TipTap HTML 格式(粗体、标题、列表、引用等)被 `contentParser.js` 全部剥除。
|
||||
|
||||
建议方案:用 `<rich-text>` 组件渲染 HTML,@mention 替换为带颜色 span,通过外层 bindtap + dataset 实现点击。**本次未实施。**
|
||||
|
||||
---
|
||||
|
||||
## 会议决议
|
||||
|
||||
1. ✅ **旧版(miniprogram)为线上正确版本**,新版仅供样式预览,不反向同步功能
|
||||
2. ✅ **loadDashboardStats 已移植**到旧版 my.js,阅读统计改为后端真实数据
|
||||
3. ✅ **后端 dashboard-stats 接口已实现并编译通过**,可直接上线使用
|
||||
4. ⬜ **富文本渲染**待后续迭代:用 `<rich-text>` 替换当前纯文本渲染
|
||||
|
||||
---
|
||||
|
||||
## 待办事项
|
||||
|
||||
| 责任角色 | 任务 | 状态 |
|
||||
|---------|------|------|
|
||||
| 小程序开发工程师 | 富文本渲染升级(rich-text 组件 + mention 处理) | 待实施 |
|
||||
| 后端工程师 | dashboard-stats 接口上线验证 | 待验证 |
|
||||
| 小程序开发工程师 | 首页阅读进度卡确认是否需要(新版没有,旧版有) | 待确认 |
|
||||
|
||||
---
|
||||
|
||||
## 问题与作答区
|
||||
|
||||
| 编号 | 问题 | 作答 |
|
||||
|------|------|------|
|
||||
| Q1 | 内容在 DB 中是纯文本还是 TipTap HTML?富文本渲染是否紧迫? | (待确认) |
|
||||
| Q2 | 首页「我的阅读」进度卡是否保留?(新版已去掉) | (待确认) |
|
||||
| Q3 | dashboard-stats 接口是否需要加缓存(高频调用场景)? | (待确认) |
|
||||
|
||||
---
|
||||
|
||||
## 各角色经验与业务理解更新
|
||||
|
||||
### 小程序开发工程师
|
||||
- `my.js` 阅读统计来源改为后端接口,不再用本地缓存随机占位
|
||||
- 富文本渲染为当前技术债,`contentParser.js` 仅剥 HTML 无格式保留
|
||||
- 新旧版对比方法论:批量 WXSS/JS 文件 diff 精确定位差异
|
||||
|
||||
### 后端工程师
|
||||
- 新增聚合接口时,优先参考已有实现版本,对比后修复 bug(去重、min值、状态码)
|
||||
- `dashboard-stats` 数据模型:reading_progress JOIN chapters + match_records count
|
||||
|
||||
### 团队
|
||||
- Mycontent-temp 是预览分支,功能不及主线,不作为迁移基准
|
||||
- 新旧版并存时,以功能更完整的主线版本为准,按需吸收新版的接口优化
|
||||
82
.cursor/meeting/2026-03-10_文章详情三端功能对齐与开发.md
Normal file
82
.cursor/meeting/2026-03-10_文章详情三端功能对齐与开发.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 会议纪要 - 2026-03-10 | 文章详情三端功能对齐与开发
|
||||
|
||||
## 基本信息
|
||||
- **时间**:2026-03-10
|
||||
- **议题**:文章详情 @某人/@linkTag/图片 三端功能对齐,发现 Bug,完成开发
|
||||
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师
|
||||
|
||||
---
|
||||
|
||||
## 现状摸底(开发前)
|
||||
|
||||
| 功能 | 后端 | 管理端 | 小程序 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| @mention 存储格式 | content 字段存 TipTap HTML | RichEditor 插入 `<span data-type="mention">` | contentParser 解析 ✅ | ✅ 正常 |
|
||||
| @mention 点击加好友 | CKBLead 接口 **只用全局 Key** | — | 已传 targetUserId,后端接不住 | ❌ 缺密钥路由 |
|
||||
| #linkTag 存储格式 | content 字段存 `<a href>` | insertLinkTag 插入标准 `<a>` | **未解析,直接被剥离** | ❌ 不可点 |
|
||||
| 图片展示 | 存 `<img src>` | 编辑器支持上传插图 | **未解析,被剥离** | ❌ 不显示 |
|
||||
| loadContent 重复定义 | — | — | 两个同名函数,旧版覆盖新版 | ❌ Bug |
|
||||
|
||||
---
|
||||
|
||||
## 本次完成的开发
|
||||
|
||||
### 后端(soul-api)
|
||||
1. **`model/person.go`** 加 `CkbApiKey` 字段(VARCHAR 100)
|
||||
2. **`handler/db_person.go`** `DBPersonSave` 接收并存储 `ckbApiKey`
|
||||
3. **`model/ckb_lead.go`** 加 `TargetPersonID`、`Source` 字段落库
|
||||
4. **`handler/ckb.go` `CKBLead`** 接收 `targetUserId`/`targetNickname`/`source`,查 `persons.ckb_api_key`,有则用,无则 fallback 全局 Key;成功文案动态化("提交成功,XXX 会尽快联系您")
|
||||
5. **`scripts/add-persons-ckb-api-key.sql`** 手动迁移备用脚本
|
||||
|
||||
### 管理端(soul-admin)
|
||||
1. **`components/RichEditor.tsx`** `PersonItem` 接口加 `ckbApiKey?: string`
|
||||
2. **`pages/content/ContentPage.tsx`**
|
||||
- `loadPersons` 映射 `ckbApiKey`
|
||||
- `newPerson` state 加 `ckbApiKey`
|
||||
- Person 配置卡片加「存客宝密钥」输入框(新建时可填)
|
||||
- Person 列表每行加铅笔编辑按钮,展开内联输入框可直接修改密钥
|
||||
- 密钥状态 badge:有密钥显示绿色 `密钥 ✓`,无则灰色 `用默认密钥`
|
||||
|
||||
### 小程序(miniprogram)
|
||||
1. **`utils/contentParser.js`** 全面重写:
|
||||
- 新增 `parseBlockToSegments`:统一处理 mention / linkTag(span) / linkTag(a) / image 四种内联元素
|
||||
- 新增解码工具函数 `decodeEntities`
|
||||
- 纯图片行独立成段,不与文字混排
|
||||
2. **`pages/read/read.js`**
|
||||
- 删除重复的旧版 `loadContent`(Bug:覆盖新版)
|
||||
- 新版 `loadContent` 补全:成功后写本地缓存,缓存降级时恢复 partTitle/chapterTitle
|
||||
- 新增 `onLinkTagTap`:内页路径直接 `navigateTo`,外链复制到剪贴板并提示
|
||||
- 新增 `onImageTap`:点击图片全屏预览(`wx.previewImage`)
|
||||
3. **`pages/read/read.wxml`** 段落块新增渲染分支:
|
||||
- `linkTag` → `<text class="link-tag" bindtap="onLinkTagTap">#{{label}}</text>`
|
||||
- `image` → `<image class="content-image" bindtap="onImageTap">`
|
||||
4. **`pages/read/read.wxss`** 新增 `.link-tag`(金色 #FFD700)、`.content-image`(宽度铺满)样式
|
||||
|
||||
---
|
||||
|
||||
## 完整加好友链路
|
||||
|
||||
```
|
||||
管理端:Person 配置填写存客宝密钥(ckbApiKey)
|
||||
↓
|
||||
文章写入 content → "@[卡若](karuo)" / "#[标签名](url)" / <img src>
|
||||
↓
|
||||
小程序 contentParser 解析 → segments
|
||||
↓ 点击 @卡若
|
||||
POST /api/miniprogram/ckb/lead
|
||||
{ targetUserId: "karuo", targetNickname: "卡若", source: "article_mention" }
|
||||
↓
|
||||
后端查 persons WHERE person_id='karuo' → 取 ckb_api_key
|
||||
→ 有:用该人专属密钥推存客宝
|
||||
→ 无:fallback 全局 CKB_LEAD_API_KEY
|
||||
↓
|
||||
落库 ckb_lead_records(target_person_id, source)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 待办
|
||||
| 角色 | 任务 |
|
||||
|---|---|
|
||||
| 产品经理 | 确认 #linkTag 外链的交互体验(复制 vs 打开 webview)是否符合预期 |
|
||||
| 测试人员 | 联调:@某人点击 → 存客宝各渠道收线索;#标签点击 → 复制/跳转;图片点击 → 全屏预览 |
|
||||
131
.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md
Normal file
131
.cursor/meeting/2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 会议纪要 - 2026-03-10 | 管理端迁移 Mycontent-temp 菜单/布局讨论
|
||||
|
||||
> 本文件由**助理橙子**在会议结束后自动生成。
|
||||
|
||||
---
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **时间**:2026-03-10 15:10
|
||||
- **议题**:管理端改为使用 `Mycontent-temp/soul-admin` 这套“新菜单 + 新布局”规范;基于现有 `soul-admin` 功能,明确菜单/布局改造方式与功能适配点
|
||||
- **触发方式**:开个会议研究下
|
||||
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
|
||||
|
||||
---
|
||||
|
||||
## 各角色发言
|
||||
|
||||
### 【产品经理】
|
||||
|
||||
- **目标**:后台的导航与信息架构要“更运营化”:核心入口更少、更聚焦;次要功能不消失但不占主导航。
|
||||
- **新规范**(以 `Mycontent-temp/soul-admin/src/layouts/AdminLayout.tsx` 为准):
|
||||
- 侧栏主菜单平铺 5 个:**数据概览 / 内容管理 / 用户管理 / 找伙伴 / 推广中心**
|
||||
- **系统设置**固定在侧栏下方
|
||||
- 原「更多」折叠的部分要么隐藏入口(从概览/页面内跳转进入),要么并入系统设置 Tab
|
||||
- **验收**:菜单一致;旧功能可达;用户操作路径更短(尤其内容/找伙伴/推广)。
|
||||
|
||||
### 【后端开发】
|
||||
|
||||
- 管理端迁移/重构不改变接口边界:只允许 `/api/admin/*`、`/api/db/*`、`/api/orders`。
|
||||
- 路由与菜单调整不需要新增后端接口;若概览页需要聚合接口(如 `/api/admin/dashboard/overview`)可作为“优化项”,同时保留降级方案(用现有 users/orders 拼)。
|
||||
- 需要注意“路由别名/跳转”不应影响鉴权:`GET /api/admin` 校验逻辑保持不变。
|
||||
|
||||
### 【管理端开发工程师】
|
||||
|
||||
- 新工程的关键差异点:
|
||||
- `AdminLayout`:取消「更多」折叠,主菜单平铺;`/settings` 永远在底部。
|
||||
- 路由:保留历史页面路由,但**不一定在菜单出现**;`/author-settings`、`/admin-users` 变为 `Navigate` 到 `/settings?tab=author|admin`(页面承载搬到 Settings Tab)。
|
||||
- 迁移策略建议:
|
||||
- **以 `Mycontent-temp/soul-admin` 为“样板/目标态”**,把现有 `soul-admin` 中已实现的页面与功能对齐过去(或反向:把目标态布局/菜单 port 回现有项目)。
|
||||
- 保持路由路径尽量不变(避免大量链接/收藏失效),通过菜单“入口收敛”达成产品目标。
|
||||
|
||||
### 【小程序开发工程师】
|
||||
|
||||
- 小程序侧只关心“内容编辑产物能否稳定下发/解析”,管理端菜单迁移不应改变内容接口或字段。
|
||||
- 若管理端页面拆分导致内容结构改动(例如富文本 HTML、mention/tag 的数据结构),必须提前同步小程序解析策略并回归阅读页兼容。
|
||||
|
||||
### 【测试人员】
|
||||
|
||||
- 重点回归点:
|
||||
- **路由可达性**:菜单入口虽减少,但旧页面必须仍能通过路由访问(尤其订单/提现/推广设置/导师等)。
|
||||
- **鉴权**:任意页面刷新后均能正确校验 token 并跳转登录(`GET /api/admin`)。
|
||||
- **信息架构一致**:侧栏 5 项 + 系统设置固定位置;`author/admin` 设置从 `/settings` 的 tab 进入。
|
||||
|
||||
---
|
||||
|
||||
## 讨论过程
|
||||
|
||||
- 对照了两套工程:
|
||||
- 旧 `soul-admin`:主菜单 3 项 + 「更多」折叠(VIP角色/作者详情/管理员/导师/导师预约/推广中心/找伙伴/匹配记录/推广设置)
|
||||
- 新 `Mycontent-temp/soul-admin`:主菜单 5 项平铺(概览/内容/用户/找伙伴/推广),系统设置固定;作者/管理员并入 Settings Tabs;其余页面保持路由但不占侧栏入口
|
||||
- 达成一致:**以新工程布局/菜单为准**,旧功能以“路由可达 + 概览/页面内跳转”方式保留。
|
||||
|
||||
---
|
||||
|
||||
## 会议决议
|
||||
|
||||
1. **目标态以 `Mycontent-temp/soul-admin` 为准**:菜单与布局“照新不照旧”,旧工程如需改造则对齐该实现。
|
||||
2. **侧栏信息架构**:
|
||||
- 主菜单固定 5 项:数据概览、内容管理、用户管理、找伙伴、推广中心
|
||||
- 系统设置固定在侧栏底部
|
||||
- 取消「更多」折叠入口
|
||||
3. **功能入口收敛规则**:
|
||||
- `author-settings`、`admin-users` 不再作为独立菜单项,统一并入 `/settings?tab=author|admin`
|
||||
- 订单/提现/推广设置/VIP角色/导师等页面:**保留路由**,但入口不进入侧栏主菜单(可由概览卡片、页面内按钮或系统设置进入)
|
||||
4. **接口与边界不变**:管理端继续只调用 `/api/admin/*`、`/api/db/*`、`/api/orders`,不得引入 `/api/miniprogram/*`。
|
||||
5. **待确认项**:
|
||||
- 哪些“非主菜单页面”需要在概览页提供快捷入口(订单、提现、推广设置等)的优先级排序。
|
||||
|
||||
---
|
||||
|
||||
## 待办事项
|
||||
|
||||
| 责任角色 | 任务 | 优先级 | 截止建议 |
|
||||
|---------|------|--------|---------|
|
||||
| 管理端开发工程师 | 基于 `Mycontent-temp/soul-admin` 梳理:侧栏主菜单 5 项、Settings Tab 承载 author/admin、其余页面入口方案(概览卡片/页面内跳转) | 高 | 2026-03-11 |
|
||||
| 产品经理 | 给出“非主菜单页面”的入口优先级(概览要露出哪些快捷卡片/按钮) | 中 | 2026-03-11 |
|
||||
| 后端开发 | 确认概览聚合接口 `/api/admin/dashboard/overview` 是否作为正式接口上线;若不上线,确认降级方案字段口径 | 中 | 2026-03-12 |
|
||||
| 测试人员 | 输出菜单/路由/鉴权回归清单(含隐藏路由可达性) | 中 | 2026-03-12 |
|
||||
| 小程序开发工程师 | 关注内容编辑产物格式是否变化;若变更,补充阅读页兼容用例 | 低 | 持续 |
|
||||
|
||||
---
|
||||
|
||||
## 问题与作答区
|
||||
|
||||
| # | 问题 | 责任角色 | 作答 |
|
||||
|---|------|---------|------|
|
||||
| 1 | 非主菜单页面(订单/提现/推广设置/VIP角色/导师等)哪些必须在概览页提供快捷入口? | 产品经理 | (待补充) |
|
||||
| 2 | `Mycontent-temp/soul-admin` 是否作为线上唯一管理端工程(替换旧 `soul-admin`),还是旧工程按新规范改造? | 团队 | (待补充) |
|
||||
|
||||
---
|
||||
|
||||
## 各角色经验与业务理解更新
|
||||
|
||||
### 产品经理
|
||||
|
||||
- 菜单信息架构收敛:主导航只保留运营主链路入口,次要功能“可达但不抢入口”。
|
||||
|
||||
### 后端开发
|
||||
|
||||
- 概览聚合接口可以作为优化项,但必须保留降级(users+orders)确保不阻塞前端迁移。
|
||||
|
||||
### 管理端开发工程师
|
||||
|
||||
- 迁移以 `Mycontent-temp/soul-admin` 为目标态;作者/管理员并入 Settings Tab;取消“更多”折叠。
|
||||
|
||||
### 小程序开发工程师
|
||||
|
||||
- 管理端迁移不应影响小程序接口边界;若内容格式变更需及时同步解析策略并回归。
|
||||
|
||||
### 测试人员
|
||||
|
||||
- 菜单减少不等于功能减少:必须覆盖“隐藏路由可达性 + 鉴权跳转 + 新侧栏一致性”。
|
||||
|
||||
### 团队共享
|
||||
|
||||
- 统一以 `Mycontent-temp/soul-admin` 的 `AdminLayout`/`SettingsPage` 为“新规范基线”,后续所有菜单/布局调整按该基线执行,避免两套后台并行发散。
|
||||
|
||||
---
|
||||
|
||||
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-10.md`*
|
||||
|
||||
83
.cursor/meeting/2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md
Normal file
83
.cursor/meeting/2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 会议纪要 - 2026-03-11 | 开发团队对齐业务逻辑与以界面定需求·会议收尾
|
||||
|
||||
> 本文件由**助理橙子**在会议结束后自动生成。
|
||||
|
||||
---
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **时间**:2026-03-11
|
||||
- **议题**:开发团队对齐业务逻辑,以界面定需求,并更新开发文档与需求文档;用户提出「结束会议」后执行收尾。
|
||||
- **触发方式**:结束会议
|
||||
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、团队(跨角色)
|
||||
|
||||
---
|
||||
|
||||
## 各角色发言
|
||||
|
||||
> 本次为收尾整理,无逐角色发言环节;结论来自本会话已完成的文档与代码变更。
|
||||
|
||||
### 【产品经理】
|
||||
|
||||
需求基准明确为《以界面定需求》;需求汇总以该文档为准,新增/变更功能先对齐界面再落需求清单。
|
||||
|
||||
### 【后端开发】
|
||||
|
||||
users 表迁移仅保留 VIP 身份/状态字段(is_vip、vip_expire_date、vip_activated_at、vip_sort、vip_role),不再新增 vip_name/vip_avatar 等资料列;chapters 表补 hot_score;sync-users-vip-and-schema.sql 与 README-schema-sync 已更新。
|
||||
|
||||
### 【管理端开发工程师】
|
||||
|
||||
管理端界面清单已纳入《以界面定需求》,路由与主要接口与 App.tsx/AdminLayout 一致,作为验收基准。
|
||||
|
||||
### 【小程序开发工程师】
|
||||
|
||||
小程序界面清单已纳入《以界面定需求》,页面与主要 /api/miniprogram/* 接口一致;展示以用户资料为准,不再依赖单独 VIP 资料列。
|
||||
|
||||
### 【团队共享】
|
||||
|
||||
以界面定需求、三端路由隔离、用户/VIP 展示以用户资料为准,已写入《以界面定需求》及运营与变更第九部分。
|
||||
|
||||
---
|
||||
|
||||
## 讨论过程
|
||||
|
||||
(本次为收尾流程,无额外讨论节点。)
|
||||
|
||||
---
|
||||
|
||||
## 会议决议
|
||||
|
||||
1. **以界面定需求**:需求基准文档已建立,小程序与管理端界面清单、主要接口、业务逻辑对齐(三端路由、VIP 资料以用户资料为准等)已落档。
|
||||
2. **开发文档联动**:README 增加《以界面定需求》链接;需求汇总增加「需求基准(必读)」;运营与变更增加第九部分记录本次对齐。
|
||||
3. **数据库迁移**:users 仅同步 VIP 身份/状态五字段;chapters 同步 hot_score;不再新增 VIP 资料列。
|
||||
4. **待确认项**:无。
|
||||
|
||||
---
|
||||
|
||||
## 待办事项
|
||||
|
||||
| 责任角色 | 任务 | 优先级 | 截止建议 |
|
||||
|---------|------|--------|---------|
|
||||
| — | 无新增待办 | — | — |
|
||||
|
||||
---
|
||||
|
||||
## 问题与作答区
|
||||
|
||||
| # | 问题 | 责任角色 | 作答 |
|
||||
|---|------|---------|------|
|
||||
| — | 无待确认问题 | — | — |
|
||||
|
||||
---
|
||||
|
||||
## 各角色经验与业务理解更新
|
||||
|
||||
- **产品经理**:需求以《以界面定需求》为准,验收与需求清单与之保持一致。
|
||||
- **后端开发**:users 表迁移仅补 VIP 身份/状态字段;chapters 补 hot_score;README-schema-sync 已区分「身份字段」与「不再新增的资料列」。
|
||||
- **管理端开发工程师**:管理端界面清单与路由/接口已作为需求与验收基准写入《以界面定需求》。
|
||||
- **小程序开发工程师**:小程序界面清单与接口已作为需求与验收基准;展示优先用户资料。
|
||||
- **团队共享**:以界面定需求、三端路由隔离、用户/VIP 资料展示规则已沉淀至《以界面定需求》与运营与变更。
|
||||
|
||||
---
|
||||
|
||||
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-11.md`*
|
||||
@@ -65,3 +65,11 @@ 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-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) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp 菜单/布局讨论 | 产品、后端、管理端、小程序、测试 | [2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md](2026-03-10_管理端迁移Mycontent-temp菜单布局讨论.md) |
|
||||
| 2026-03-10 | 小程序新旧版对比分析与 dashboard-stats 接口新增 | 小程序、后端、团队 | [2026-03-10_小程序新旧版对比与dashboard接口新增.md](2026-03-10_小程序新旧版对比与dashboard接口新增.md) |
|
||||
| 2026-03-10 | 文章详情三端功能对齐与开发(@mention/#linkTag/图片) | 产品、后端、管理端、小程序 | [2026-03-10_文章详情三端功能对齐与开发.md](2026-03-10_文章详情三端功能对齐与开发.md) |
|
||||
| 2026-03-10 | Toast 通知系统全局落地 & hot_score 数据库迁移 | 管理端、后端、团队 | [2026-03-10_Toast通知系统全局落地.md](2026-03-10_Toast通知系统全局落地.md) |
|
||||
| 2026-03-11 | 开发团队对齐业务逻辑与以界面定需求·会议收尾 | 产品、后端、管理端、小程序、团队 | [2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md](2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md) |
|
||||
|
||||
@@ -40,6 +40,13 @@ description: Soul 创业派对管理端开发规范。在 soul-admin/ 下编辑
|
||||
- **命名**:组件 PascalCase;文件与组件名一致(如 `WithdrawalsPage.tsx`);接口/类型用 PascalCase 或小驼峰按习惯。
|
||||
- **类型**:请求响应用 TypeScript 接口定义(如 `interface Withdrawal { ... }`),可用 `get<WithdrawalsRes>(...)` 泛型。
|
||||
|
||||
### 3.1 TypeScript 严格类型(可选→必填、API 映射)
|
||||
|
||||
- **可选字段赋给必填**:从 `T | undefined` 赋给 `T` 时,用 `?? defaultValue` 兜底,如 `t.appId ?? ''`、`t.pagePath ?? ''`
|
||||
- **接口与 API 对齐**:若 API 返回某字段而接口未声明,在接口中补 `field?: Type`(如 `isPinned?: boolean`)
|
||||
- **API 映射兜底**:`data.map(p => ({ id: p.token ?? p.personId ?? '', label: p.label ?? '', ... }))`,避免 `undefined` 流入 state
|
||||
- **setState 传参**:`useState<string | null>` 的 setter 不接受 `undefined`,传 `value ?? null` 而非裸 `value`
|
||||
|
||||
---
|
||||
|
||||
## 4. 列表页标准(必守)
|
||||
@@ -57,7 +64,7 @@ description: Soul 创业派对管理端开发规范。在 soul-admin/ 下编辑
|
||||
|
||||
**可选**:导出 CSV、列头排序、批量操作。
|
||||
|
||||
**禁止**:不得用原生 `alert` 做加载失败提示;不得调用 `/api/miniprogram/*`。
|
||||
**禁止**:不得用原生 `alert` 做任何提示(包括成功、失败、验证);不得调用 `/api/miniprogram/*`。
|
||||
|
||||
**检查清单**:分页、搜索防抖、刷新、loading、空状态、错误条、仅 admin/db 路径。详见 `开发文档/列表标准与角色分工.md`。
|
||||
|
||||
@@ -65,7 +72,41 @@ description: Soul 创业派对管理端开发规范。在 soul-admin/ 下编辑
|
||||
|
||||
**口诀**:外边包 div/view,内部 input width 100%。设置 input 的 padding、背景、边框时,用 div 包裹 input;padding 写在容器上,input 仅做文字样式(`width: 100%`)。禁止在 input 自身上设 padding,可避免光标截断、布局异常。React:`<div className="form-input"><input className="form-input-inner" ... /></div>`。
|
||||
|
||||
### 4.2 表单弹窗与「可选择+可手动填写」(吸收 SetVipModal 经验)
|
||||
### 4.2 用户操作反馈:统一使用 Toast(禁止 alert)
|
||||
|
||||
**规则**:管理端所有操作反馈(保存成功、操作失败、表单验证提示等)**必须使用 `@/utils/toast`**,严禁使用原生 `window.alert()`。
|
||||
|
||||
```typescript
|
||||
import toast from '@/utils/toast'
|
||||
|
||||
// ✅ 正确
|
||||
toast.success('已保存')
|
||||
toast.success(`章节「${title}」创建成功`)
|
||||
toast.error('保存失败: ' + (res?.error || '未知错误'))
|
||||
toast.error('请填写章节ID和标题') // 表单验证也用 error
|
||||
toast.info('暂无数据可导出') // 中性说明用 info
|
||||
|
||||
// ❌ 错误
|
||||
alert('已保存')
|
||||
alert('保存失败')
|
||||
```
|
||||
|
||||
**类型语义**:
|
||||
|
||||
| 方法 | 使用场景 | 颜色 |
|
||||
|------|---------|------|
|
||||
| `toast.success()` | 操作成功、保存成功、创建/删除成功 | 绿色 |
|
||||
| `toast.error()` | 接口报错、表单验证不通过、操作失败 | 红色 |
|
||||
| `toast.info()` | 中性提示(无数据、当前状态说明) | 蓝色 |
|
||||
|
||||
**注意**:
|
||||
- `confirm()` 仍可用于删除等破坏性操作的二次确认
|
||||
- toast 自动 3 秒消失,不阻断流程;若需用户强制确认才能继续,使用 `confirm()`
|
||||
- 新增页面/组件时,**不得引入新的 `alert()`**
|
||||
|
||||
> 来源:2026-03-10 全系统 alert → toast 改造(18 文件约 90 处)
|
||||
|
||||
### 4.4 表单弹窗与「可选择+可手动填写」(吸收 SetVipModal 经验)
|
||||
|
||||
当表单需支持「从预设选项选择」或「手动填写自定义值」时:
|
||||
|
||||
@@ -80,7 +121,7 @@ description: Soul 创业派对管理端开发规范。在 soul-admin/ 下编辑
|
||||
|
||||
- **鉴权**:登录后 token 存 localStorage(`admin_token`);请求头由 client 自动带 Bearer token;401 时跳转登录页并清除 token。
|
||||
- **提现**:列表/统计用 `GET /api/admin/withdrawals`;审核/拒绝用 `PUT /api/admin/withdrawals`(如 action: approve/reject);状态与 soul-api 一致(如 pending、processing、success、failed),前端展示可映射为「已完成/已拒绝」等文案。
|
||||
- **用户/订单**:用户列表 `GET /api/db/users`;订单 `GET /api/orders`;字段名与 soul-api 返回一致(如 userNickname、userAvatar、status、amount)。
|
||||
- **用户/订单**:用户列表 `GET /api/db/users`;订单 `GET /api/admin/orders`;字段名与 soul-api 返回一致(如 userNickname、userAvatar、status、amount)。
|
||||
- **内容/章节**:`/api/admin/chapters`、`/api/admin/content` 等 CRUD;配置类用 `/api/admin/settings`、`/api/admin/referral-settings`、`/api/db/config/full`。
|
||||
- **列表与表格**:管理端列表需有 user_name/userNickname、userAvatar、status、amount 等字段以便通用展示;若 soul-api 返回字段不同,仅在管理端做字段映射,不修改 soul-api 的 miniprogram 接口。
|
||||
|
||||
|
||||
184
.cursor/skills/lobster-macos-vm/SKILL.md
Normal file
184
.cursor/skills/lobster-macos-vm/SKILL.md
Normal file
@@ -0,0 +1,184 @@
|
||||
---
|
||||
name: lobster-macos-vm
|
||||
description: Automates provisioning and troubleshooting of macOS virtual machines on Windows using WSL2, QEMU/KVM, and the OneClick-macOS-Simple-KVM project. Use when the user mentions 龙虾, macOS 虚拟机, 一键安装苹果系统 on Windows, or needs to re-deploy the Ventura/Sonoma VM.
|
||||
---
|
||||
|
||||
# 龙虾(lobster-macos-vm)
|
||||
|
||||
> 专门负责:在 **Windows 10/11** 上,通过 **WSL2 + Ubuntu + QEMU/KVM + OneClick-macOS-Simple-KVM** 自动拉起一台 macOS 虚拟机(Ventura 为默认),并处理常见网络 / WSL / KVM 问题。
|
||||
|
||||
## 触发场景
|
||||
|
||||
- 用户提到:**“龙虾”**、**“苹果系统虚拟机”**、**“Windows 上跑 macOS”**、**“一键安装 macOS 虚拟机”**
|
||||
- 用户需要:在 Windows 上**演示 / 测试** macOS,而不是 Docker 容器
|
||||
- 用户遇到:WSL 安装失败、`0x80072ee2` 网络错误、`kvm-ok`、`nested` 配置问题、`git clone` TLS 超时、OneClick 项目下载问题
|
||||
|
||||
## 核心能力
|
||||
|
||||
1. **环境检测与前置说明**
|
||||
- 明确告诉用户:**macOS 不能在 Docker 里运行,必须用 WSL2 + 虚拟机**。
|
||||
- 检查:
|
||||
- `wsl -l -v` → 是否有 `Ubuntu-24.04`,是否为 Version 2
|
||||
- `docker --version` 仅作背景信息,不作为必需条件
|
||||
- 若缺失 WSL2:
|
||||
- 指导用户在**管理员 PowerShell**中执行:
|
||||
- `wsl --install`
|
||||
- 重启后执行 `wsl --install -d Ubuntu-24.04 --web-download`(必要时)
|
||||
|
||||
2. **固定部署目录约定**
|
||||
- 所有与 macOS VM 相关的文件,统一放到:
|
||||
- Windows 路径:`C:\Users\{USERNAME}\Mycontent\macos-vm`
|
||||
- WSL 路径:`/mnt/c/Users/{USERNAME}/Mycontent/macos-vm`
|
||||
- 该目录下结构:
|
||||
- `OneClick-macOS-Simple-KVM/`(从 GitHub 下载的项目)
|
||||
- `BaseSystem.dmg` / `BaseSystem.img`
|
||||
- `macOS.qcow2`
|
||||
- 可能还有 `OneClick.zip` 等临时文件
|
||||
|
||||
3. **获取 OneClick 源码(优先 zip,退而求其次 git)**
|
||||
|
||||
优先使用 **codeload.zip**,避免长时间 `git clone` TLS 超时:
|
||||
|
||||
- 在 `macos-vm/` 目录内执行:
|
||||
|
||||
```bash
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y curl unzip
|
||||
rm -rf OneClick-macOS-Simple-KVM OneClick.zip
|
||||
curl -L --retry 8 --retry-delay 2 --connect-timeout 20 --max-time 600 \
|
||||
-o OneClick.zip \
|
||||
https://codeload.github.com/notAperson535/OneClick-macOS-Simple-KVM/zip/refs/heads/master
|
||||
unzip -q OneClick.zip
|
||||
mv OneClick-macOS-Simple-KVM-master OneClick-macOS-Simple-KVM
|
||||
```
|
||||
|
||||
仅当用户网络环境允许且确有需要时,才尝试:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git
|
||||
```
|
||||
|
||||
出现 `GnuTLS recv error (-110)` 或 TLS 断开时,**不要重复 git clone**,改走 zip 方案。
|
||||
|
||||
4. **依赖安装与 KVM 检查**
|
||||
|
||||
在 `Ubuntu-24.04` 内执行:
|
||||
|
||||
```bash
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y qemu-system qemu-utils python3 python3-pip cpu-checker
|
||||
kvm-ok
|
||||
```
|
||||
|
||||
预期输出:
|
||||
|
||||
- `INFO: /dev/kvm exists`
|
||||
- `KVM acceleration can be used`
|
||||
|
||||
若 `nested` 为 `N` 但 `kvm-ok` 正常:
|
||||
|
||||
- 提示用户在 `C:\Users\{USERNAME}\.wslconfig` 中写入:
|
||||
|
||||
```ini
|
||||
[wsl2]
|
||||
nestedVirtualization=true
|
||||
kernel=C:\\Users\\{USERNAME}\\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` 之后重试。
|
||||
|
||||
5. **下载 macOS Ventura 恢复镜像并生成 BaseSystem.img**
|
||||
|
||||
在 `OneClick-macOS-Simple-KVM` 目录内:
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/{USERNAME}/Mycontent/macos-vm/OneClick-macOS-Simple-KVM
|
||||
chmod +x *.sh *.py
|
||||
[ -f macOS.qcow2 ] || qemu-img create -f qcow2 macOS.qcow2 64G
|
||||
python3 fetch-macOS-v2.py -s ventura
|
||||
[ -f RecoveryImage.dmg ] && mv RecoveryImage.dmg BaseSystem.dmg
|
||||
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
|
||||
ls -lah BaseSystem.* macOS.qcow2
|
||||
```
|
||||
|
||||
成功标志:
|
||||
|
||||
- `BaseSystem.dmg` ≈ 678 MB
|
||||
- `BaseSystem.img` ≈ 3.0 GB
|
||||
- `macOS.qcow2` 已存在(几十 KB 起步)
|
||||
|
||||
6. **启动虚拟机(headless + VNC: localhost:5900)**
|
||||
|
||||
在 `OneClick-macOS-Simple-KVM` 目录内执行:
|
||||
|
||||
```bash
|
||||
sudo HEADLESS=1 ./basic.sh
|
||||
```
|
||||
|
||||
常见日志:
|
||||
|
||||
- ALSA / audio 报错(没有声卡驱动)→ **可以忽略**
|
||||
- `BdsDxe: loading Boot0001 "UEFI QEMU HARDDISK QM00017"...` → 已经开始从虚拟硬盘启动
|
||||
|
||||
在 Windows 侧确认端口:
|
||||
|
||||
```powershell
|
||||
Get-NetTCPConnection -LocalPort 5900 -State Listen
|
||||
```
|
||||
|
||||
若监听正常,提示用户:
|
||||
|
||||
- 安装 VNC 客户端并连接 `localhost:5900`,进入 macOS 安装向导(磁盘工具抹盘 + 安装系统)。
|
||||
- **常用 VNC 客户端**:RealVNC Viewer、**TightVNC**(用户环境已采用)、TigerVNC 等均可,连接地址均为 `localhost:5900`。
|
||||
|
||||
7. **WSL / 网络故障排查**
|
||||
|
||||
- 若 `wsl` 进程过多、`wsl --shutdown` 卡死:
|
||||
- 在 PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
Get-Process -Name wsl -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
wsl --shutdown
|
||||
wsl -l -v
|
||||
```
|
||||
|
||||
- 若 `wsl --install` 报 `0x80072ee2` 或无法访问 `raw.githubusercontent.com`:
|
||||
- 提醒用户这是 **网络 / DNS 问题**,可尝试:
|
||||
- 切换 DNS 到 `8.8.8.8`
|
||||
- 使用合规代理 / VPN
|
||||
- 使用 `--web-download` 方式安装发行版:
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-24.04 --web-download
|
||||
```
|
||||
|
||||
8. **Python 一键脚本(lobster_macos_vm.py)协同**
|
||||
|
||||
当仓库中存在 `开发文档/服务器管理/scripts/lobster_macos_vm.py` 时:
|
||||
|
||||
- 优先引导用户在 **PowerShell** 中执行:
|
||||
|
||||
```powershell
|
||||
python C:\Users\{USERNAME}\Mycontent\macos-vm\lobster_macos_vm.py
|
||||
```
|
||||
|
||||
- 该脚本应负责:
|
||||
- 检查 / 安装 WSL2 + Ubuntu-24.04(必要时提示用户重启)
|
||||
- 确保 `C:\Users\{USERNAME}\Mycontent\macos-vm` 目录存在
|
||||
- 在 WSL 内下载 OneClick 源码 zip、解压到固定目录
|
||||
- 安装 QEMU / Python 依赖并检查 `kvm-ok`
|
||||
- 下载 Ventura 恢复镜像并生成 `BaseSystem.img`
|
||||
- 启动 `sudo HEADLESS=1 ./basic.sh`
|
||||
- 输出清晰的步骤说明(包括如何用 VNC 连接)
|
||||
|
||||
## 使用示例
|
||||
|
||||
- 用户说:「**龙虾,帮我在这台 Windows 上一键装一个 macOS 虚拟机,用来演示**」
|
||||
- 按上述步骤依次执行:环境检测 → 创建 `macos-vm` 目录 → 下载 OneClick → 安装依赖 → 下载 Ventura → 生成 `BaseSystem.img` → 启动虚拟机,并提醒用户用 VNC 连 `localhost:5900` 完成图形安装。
|
||||
|
||||
- 用户说:「**龙虾,之前的 macOS 虚拟机挂了,重装一遍**」
|
||||
- 复用相同目录和镜像文件,必要时重新下载 `BaseSystem.dmg`,再启动 `HEADLESS=1 ./basic.sh`。
|
||||
|
||||
@@ -114,7 +114,9 @@ MCP 配置参考:`开发文档/8、部署/Soul-MySQL-MCP配置说明.md`(连
|
||||
| **插入默认数据** | `INSERT IGNORE INTO xxx (name, sort) VALUES (...)` | 配合 `UNIQUE(name)` 防重复;不指定 id 让 AUTO_INCREMENT 生效 |
|
||||
| **脚本位置** | `soul-api/scripts/add-xxx.sql` | 命名清晰,便于部署时按顺序执行 |
|
||||
|
||||
**完整流程**:1) 编写 SQL 脚本 → 2) 在 `database.go` 中 `AutoMigrate(&model.Xxx{})` → 3) 同步修改 `internal/model` → 4) 部署前执行 `mysql -u user -p db < scripts/add-xxx.sql`。
|
||||
**完整流程**:1) 编写 SQL 脚本 → 2) 同步修改 `internal/model` → 3) **执行迁移脚本**(`node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-xxx.sql`)→ 4) 重启 soul-api。
|
||||
|
||||
**重要**:GORM AutoMigrate 不一定自动新增列(取决于启动时机与连接),**Model 新增字段后必须显式执行 ALTER 脚本**,否则会报 `Unknown column 'xxx' in 'field list'`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,14 +0,0 @@
|
||||
# 根目录忽略
|
||||
.DS_Store
|
||||
*.zip
|
||||
.env
|
||||
.env.*
|
||||
!.env.*.example
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
log/
|
||||
tmp/
|
||||
|
||||
# 各子项目已有 .gitignore,此处仅补充分支通用项
|
||||
node_modules/
|
||||
BIN
20260226一场.zip
Normal file
BIN
20260226一场.zip
Normal file
Binary file not shown.
6
Cunkebao/.env.development
Normal file
6
Cunkebao/.env.development
Normal file
@@ -0,0 +1,6 @@
|
||||
# 基础环境变量示例
|
||||
VITE_API_BASE_URL=http://www.yishi.com
|
||||
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
|
||||
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
|
||||
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||
VITE_APP_TITLE=存客宝
|
||||
1
Cunkebao/.env.local
Normal file
1
Cunkebao/.env.local
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_BASE_URL= http://yishi.com
|
||||
6
Cunkebao/.env.production
Normal file
6
Cunkebao/.env.production
Normal file
@@ -0,0 +1,6 @@
|
||||
# 基础环境变量示例
|
||||
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
|
||||
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
|
||||
# VITE_API_BASE_URL=http://www.yishi.com
|
||||
VITE_APP_TITLE=存客宝
|
||||
64
Cunkebao/.eslintrc.js
Normal file
64
Cunkebao/.eslintrc.js
Normal file
@@ -0,0 +1,64 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended", // 这个配置会自动处理大部分冲突
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 12,
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["react", "react-hooks", "@typescript-eslint", "prettier"],
|
||||
rules: {
|
||||
"prettier/prettier": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "warn",
|
||||
"react/prop-types": "off",
|
||||
"linebreak-style": "off",
|
||||
"eol-last": "off",
|
||||
"no-empty": "warn",
|
||||
"prefer-const": "warn",
|
||||
// 确保与 Prettier 完全兼容
|
||||
"comma-dangle": "off",
|
||||
"comma-spacing": "off",
|
||||
"comma-style": "off",
|
||||
"object-curly-spacing": "off",
|
||||
"array-bracket-spacing": "off",
|
||||
indent: "off",
|
||||
quotes: "off",
|
||||
semi: "off",
|
||||
"arrow-parens": "off",
|
||||
"no-multiple-empty-lines": "off",
|
||||
"max-len": "off",
|
||||
"space-before-function-paren": "off",
|
||||
"space-before-blocks": "off",
|
||||
"keyword-spacing": "off",
|
||||
"space-infix-ops": "off",
|
||||
"space-in-parens": "off",
|
||||
"space-in-brackets": "off",
|
||||
"object-property-newline": "off",
|
||||
"array-element-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
"object-curly-newline": "off",
|
||||
"array-bracket-newline": "off",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
};
|
||||
27
Cunkebao/.gitattributes
vendored
Normal file
27
Cunkebao/.gitattributes
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# 设置默认行为,如果core.autocrlf没有设置,Git会自动处理行尾符
|
||||
* text=auto
|
||||
|
||||
# 明确指定文本文件使用LF
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# 二进制文件
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
11
Cunkebao/.gitignore
vendored
Normal file
11
Cunkebao/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
yarn.lock
|
||||
.env
|
||||
.DS_Store
|
||||
dist/*
|
||||
.cursorindexingignore
|
||||
*.zip
|
||||
.idea/
|
||||
.next/
|
||||
13
Cunkebao/.prettierrc
Normal file
13
Cunkebao/.prettierrc
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed"
|
||||
}
|
||||
8
Cunkebao/.vite/deps/_metadata.json
Normal file
8
Cunkebao/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "efe0acf4",
|
||||
"configHash": "2bed34b3",
|
||||
"lockfileHash": "ef01d341",
|
||||
"browserHash": "91bd3b2c",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
Cunkebao/.vite/deps/package.json
Normal file
3
Cunkebao/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
11
Cunkebao/.vscode/extensions.json
vendored
Normal file
11
Cunkebao/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"formulahendry.auto-rename-tag",
|
||||
"christian-kohler.path-intellisense",
|
||||
"ms-vscode.vscode-json"
|
||||
]
|
||||
}
|
||||
45
Cunkebao/.vscode/settings.json
vendored
Normal file
45
Cunkebao/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"files.eol": "\n",
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
"eslint.format.enable": false,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"typescript.suggest.autoImports": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false
|
||||
}
|
||||
95
Cunkebao/devlop.py
Normal file
95
Cunkebao/devlop.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
import zipfile
|
||||
import paramiko
|
||||
|
||||
# 配置
|
||||
local_dir = './dist' # 本地要打包的目录
|
||||
zip_name = 'dist.zip'
|
||||
# 上传到服务器的 zip 路径
|
||||
remote_path = '/www/wwwroot/auto-devlop/ckb-operation/dist.zip' # 服务器上的临时zip路径
|
||||
server_ip = '42.194.245.239'
|
||||
server_port = 6523
|
||||
server_user = 'yongpxu'
|
||||
server_pwd = 'Aa123456789.'
|
||||
# 服务器 dist 相关目录
|
||||
remote_base_dir = '/www/wwwroot/auto-devlop/ckb-operation'
|
||||
dist_dir = f'{remote_base_dir}/dist'
|
||||
dist1_dir = f'{remote_base_dir}/dist1'
|
||||
dist2_dir = f'{remote_base_dir}/dist2'
|
||||
|
||||
# 美化输出用的函数
|
||||
from datetime import datetime
|
||||
|
||||
def info(msg):
|
||||
print(f"\033[36m[INFO {datetime.now().strftime('%H:%M:%S')}] {msg}\033[0m")
|
||||
|
||||
def success(msg):
|
||||
print(f"\033[32m[SUCCESS] {msg}\033[0m")
|
||||
|
||||
def error(msg):
|
||||
print(f"\033[31m[ERROR] {msg}\033[0m")
|
||||
|
||||
def step(msg):
|
||||
print(f"\n\033[35m==== {msg} ====" + "\033[0m")
|
||||
|
||||
# 1. 先运行 pnpm build
|
||||
step('Step 1: 构建项目 (pnpm build)')
|
||||
info('开始执行 pnpm build...')
|
||||
ret = os.system('pnpm build')
|
||||
if ret != 0:
|
||||
error('pnpm build 失败,终止部署!')
|
||||
exit(1)
|
||||
success('pnpm build 完成')
|
||||
|
||||
# 2. 打包
|
||||
step('Step 2: 打包 dist 目录为 zip')
|
||||
info('开始打包 dist 目录...')
|
||||
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for root, dirs, files in os.walk(local_dir):
|
||||
for file in files:
|
||||
filepath = os.path.join(root, file)
|
||||
arcname = os.path.relpath(filepath, local_dir)
|
||||
zipf.write(filepath, arcname)
|
||||
success('本地打包完成')
|
||||
|
||||
# 3. 上传
|
||||
step('Step 3: 上传 zip 包到服务器')
|
||||
info('开始上传 zip 包...')
|
||||
transport = paramiko.Transport((server_ip, server_port))
|
||||
transport.connect(username=server_user, password=server_pwd)
|
||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||
sftp.put(zip_name, remote_path)
|
||||
sftp.close()
|
||||
transport.close()
|
||||
success('上传到服务器完成')
|
||||
|
||||
# 删除本地 dist.zip
|
||||
try:
|
||||
os.remove(zip_name)
|
||||
success('本地 dist.zip 已删除')
|
||||
except Exception as e:
|
||||
error(f'本地 dist.zip 删除失败: {e}')
|
||||
|
||||
# 4. 远程解压并覆盖
|
||||
step('Step 4: 服务器端解压、切换目录')
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(server_ip, server_port, server_user, server_pwd)
|
||||
commands = [
|
||||
f'unzip -oq {remote_path} -d {dist2_dir}', # 静默解压
|
||||
f'rm {remote_path}',
|
||||
f'if [ -d {dist_dir} ]; then mv {dist_dir} {dist1_dir}; fi',
|
||||
f'mv {dist2_dir} {dist_dir}',
|
||||
f'rm -rf {dist1_dir}'
|
||||
]
|
||||
for i, cmd in enumerate(commands, 1):
|
||||
info(f'执行第{i}步: {cmd}')
|
||||
stdin, stdout, stderr = ssh.exec_command(cmd)
|
||||
out, err = stdout.read().decode(), stderr.read().decode()
|
||||
# 只打印非 unzip 命令的输出
|
||||
if i != 1 and out.strip():
|
||||
print(out.strip())
|
||||
if err.strip():
|
||||
error(err.strip())
|
||||
ssh.close()
|
||||
success('服务器解压并覆盖完成,部署成功!')
|
||||
BIN
Cunkebao/favicon.ico
Normal file
BIN
Cunkebao/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
19
Cunkebao/index.html
Normal file
19
Cunkebao/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>存客宝</title>
|
||||
<style>
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
<!-- 引入 uni-app web-view SDK(必须) -->
|
||||
<script type="text/javascript" src="/websdk.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
52
Cunkebao/package.json
Normal file
52
Cunkebao/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "cunkebao",
|
||||
"version": "3.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"antd": "^5.13.1",
|
||||
"antd-mobile": "^5.39.1",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-window": "^1.8.11",
|
||||
"vconsole": "^3.15.1",
|
||||
"xmldom": "^0.6.0",
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.14",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^7.7.0",
|
||||
"@typescript-eslint/parser": "^7.7.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss-pxtorem": "^6.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"sass": "^1.75.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^7.0.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm vite",
|
||||
"build": "pnpm vite build",
|
||||
"build:check": "tsc && pnpm vite build",
|
||||
"preview": "pnpm vite preview",
|
||||
"lint": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\"",
|
||||
"lint:check": "eslint src --ext .js,.jsx,.ts,.tsx",
|
||||
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\""
|
||||
}
|
||||
}
|
||||
4990
Cunkebao/pnpm-lock.yaml
generated
Normal file
4990
Cunkebao/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
Cunkebao/postcss.config.js
Normal file
8
Cunkebao/postcss.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-pxtorem': {
|
||||
rootValue: 16,
|
||||
propList: ['*'],
|
||||
},
|
||||
},
|
||||
};
|
||||
BIN
Cunkebao/public/logo.png
Normal file
BIN
Cunkebao/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 488 KiB |
30
Cunkebao/public/manifest.json
Normal file
30
Cunkebao/public/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Cunkebao",
|
||||
"short_name": "Cunkebao",
|
||||
"description": "Cunkebao Mobile App",
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "logo.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
308
Cunkebao/public/websdk.js
Normal file
308
Cunkebao/public/websdk.js
Normal file
@@ -0,0 +1,308 @@
|
||||
!(function (e, n) {
|
||||
"object" == typeof exports && "undefined" != typeof module
|
||||
? (module.exports = n())
|
||||
: "function" == typeof define && define.amd
|
||||
? define(n)
|
||||
: ((e = e || self).uni = n());
|
||||
})(this, function () {
|
||||
"use strict";
|
||||
try {
|
||||
var e = {};
|
||||
(Object.defineProperty(e, "passive", {
|
||||
get: function () {
|
||||
!0;
|
||||
},
|
||||
}),
|
||||
window.addEventListener("test-passive", null, e));
|
||||
} catch (e) {}
|
||||
var n = Object.prototype.hasOwnProperty;
|
||||
function i(e, i) {
|
||||
return n.call(e, i);
|
||||
}
|
||||
var t = [];
|
||||
function o() {
|
||||
return window.__dcloud_weex_postMessage || window.__dcloud_weex_;
|
||||
}
|
||||
function a() {
|
||||
return window.__uniapp_x_postMessage || window.__uniapp_x_;
|
||||
}
|
||||
var r = function (e, n) {
|
||||
var i = { options: { timestamp: +new Date() }, name: e, arg: n };
|
||||
if (a()) {
|
||||
if ("postMessage" === e) {
|
||||
var r = { data: n };
|
||||
return window.__uniapp_x_postMessage
|
||||
? window.__uniapp_x_postMessage(r)
|
||||
: window.__uniapp_x_.postMessage(JSON.stringify(r));
|
||||
}
|
||||
var d = {
|
||||
type: "WEB_INVOKE_APPSERVICE",
|
||||
args: { data: i, webviewIds: t },
|
||||
};
|
||||
window.__uniapp_x_postMessage
|
||||
? window.__uniapp_x_postMessageToService(d)
|
||||
: window.__uniapp_x_.postMessageToService(JSON.stringify(d));
|
||||
} else if (o()) {
|
||||
if ("postMessage" === e) {
|
||||
var s = { data: [n] };
|
||||
return window.__dcloud_weex_postMessage
|
||||
? window.__dcloud_weex_postMessage(s)
|
||||
: window.__dcloud_weex_.postMessage(JSON.stringify(s));
|
||||
}
|
||||
var w = {
|
||||
type: "WEB_INVOKE_APPSERVICE",
|
||||
args: { data: i, webviewIds: t },
|
||||
};
|
||||
window.__dcloud_weex_postMessage
|
||||
? window.__dcloud_weex_postMessageToService(w)
|
||||
: window.__dcloud_weex_.postMessageToService(JSON.stringify(w));
|
||||
} else {
|
||||
if (!window.plus)
|
||||
return window.parent.postMessage(
|
||||
{ type: "WEB_INVOKE_APPSERVICE", data: i, pageId: "" },
|
||||
"*",
|
||||
);
|
||||
if (0 === t.length) {
|
||||
var u = plus.webview.currentWebview();
|
||||
if (!u) throw new Error("plus.webview.currentWebview() is undefined");
|
||||
var g = u.parent(),
|
||||
v = "";
|
||||
((v = g ? g.id : u.id), t.push(v));
|
||||
}
|
||||
if (plus.webview.getWebviewById("__uniapp__service"))
|
||||
plus.webview.postMessageToUniNView(
|
||||
{ type: "WEB_INVOKE_APPSERVICE", args: { data: i, webviewIds: t } },
|
||||
"__uniapp__service",
|
||||
);
|
||||
else {
|
||||
var c = JSON.stringify(i);
|
||||
plus.webview
|
||||
.getLaunchWebview()
|
||||
.evalJS(
|
||||
'UniPlusBridge.subscribeHandler("'
|
||||
.concat("WEB_INVOKE_APPSERVICE", '",')
|
||||
.concat(c, ",")
|
||||
.concat(JSON.stringify(t), ");"),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
d = {
|
||||
navigateTo: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||
n = e.url;
|
||||
r("navigateTo", { url: encodeURI(n) });
|
||||
},
|
||||
navigateBack: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||
n = e.delta;
|
||||
r("navigateBack", { delta: parseInt(n) || 1 });
|
||||
},
|
||||
switchTab: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||
n = e.url;
|
||||
r("switchTab", { url: encodeURI(n) });
|
||||
},
|
||||
reLaunch: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||
n = e.url;
|
||||
r("reLaunch", { url: encodeURI(n) });
|
||||
},
|
||||
redirectTo: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||
n = e.url;
|
||||
r("redirectTo", { url: encodeURI(n) });
|
||||
},
|
||||
getEnv: function (e) {
|
||||
a()
|
||||
? e({ uvue: !0 })
|
||||
: o()
|
||||
? e({ nvue: !0 })
|
||||
: window.plus
|
||||
? e({ plus: !0 })
|
||||
: e({ h5: !0 });
|
||||
},
|
||||
postMessage: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {};
|
||||
r("postMessage", e.data || {});
|
||||
},
|
||||
},
|
||||
s = /uni-app/i.test(navigator.userAgent),
|
||||
w = /Html5Plus/i.test(navigator.userAgent),
|
||||
u = /complete|loaded|interactive/;
|
||||
var g =
|
||||
window.my &&
|
||||
navigator.userAgent.indexOf(
|
||||
["t", "n", "e", "i", "l", "C", "y", "a", "p", "i", "l", "A"]
|
||||
.reverse()
|
||||
.join(""),
|
||||
) > -1;
|
||||
var v =
|
||||
window.swan && window.swan.webView && /swan/i.test(navigator.userAgent);
|
||||
var c =
|
||||
window.qq &&
|
||||
window.qq.miniProgram &&
|
||||
/QQ/i.test(navigator.userAgent) &&
|
||||
/miniProgram/i.test(navigator.userAgent);
|
||||
var p =
|
||||
window.tt &&
|
||||
window.tt.miniProgram &&
|
||||
/toutiaomicroapp/i.test(navigator.userAgent);
|
||||
var _ =
|
||||
window.wx &&
|
||||
window.wx.miniProgram &&
|
||||
/micromessenger/i.test(navigator.userAgent) &&
|
||||
/miniProgram/i.test(navigator.userAgent);
|
||||
var m = window.qa && /quickapp/i.test(navigator.userAgent);
|
||||
var f =
|
||||
window.ks &&
|
||||
window.ks.miniProgram &&
|
||||
/micromessenger/i.test(navigator.userAgent) &&
|
||||
/miniProgram/i.test(navigator.userAgent);
|
||||
var l =
|
||||
window.tt &&
|
||||
window.tt.miniProgram &&
|
||||
/Lark|Feishu/i.test(navigator.userAgent);
|
||||
var E =
|
||||
window.jd && window.jd.miniProgram && /jdmp/i.test(navigator.userAgent);
|
||||
var x =
|
||||
window.xhs &&
|
||||
window.xhs.miniProgram &&
|
||||
/xhsminiapp/i.test(navigator.userAgent);
|
||||
for (
|
||||
var S,
|
||||
h = function () {
|
||||
((window.UniAppJSBridge = !0),
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("UniAppJSBridgeReady", {
|
||||
bubbles: !0,
|
||||
cancelable: !0,
|
||||
}),
|
||||
));
|
||||
},
|
||||
y = [
|
||||
function (e) {
|
||||
if (s || w)
|
||||
return (
|
||||
window.__uniapp_x_postMessage ||
|
||||
window.__uniapp_x_ ||
|
||||
window.__dcloud_weex_postMessage ||
|
||||
window.__dcloud_weex_
|
||||
? document.addEventListener("DOMContentLoaded", e)
|
||||
: window.plus && u.test(document.readyState)
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("plusready", e),
|
||||
d
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (_)
|
||||
return (
|
||||
window.WeixinJSBridge && window.WeixinJSBridge.invoke
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("WeixinJSBridgeReady", e),
|
||||
window.wx.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (c)
|
||||
return (
|
||||
window.QQJSBridge && window.QQJSBridge.invoke
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("QQJSBridgeReady", e),
|
||||
window.qq.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (g) {
|
||||
document.addEventListener("DOMContentLoaded", e);
|
||||
var n = window.my;
|
||||
return {
|
||||
navigateTo: n.navigateTo,
|
||||
navigateBack: n.navigateBack,
|
||||
switchTab: n.switchTab,
|
||||
reLaunch: n.reLaunch,
|
||||
redirectTo: n.redirectTo,
|
||||
postMessage: n.postMessage,
|
||||
getEnv: n.getEnv,
|
||||
};
|
||||
}
|
||||
},
|
||||
function (e) {
|
||||
if (v)
|
||||
return (
|
||||
document.addEventListener("DOMContentLoaded", e),
|
||||
window.swan.webView
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (p)
|
||||
return (
|
||||
document.addEventListener("DOMContentLoaded", e),
|
||||
window.tt.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (m) {
|
||||
window.QaJSBridge && window.QaJSBridge.invoke
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("QaJSBridgeReady", e);
|
||||
var n = window.qa;
|
||||
return {
|
||||
navigateTo: n.navigateTo,
|
||||
navigateBack: n.navigateBack,
|
||||
switchTab: n.switchTab,
|
||||
reLaunch: n.reLaunch,
|
||||
redirectTo: n.redirectTo,
|
||||
postMessage: n.postMessage,
|
||||
getEnv: n.getEnv,
|
||||
};
|
||||
}
|
||||
},
|
||||
function (e) {
|
||||
if (f)
|
||||
return (
|
||||
window.WeixinJSBridge && window.WeixinJSBridge.invoke
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("WeixinJSBridgeReady", e),
|
||||
window.ks.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (l)
|
||||
return (
|
||||
document.addEventListener("DOMContentLoaded", e),
|
||||
window.tt.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (E)
|
||||
return (
|
||||
window.JDJSBridgeReady && window.JDJSBridgeReady.invoke
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("JDJSBridgeReady", e),
|
||||
window.jd.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (x) return window.xhs.miniProgram;
|
||||
},
|
||||
function (e) {
|
||||
return (document.addEventListener("DOMContentLoaded", e), d);
|
||||
},
|
||||
],
|
||||
M = 0;
|
||||
M < y.length && !(S = y[M](h));
|
||||
M++
|
||||
);
|
||||
S || (S = {});
|
||||
var P = "undefined" != typeof uni ? uni : {};
|
||||
if (!P.navigateTo) for (var b in S) i(S, b) && (P[b] = S[b]);
|
||||
return ((P.webView = S), P);
|
||||
});
|
||||
14
Cunkebao/src/App.tsx
Normal file
14
Cunkebao/src/App.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import AppRouter from "@/router";
|
||||
import UpdateNotification from "@/components/UpdateNotification";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<AppRouter />
|
||||
<UpdateNotification position="top" autoReload={false} showToast={true} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
352
Cunkebao/src/android-polyfills.ts
Normal file
352
Cunkebao/src/android-polyfills.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
// Android 专用 polyfill - 解决Android 7等低版本系统的兼容性问题
|
||||
|
||||
// 检测是否为Android设备
|
||||
const isAndroid = () => {
|
||||
return /Android/i.test(navigator.userAgent);
|
||||
};
|
||||
|
||||
// 检测Android版本
|
||||
const getAndroidVersion = () => {
|
||||
const match = navigator.userAgent.match(/Android\s+(\d+)/);
|
||||
return match ? parseInt(match[1]) : 0;
|
||||
};
|
||||
|
||||
// 检测是否为低版本Android
|
||||
const isLowVersionAndroid = () => {
|
||||
const version = getAndroidVersion();
|
||||
return version <= 7; // Android 7及以下版本
|
||||
};
|
||||
|
||||
// 只在Android设备上执行polyfill
|
||||
if (isAndroid() && isLowVersionAndroid()) {
|
||||
console.log("检测到低版本Android系统,启用兼容性polyfill");
|
||||
|
||||
// 修复Array.prototype.includes在Android WebView中的问题
|
||||
if (!Array.prototype.includes) {
|
||||
Array.prototype.includes = function (searchElement, fromIndex) {
|
||||
if (this == null) {
|
||||
throw new TypeError('"this" is null or not defined');
|
||||
}
|
||||
var o = Object(this);
|
||||
var len = o.length >>> 0;
|
||||
if (len === 0) {
|
||||
return false;
|
||||
}
|
||||
var n = fromIndex | 0;
|
||||
var k = Math.max(n >= 0 ? n : len + n, 0);
|
||||
while (k < len) {
|
||||
if (o[k] === searchElement) {
|
||||
return true;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
// 修复String.prototype.includes在Android WebView中的问题
|
||||
if (!String.prototype.includes) {
|
||||
String.prototype.includes = function (search, start) {
|
||||
if (typeof start !== "number") {
|
||||
start = 0;
|
||||
}
|
||||
if (start + search.length > this.length) {
|
||||
return false;
|
||||
} else {
|
||||
return this.indexOf(search, start) !== -1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 修复String.prototype.startsWith在Android WebView中的问题
|
||||
if (!String.prototype.startsWith) {
|
||||
String.prototype.startsWith = function (searchString, position) {
|
||||
position = position || 0;
|
||||
return this.substr(position, searchString.length) === searchString;
|
||||
};
|
||||
}
|
||||
|
||||
// 修复String.prototype.endsWith在Android WebView中的问题
|
||||
if (!String.prototype.endsWith) {
|
||||
String.prototype.endsWith = function (searchString, length) {
|
||||
if (length === undefined || length > this.length) {
|
||||
length = this.length;
|
||||
}
|
||||
return (
|
||||
this.substring(length - searchString.length, length) === searchString
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// 修复Array.prototype.find在Android WebView中的问题
|
||||
if (!Array.prototype.find) {
|
||||
Array.prototype.find = function (predicate) {
|
||||
if (this == null) {
|
||||
throw new TypeError("Array.prototype.find called on null or undefined");
|
||||
}
|
||||
if (typeof predicate !== "function") {
|
||||
throw new TypeError("predicate must be a function");
|
||||
}
|
||||
var list = Object(this);
|
||||
var length = parseInt(list.length) || 0;
|
||||
var thisArg = arguments[1];
|
||||
for (var i = 0; i < length; i++) {
|
||||
var element = list[i];
|
||||
if (predicate.call(thisArg, element, i, list)) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// 修复Array.prototype.findIndex在Android WebView中的问题
|
||||
if (!Array.prototype.findIndex) {
|
||||
Array.prototype.findIndex = function (predicate) {
|
||||
if (this == null) {
|
||||
throw new TypeError(
|
||||
"Array.prototype.findIndex called on null or undefined",
|
||||
);
|
||||
}
|
||||
if (typeof predicate !== "function") {
|
||||
throw new TypeError("predicate must be a function");
|
||||
}
|
||||
var list = Object(this);
|
||||
var length = parseInt(list.length) || 0;
|
||||
var thisArg = arguments[1];
|
||||
for (var i = 0; i < length; i++) {
|
||||
var element = list[i];
|
||||
if (predicate.call(thisArg, element, i, list)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
}
|
||||
|
||||
// 修复Object.assign在Android WebView中的问题
|
||||
if (typeof Object.assign !== "function") {
|
||||
Object.assign = function (target) {
|
||||
if (target == null) {
|
||||
throw new TypeError("Cannot convert undefined or null to object");
|
||||
}
|
||||
var to = Object(target);
|
||||
for (var index = 1; index < arguments.length; index++) {
|
||||
var nextSource = arguments[index];
|
||||
if (nextSource != null) {
|
||||
for (var nextKey in nextSource) {
|
||||
if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
|
||||
to[nextKey] = nextSource[nextKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return to;
|
||||
};
|
||||
}
|
||||
|
||||
// 修复Array.from在Android WebView中的问题
|
||||
if (!Array.from) {
|
||||
Array.from = (function () {
|
||||
var toStr = Object.prototype.toString;
|
||||
var isCallable = function (fn) {
|
||||
return (
|
||||
typeof fn === "function" || toStr.call(fn) === "[object Function]"
|
||||
);
|
||||
};
|
||||
var toInteger = function (value) {
|
||||
var number = Number(value);
|
||||
if (isNaN(number)) {
|
||||
return 0;
|
||||
}
|
||||
if (number === 0 || !isFinite(number)) {
|
||||
return number;
|
||||
}
|
||||
return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number));
|
||||
};
|
||||
var maxSafeInteger = Math.pow(2, 53) - 1;
|
||||
var toLength = function (value) {
|
||||
var len = toInteger(value);
|
||||
return Math.min(Math.max(len, 0), maxSafeInteger);
|
||||
};
|
||||
return function from(arrayLike) {
|
||||
var C = this;
|
||||
var items = Object(arrayLike);
|
||||
if (arrayLike == null) {
|
||||
throw new TypeError(
|
||||
"Array.from requires an array-like object - not null or undefined",
|
||||
);
|
||||
}
|
||||
var mapFunction = arguments.length > 1 ? arguments[1] : void undefined;
|
||||
var T;
|
||||
if (typeof mapFunction !== "undefined") {
|
||||
if (typeof mapFunction !== "function") {
|
||||
throw new TypeError(
|
||||
"Array.from: when provided, the second argument must be a function",
|
||||
);
|
||||
}
|
||||
if (arguments.length > 2) {
|
||||
T = arguments[2];
|
||||
}
|
||||
}
|
||||
var len = toLength(items.length);
|
||||
var A = isCallable(C) ? Object(new C(len)) : new Array(len);
|
||||
var k = 0;
|
||||
var kValue;
|
||||
while (k < len) {
|
||||
kValue = items[k];
|
||||
if (mapFunction) {
|
||||
A[k] =
|
||||
typeof T === "undefined"
|
||||
? mapFunction(kValue, k)
|
||||
: mapFunction.call(T, kValue, k);
|
||||
} else {
|
||||
A[k] = kValue;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
A.length = len;
|
||||
return A;
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
// 修复requestAnimationFrame在Android WebView中的问题
|
||||
if (!window.requestAnimationFrame) {
|
||||
window.requestAnimationFrame = function (callback) {
|
||||
return setTimeout(function () {
|
||||
callback(Date.now());
|
||||
}, 1000 / 60);
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.cancelAnimationFrame) {
|
||||
window.cancelAnimationFrame = function (id) {
|
||||
clearTimeout(id);
|
||||
};
|
||||
}
|
||||
|
||||
// 修复IntersectionObserver在Android WebView中的问题
|
||||
if (!window.IntersectionObserver) {
|
||||
window.IntersectionObserver = function (callback, options) {
|
||||
this.callback = callback;
|
||||
this.options = options || {};
|
||||
this.observers = [];
|
||||
|
||||
this.observe = function (element) {
|
||||
this.observers.push(element);
|
||||
// 简单的实现,实际项目中可能需要更复杂的逻辑
|
||||
setTimeout(() => {
|
||||
this.callback([
|
||||
{
|
||||
target: element,
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 1,
|
||||
},
|
||||
]);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
this.unobserve = function (element) {
|
||||
var index = this.observers.indexOf(element);
|
||||
if (index > -1) {
|
||||
this.observers.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
this.disconnect = function () {
|
||||
this.observers = [];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// 修复ResizeObserver在Android WebView中的问题
|
||||
if (!window.ResizeObserver) {
|
||||
window.ResizeObserver = function (callback) {
|
||||
this.callback = callback;
|
||||
this.observers = [];
|
||||
|
||||
this.observe = function (element) {
|
||||
this.observers.push(element);
|
||||
};
|
||||
|
||||
this.unobserve = function (element) {
|
||||
var index = this.observers.indexOf(element);
|
||||
if (index > -1) {
|
||||
this.observers.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
this.disconnect = function () {
|
||||
this.observers = [];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// 修复URLSearchParams在Android WebView中的问题
|
||||
if (!window.URLSearchParams) {
|
||||
window.URLSearchParams = function (init) {
|
||||
this.params = {};
|
||||
|
||||
if (init) {
|
||||
if (typeof init === "string") {
|
||||
if (init.charAt(0) === "?") {
|
||||
init = init.slice(1);
|
||||
}
|
||||
var pairs = init.split("&");
|
||||
for (var i = 0; i < pairs.length; i++) {
|
||||
var pair = pairs[i].split("=");
|
||||
var key = decodeURIComponent(pair[0]);
|
||||
var value = decodeURIComponent(pair[1] || "");
|
||||
this.append(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.append = function (name, value) {
|
||||
if (!this.params[name]) {
|
||||
this.params[name] = [];
|
||||
}
|
||||
this.params[name].push(value);
|
||||
};
|
||||
|
||||
this.get = function (name) {
|
||||
return this.params[name] ? this.params[name][0] : null;
|
||||
};
|
||||
|
||||
this.getAll = function (name) {
|
||||
return this.params[name] || [];
|
||||
};
|
||||
|
||||
this.has = function (name) {
|
||||
return !!this.params[name];
|
||||
};
|
||||
|
||||
this.set = function (name, value) {
|
||||
this.params[name] = [value];
|
||||
};
|
||||
|
||||
this.delete = function (name) {
|
||||
delete this.params[name];
|
||||
};
|
||||
|
||||
this.toString = function () {
|
||||
var pairs = [];
|
||||
for (var key in this.params) {
|
||||
if (this.params.hasOwnProperty(key)) {
|
||||
for (var i = 0; i < this.params[key].length; i++) {
|
||||
pairs.push(
|
||||
encodeURIComponent(key) +
|
||||
"=" +
|
||||
encodeURIComponent(this.params[key][i]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs.join("&");
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
console.log("Android兼容性polyfill已加载完成");
|
||||
}
|
||||
37
Cunkebao/src/api/common.ts
Normal file
37
Cunkebao/src/api/common.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import axios from "axios";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
|
||||
/**
|
||||
* 通用文件上传方法(支持图片、文件)
|
||||
* @param {File} file - 要上传的文件对象
|
||||
* @param {string} [uploadUrl='/v1/attachment/upload'] - 上传接口地址
|
||||
* @returns {Promise<string>} - 上传成功后返回文件url
|
||||
*/
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
uploadUrl: string = "/v1/attachment/upload",
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 创建 FormData 对象用于文件上传
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
// 获取用户token
|
||||
const { token } = useUserStore.getState();
|
||||
|
||||
const fullUrl = `${(import.meta as any).env?.VITE_API_BASE_URL || "/api"}${uploadUrl}`;
|
||||
|
||||
// 直接使用 axios 上传文件
|
||||
const response = await axios.post(fullUrl, formData, {
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : undefined,
|
||||
},
|
||||
timeout: 20000,
|
||||
});
|
||||
return response?.data?.data?.url || "";
|
||||
} catch (e: any) {
|
||||
const errorMessage =
|
||||
e.response?.data?.message || e.message || "文件上传失败";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
90
Cunkebao/src/api/request.ts
Normal file
90
Cunkebao/src/api/request.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
Method,
|
||||
AxiosResponse,
|
||||
} from "axios";
|
||||
import { Toast } from "antd-mobile";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
const { token } = useUserStore.getState();
|
||||
const DEFAULT_DEBOUNCE_GAP = 1000;
|
||||
const debounceMap = new Map<string, number>();
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
|
||||
timeout: 20000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
instance.interceptors.request.use((config: any) => {
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(res: AxiosResponse) => {
|
||||
const { code, success, msg } = res.data || {};
|
||||
if (code === 200 || success) {
|
||||
return res.data.data ?? res.data;
|
||||
}
|
||||
Toast.show({ content: msg || "接口错误", position: "top" });
|
||||
if (code === 401) {
|
||||
localStorage.removeItem("token");
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
if (currentPath === "/login") {
|
||||
window.location.href = "/login";
|
||||
} else {
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
|
||||
}
|
||||
}
|
||||
return Promise.reject(msg || "接口错误");
|
||||
},
|
||||
err => {
|
||||
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
export function request(
|
||||
url: string,
|
||||
data?: any,
|
||||
method: Method = "GET",
|
||||
config?: AxiosRequestConfig,
|
||||
debounceGap?: number,
|
||||
): Promise<any> {
|
||||
const gap =
|
||||
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
|
||||
const key = `${method}_${url}_${JSON.stringify(data)}`;
|
||||
const now = Date.now();
|
||||
const last = debounceMap.get(key) || 0;
|
||||
if (gap > 0 && now - last < gap) {
|
||||
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
|
||||
return Promise.reject("请求过于频繁,请稍后再试");
|
||||
}
|
||||
debounceMap.set(key, now);
|
||||
|
||||
const axiosConfig: AxiosRequestConfig = {
|
||||
url,
|
||||
method,
|
||||
...config,
|
||||
};
|
||||
|
||||
// 如果是FormData,不设置Content-Type,让浏览器自动设置
|
||||
if (data instanceof FormData) {
|
||||
delete axiosConfig.headers?.["Content-Type"];
|
||||
}
|
||||
|
||||
if (method.toUpperCase() === "GET") {
|
||||
axiosConfig.params = data;
|
||||
} else {
|
||||
axiosConfig.data = data;
|
||||
}
|
||||
return instance(axiosConfig);
|
||||
}
|
||||
|
||||
export default request;
|
||||
89
Cunkebao/src/api/request2.ts
Normal file
89
Cunkebao/src/api/request2.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
Method,
|
||||
AxiosResponse,
|
||||
} from "axios";
|
||||
import { Toast } from "antd-mobile";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
const DEFAULT_DEBOUNCE_GAP = 1000;
|
||||
const debounceMap = new Map<string, number>();
|
||||
|
||||
interface RequestConfig extends AxiosRequestConfig {
|
||||
headers: {
|
||||
Client?: string;
|
||||
"Content-Type"?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: (import.meta as any).env?.VITE_API_BASE_URL2 || "/api",
|
||||
timeout: 20000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Client: "kefu-client",
|
||||
},
|
||||
});
|
||||
|
||||
instance.interceptors.request.use((config: any) => {
|
||||
// 在每次请求时动态获取最新的 token2
|
||||
const { token2 } = useUserStore.getState();
|
||||
if (token2) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers["Authorization"] = `bearer ${token2}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(res: AxiosResponse) => {
|
||||
return res.data;
|
||||
},
|
||||
err => {
|
||||
// 处理401错误,跳转到登录页面
|
||||
if (err.response && err.response.status === 401) {
|
||||
Toast.show({ content: "登录已过期,请重新登录", position: "top" });
|
||||
// 获取当前路径,用于登录后跳回
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
window.location.href = `/login?returnUrl=${encodeURIComponent(currentPath)}`;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
export function request(
|
||||
url: string,
|
||||
data?: any,
|
||||
method: Method = "GET",
|
||||
config?: RequestConfig,
|
||||
debounceGap?: number,
|
||||
): Promise<any> {
|
||||
const gap =
|
||||
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
|
||||
const key = `${method}_${url}_${JSON.stringify(data)}`;
|
||||
const now = Date.now();
|
||||
const last = debounceMap.get(key) || 0;
|
||||
if (gap > 0 && now - last < gap) {
|
||||
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
|
||||
return Promise.reject("请求过于频繁,请稍后再试");
|
||||
}
|
||||
debounceMap.set(key, now);
|
||||
|
||||
const axiosConfig: RequestConfig = {
|
||||
url,
|
||||
method,
|
||||
...config,
|
||||
};
|
||||
|
||||
if (method.toUpperCase() === "GET") {
|
||||
axiosConfig.params = data;
|
||||
} else {
|
||||
axiosConfig.data = data;
|
||||
}
|
||||
return instance(axiosConfig);
|
||||
}
|
||||
|
||||
export default request;
|
||||
10
Cunkebao/src/components/AccountSelection/api.ts
Normal file
10
Cunkebao/src/components/AccountSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取好友列表
|
||||
export function getAccountList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/workbench/account-list", params, "GET");
|
||||
}
|
||||
35
Cunkebao/src/components/AccountSelection/data.ts
Normal file
35
Cunkebao/src/components/AccountSelection/data.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// 账号对象类型
|
||||
export interface AccountItem {
|
||||
id: number;
|
||||
userName: string;
|
||||
realName: string;
|
||||
departmentName: string;
|
||||
avatar?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
//弹窗的
|
||||
export interface SelectionPopupProps {
|
||||
visible: boolean;
|
||||
onVisibleChange: (visible: boolean) => void;
|
||||
selectedOptions: AccountItem[];
|
||||
onSelect: (options: AccountItem[]) => void;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (selectedOptions: AccountItem[]) => void;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export interface AccountSelectionProps {
|
||||
selectedOptions: AccountItem[];
|
||||
onSelect: (options: AccountItem[]) => void;
|
||||
accounts?: AccountItem[]; // 可选:用于在外层显示已选账号详情
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
selectedListMaxHeight?: number;
|
||||
showInput?: boolean;
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (selectedOptions: AccountItem[]) => void;
|
||||
accountGroups?: any[]; // 传递账号组数据
|
||||
}
|
||||
231
Cunkebao/src/components/AccountSelection/index.module.scss
Normal file
231
Cunkebao/src/components/AccountSelection/index.module.scss
Normal file
@@ -0,0 +1,231 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 48px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 24px;
|
||||
}
|
||||
.popupTitle {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.searchWrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.searchInput {
|
||||
padding-left: 40px !important;
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 15px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 16px;
|
||||
}
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 50%;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.friendList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.friendListInner {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.friendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.radioWrapper {
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e6eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1890ff;
|
||||
}
|
||||
.friendInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.friendAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.friendDetail {
|
||||
flex: 1;
|
||||
}
|
||||
.friendName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.friendId {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.friendCustomer {
|
||||
font-size: 13px;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.cancelBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
.confirmBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
139
Cunkebao/src/components/AccountSelection/index.tsx
Normal file
139
Cunkebao/src/components/AccountSelection/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from "react";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import style from "./index.module.scss";
|
||||
import SelectionPopup from "./selectionPopup";
|
||||
import { AccountItem, AccountSelectionProps } from "./data";
|
||||
|
||||
export default function AccountSelection({
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
accounts: propAccounts = [],
|
||||
placeholder = "选择账号",
|
||||
className = "",
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedListMaxHeight = 300,
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: AccountSelectionProps) {
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
|
||||
// 受控弹窗逻辑
|
||||
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (onVisibleChange) onVisibleChange(v);
|
||||
if (visible === undefined) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setRealVisible(true);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedOptions.length === 0) return "";
|
||||
return `已选择 ${selectedOptions.length} 个账号`;
|
||||
};
|
||||
|
||||
// 删除已选账号
|
||||
const handleRemoveAccount = (id: number) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedOptions.filter(d => d.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
{showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选账号列表窗口 */}
|
||||
{showSelectedList && selectedOptions.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedOptions.map(acc => (
|
||||
<div
|
||||
key={acc.id}
|
||||
className={style.selectedListRow}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
【{acc.realName}】 {acc.userName}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveAccount(acc.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<SelectionPopup
|
||||
visible={realVisible}
|
||||
onVisibleChange={setRealVisible}
|
||||
selectedOptions={selectedOptions}
|
||||
onSelect={onSelect}
|
||||
readonly={readonly}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
237
Cunkebao/src/components/AccountSelection/selectionPopup.tsx
Normal file
237
Cunkebao/src/components/AccountSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Popup } from "antd-mobile";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import style from "./index.module.scss";
|
||||
import { getAccountList } from "./api";
|
||||
import { AccountItem, SelectionPopupProps } from "./data";
|
||||
|
||||
export default function SelectionPopup({
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: SelectionPopupProps) {
|
||||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalAccounts, setTotalAccounts] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tempSelectedOptions, setTempSelectedOptions] = useState<AccountItem[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
// 累积已加载过的账号,确保确认时能返回更完整的对象
|
||||
const loadedAccountMapRef = useRef<Map<number, AccountItem>>(new Map());
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
const fetchAccounts = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, limit: pageSize };
|
||||
if (keyword.trim()) params.keyword = keyword.trim();
|
||||
|
||||
const response = await getAccountList(params);
|
||||
if (response && response.list) {
|
||||
setAccounts(response.list);
|
||||
const total: number = response.total || response.list.length || 0;
|
||||
setTotalAccounts(total);
|
||||
setTotalPages(Math.max(1, Math.ceil(total / pageSize)));
|
||||
|
||||
// 累积到映射表
|
||||
response.list.forEach((acc: AccountItem) => {
|
||||
loadedAccountMapRef.current.set(acc.id, acc);
|
||||
});
|
||||
} else {
|
||||
setAccounts([]);
|
||||
setTotalAccounts(0);
|
||||
setTotalPages(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取账号列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountToggle = (account: AccountItem) => {
|
||||
if (readonly || !onSelect) return;
|
||||
const isSelected = tempSelectedOptions.some(opt => opt.id === account.id);
|
||||
const next = isSelected
|
||||
? tempSelectedOptions.filter(opt => opt.id !== account.id)
|
||||
: tempSelectedOptions.concat(account);
|
||||
setTempSelectedOptions(next);
|
||||
};
|
||||
|
||||
// 全选当前页
|
||||
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||
if (readonly) return;
|
||||
|
||||
if (checked) {
|
||||
// 全选:添加当前页面所有未选中的账号
|
||||
const currentPageAccounts = accounts.filter(
|
||||
account => !tempSelectedOptions.some(a => a.id === account.id),
|
||||
);
|
||||
setTempSelectedOptions(prev => [...prev, ...currentPageAccounts]);
|
||||
} else {
|
||||
// 取消全选:移除当前页面的所有账号
|
||||
const currentPageAccountIds = accounts.map(a => a.id);
|
||||
setTempSelectedOptions(prev =>
|
||||
prev.filter(a => !currentPageAccountIds.includes(a.id)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查当前页是否全选
|
||||
const isCurrentPageAllSelected =
|
||||
accounts.length > 0 &&
|
||||
accounts.every(account =>
|
||||
tempSelectedOptions.some(a => a.id === account.id),
|
||||
);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(tempSelectedOptions);
|
||||
}
|
||||
if (onSelect) {
|
||||
onSelect(tempSelectedOptions);
|
||||
}
|
||||
onVisibleChange(false);
|
||||
};
|
||||
|
||||
// 弹窗打开时初始化数据
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentPage(1);
|
||||
setSearchQuery("");
|
||||
loadedAccountMapRef.current.clear();
|
||||
// 复制一份selectedOptions到临时变量
|
||||
setTempSelectedOptions([...selectedOptions]);
|
||||
fetchAccounts(1, "");
|
||||
}
|
||||
}, [visible, selectedOptions]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (searchQuery === "") return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchAccounts(1, searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, visible]);
|
||||
|
||||
// 页码变化
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
fetchAccounts(currentPage, searchQuery);
|
||||
}, [currentPage, visible, searchQuery]);
|
||||
|
||||
const selectedIdSet = useMemo(
|
||||
() => new Set(tempSelectedOptions.map(opt => opt.id)),
|
||||
[tempSelectedOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible && !readonly}
|
||||
onMaskClick={() => onVisibleChange(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择账号"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索账号"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchAccounts(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={tempSelectedOptions.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => onVisibleChange(false)}
|
||||
onConfirm={handleConfirm}
|
||||
isAllSelected={isCurrentPageAllSelected}
|
||||
onSelectAll={handleSelectAllCurrentPage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.friendList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : accounts.length > 0 ? (
|
||||
<div className={style.friendListInner}>
|
||||
{accounts.map(acc => (
|
||||
<label
|
||||
key={acc.id}
|
||||
className={style.friendItem}
|
||||
onClick={() => !readonly && handleAccountToggle(acc)}
|
||||
>
|
||||
<div className={style.radioWrapper}>
|
||||
<div
|
||||
className={
|
||||
selectedIdSet.has(acc.id)
|
||||
? style.radioSelected
|
||||
: style.radioUnselected
|
||||
}
|
||||
>
|
||||
{selectedIdSet.has(acc.id) && (
|
||||
<div className={style.radioDot}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.friendInfo}>
|
||||
<div className={style.friendAvatar}>
|
||||
{acc.avatar ? (
|
||||
<img
|
||||
src={acc.avatar}
|
||||
alt={acc.userName}
|
||||
className={style.avatarImg}
|
||||
/>
|
||||
) : (
|
||||
(acc.userName?.charAt(0) ?? "?")
|
||||
)}
|
||||
</div>
|
||||
<div className={style.friendDetail}>
|
||||
<div className={style.friendName}>{acc.userName}</div>
|
||||
<div className={style.friendId}>
|
||||
真实姓名: {acc.realName}
|
||||
</div>
|
||||
<div className={style.friendId}>
|
||||
部门: {acc.departmentName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>
|
||||
{searchQuery
|
||||
? `没有找到包含"${searchQuery}"的账号`
|
||||
: "没有找到账号"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
228
Cunkebao/src/components/AndroidCompatibilityCheck.tsx
Normal file
228
Cunkebao/src/components/AndroidCompatibilityCheck.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface AndroidCompatibilityInfo {
|
||||
isAndroid: boolean;
|
||||
androidVersion: number;
|
||||
chromeVersion: number;
|
||||
webViewVersion: number;
|
||||
issues: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
const AndroidCompatibilityCheck: React.FC = () => {
|
||||
const [compatibility, setCompatibility] = useState<AndroidCompatibilityInfo>({
|
||||
isAndroid: false,
|
||||
androidVersion: 0,
|
||||
chromeVersion: 0,
|
||||
webViewVersion: 0,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkAndroidCompatibility = () => {
|
||||
const ua = navigator.userAgent;
|
||||
const issues: string[] = [];
|
||||
const suggestions: string[] = [];
|
||||
let isAndroid = false;
|
||||
let androidVersion = 0;
|
||||
let chromeVersion = 0;
|
||||
let webViewVersion = 0;
|
||||
|
||||
// 检测Android系统
|
||||
if (ua.indexOf("Android") > -1) {
|
||||
isAndroid = true;
|
||||
const androidMatch = ua.match(/Android\s+(\d+)/);
|
||||
if (androidMatch) {
|
||||
androidVersion = parseInt(androidMatch[1]);
|
||||
}
|
||||
|
||||
// 检测Chrome版本
|
||||
const chromeMatch = ua.match(/Chrome\/(\d+)/);
|
||||
if (chromeMatch) {
|
||||
chromeVersion = parseInt(chromeMatch[1]);
|
||||
}
|
||||
|
||||
// 检测WebView版本
|
||||
const webViewMatch = ua.match(/Version\/\d+\.\d+/);
|
||||
if (webViewMatch) {
|
||||
const versionMatch = webViewMatch[0].match(/\d+/);
|
||||
if (versionMatch) {
|
||||
webViewVersion = parseInt(versionMatch[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Android 7 (API 24) 兼容性检查
|
||||
if (androidVersion === 7) {
|
||||
issues.push("Android 7 系统对ES6+特性支持不完整");
|
||||
suggestions.push("建议升级到Android 8+或使用最新版Chrome");
|
||||
}
|
||||
|
||||
// Android 6 (API 23) 兼容性检查
|
||||
if (androidVersion === 6) {
|
||||
issues.push("Android 6 系统对现代Web特性支持有限");
|
||||
suggestions.push("强烈建议升级系统或使用最新版Chrome");
|
||||
}
|
||||
|
||||
// Chrome版本检查
|
||||
if (chromeVersion > 0 && chromeVersion < 50) {
|
||||
issues.push(`Chrome版本过低 (${chromeVersion}),建议升级到50+`);
|
||||
suggestions.push("请在Google Play商店更新Chrome浏览器");
|
||||
}
|
||||
|
||||
// WebView版本检查
|
||||
if (webViewVersion > 0 && webViewVersion < 50) {
|
||||
issues.push(`WebView版本过低 (${webViewVersion}),可能影响应用功能`);
|
||||
suggestions.push("建议使用Chrome浏览器或更新系统WebView");
|
||||
}
|
||||
|
||||
// 检测特定问题
|
||||
const features = {
|
||||
Promise: typeof Promise !== "undefined",
|
||||
fetch: typeof fetch !== "undefined",
|
||||
"Array.from": typeof Array.from !== "undefined",
|
||||
"Object.assign": typeof Object.assign !== "undefined",
|
||||
"String.includes": typeof String.prototype.includes !== "undefined",
|
||||
"Array.includes": typeof Array.prototype.includes !== "undefined",
|
||||
requestAnimationFrame: typeof requestAnimationFrame !== "undefined",
|
||||
IntersectionObserver: typeof IntersectionObserver !== "undefined",
|
||||
ResizeObserver: typeof ResizeObserver !== "undefined",
|
||||
URLSearchParams: typeof URLSearchParams !== "undefined",
|
||||
TextEncoder: typeof TextEncoder !== "undefined",
|
||||
AbortController: typeof AbortController !== "undefined",
|
||||
};
|
||||
|
||||
Object.entries(features).forEach(([feature, supported]) => {
|
||||
if (!supported) {
|
||||
issues.push(`${feature} 特性不支持`);
|
||||
}
|
||||
});
|
||||
|
||||
// 微信内置浏览器检测
|
||||
if (ua.indexOf("MicroMessenger") > -1) {
|
||||
issues.push("微信内置浏览器对某些Web特性支持有限");
|
||||
suggestions.push("建议在系统浏览器中打开以获得最佳体验");
|
||||
}
|
||||
|
||||
// QQ内置浏览器检测
|
||||
if (ua.indexOf("QQ/") > -1) {
|
||||
issues.push("QQ内置浏览器对某些Web特性支持有限");
|
||||
suggestions.push("建议在系统浏览器中打开以获得最佳体验");
|
||||
}
|
||||
}
|
||||
|
||||
setCompatibility({
|
||||
isAndroid,
|
||||
androidVersion,
|
||||
chromeVersion,
|
||||
webViewVersion,
|
||||
issues,
|
||||
suggestions,
|
||||
});
|
||||
};
|
||||
|
||||
checkAndroidCompatibility();
|
||||
}, []);
|
||||
|
||||
if (!compatibility.isAndroid || compatibility.issues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#fff3cd",
|
||||
border: "1px solid #ffeaa7",
|
||||
padding: "15px",
|
||||
zIndex: 9999,
|
||||
textAlign: "center",
|
||||
fontSize: "14px",
|
||||
maxHeight: "50vh",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ fontWeight: "bold", marginBottom: "10px", color: "#856404" }}
|
||||
>
|
||||
🚨 Android 兼容性警告
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "8px", fontSize: "12px" }}>
|
||||
系统版本: Android {compatibility.androidVersion}
|
||||
{compatibility.chromeVersion > 0 &&
|
||||
` | Chrome: ${compatibility.chromeVersion}`}
|
||||
{compatibility.webViewVersion > 0 &&
|
||||
` | WebView: ${compatibility.webViewVersion}`}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<div
|
||||
style={{ fontWeight: "bold", marginBottom: "5px", color: "#856404" }}
|
||||
>
|
||||
检测到的问题:
|
||||
</div>
|
||||
<div style={{ color: "#856404", fontSize: "12px" }}>
|
||||
{compatibility.issues.map((issue, index) => (
|
||||
<div key={index} style={{ marginBottom: "3px" }}>
|
||||
• {issue}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{compatibility.suggestions.length > 0 && (
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "5px",
|
||||
color: "#155724",
|
||||
}}
|
||||
>
|
||||
建议解决方案:
|
||||
</div>
|
||||
<div style={{ color: "#155724", fontSize: "12px" }}>
|
||||
{compatibility.suggestions.map((suggestion, index) => (
|
||||
<div key={index} style={{ marginBottom: "3px" }}>
|
||||
• {suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: "11px", color: "#6c757d", marginTop: "10px" }}>
|
||||
💡 应用已启用兼容模式,但建议升级系统以获得最佳体验
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const element = document.querySelector(
|
||||
'[style*="position: fixed"][style*="top: 0"]',
|
||||
) as HTMLElement;
|
||||
if (element) {
|
||||
element.style.display = "none";
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "5px",
|
||||
right: "10px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
fontSize: "18px",
|
||||
cursor: "pointer",
|
||||
color: "#856404",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AndroidCompatibilityCheck;
|
||||
125
Cunkebao/src/components/CompatibilityCheck.tsx
Normal file
125
Cunkebao/src/components/CompatibilityCheck.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface CompatibilityInfo {
|
||||
isCompatible: boolean;
|
||||
browser: string;
|
||||
version: string;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
const CompatibilityCheck: React.FC = () => {
|
||||
const [compatibility, setCompatibility] = useState<CompatibilityInfo>({
|
||||
isCompatible: true,
|
||||
browser: "",
|
||||
version: "",
|
||||
issues: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkCompatibility = () => {
|
||||
const ua = navigator.userAgent;
|
||||
const issues: string[] = [];
|
||||
let browser = "Unknown";
|
||||
let version = "Unknown";
|
||||
|
||||
// 检测浏览器类型和版本
|
||||
if (ua.indexOf("Chrome") > -1) {
|
||||
browser = "Chrome";
|
||||
const match = ua.match(/Chrome\/(\d+)/);
|
||||
version = match ? match[1] : "Unknown";
|
||||
if (parseInt(version) < 50) {
|
||||
issues.push("Chrome版本过低,建议升级到50+");
|
||||
}
|
||||
} else if (ua.indexOf("Firefox") > -1) {
|
||||
browser = "Firefox";
|
||||
const match = ua.match(/Firefox\/(\d+)/);
|
||||
version = match ? match[1] : "Unknown";
|
||||
if (parseInt(version) < 50) {
|
||||
issues.push("Firefox版本过低,建议升级到50+");
|
||||
}
|
||||
} else if (ua.indexOf("Safari") > -1 && ua.indexOf("Chrome") === -1) {
|
||||
browser = "Safari";
|
||||
const match = ua.match(/Version\/(\d+)/);
|
||||
version = match ? match[1] : "Unknown";
|
||||
if (parseInt(version) < 10) {
|
||||
issues.push("Safari版本过低,建议升级到10+");
|
||||
}
|
||||
} else if (ua.indexOf("MSIE") > -1 || ua.indexOf("Trident") > -1) {
|
||||
browser = "Internet Explorer";
|
||||
const match = ua.match(/(?:MSIE |rv:)(\d+)/);
|
||||
version = match ? match[1] : "Unknown";
|
||||
issues.push("Internet Explorer不受支持,建议使用现代浏览器");
|
||||
} else if (ua.indexOf("Edge") > -1) {
|
||||
browser = "Edge";
|
||||
const match = ua.match(/Edge\/(\d+)/);
|
||||
version = match ? match[1] : "Unknown";
|
||||
if (parseInt(version) < 12) {
|
||||
issues.push("Edge版本过低,建议升级到12+");
|
||||
}
|
||||
}
|
||||
|
||||
// 检测ES6+特性支持
|
||||
const features = {
|
||||
Promise: typeof Promise !== "undefined",
|
||||
fetch: typeof fetch !== "undefined",
|
||||
"Array.from": typeof Array.from !== "undefined",
|
||||
"Object.assign": typeof Object.assign !== "undefined",
|
||||
"String.includes": typeof String.prototype.includes !== "undefined",
|
||||
"Array.includes": typeof Array.prototype.includes !== "undefined",
|
||||
};
|
||||
|
||||
Object.entries(features).forEach(([feature, supported]) => {
|
||||
if (!supported) {
|
||||
issues.push(`${feature} 特性不支持`);
|
||||
}
|
||||
});
|
||||
|
||||
setCompatibility({
|
||||
isCompatible: issues.length === 0,
|
||||
browser,
|
||||
version,
|
||||
issues,
|
||||
});
|
||||
};
|
||||
|
||||
checkCompatibility();
|
||||
}, []);
|
||||
|
||||
if (compatibility.isCompatible) {
|
||||
return null; // 兼容时不需要显示
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#fff3cd",
|
||||
border: "1px solid #ffeaa7",
|
||||
padding: "10px",
|
||||
zIndex: 9999,
|
||||
textAlign: "center",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "5px" }}>
|
||||
浏览器兼容性警告
|
||||
</div>
|
||||
<div style={{ marginBottom: "5px" }}>
|
||||
当前浏览器: {compatibility.browser} {compatibility.version}
|
||||
</div>
|
||||
<div style={{ color: "#856404" }}>
|
||||
{compatibility.issues.map((issue, index) => (
|
||||
<div key={index}>{issue}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: "10px", fontSize: "12px" }}>
|
||||
建议使用 Chrome 50+、Firefox 50+、Safari 10+ 或 Edge 12+
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompatibilityCheck;
|
||||
5
Cunkebao/src/components/ContentSelection/api.ts
Normal file
5
Cunkebao/src/components/ContentSelection/api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
export function getContentLibraryList(params: any) {
|
||||
return request("/v1/content/library/list", { ...params, formType: 0 }, "GET");
|
||||
}
|
||||
21
Cunkebao/src/components/ContentSelection/data.ts
Normal file
21
Cunkebao/src/components/ContentSelection/data.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// 内容库接口类型
|
||||
export interface ContentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export interface ContentSelectionProps {
|
||||
selectedOptions: ContentItem[];
|
||||
onSelect: (selectedItems: ContentItem[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
selectedListMaxHeight?: number;
|
||||
showInput?: boolean;
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (selectedItems: ContentItem[]) => void;
|
||||
}
|
||||
117
Cunkebao/src/components/ContentSelection/index.module.scss
Normal file
117
Cunkebao/src/components/ContentSelection/index.module.scss
Normal file
@@ -0,0 +1,117 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.selectedListWindow {
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedListRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.libraryList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.libraryListInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.libraryItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.checkboxWrapper {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.checkboxSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
background: #1677ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.checkboxUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e6eb;
|
||||
background: #fff;
|
||||
}
|
||||
.checkboxDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
}
|
||||
.libraryInfo {
|
||||
flex: 1;
|
||||
}
|
||||
.libraryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.libraryName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
}
|
||||
.typeTag {
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
border: 1px solid #1677ff;
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
margin-left: 8px;
|
||||
background: #f4f8ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.libraryMeta {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.libraryDesc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
145
Cunkebao/src/components/ContentSelection/index.tsx
Normal file
145
Cunkebao/src/components/ContentSelection/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useState } from "react";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import style from "./index.module.scss";
|
||||
import { ContentItem, ContentSelectionProps } from "./data";
|
||||
import SelectionPopup from "./selectionPopup";
|
||||
|
||||
const ContentSelection: React.FC<ContentSelectionProps> = ({
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
placeholder = "选择内容库",
|
||||
className = "",
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedListMaxHeight = 300,
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}) => {
|
||||
// 弹窗控制
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (onVisibleChange) onVisibleChange(v);
|
||||
if (visible === undefined) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setRealVisible(true);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedOptions.length === 0) return "";
|
||||
return `已选择 ${selectedOptions.length} 个内容库`;
|
||||
};
|
||||
|
||||
// 删除已选内容库
|
||||
const handleRemoveLibrary = (id: number) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedOptions.filter(c => c.id !== id));
|
||||
};
|
||||
|
||||
// 清除所有已选内容库
|
||||
const handleClearAll = () => {
|
||||
if (readonly) return;
|
||||
onSelect([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
{showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
onClear={handleClearAll}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选内容库列表窗口 */}
|
||||
{showSelectedList && selectedOptions.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedOptions.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={style.selectedListRow}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{item.name || item.id}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveLibrary(item.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<SelectionPopup
|
||||
visible={realVisible && !readonly}
|
||||
onClose={() => setRealVisible(false)}
|
||||
selectedOptions={selectedOptions}
|
||||
onSelect={onSelect}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentSelection;
|
||||
257
Cunkebao/src/components/ContentSelection/selectionPopup.tsx
Normal file
257
Cunkebao/src/components/ContentSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Checkbox, Popup } from "antd-mobile";
|
||||
import { getContentLibraryList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import { ContentItem } from "./data";
|
||||
|
||||
interface SelectionPopupProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
selectedOptions: ContentItem[];
|
||||
onSelect: (libraries: ContentItem[]) => void;
|
||||
onConfirm?: (libraries: ContentItem[]) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// 类型标签文本
|
||||
const getTypeText = (type?: number) => {
|
||||
if (type === 1) return "文本";
|
||||
if (type === 2) return "图片";
|
||||
if (type === 3) return "视频";
|
||||
return "未知";
|
||||
};
|
||||
|
||||
// 时间格式化
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "-";
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return "-";
|
||||
return `${d.getFullYear()}/${(d.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d
|
||||
.getHours()
|
||||
.toString()
|
||||
.padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d
|
||||
.getSeconds()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
onConfirm,
|
||||
}) => {
|
||||
// 内容库数据
|
||||
const [libraries, setLibraries] = useState<ContentItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true); // 默认设置为加载中状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalLibraries, setTotalLibraries] = useState(0);
|
||||
const [tempSelectedOptions, setTempSelectedOptions] = useState<ContentItem[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
// 获取内容库列表,支持keyword和分页
|
||||
const fetchLibraries = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
};
|
||||
if (keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
const response = await getContentLibraryList(params);
|
||||
if (response && response.list) {
|
||||
setLibraries(response.list);
|
||||
setTotalLibraries(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / PAGE_SIZE));
|
||||
} else {
|
||||
// 如果没有返回列表数据,设置为空数组
|
||||
setLibraries([]);
|
||||
setTotalLibraries(0);
|
||||
setTotalPages(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取内容库列表失败:", error);
|
||||
// 请求失败时,设置为空数组
|
||||
setLibraries([]);
|
||||
setTotalLibraries(0);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 打开弹窗时获取第一页
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
// 复制一份selectedOptions到临时变量
|
||||
setTempSelectedOptions([...selectedOptions]);
|
||||
// 设置loading状态,避免显示空内容
|
||||
setLoading(true);
|
||||
fetchLibraries(1, "");
|
||||
} else {
|
||||
// 关闭弹窗时重置加载状态,确保下次打开时显示加载中
|
||||
setLoading(true);
|
||||
}
|
||||
}, [visible, selectedOptions]);
|
||||
|
||||
// 搜索处理函数
|
||||
const handleSearch = (query: string) => {
|
||||
if (!visible) return;
|
||||
setCurrentPage(1);
|
||||
fetchLibraries(1, query);
|
||||
};
|
||||
|
||||
// 搜索输入变化时的处理
|
||||
const handleSearchChange = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
};
|
||||
|
||||
// 翻页处理函数
|
||||
const handlePageChange = (page: number) => {
|
||||
if (!visible || page === currentPage) return;
|
||||
setCurrentPage(page);
|
||||
fetchLibraries(page, searchQuery);
|
||||
};
|
||||
|
||||
// 处理内容库选择
|
||||
const handleLibraryToggle = (library: ContentItem) => {
|
||||
const newSelected = tempSelectedOptions.some(c => c.id === library.id)
|
||||
? tempSelectedOptions.filter(c => c.id !== library.id)
|
||||
: [...tempSelectedOptions, library];
|
||||
setTempSelectedOptions(newSelected);
|
||||
};
|
||||
|
||||
// 全选当前页
|
||||
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// 全选:添加当前页面所有未选中的内容库
|
||||
const currentPageLibraries = libraries.filter(
|
||||
library => !tempSelectedOptions.some(l => l.id === library.id),
|
||||
);
|
||||
setTempSelectedOptions(prev => [...prev, ...currentPageLibraries]);
|
||||
} else {
|
||||
// 取消全选:移除当前页面的所有内容库
|
||||
const currentPageLibraryIds = libraries.map(l => l.id);
|
||||
setTempSelectedOptions(prev =>
|
||||
prev.filter(l => !currentPageLibraryIds.includes(l.id)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查当前页是否全选
|
||||
const isCurrentPageAllSelected =
|
||||
libraries.length > 0 &&
|
||||
libraries.every(library =>
|
||||
tempSelectedOptions.some(l => l.id === library.id),
|
||||
);
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
// 用户点击确认时,才更新实际的selectedOptions
|
||||
onSelect(tempSelectedOptions);
|
||||
if (onConfirm) {
|
||||
onConfirm(tempSelectedOptions);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
// 渲染内容库列表或空状态提示
|
||||
const OptionsList = () => {
|
||||
return libraries.length > 0 ? (
|
||||
<div className={style.libraryListInner}>
|
||||
{libraries.map(item => (
|
||||
<label key={item.id} className={style.libraryItem}>
|
||||
<Checkbox
|
||||
checked={tempSelectedOptions.map(c => c.id).includes(item.id)}
|
||||
onChange={() => handleLibraryToggle(item)}
|
||||
className={style.checkboxWrapper}
|
||||
/>
|
||||
<div className={style.libraryInfo}>
|
||||
<div className={style.libraryHeader}>
|
||||
<span className={style.libraryName}>{item.name}</span>
|
||||
<span className={style.typeTag}>
|
||||
{getTypeText(item.sourceType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.libraryMeta}>
|
||||
<div>创建人: {item.creatorName || "-"}</div>
|
||||
<div>更新时间: {formatDate(item.updateTime)}</div>
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className={style.libraryDesc}>{item.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>数据为空</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择内容库"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={handleSearchChange}
|
||||
searchPlaceholder="搜索内容库"
|
||||
loading={loading}
|
||||
onSearch={handleSearch}
|
||||
onRefresh={() => fetchLibraries(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={tempSelectedOptions.length}
|
||||
onPageChange={handlePageChange}
|
||||
onCancel={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
isAllSelected={isCurrentPageAllSelected}
|
||||
onSelectAll={handleSelectAllCurrentPage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.libraryList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
OptionsList()
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionPopup;
|
||||
10
Cunkebao/src/components/DeviceSelection/api.ts
Normal file
10
Cunkebao/src/components/DeviceSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取设备列表
|
||||
export function getDeviceList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/devices", params, "GET");
|
||||
}
|
||||
30
Cunkebao/src/components/DeviceSelection/data.ts
Normal file
30
Cunkebao/src/components/DeviceSelection/data.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// 设备选择项接口
|
||||
export interface DeviceSelectionItem {
|
||||
id: number;
|
||||
memo: string;
|
||||
imei: string;
|
||||
wechatId: string;
|
||||
status: "online" | "offline";
|
||||
wxid?: string;
|
||||
nickname?: string;
|
||||
usedInPlans?: number;
|
||||
avatar?: string;
|
||||
totalFriend?: number;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export interface DeviceSelectionProps {
|
||||
selectedOptions: DeviceSelectionItem[];
|
||||
onSelect: (devices: DeviceSelectionItem[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
mode?: "input" | "dialog"; // 新增,默认input
|
||||
open?: boolean; // 仅mode=dialog时生效
|
||||
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
|
||||
selectedListMaxHeight?: number; // 新增,已选列表最大高度,默认500
|
||||
showInput?: boolean; // 新增
|
||||
showSelectedList?: boolean; // 新增
|
||||
readonly?: boolean; // 新增
|
||||
deviceGroups?: any[]; // 传递设备组数据
|
||||
singleSelect?: boolean; // 新增,是否单选模式
|
||||
}
|
||||
274
Cunkebao/src/components/DeviceSelection/index.module.scss
Normal file
274
Cunkebao/src/components/DeviceSelection/index.module.scss
Normal file
@@ -0,0 +1,274 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
z-index: 10;
|
||||
font-size: 18px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 56px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.popupTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.popupSearchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.popupSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.popupSearchInput {
|
||||
padding-left: 36px !important;
|
||||
border-radius: 12px !important;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.statusSelect {
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
font-size: 15px;
|
||||
padding: 0 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.deviceList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.deviceListInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.deviceItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid #f5f5f5;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.imeiText {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
}
|
||||
.deviceCheckbox {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.deviceInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.deviceAvatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarText {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.deviceContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deviceInfoRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.deviceName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.statusOnline {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
font-weight: 500;
|
||||
}
|
||||
.statusOffline {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #ffccc7;
|
||||
font-weight: 500;
|
||||
}
|
||||
.deviceInfoDetail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
|
||||
&.imei {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&.friendCount {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.refreshBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
margin: 0 8px;
|
||||
}
|
||||
192
Cunkebao/src/components/DeviceSelection/index.tsx
Normal file
192
Cunkebao/src/components/DeviceSelection/index.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from "react";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Input, Button } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { DeviceSelectionProps } from "./data";
|
||||
import SelectionPopup from "./selectionPopup";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
placeholder = "选择设备",
|
||||
className = "",
|
||||
mode = "input",
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedListMaxHeight = 300, // 默认300
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
singleSelect = false,
|
||||
}) => {
|
||||
// 弹窗控制
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const isDialog = mode === "dialog";
|
||||
const realVisible = isDialog ? !!open : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (isDialog && onOpenChange) onOpenChange(v);
|
||||
if (!isDialog) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setRealVisible(true);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedOptions.length === 0) return "";
|
||||
if (singleSelect && selectedOptions.length > 0) {
|
||||
return selectedOptions[0].memo || selectedOptions[0].wechatId || "已选择设备";
|
||||
}
|
||||
return `已选择 ${selectedOptions.length} 个设备`;
|
||||
};
|
||||
|
||||
// 删除已选设备
|
||||
const handleRemoveDevice = (id: number) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedOptions.filter(v => v.id !== id));
|
||||
};
|
||||
|
||||
// 清除所有已选设备
|
||||
const handleClearAll = () => {
|
||||
if (readonly) return;
|
||||
onSelect([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* mode=input 显示输入框,mode=dialog不显示 */}
|
||||
{mode === "input" && showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
onClear={handleClearAll}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选设备列表窗口 */}
|
||||
{mode === "input" && showSelectedList && selectedOptions.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedOptions.map(device => (
|
||||
<div
|
||||
key={device.id}
|
||||
className={style.selectedListRow}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 12px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{/* 头像 */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "6px",
|
||||
background:
|
||||
"linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
|
||||
marginRight: "12px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{device.avatar ? (
|
||||
<img
|
||||
src={device.avatar}
|
||||
alt="头像"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
textShadow: "0 1px 3px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{(device.memo || device.wechatId || "设")[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{device.memo} - {device.wechatId}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveDevice(device.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<SelectionPopup
|
||||
visible={realVisible && !readonly}
|
||||
onClose={() => setRealVisible(false)}
|
||||
selectedOptions={selectedOptions}
|
||||
onSelect={onSelect}
|
||||
singleSelect={singleSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceSelection;
|
||||
287
Cunkebao/src/components/DeviceSelection/selectionPopup.tsx
Normal file
287
Cunkebao/src/components/DeviceSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Checkbox, Popup } from "antd-mobile";
|
||||
import { getDeviceList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import { DeviceSelectionItem } from "./data";
|
||||
|
||||
interface SelectionPopupProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
selectedOptions: DeviceSelectionItem[];
|
||||
onSelect: (devices: DeviceSelectionItem[]) => void;
|
||||
singleSelect?: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
singleSelect = false,
|
||||
}) => {
|
||||
// 设备数据
|
||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [tempSelectedOptions, setTempSelectedOptions] = useState<
|
||||
DeviceSelectionItem[]
|
||||
>([]);
|
||||
|
||||
// 获取设备列表,支持keyword和分页
|
||||
const fetchDevices = useCallback(
|
||||
async (keyword: string = "", page: number = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getDeviceList({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
keyword: keyword.trim() || undefined,
|
||||
});
|
||||
if (res && Array.isArray(res.list)) {
|
||||
setDevices(
|
||||
res.list.map((d: any) => ({
|
||||
id: d.id?.toString() || "",
|
||||
memo: d.memo || d.imei || "",
|
||||
imei: d.imei || "",
|
||||
wechatId: d.wechatId || "",
|
||||
status: d.alive === 1 ? "online" : "offline",
|
||||
wxid: d.wechatId || "",
|
||||
nickname: d.nickname || "",
|
||||
usedInPlans: d.usedInPlans || 0,
|
||||
avatar: d.avatar || "",
|
||||
totalFriend: d.totalFriend || 0,
|
||||
})),
|
||||
);
|
||||
setTotal(res.total || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 打开弹窗时获取第一页
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
// 复制一份selectedOptions到临时变量
|
||||
setTempSelectedOptions([...selectedOptions]);
|
||||
fetchDevices("", 1);
|
||||
}
|
||||
}, [visible, fetchDevices, selectedOptions]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchDevices(searchQuery, 1);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, visible, fetchDevices]);
|
||||
|
||||
// 翻页时重新请求
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
fetchDevices(searchQuery, currentPage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
// 过滤设备(只保留状态过滤)
|
||||
const filteredDevices = devices.filter(device => {
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline");
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
// 处理设备选择
|
||||
const handleDeviceToggle = (device: DeviceSelectionItem) => {
|
||||
if (singleSelect) {
|
||||
// 单选模式:如果已选中,则取消选择;否则替换为当前设备
|
||||
if (tempSelectedOptions.some(v => v.id === device.id)) {
|
||||
setTempSelectedOptions([]);
|
||||
} else {
|
||||
setTempSelectedOptions([device]);
|
||||
}
|
||||
} else {
|
||||
// 多选模式:原有的逻辑
|
||||
if (tempSelectedOptions.some(v => v.id === device.id)) {
|
||||
setTempSelectedOptions(
|
||||
tempSelectedOptions.filter(v => v.id !== device.id),
|
||||
);
|
||||
} else {
|
||||
const newSelectedOptions = [...tempSelectedOptions, device];
|
||||
setTempSelectedOptions(newSelectedOptions);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 全选当前页
|
||||
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// 全选:添加当前页面所有未选中的设备
|
||||
const currentPageDevices = filteredDevices.filter(
|
||||
device => !tempSelectedOptions.some(d => d.id === device.id),
|
||||
);
|
||||
setTempSelectedOptions(prev => [...prev, ...currentPageDevices]);
|
||||
} else {
|
||||
// 取消全选:移除当前页面的所有设备
|
||||
const currentPageDeviceIds = filteredDevices.map(d => d.id);
|
||||
setTempSelectedOptions(prev =>
|
||||
prev.filter(d => !currentPageDeviceIds.includes(d.id)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查当前页是否全选
|
||||
const isCurrentPageAllSelected =
|
||||
filteredDevices.length > 0 &&
|
||||
filteredDevices.every(device =>
|
||||
tempSelectedOptions.some(d => d.id === device.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择设备"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索设备IMEI/备注/微信号"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchDevices(searchQuery, currentPage)}
|
||||
showTabs={true}
|
||||
tabsConfig={{
|
||||
activeKey: statusFilter,
|
||||
onChange: setStatusFilter,
|
||||
tabs: [
|
||||
{ title: "全部", key: "all" },
|
||||
{ title: "在线", key: "online" },
|
||||
{ title: "离线", key: "offline" },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={tempSelectedOptions.length}
|
||||
singleSelect={singleSelect}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={onClose}
|
||||
onConfirm={() => {
|
||||
// 用户点击确认时,才更新实际的selectedOptions
|
||||
onSelect(tempSelectedOptions);
|
||||
onClose();
|
||||
}}
|
||||
isAllSelected={isCurrentPageAllSelected}
|
||||
onSelectAll={singleSelect ? undefined : handleSelectAllCurrentPage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.deviceList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.deviceListInner}>
|
||||
{filteredDevices.map(device => (
|
||||
<div key={device.id} className={style.deviceItem}>
|
||||
{/* 顶部行:选择框和IMEI */}
|
||||
<div className={style.headerRow}>
|
||||
<div className={style.checkboxContainer}>
|
||||
<Checkbox
|
||||
checked={tempSelectedOptions.some(
|
||||
v => v.id === device.id,
|
||||
)}
|
||||
onChange={() => handleDeviceToggle(device)}
|
||||
className={style.deviceCheckbox}
|
||||
/>
|
||||
</div>
|
||||
<span className={style.imeiText}>
|
||||
IMEI: {device.imei?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域:头像和详细信息 */}
|
||||
<div className={style.mainContent}>
|
||||
{/* 头像 */}
|
||||
<div className={style.deviceAvatar}>
|
||||
{device.avatar ? (
|
||||
<img src={device.avatar} alt="头像" />
|
||||
) : (
|
||||
<span className={style.avatarText}>
|
||||
{(device.memo || device.wechatId || "设")[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 设备信息 */}
|
||||
<div className={style.deviceContent}>
|
||||
<div className={style.deviceInfoRow}>
|
||||
<span className={style.deviceName}>{device.memo}</span>
|
||||
<div
|
||||
className={
|
||||
device.status === "online"
|
||||
? style.statusOnline
|
||||
: style.statusOffline
|
||||
}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.deviceInfoDetail}>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.infoLabel}>微信号:</span>
|
||||
<span className={style.infoValue}>
|
||||
{device.wechatId}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.infoLabel}>好友数:</span>
|
||||
<span
|
||||
className={`${style.infoValue} ${style.friendCount}`}
|
||||
>
|
||||
{device.totalFriend ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionPopup;
|
||||
167
Cunkebao/src/components/EmojiSeclection/EmojiPicker.css
Normal file
167
Cunkebao/src/components/EmojiSeclection/EmojiPicker.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/* 表情选择器容器 */
|
||||
.emoji-picker-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 默认触发器按钮 */
|
||||
.emoji-picker-trigger {
|
||||
background: none;
|
||||
font-size: 16px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.emoji-picker-trigger:hover {
|
||||
background-color: #e9e9e9;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
/* 表情选择器面板 */
|
||||
.emoji-picker-panel {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
width: 320px;
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 分类标签栏 */
|
||||
.emoji-categories {
|
||||
display: flex;
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 表情网格 */
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 表情项 */
|
||||
.emoji-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.emoji-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.emoji-image {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.emoji-empty {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.emoji-grid::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.emoji-grid::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.emoji-grid::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.emoji-grid::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.emoji-picker-panel {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.emoji-picker-panel {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.emoji-categories {
|
||||
background-color: #1a202c;
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
|
||||
.emoji-item:hover {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
|
||||
.emoji-picker-trigger {
|
||||
border-color: #4a5568;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.emoji-picker-trigger:hover {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
}
|
||||
115
Cunkebao/src/components/EmojiSeclection/EmojiPicker.tsx
Normal file
115
Cunkebao/src/components/EmojiSeclection/EmojiPicker.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { EmojiCategory, EmojiInfo, getEmojisByCategory } from "./wechatEmoji";
|
||||
import "./EmojiPicker.css";
|
||||
|
||||
interface EmojiPickerProps {
|
||||
onEmojiSelect: (emoji: EmojiInfo) => void;
|
||||
trigger?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EmojiPicker: React.FC<EmojiPickerProps> = ({
|
||||
onEmojiSelect,
|
||||
trigger,
|
||||
className = "",
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<EmojiCategory>(
|
||||
EmojiCategory.FACE,
|
||||
);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 分类配置
|
||||
const categories = [
|
||||
{ key: EmojiCategory.FACE, label: "😊", title: "人脸" },
|
||||
{ key: EmojiCategory.GESTURE, label: "👋", title: "手势" },
|
||||
{ key: EmojiCategory.ANIMAL, label: "🐷", title: "动物" },
|
||||
{ key: EmojiCategory.BLESSING, label: "🎉", title: "祝福" },
|
||||
{ key: EmojiCategory.OTHER, label: "❤️", title: "其他" },
|
||||
];
|
||||
|
||||
// 获取当前分类的表情
|
||||
const currentEmojis = getEmojisByCategory(activeCategory);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
pickerRef.current &&
|
||||
!pickerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 处理表情选择
|
||||
const handleEmojiClick = (emoji: EmojiInfo) => {
|
||||
onEmojiSelect(emoji);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// 默认触发器
|
||||
const defaultTrigger = <span className="emoji-picker-trigger">😊</span>;
|
||||
|
||||
return (
|
||||
<div className={`emoji-picker-container ${className}`} ref={pickerRef}>
|
||||
{/* 触发器 */}
|
||||
<div onClick={() => setIsOpen(!isOpen)}>{trigger || defaultTrigger}</div>
|
||||
|
||||
{/* 表情选择器面板 */}
|
||||
{isOpen && (
|
||||
<div className="emoji-picker-panel">
|
||||
{/* 分类标签 */}
|
||||
<div className="emoji-categories">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category.key}
|
||||
className={`category-btn ${
|
||||
activeCategory === category.key ? "active" : ""
|
||||
}`}
|
||||
onClick={() => setActiveCategory(category.key)}
|
||||
title={category.title}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 表情网格 */}
|
||||
<div className="emoji-grid">
|
||||
{currentEmojis.map(emoji => (
|
||||
<div
|
||||
key={emoji.name}
|
||||
className="emoji-item"
|
||||
onClick={() => handleEmojiClick(emoji)}
|
||||
title={emoji.name}
|
||||
>
|
||||
<img
|
||||
src={emoji.path}
|
||||
alt={emoji.name}
|
||||
className="emoji-image"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 空状态 */}
|
||||
{currentEmojis.length === 0 && (
|
||||
<div className="emoji-empty">暂无表情</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPicker;
|
||||
18
Cunkebao/src/components/EmojiSeclection/index.ts
Normal file
18
Cunkebao/src/components/EmojiSeclection/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// 导出主要组件
|
||||
export { default as EmojiPicker } from "./EmojiPicker";
|
||||
|
||||
// 导出表情数据和类型
|
||||
export {
|
||||
EmojiCategory,
|
||||
type EmojiInfo,
|
||||
type EmojiName,
|
||||
getAllEmojis,
|
||||
getEmojisByCategory,
|
||||
getEmojiInfo,
|
||||
getEmojiPath,
|
||||
searchEmojis,
|
||||
EMOJI_CATEGORIES,
|
||||
} from "./wechatEmoji";
|
||||
|
||||
// 默认导出
|
||||
export { default } from "./EmojiPicker";
|
||||
902
Cunkebao/src/components/EmojiSeclection/wechatEmoji.ts
Normal file
902
Cunkebao/src/components/EmojiSeclection/wechatEmoji.ts
Normal file
@@ -0,0 +1,902 @@
|
||||
/**
|
||||
* 微信表情包 TypeScript 模块
|
||||
* 提供类型安全的表情访问和图片路径获取功能
|
||||
*/
|
||||
|
||||
/**
|
||||
* 表情类别枚举
|
||||
*/
|
||||
export enum EmojiCategory {
|
||||
/** 人脸表情 */
|
||||
FACE = "face",
|
||||
/** 手势表情 */
|
||||
GESTURE = "gesture",
|
||||
/** 动物表情 */
|
||||
ANIMAL = "animal",
|
||||
/** 祝福表情 */
|
||||
BLESSING = "blessing",
|
||||
/** 其他表情 */
|
||||
OTHER = "other",
|
||||
}
|
||||
|
||||
/**
|
||||
* 表情信息接口
|
||||
*/
|
||||
export interface EmojiInfo {
|
||||
/** 表情名称 */
|
||||
name: string;
|
||||
/** 表情类别 */
|
||||
category: EmojiCategory;
|
||||
/** 图片文件路径 */
|
||||
path: string;
|
||||
/** 英文名称(可选) */
|
||||
englishName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表情名称类型
|
||||
*/
|
||||
export type EmojiName =
|
||||
// 人脸表情
|
||||
| "微笑"
|
||||
| "撇嘴"
|
||||
| "色"
|
||||
| "发呆"
|
||||
| "得意"
|
||||
| "流泪"
|
||||
| "害羞"
|
||||
| "闭嘴"
|
||||
| "睡"
|
||||
| "大哭"
|
||||
| "尴尬"
|
||||
| "发怒"
|
||||
| "调皮"
|
||||
| "呲牙"
|
||||
| "惊讶"
|
||||
| "难过"
|
||||
| "囧"
|
||||
| "抓狂"
|
||||
| "吐"
|
||||
| "偷笑"
|
||||
| "愉快"
|
||||
| "白眼"
|
||||
| "傲慢"
|
||||
| "困"
|
||||
| "惊恐"
|
||||
| "憨笑"
|
||||
| "悠闲"
|
||||
| "咒骂"
|
||||
| "疑问"
|
||||
| "嘘"
|
||||
| "晕"
|
||||
| "衰"
|
||||
| "骷髅"
|
||||
| "敲打"
|
||||
| "再见"
|
||||
| "擦汗"
|
||||
| "抠鼻"
|
||||
| "鼓掌"
|
||||
| "坏笑"
|
||||
| "右哼哼"
|
||||
| "鄙视"
|
||||
| "委屈"
|
||||
| "快哭了"
|
||||
| "阴险"
|
||||
| "亲亲"
|
||||
| "可怜"
|
||||
| "笑脸"
|
||||
| "生病"
|
||||
| "脸红"
|
||||
| "破涕为笑"
|
||||
| "恐惧"
|
||||
| "失望"
|
||||
| "无语"
|
||||
| "嘿哈"
|
||||
| "捂脸"
|
||||
| "机智"
|
||||
| "皱眉"
|
||||
| "耶"
|
||||
| "吃瓜"
|
||||
| "加油"
|
||||
| "汗"
|
||||
| "天啊"
|
||||
| "Emm"
|
||||
| "社会社会"
|
||||
| "旺柴"
|
||||
| "好的"
|
||||
| "打脸"
|
||||
| "哇"
|
||||
| "翻白眼"
|
||||
| "666"
|
||||
| "让我看看"
|
||||
| "叹气"
|
||||
| "苦涩"
|
||||
| "裂开"
|
||||
| "奸笑"
|
||||
// 手势表情
|
||||
| "握手"
|
||||
| "胜利"
|
||||
| "抱拳"
|
||||
| "勾引"
|
||||
| "拳头"
|
||||
| "OK"
|
||||
| "合十"
|
||||
| "强"
|
||||
| "拥抱"
|
||||
| "弱"
|
||||
// 动物表情
|
||||
| "猪头"
|
||||
| "跳跳"
|
||||
| "发抖"
|
||||
| "转圈"
|
||||
// 祝福表情
|
||||
| "庆祝"
|
||||
| "礼物"
|
||||
| "红包"
|
||||
| "發"
|
||||
| "福"
|
||||
| "烟花"
|
||||
| "爆竹"
|
||||
// 其他表情
|
||||
| "嘴唇"
|
||||
| "爱心"
|
||||
| "心碎"
|
||||
| "啤酒"
|
||||
| "咖啡"
|
||||
| "蛋糕"
|
||||
| "凋谢"
|
||||
| "菜刀"
|
||||
| "炸弹"
|
||||
| "便便"
|
||||
| "太阳"
|
||||
| "月亮"
|
||||
| "玫瑰";
|
||||
|
||||
/**
|
||||
* 表情数据映射
|
||||
* 将表情名称映射到完整的表情信息
|
||||
*/
|
||||
const EMOJI_DATA: Record<EmojiName, EmojiInfo> = {
|
||||
// 人脸表情
|
||||
微笑: {
|
||||
name: "微笑",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/smile.png",
|
||||
},
|
||||
撇嘴: {
|
||||
name: "撇嘴",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/pout.png",
|
||||
},
|
||||
色: {
|
||||
name: "色",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/lustful.png",
|
||||
},
|
||||
发呆: {
|
||||
name: "发呆",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/daze.png",
|
||||
},
|
||||
得意: {
|
||||
name: "得意",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/smug.png",
|
||||
},
|
||||
流泪: {
|
||||
name: "流泪",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/crying.png",
|
||||
},
|
||||
害羞: {
|
||||
name: "害羞",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/shy.png",
|
||||
},
|
||||
闭嘴: {
|
||||
name: "闭嘴",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/shut-up.png",
|
||||
},
|
||||
睡: {
|
||||
name: "睡",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/sleep.png",
|
||||
},
|
||||
大哭: {
|
||||
name: "大哭",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/wail.png",
|
||||
},
|
||||
尴尬: {
|
||||
name: "尴尬",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/awkward.png",
|
||||
},
|
||||
发怒: {
|
||||
name: "发怒",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/angry.png",
|
||||
},
|
||||
调皮: {
|
||||
name: "调皮",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/naughty.png",
|
||||
},
|
||||
呲牙: {
|
||||
name: "呲牙",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/grin.png",
|
||||
},
|
||||
惊讶: {
|
||||
name: "惊讶",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/surprised.png",
|
||||
},
|
||||
难过: {
|
||||
name: "难过",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/sad.png",
|
||||
},
|
||||
囧: {
|
||||
name: "囧",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/embarrassed.png",
|
||||
},
|
||||
抓狂: {
|
||||
name: "抓狂",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/crazy.png",
|
||||
},
|
||||
吐: {
|
||||
name: "吐",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/vomit.png",
|
||||
},
|
||||
偷笑: {
|
||||
name: "偷笑",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/snicker.png",
|
||||
},
|
||||
愉快: {
|
||||
name: "愉快",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/happy.png",
|
||||
},
|
||||
白眼: {
|
||||
name: "白眼",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/roll-eyes.png",
|
||||
},
|
||||
傲慢: {
|
||||
name: "傲慢",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/arrogant.png",
|
||||
},
|
||||
困: {
|
||||
name: "困",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/sleepy.png",
|
||||
},
|
||||
惊恐: {
|
||||
name: "惊恐",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/panic.png",
|
||||
},
|
||||
憨笑: {
|
||||
name: "憨笑",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/silly-smile.png",
|
||||
},
|
||||
悠闲: {
|
||||
name: "悠闲",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/leisurely.png",
|
||||
},
|
||||
咒骂: {
|
||||
name: "咒骂",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/curse.png",
|
||||
},
|
||||
疑问: {
|
||||
name: "疑问",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/question.png",
|
||||
},
|
||||
嘘: {
|
||||
name: "嘘",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/shush.png",
|
||||
},
|
||||
晕: {
|
||||
name: "晕",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/dizzy.png",
|
||||
},
|
||||
衰: {
|
||||
name: "衰",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/unlucky.png",
|
||||
},
|
||||
骷髅: {
|
||||
name: "骷髅",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/skull.png",
|
||||
},
|
||||
敲打: {
|
||||
name: "敲打",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/knock.png",
|
||||
},
|
||||
再见: {
|
||||
name: "再见",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/goodbye.png",
|
||||
},
|
||||
擦汗: {
|
||||
name: "擦汗",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/wipe-sweat.png",
|
||||
},
|
||||
抠鼻: {
|
||||
name: "抠鼻",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/pick-nose.png",
|
||||
},
|
||||
鼓掌: {
|
||||
name: "鼓掌",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/clap.png",
|
||||
},
|
||||
坏笑: {
|
||||
name: "坏笑",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/evil-smile.png",
|
||||
},
|
||||
右哼哼: {
|
||||
name: "右哼哼",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/right-hum.png",
|
||||
},
|
||||
鄙视: {
|
||||
name: "鄙视",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/despise.png",
|
||||
},
|
||||
委屈: {
|
||||
name: "委屈",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/wronged.png",
|
||||
},
|
||||
快哭了: {
|
||||
name: "快哭了",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/about-to-cry.png",
|
||||
},
|
||||
阴险: {
|
||||
name: "阴险",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/sinister.png",
|
||||
},
|
||||
亲亲: {
|
||||
name: "亲亲",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/kiss.png",
|
||||
},
|
||||
可怜: {
|
||||
name: "可怜",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/pitiful.png",
|
||||
},
|
||||
笑脸: {
|
||||
name: "笑脸",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/smiley.png",
|
||||
},
|
||||
生病: {
|
||||
name: "生病",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/sick.png",
|
||||
},
|
||||
脸红: {
|
||||
name: "脸红",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/blush.png",
|
||||
},
|
||||
破涕为笑: {
|
||||
name: "破涕为笑",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/tears-to-smile.png",
|
||||
},
|
||||
恐惧: {
|
||||
name: "恐惧",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/fear.png",
|
||||
},
|
||||
失望: {
|
||||
name: "失望",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/disappointed.png",
|
||||
},
|
||||
无语: {
|
||||
name: "无语",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/speechless.png",
|
||||
},
|
||||
嘿哈: {
|
||||
name: "嘿哈",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/hey-ha.png",
|
||||
},
|
||||
捂脸: {
|
||||
name: "捂脸",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/facepalm.png",
|
||||
},
|
||||
机智: {
|
||||
name: "机智",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/smart.png",
|
||||
},
|
||||
皱眉: {
|
||||
name: "皱眉",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/frown.png",
|
||||
},
|
||||
耶: {
|
||||
name: "耶",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/yeah.png",
|
||||
},
|
||||
吃瓜: {
|
||||
name: "吃瓜",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/eat-melon.png",
|
||||
},
|
||||
加油: {
|
||||
name: "加油",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/cheer-up.png",
|
||||
},
|
||||
|
||||
汗: {
|
||||
name: "汗",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/sweat.png",
|
||||
},
|
||||
天啊: {
|
||||
name: "天啊",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/oh-my.png",
|
||||
},
|
||||
Emm: {
|
||||
name: "Emm",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/Emm.png",
|
||||
},
|
||||
社会社会: {
|
||||
name: "社会社会",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/social.png",
|
||||
},
|
||||
旺柴: {
|
||||
name: "旺柴",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/doge.png",
|
||||
},
|
||||
好的: {
|
||||
name: "好的",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/good.png",
|
||||
},
|
||||
打脸: {
|
||||
name: "打脸",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/slap-face.png",
|
||||
},
|
||||
哇: {
|
||||
name: "哇",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/wow.png",
|
||||
},
|
||||
翻白眼: {
|
||||
name: "翻白眼",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/eye-roll.png",
|
||||
},
|
||||
"666": {
|
||||
name: "666",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/666.png",
|
||||
},
|
||||
让我看看: {
|
||||
name: "让我看看",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/let-me-see.png",
|
||||
},
|
||||
叹气: {
|
||||
name: "叹气",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/sigh.png",
|
||||
},
|
||||
苦涩: {
|
||||
name: "苦涩",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/bitter.png",
|
||||
},
|
||||
裂开: {
|
||||
name: "裂开",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/crack.png",
|
||||
},
|
||||
奸笑: {
|
||||
name: "奸笑",
|
||||
category: EmojiCategory.FACE,
|
||||
path: "/assets/face/sly-smile.png",
|
||||
},
|
||||
|
||||
// 手势表情
|
||||
握手: {
|
||||
name: "握手",
|
||||
category: EmojiCategory.GESTURE,
|
||||
path: "/assets/gesture/handshake.png",
|
||||
},
|
||||
胜利: {
|
||||
name: "胜利",
|
||||
category: EmojiCategory.GESTURE,
|
||||
path: "/assets/gesture/victory.png",
|
||||
},
|
||||
抱拳: {
|
||||
name: "抱拳",
|
||||
category: EmojiCategory.GESTURE,
|
||||
path: "/assets/gesture/fist-salute.png",
|
||||
},
|
||||
勾引: {
|
||||
name: "勾引",
|
||||
category: EmojiCategory.GESTURE,
|
||||
path: "/assets/gesture/beckon.png",
|
||||
},
|
||||
拳头: {
|
||||
name: "拳头",
|
||||
category: EmojiCategory.GESTURE,
|
||||
path: "/assets/gesture/fist.png",
|
||||
},
|
||||
OK: {
|
||||
name: "OK",
|
||||
category: EmojiCategory.GESTURE,
|
||||
path: "/assets/gesture/OK.png",
|
||||
},
|
||||
合十: {
|
||||
name: "合十",
|
||||
category: EmojiCategory.GESTURE,
|
||||
path: "/assets/gesture/pray.png",
|
||||
},
|
||||
强: {
|
||||
name: "强",
|
||||
category: EmojiCategory.GESTURE,
|
||||
path: "/assets/gesture/strong.png",
|
||||
},
|
||||
拥抱: {
|
||||
name: "拥抱",
|
||||
category: EmojiCategory.GESTURE,
|
||||
path: "/assets/gesture/hug.png",
|
||||
},
|
||||
弱: {
|
||||
name: "弱",
|
||||
category: EmojiCategory.GESTURE,
|
||||
path: "/assets/gesture/weak.png",
|
||||
},
|
||||
|
||||
// 动物表情
|
||||
猪头: {
|
||||
name: "猪头",
|
||||
category: EmojiCategory.ANIMAL,
|
||||
path: "/assets/animal/pig.png",
|
||||
},
|
||||
跳跳: {
|
||||
name: "跳跳",
|
||||
category: EmojiCategory.ANIMAL,
|
||||
path: "/assets/animal/jump.png",
|
||||
},
|
||||
发抖: {
|
||||
name: "发抖",
|
||||
category: EmojiCategory.ANIMAL,
|
||||
path: "/assets/animal/tremble.png",
|
||||
},
|
||||
转圈: {
|
||||
name: "转圈",
|
||||
category: EmojiCategory.ANIMAL,
|
||||
path: "/assets/animal/circle.png",
|
||||
},
|
||||
|
||||
// 祝福表情
|
||||
庆祝: {
|
||||
name: "庆祝",
|
||||
category: EmojiCategory.BLESSING,
|
||||
path: "/assets/blessing/celebrate.png",
|
||||
},
|
||||
礼物: {
|
||||
name: "礼物",
|
||||
category: EmojiCategory.BLESSING,
|
||||
path: "/assets/blessing/gift.png",
|
||||
},
|
||||
红包: {
|
||||
name: "红包",
|
||||
category: EmojiCategory.BLESSING,
|
||||
path: "/assets/blessing/red-envelope.png",
|
||||
},
|
||||
發: {
|
||||
name: "發",
|
||||
category: EmojiCategory.BLESSING,
|
||||
path: "/assets/blessing/get-rich.png",
|
||||
},
|
||||
福: {
|
||||
name: "福",
|
||||
category: EmojiCategory.BLESSING,
|
||||
path: "/assets/blessing/fortune.png",
|
||||
},
|
||||
烟花: {
|
||||
name: "烟花",
|
||||
category: EmojiCategory.BLESSING,
|
||||
path: "/assets/blessing/fireworks.png",
|
||||
},
|
||||
爆竹: {
|
||||
name: "爆竹",
|
||||
category: EmojiCategory.BLESSING,
|
||||
path: "/assets/blessing/firecrackers.png",
|
||||
},
|
||||
|
||||
// 其他表情
|
||||
嘴唇: {
|
||||
name: "嘴唇",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/lips.png",
|
||||
},
|
||||
爱心: {
|
||||
name: "爱心",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/heart.png",
|
||||
},
|
||||
心碎: {
|
||||
name: "心碎",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/broken-heart.png",
|
||||
},
|
||||
啤酒: {
|
||||
name: "啤酒",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/beer.png",
|
||||
},
|
||||
咖啡: {
|
||||
name: "咖啡",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/coffee.png",
|
||||
},
|
||||
蛋糕: {
|
||||
name: "蛋糕",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/cake.png",
|
||||
},
|
||||
凋谢: {
|
||||
name: "凋谢",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/wither.png",
|
||||
},
|
||||
菜刀: {
|
||||
name: "菜刀",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/knife.png",
|
||||
},
|
||||
炸弹: {
|
||||
name: "炸弹",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/bomb.png",
|
||||
},
|
||||
便便: {
|
||||
name: "便便",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/poop.png",
|
||||
},
|
||||
太阳: {
|
||||
name: "太阳",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/sun.png",
|
||||
},
|
||||
月亮: {
|
||||
name: "月亮",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/moon.png",
|
||||
},
|
||||
玫瑰: {
|
||||
name: "玫瑰",
|
||||
category: EmojiCategory.OTHER,
|
||||
path: "/assets/other/rose.png",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有表情数据的辅助函数
|
||||
*/
|
||||
function getAllEmojiData(): EmojiInfo[] {
|
||||
const result: EmojiInfo[] = [];
|
||||
for (const key in EMOJI_DATA) {
|
||||
if (Object.prototype.hasOwnProperty.call(EMOJI_DATA, key)) {
|
||||
result.push(EMOJI_DATA[key as EmojiName]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类别分组的表情数据
|
||||
*/
|
||||
export const EMOJI_CATEGORIES = {
|
||||
[EmojiCategory.FACE]: getAllEmojiData().filter(
|
||||
emoji => emoji.category === EmojiCategory.FACE,
|
||||
),
|
||||
[EmojiCategory.GESTURE]: getAllEmojiData().filter(
|
||||
emoji => emoji.category === EmojiCategory.GESTURE,
|
||||
),
|
||||
[EmojiCategory.ANIMAL]: getAllEmojiData().filter(
|
||||
emoji => emoji.category === EmojiCategory.ANIMAL,
|
||||
),
|
||||
[EmojiCategory.BLESSING]: getAllEmojiData().filter(
|
||||
emoji => emoji.category === EmojiCategory.BLESSING,
|
||||
),
|
||||
[EmojiCategory.OTHER]: getAllEmojiData().filter(
|
||||
emoji => emoji.category === EmojiCategory.OTHER,
|
||||
),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 获取表情图片路径
|
||||
* @param name 表情名称
|
||||
* @returns 图片路径,如果表情不存在则返回 null
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const path = getEmojiPath('微笑'); // 'assets/face/微笑.png'
|
||||
* const invalidPath = getEmojiPath('不存在'); // null
|
||||
* ```
|
||||
*/
|
||||
export function getEmojiPath(name: EmojiName): string | null {
|
||||
const emoji = EMOJI_DATA[name];
|
||||
return emoji ? emoji.path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表情信息
|
||||
* @param name 表情名称
|
||||
* @returns 表情信息对象,如果表情不存在则返回 null
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const emoji = getEmojiInfo('微笑');
|
||||
* // { name: '微笑', category: EmojiCategory.FACE, path: 'assets/face/微笑.png' }
|
||||
* ```
|
||||
*/
|
||||
export function getEmojiInfo(name: EmojiName): EmojiInfo | null {
|
||||
return EMOJI_DATA[name] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类别获取表情列表
|
||||
* @param category 表情类别
|
||||
* @returns 该类别下的所有表情信息
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const faceEmojis = getEmojisByCategory(EmojiCategory.FACE);
|
||||
* ```
|
||||
*/
|
||||
export function getEmojisByCategory(category: EmojiCategory): EmojiInfo[] {
|
||||
return EMOJI_CATEGORIES[category];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有表情信息
|
||||
* @returns 所有表情的信息数组
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const allEmojis = getAllEmojis();
|
||||
* console.log(`总共有 ${allEmojis.length} 个表情`);
|
||||
* ```
|
||||
*/
|
||||
export function getAllEmojis(): EmojiInfo[] {
|
||||
return getAllEmojiData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索表情
|
||||
* @param keyword 搜索关键词
|
||||
* @returns 匹配的表情信息数组
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const results = searchEmojis('笑');
|
||||
* // 返回包含 '微笑', '偷笑', '坏笑' 等的表情
|
||||
* ```
|
||||
*/
|
||||
export function searchEmojis(keyword: string): EmojiInfo[] {
|
||||
return getAllEmojiData().filter(emoji => emoji.name.indexOf(keyword) !== -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查表情是否存在
|
||||
* @param name 表情名称
|
||||
* @returns 是否存在该表情
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const exists = hasEmoji('微笑'); // true
|
||||
* const notExists = hasEmoji('不存在的表情'); // false
|
||||
* ```
|
||||
*/
|
||||
export function hasEmoji(name: EmojiName): boolean {
|
||||
return name in EMOJI_DATA;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表情名称列表
|
||||
* @param category 可选的类别筛选
|
||||
* @returns 表情名称数组
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const allNames = getEmojiNames();
|
||||
* const faceNames = getEmojiNames(EmojiCategory.FACE);
|
||||
* ```
|
||||
*/
|
||||
export function getEmojiNames(category?: EmojiCategory): string[] {
|
||||
if (category) {
|
||||
return getEmojisByCategory(category).map(emoji => emoji.name);
|
||||
}
|
||||
const names: string[] = [];
|
||||
for (const key in EMOJI_DATA) {
|
||||
if (Object.prototype.hasOwnProperty.call(EMOJI_DATA, key)) {
|
||||
names.push(key);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机获取表情
|
||||
* @param category 可选的类别筛选
|
||||
* @returns 随机表情信息
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const randomEmoji = getRandomEmoji();
|
||||
* const randomFaceEmoji = getRandomEmoji(EmojiCategory.FACE);
|
||||
* ```
|
||||
*/
|
||||
export function getRandomEmoji(category?: EmojiCategory): EmojiInfo {
|
||||
const emojis = category ? getEmojisByCategory(category) : getAllEmojis();
|
||||
const randomIndex = Math.floor(Math.random() * emojis.length);
|
||||
return emojis[randomIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认导出对象,包含所有主要功能
|
||||
*/
|
||||
const WeChatEmojis = {
|
||||
// 枚举和类型
|
||||
EmojiCategory,
|
||||
|
||||
// 数据
|
||||
EMOJI_CATEGORIES,
|
||||
|
||||
// 工具函数
|
||||
getEmojiPath,
|
||||
getEmojiInfo,
|
||||
getEmojisByCategory,
|
||||
getAllEmojis,
|
||||
searchEmojis,
|
||||
hasEmoji,
|
||||
getEmojiNames,
|
||||
getRandomEmoji,
|
||||
} as const;
|
||||
|
||||
export default WeChatEmojis;
|
||||
@@ -0,0 +1,128 @@
|
||||
.modalMask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.videoContainer {
|
||||
width: 100%;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoWrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding-top: 56.25%; // 16:9 比例
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// 动画
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.modalMask {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.videoContainer {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user