Compare commits

24 Commits
main ... devlop

Author SHA1 Message Date
卡若
708547d0dd feat: 数据概览简化 + 用户管理增加余额/提现列
- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额
- 数据概览:移除余额统计区块(余额改在用户管理中展示)
- 数据概览:恢复转化率卡片(唯一付费用户/总用户)
- 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额
- 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段
- 后端:User model 添加 WalletBalance 非数据库字段
- 包含之前的小程序埋点和管理后台点击统计面板

Made-with: Cursor
2026-03-15 15:57:09 +08:00
卡若
991e17698c feat: 内容管理第5批优化 - Bug修复 + 分享功能 + 代付功能
1. Bug修复:
   - 修复Markdown星号/下划线在小程序端原样显示问题(markdownToHtml增加__和_支持,contentParser增加Markdown格式剥离)
   - 修复@提及无反应(MentionSuggestion使用ref保持persons最新值,解决闭包捕获空数组问题)
   - 修复#链接标签点击"未找到小程序配置"(增加appId直接跳转降级路径)

2. 分享功能优化:
   - "分享到朋友圈"改为"分享给好友"(open-type从shareTimeline改为share)
   - 90%收益提示移到分享按钮下方
   - 阅读20%后向上滑动弹出分享浮层提示(4秒自动消失)

3. 代付功能:
   - 后端:新增UserBalance/BalanceTransaction/GiftUnlock三个模型
   - 后端:新增8个余额相关API(查询/充值/充值确认/代付/领取/退款/交易记录/礼物信息)
   - 小程序:阅读页新增"代付分享"按钮,支持用余额为好友解锁章节
   - 分享链接携带gift参数,好友打开自动领取解锁

Made-with: Cursor
2026-03-15 09:20:27 +08:00
Alex-larget
8778a42429 2026年3月14日14:37:06 2026-03-14 14:37:17 +08:00
Alex-larget
db4b4b8b87 Add linked mini program functionality and enhance link tag handling
- Introduced `navigateToMiniProgramAppIdList` in app.json for mini program navigation.
- Updated link tag handling in the read page to support mini program keys and app IDs.
- Enhanced content parsing to include app ID and mini program key in link tags.
- Added linked mini programs management in the admin panel with API endpoints for CRUD operations.
- Improved UI for selecting linked mini programs in the content creation page.
2026-03-12 16:51:12 +08:00
Alex-larget
41ebc70a50 Refactor BookRecommended function to streamline chapter recommendations. Simplify error handling and ensure consistent output format. Remove unused code related to fallback logic for chapter retrieval, enhancing performance and maintainability. 2026-03-12 11:42:13 +08:00
Alex-larget
d3b67681d7 Refactor user profile handling and navigation logic in the mini program. Introduce functions to ensure user profile completeness after login, update avatar selection process, and enhance navigation between chapters based on backend data. Update API endpoints for user data synchronization and improve user experience with new UI elements for profile editing. 2026-03-12 11:36:50 +08:00
Alex-larget
da6d2c0852 Update API endpoints for order retrieval in admin panel; change from /api/orders to /api/admin/orders in multiple files for consistency. Remove unused CSS and JS files from the distribution folder to streamline the build. Enhance order synchronization logic in the backend to handle order states more effectively. 2026-03-11 16:29:20 +08:00
Alex-larget
3d8873fe24 1 2026-03-11 14:49:45 +08:00
Alex-larget
68c0bb1588 Merge branch 'yongxu' into devlop 2026-03-11 12:33:08 +08:00
Alex-larget
90edabfca2 清理 2026-03-11 11:00:41 +08:00
Alex-larget
f2af615087 1 2026-03-10 20:53:52 +08:00
Alex-larget
08dd0703ec 1 2026-03-10 20:21:47 +08:00
Alex-larget
a8c7dc9306 Merge branch 'yongxu' into devlop
# Conflicts:
#	.cursor/meeting/README.md   resolved by yongxu version
#	.gitignore   resolved by yongxu version
#	miniprogram/pages/index/index.js   resolved by yongxu version
#	miniprogram/pages/read/read.js   resolved by yongxu version
#	miniprogram/pages/read/read.wxml   resolved by yongxu version
#	soul-admin/dist/index.html   resolved by yongxu version
#	soul-admin/src/App.tsx   resolved by yongxu version
#	soul-admin/src/components/RichEditor.css   resolved by yongxu version
#	soul-admin/src/components/RichEditor.tsx   resolved by yongxu version
#	soul-admin/src/components/modules/user/UserDetailModal.tsx   resolved by yongxu version
#	soul-admin/src/layouts/AdminLayout.tsx   resolved by yongxu version
#	soul-admin/src/pages/chapters/ChaptersPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/content/ContentPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/dashboard/DashboardPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/FindPartnerPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/CKBConfigPanel.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/FindPartnerTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MatchRecordsTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MentorTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/ResourceDockingTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/mentors/MentorsPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/settings/SettingsPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/users/UsersPage.tsx   resolved by yongxu version
#	soul-admin/tsconfig.tsbuildinfo   resolved by yongxu version
#	soul-api/internal/database/database.go   resolved by yongxu version
#	soul-api/internal/handler/admin_dashboard.go   resolved by yongxu version
#	soul-api/internal/handler/book.go   resolved by yongxu version
#	soul-api/internal/handler/ckb.go   resolved by yongxu version
#	soul-api/internal/handler/db_book.go   resolved by yongxu version
#	soul-api/internal/handler/db_person.go   resolved by yongxu version
#	soul-api/internal/handler/match_records.go   resolved by yongxu version
#	soul-api/internal/handler/user.go   resolved by yongxu version
#	soul-api/internal/model/chapter.go   resolved by yongxu version
#	soul-api/internal/model/person.go   resolved by yongxu version
#	soul-api/internal/router/router.go   resolved by yongxu version
#	开发文档/10、项目管理/运营与变更.md   resolved by yongxu version
#	开发文档/1、需求/需求汇总.md   resolved by yongxu version
#	开发文档/README.md   resolved by yongxu version
2026-03-10 20:20:59 +08:00
Alex-larget
dc3597c906 更新小程序,优化单页模式下的用户引导逻辑,确保用户在朋友圈等环境中能够顺利登录和访问完整内容。调整章节内容获取逻辑,确保未授权用户无法访问完整内容。新增手机号同步功能,提升用户资料管理体验。 2026-03-10 20:20:03 +08:00
Alex-larget
3b942fd7a4 更新数据库结构,向章节表添加 hot_score 字段以修复前端保存章节时的 1054 错误。同时,实施 Toast 通知系统,替换全系统的 alert 提示,提升用户体验。更新相关文档以反映变更流程与最佳实践。 2026-03-10 18:11:06 +08:00
Alex-larget
aebb533507 更新管理端迁移Mycontent-temp的菜单与布局规范,确保主导航收敛并优化隐藏页面入口。新增相关会议记录与文档,反映团队讨论的最新决策与实施建议。 2026-03-10 18:06:10 +08:00
Alex-larget
e23eba5d3e 优化错误处理逻辑,增加用户不存在时的自动登出功能。更新阅读页内容解析,支持TipTap HTML格式,提升用户体验。 2026-03-10 14:32:20 +08:00
Alex-larget
3e22e54f75 更新经验清单,新增2026-03-10团队架构/运维约定,最后更新日期调整至2026-03-10。 2026-03-10 12:54:41 +08:00
Alex-larget
f1afeee5e0 1 2026-03-10 11:05:05 +08:00
Alex-larget
05ac60dc7e 更新小程序,新增VIP会员状态管理功能,优化章节解锁逻辑,支持VIP用户访问增值内容。调整用户详情页面,增加VIP相关字段和功能,提升用户体验。更新会议记录,反映最新讨论内容。 2026-03-10 11:04:34 +08:00
Alex-larget
f00315d785 1 2026-03-09 16:17:00 +08:00
Alex-larget
30ebdb5ac7 新增2026-03-09会议记录,包含代码完整性分析与分支合并准备的讨论,更新相关文档以反映最新会议内容。 2026-03-09 15:20:48 +08:00
卡若
07e8a43bff chore: 删除 devlop 分支下的开发文档目录
Made-with: Cursor
2026-03-09 15:17:35 +08:00
Alex-larget
c3de123ef8 1 2026-03-09 11:53:49 +08:00
647 changed files with 110058 additions and 5246 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

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

View File

@@ -0,0 +1,7 @@
# 产品经理 经验记录 - 2026-03-11
## 需求基准:以界面定需求
- 需求与验收以《开发文档/1、需求/以界面定需求》为准;新增/变更功能时先对齐界面再更新《需求汇总》需求清单。
- 小程序与管理端界面清单、主要接口、业务逻辑对齐(用户/VIP 资料展示、三端 API 边界等)已落档,作为验收基准。
- 详见团队共享:`agent/团队/evolution/2026-03-11.md`

View File

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

View File

@@ -0,0 +1,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`

View 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_overrideModel 使用 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`

View 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 买断」。

View 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` 保持一致」的安全模式,避免在不同字段中混放全文与预览内容。
- 涉及付费内容时,优先在后端用「权限判断 + 统一内容裁剪」实现安全边界,前端只根据状态选择展示预览还是全文。

View File

@@ -4,3 +4,5 @@
|------|------|------| |------|------|------|
| 2026-03-05 | soul-api 合并状态确认orders、distribution 接口核对 | [2026-03-05.md](./2026-03-05.md) | | 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-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) |

View 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 变更,部署后需重启
- 管理端:确保免费章节配置正确
- 产品:作为验收规则
- 小程序:无变更

View 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`
- CPU1 颗 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`

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

View 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` → 代表会员权益(案例库、增值版等),两者可以独立打开。

View 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` 行为,保持三端一致。

View File

@@ -5,3 +5,5 @@
| 日期 | 摘要 | 文件 | | 日期 | 摘要 | 文件 |
|------|------|------| |------|------|------|
| 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) |
| 2026-03-12 | 密钥/token 设计:关联小程序 key、@ 人物 token不暴露真实密钥、服务端兑换 | [2026-03-12.md](./2026-03-12.md) |

View File

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

View File

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

View 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` 为 tokenseg.userId = token
- **onMentionTap**`targetUserId` 传 tokenCKBLead 接口用 token 兑换 ckb_api_key
- 无需在 config 中拉取 personstoken 已嵌入 content

View File

@@ -6,3 +6,5 @@
| 2026-03-03 | 我的页面卡片区边距优化16rpx 推荐值 | [2026-03-03.md](./2026-03-03.md) | | 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 | 分支合并后核心流程自测app.json 拆行orders 接口确认 | [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-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) |

View File

@@ -0,0 +1,7 @@
# 开发助理 经验记录 - 2026-03-11
## 会议收尾:开发团队对齐业务逻辑与以界面定需求
- 用户提出「结束会议」后执行会议收尾:生成会议纪要、各角色经验入库、更新项目索引与会议记录索引。
- 纪要:`.cursor/meeting/2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md`
- 各角色经验已写入:团队、产品经理、后端工程师、管理端开发工程师、小程序开发工程师 evolution/2026-03-11.md项目索引团队、产品、后端、管理端、小程序、助理橙子开发进度表已追加 2026-03-11。

View File

@@ -25,6 +25,21 @@
| 2026-02-27 | 小程序、团队 | 最佳实践 | SKILL-小程序开发 §6、SKILL-管理端开发 §4.1 | 输入框 padding 用 view/div 包裹 | | 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-02-28 | 小程序、管理端 | 最佳实践 | miniprogram §6、admin §4.1 | input 边距口诀「外边包 view、内部 width 100%」match 弹窗已修正 |
| 2026-03-03 | 小程序 | 最佳实践 | miniprogram §8 | 我的页面卡片区边距 16rpx个人中心类页面布局规范 | | 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/tokenonLinkTagTap、onMentionTap 传 key/token 兑换 |
| 2026-03-10 | 后端 | bug 修复 | api-dev SKILL | chapters 表新增 hot_score 列,修复 1054 Unknown column 错误DB 变更 SOPALTER→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文章预览规则统一、小程序与后端对齐

View File

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

View File

@@ -16,9 +16,10 @@
|------|------|------| |------|------|------|
| 2026-02-26 | 项目索引初始化经验库五角色目录结构搭建SKILL 补充角色映射表与跨端写入规则 | 已完成 | | 2026-02-26 | 项目索引初始化经验库五角色目录结构搭建SKILL 补充角色映射表与跨端写入规则 | 已完成 |
| 2026-02-28 | .cursor 按 cursor标准模板 重构agent 目录、config、evolution.py、meeting | 已完成 | | 2026-02-28 | .cursor 按 cursor标准模板 重构agent 目录、config、evolution.py、meeting | 已完成 |
| 2026-03-11 | 会议收尾:开发团队对齐业务逻辑与以界面定需求;纪要生成、各角色经验入库、项目索引与会议索引更新 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置 > **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
--- ---
**最后更新**2026-02-28 **最后更新**2026-03-11

View File

@@ -20,9 +20,16 @@ soul-apiGo + Gin + GORM + MySQL提供三组路由`/api/miniprogram/*`
| 2026-02-28 | stitch_soul 需求评审:需梳理 chapter/book/vip设计导师/预约/会员权益模型与接口 | 待续 | | 2026-02-28 | stitch_soul 需求评审:需梳理 chapter/book/vip设计导师/预约/会员权益模型与接口 | 待续 |
| 2026-03-05 | 分支冲突后功能完整性分析会议:在 soul-api 确认合并状态,核对 orders、distribution 接口 | 待续 | | 2026-03-05 | 分支冲突后功能完整性分析会议:在 soul-api 确认合并状态,核对 orders、distribution 接口 | 待续 |
| 2026-03-05 | 文章详情@某人加好友方案讨论content 内嵌 @ 标记、miniprogram 添加好友接口 | 待续 | | 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_scoresync 脚本与 README-schema-sync 更新 | 已完成 |
| 2026-03-12 | persons 表新增 token 字段add-persons-token.sqlCKBLead 用 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状态用已完成 / 进行中 / 待续 / 搁置 > **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
--- ---
**最后更新**2026-03-05 **最后更新**2026-03-13

View File

@@ -16,9 +16,17 @@ Soul 创业派对全项目架构与约定路由隔离miniprogram/admin/db
|------|------|------| |------|------|------|
| 2026-02-27 | 项目索引初始化;团队经验库目录建立 | 已完成 | | 2026-02-27 | 项目索引初始化;团队经验库目录建立 | 已完成 |
| 2026-02-28 | stitch_soul 需求评审:内容→会员→导师变现路径,需与现有三端架构协同 | 已完成 | | 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 > **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
--- ---
**最后更新**2026-02-28 **最后更新**2026-03-13

View File

@@ -23,9 +23,15 @@
| 2026-03-03 | 吸收经验我的页面卡片区边距优化16rpx 为个人中心类页面推荐值,已升级 SKILL §8 | 已完成 | | 2026-03-03 | 吸收经验我的页面卡片区边距优化16rpx 为个人中心类页面推荐值,已升级 SKILL §8 | 已完成 |
| 2026-03-05 | 分支冲突后功能完整性分析会议:修正 app.json 拆行、核心流程自测、确认 orders 接口 | 待续 | | 2026-03-05 | 分支冲突后功能完整性分析会议:修正 app.json 拆行、核心流程自测、确认 orders 接口 | 待续 |
| 2026-03-05 | 文章详情@某人加好友方案讨论:阅读页解析 @、高亮可点击、调添加好友接口 | 待续 | | 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_keycontentParser、onLinkTagTap、onMentionTap | 已完成 |
| 2026-03-13 | 阅读页文章预览与付费解锁对齐:预览长度改由后端统一计算,前端按 accessState 显示预览/全文,避免 data.content 泄露全文 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置 > **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
--- ---
**最后更新**2026-03-05 **最后更新**2026-03-13

View File

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

View File

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

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

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

View 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 位 tokenPersonItem.id = tokenRichEditor 插入用)
- 列表展示 token编辑/删除用 personIdAPI 仍用 personId
- 文章 @ 时 data-id 存 token

View File

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

View File

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

View File

@@ -4,3 +4,4 @@
|------|------|------| |------|------|------|
| 2026-03-05 | 分支合并后回归清单制定;三端联调验证 | [2026-03-05.md](./2026-03-05.md) | | 2026-03-05 | 分支合并后回归清单制定;三端联调验证 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-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
View 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「刷新接口文档数据」。

View File

@@ -0,0 +1,157 @@
# 会议纪要 - 2026-03-09 | devlop 与 yongxu 分支差异分析
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-09
- **议题**:分析 devlopdev 分支 / Mycontent-temp与 yongxu当前分支两个项目的区别
- **触发方式**:开个会议
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
---
## 各角色发言
### 【产品经理】
**分支定位**
- **devlop**:老板的老板主改,侧重内容管理、用户管理、找伙伴等管理端能力,开发文档已删除(迁移至他处)
- **yongxu**:你主改,侧重 C 端体验:@提及、一键收款、个人资料页、找伙伴、推荐码绑定、分享带 ref、退款等
**差异要点**
- devlop 删除了整个 `开发文档/` 目录(约 100+ 文件yongxu 保留
- 需求文档20260308 内容管理、用户管理、找伙伴)在 devlop 侧有更新yongxu 侧沿用旧版
- 合并后需确保C 端功能(@提及、推荐码、一键收款不丢管理端能力ContentPage、FindPartnerPage、UsersPage不丢
### 【后端开发】
**devlop 独有**yongxu 没有):
- `admin_dashboard.go``admin_rfm.go``admin_shensheshou.go``admin_user_rules.go`
- `db_book.go``db_person.go`db 路由组扩展)
- `ckb.go` 大改(存客宝扩展)
- `match.go``match_records.go` 扩展
- `book.go``user.go``cron.go` 等逻辑更新
- `person.go``user_rule.go` 等 model 新增
- 路由、数据库、配置等变更
**yongxu 独有**devlop 没有):
- @提及相关接口、免费章节判断、存客宝限频、退款逻辑等(在共同祖先 90d32a51 之后)
**共同修改文件(易冲突)**
- `soul-api/internal/handler/miniprogram.go`
- `soul-api/internal/config/config.go`
- `soul-api/internal/database/database.go`
- `soul-api/internal/router/router.go`
### 【管理端开发工程师】
**devlop 独有**yongxu 没有):
- `ContentPage.tsx` 大改(约 1395 行变更)
- `ChapterTree.tsx``ChaptersPage.tsx` 新增/重构
- `FindPartnerPage.tsx` 及多 TabCKBConfigPanel、CKBStatsTab、FindPartnerTab、MatchPoolTab、MatchRecordsTab、MentorBookingTab、MentorTab、ResourceDockingTab、TeamRecruitTab
- `RichEditor.tsx``UserDetailModal.tsx` 扩展
- `UsersPage.tsx` 大改(约 1267 行)
- `DashboardPage.tsx``DistributionPage.tsx``SettingsPage.tsx` 等更新
- `client.ts``AdminLayout.tsx``App.tsx` 配置调整
**yongxu**:管理端改动较少,主要在小程序侧
**合并策略**:以 devlop 管理端为主yongxu 若有管理端改动需手工合入
### 【小程序开发工程师】
**yongxu 独有**(相对共同祖先 90d32a51
- `app.js`baseUrl 真实后端、goBackOrToHome、推荐码/访问记录、checkUpdate
- `read/*`@提及解析与高亮、mid 优先跳转
- `chapters/*`:章节列表、分享
- `index/*`:首页、已读/待读
- `my/*`:个人中心、导航栏
**devlop 也改了同一批文件**
- `miniprogram/app.js``app.json`
- `miniprogram/pages/chapters/chapters.js``chapters.json`
- `miniprogram/pages/index/index.js``index.wxml`
- `miniprogram/pages/my/my.js`
- `miniprogram/pages/read/read.js``read.wxml``read.wxss`
- `miniprogram/project.private.config.json`
- `miniprogram/utils/readingTracker.js`
**合并重点**:上述文件两分支均有修改,合并时需保留 yongxu 的 @提及、推荐码、baseUrl、goBackOrToHome 等业务逻辑,同时接纳 devlop 的其它改动(若有)
### 【测试人员】
合并后需做:
- 三端联调小程序↔API、管理端↔API
- 回归测试:@提及、推荐码、找伙伴、内容管理、用户管理、存客宝、一键收款、退款
- 建议合并完成后拉一份回归清单,逐项验证
---
## 讨论过程
- 用户明确Mycontent-temp 对应 dev 分支devlop当前打开的是 yongxu 分支
- 基于 `git diff``git log` 分析两分支自共同祖先 90d32a51 以来的差异
- 共识devlop 改动量大248 文件、约 6 万行变更yongxu 改动小7 文件、约 366 行),合并时需分模块处理
---
## 会议决议
1. **差异总结**devlop 侧重管理端与脚本内容管理、找伙伴、飞书导出、开发文档删除yongxu 侧重 C 端(@提及、一键收款、推荐码、baseUrl 真实后端)
2. **合并策略**
- 管理端、soul-api 新增能力:以 devlop 为主
- 小程序:保留 yongxu 的 @提及、推荐码、goBackOrToHome、baseUrl 等,与 devlop 改动手工合并
- 开发文档:若需保留,从 yongxu 恢复;若已迁移他处,可沿用 devlop 的删除
3. **待确认项**:开发文档最终保留在仓库内还是迁移到外部?合并冲突时以哪边为准(按模块已约定)
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 用户 | 确认开发文档保留策略 | 中 | 合并前 |
| 用户 | 执行分支合并(如 git merge devlop 或 git merge yongxu | 高 | 待用户操作 |
| 助理橙子 | 合并时协助逐文件解决冲突 | 高 | 用户合并时 |
| 测试人员 | 合并后回归测试 | 中 | 合并完成 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 开发文档最终保留在仓库内还是迁移到外部? | 用户 | (待补充) |
| 2 | 合并时以 devlop 为基准合并 yongxu还是以 yongxu 为基准合并 devlop | 用户 | (待补充) |
---
## 两分支差异速查表
| 维度 | devlopdev 分支) | yongxu当前分支 |
|------|-------------------|---------------------|
| 共同祖先 | 90d32a51 | 90d32a51 |
| 独有提交数 | 约 200+ | 2 |
| 变更文件数 | 248 | 7 |
| 开发文档 | 已删除 | 保留 |
| 小程序 | 有改动(与 yongxu 重叠) | @提及、推荐码、baseUrl、goBackOrToHome 等 |
| 管理端 | ContentPage、FindPartnerPage、UsersPage 等大改 | 改动少 |
| soul-api | admin_*、db_*、ckb、match 等扩展 | 免费章节、存客宝限频、退款等 |
| 脚本 | 飞书导出、content_upload、Gitea 推送等 | 无 |
| 会议纪要 | 合并策略、管理端与 API 分析等 | 代码完整性分析、各成员功能检测 |
---
## 各角色经验与业务理解更新
### 团队共享
- 分支差异分析会议:先确定共同祖先,再用 `git diff --stat``git log` 分模块梳理,便于制定合并策略
- 多分支合并时按模块约定「以谁为主」:管理端/soul-api 以 devlop 为主,小程序保留 yongxu 业务逻辑
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-09.md`*

View File

@@ -0,0 +1,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、UsersPageRFM、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 已包含更多 handleryongxu 若有 soul-api 独有改动需手工核对
4. **开发文档**:若需保留,可从 yongxu 或历史 commit 恢复 `开发文档/` 目录
### 5.2 冲突处理优先级
- 冲突时以 **devlop 为主**,再手工补回 yongxu 中你确认必须保留的功能
---
## 六、会议决议
1. **小程序**:边界合规;需核对 @提及、一键收款、mid 在 devlop 中是否完整
2. **管理端**:边界合规;新增接口均有对应后端
3. **后端**:路由分组正确;/api/orders 鉴权待补
4. **开发文档**:若需保留,可从 yongxu 恢复
---
*报告生成时间2026-03-09 | 基于 devlop 分支与 yongxu 比较*

View File

@@ -0,0 +1,165 @@
# dev 分支需求分析与 yongxu 迁移方案
> 基于用户反馈dev 分支只改了样式,未考虑后端/API/小程序三端协调,导致 bug 或功能不全yongxu 分支功能完善。本文档分析 dev 需求、识别三端缺口,并给出「新功能迁入 yongxu + 补全功能」的执行方案。
---
## 一、dev 分支变更概览
### 1.1 变更性质(用户结论)
- **dev 侧重**:管理端 UI/样式、新页面布局
- **问题**:未同步考虑 soul-api、miniprogram 的接口与逻辑,导致:
- 管理端新页面调用的接口在 yongxu 的 soul-api 中不存在或不全
- 小程序端与 dev 管理端配置/数据模型不一致
- 三端联调时出现 bug 或功能不全
### 1.2 dev 相对 yongxu 的增量(按模块)
| 模块 | dev 新增/改动 | 依赖的 API/能力 |
|------|---------------|-----------------|
| **soul-admin** | ContentPage 大改、ChapterTree、RichEditor、@提及、persons、link-tags 配置 | `/api/db/book`(含 section-orders`/api/db/persons``/api/db/link-tags``/api/db/config/full?key=xxx` |
| **soul-admin** | FindPartnerPage + 多 TabCKBConfigPanel、CKBStatsTab、MatchPoolTab 等) | `/api/db/config/full?key=ckb_config``/api/db/ckb-leads` |
| **soul-admin** | UsersPage 大改、UserDetailModal 扩展 | `/api/db/users` 已有,可能需扩展字段 |
| **soul-admin** | ChaptersPage 新增、DashboardPage 调整 | 依赖 db/book、db/users 等 |
| **soul-admin** | DistributionPage、SettingsPage 等样式调整 | 现有 admin 接口 |
| **soul-api** | admin_dashboard、admin_rfm、admin_shensheshou、admin_user_rules | 新增 handler |
| **soul-api** | db_person、db_book 扩展section-orders、move-sections | 扩展 db_book action |
| **soul-api** | ckb 扩展、match/match_records 扩展 | 扩展 ckb、match handler |
| **miniprogram** | 部分页面简化/样式调整 | 与 yongxu 的 @提及、推荐码等可能冲突 |
---
## 二、三端缺口分析yongxu 当前缺失)
### 2.1 soul-api 缺口(管理端新页面依赖)
| 接口/能力 | dev 管理端调用 | yongxu 是否已有 | 缺口说明 |
|-----------|---------------|-----------------|----------|
| `GET/POST/DELETE /api/db/persons` | ContentPage @提及人物配置 | ❌ 无 | 需新增 persons 表、model、handler |
| `GET/POST/DELETE /api/db/link-tags` | ContentPage 链接标签配置 | ❌ 无 | 需新增 link_tags 表、model、handler |
| `GET /api/db/book?action=section-orders&id=xxx` | ContentPage 章节内排序 | ❌ 无 | 需在 db_book 中扩展 action |
| `PUT /api/db/book` action=move-sections | ContentPage 跨章移动 | ❌ 无 | 需在 db_book 中扩展 |
| `GET /api/db/config/full?key=article_ranking_weights` | ContentPage | ✅ 有 | DBConfigGet 已支持 |
| `GET /api/db/config/full?key=pinned_section_ids` | ContentPage | ✅ 有 | 同上 |
| `GET /api/db/config/full?key=unpaid_preview_percent` | ContentPage | ✅ 有 | 同上 |
| `GET /api/db/config/full?key=ckb_config` | FindPartnerPage CKBConfigPanel | ✅ 有 | 同上 |
| `GET /api/db/ckb-leads?mode=submitted&page=1&pageSize=50` | FindPartnerPage 存客宝线索 | ❌ 无 | 需新增 ckb_leads 相关接口 |
| admin_dashboard、admin_rfm、admin_shensheshou、admin_user_rules | Dashboard、神奢手等 | ❌ 无 | 可选,按业务需要 |
### 2.2 小程序端
- **yongxu 已完善**@提及、推荐码、baseUrl、goBackOrToHome、一键收款、免费章节、存客宝限频、退款等
- **策略****保留 yongxu 小程序全部逻辑**,不引入 dev 对 miniprogram 的简化dev 可能删减了功能)
### 2.3 管理端soul-admin
- **yongxu 已有**ContentPage、ChapterTree、UsersPage、DashboardPage 等基础版本
- **dev 增量**:更丰富的 ContentPagepersons、link-tags、RichEditor @提及、FindPartnerPage 整页、UsersPage 扩展
- **策略**:在 soul-api 补全接口后,再选择性迁入 dev 的页面组件
---
## 三、迁移原则
1. **基准分支**yongxu功能完善、三端协调良好
2. **不引入**dev 对 miniprogram 的改动(避免覆盖 yongxu 的 @提及、推荐码等)
3. **分阶段**:先补 soul-api 缺口 → 再迁入 soul-admin 新页面/组件
4. **三端协调**:每迁入一个管理端功能,必须确保 soul-api 已有对应接口,且小程序若依赖该配置则已对齐
---
## 四、执行方案(按优先级)
### 阶段 1soul-api 补全(必须)
| 序号 | 任务 | 说明 |
|------|------|------|
| 1.1 | 新增 `db/persons` | model.Person、表 personsGET/POST/DELETE供 ContentPage @提及人物配置 |
| 1.2 | 新增 `db/link-tags` | model.LinkTag、表 link_tagsGET/POST/DELETE供 ContentPage 链接标签 |
| 1.3 | 扩展 `db/book` | 增加 action=section-orders、action=move-sections若 ContentPage 需要) |
| 1.4 | 新增 `db/ckb-leads` | 若存在 ckb_leads 表GET 支持 mode=submitted/contact 分页;否则先建表再实现 |
### 阶段 2soul-admin 迁入(在阶段 1 完成后)
| 序号 | 任务 | 说明 |
|------|------|------|
| 2.1 | ContentPage 增强 | 从 dev 迁入 persons、link-tags 配置区RichEditor @提及(需 persons 接口) |
| 2.2 | FindPartnerPage | 从 dev 迁入整页 + TabCKBConfigPanel、CKBStatsTab 等),需 ckb_config、ckb-leads 接口 |
| 2.3 | UsersPage 扩展 | 从 dev 迁入 UserDetailModal 等扩展(若字段与 yongxu 的 db/users 一致) |
| 2.4 | 菜单与路由 | 在 AdminLayout、App.tsx 中增加 FindPartner 入口(若尚未有) |
### 阶段 3可选按业务需要
| 序号 | 任务 | 说明 |
|------|------|------|
| 3.1 | admin_dashboard、admin_rfm 等 | 若 Dashboard 需要新统计维度,再补充 |
| 3.2 | dev 的 scripts、飞书导出等 | 与三端功能无直接关系,可单独评估 |
---
## 五、补全功能清单(可直接用于开发)
### 5.1 soul-api 必须补全
```
[ ] 1. persons 表 + model + GET/POST/DELETE /api/db/persons
[ ] 2. link_tags 表 + model + GET/POST/DELETE /api/db/link-tags
[ ] 3. db_book 扩展GET ?action=section-orders&id=xxx
[ ] 4. db_book 扩展PUT action=move-sections若 ContentPage 需要)
[ ] 5. ckb_leads 表(若无)+ GET /api/db/ckb-leads?mode=xxx&page=1&pageSize=50
```
### 5.2 soul-admin 迁入(依赖 5.1
```
[ ] 6. ContentPagepersons 配置区、link-tags 配置区
[ ] 7. ContentPageRichEditor @提及(调用 persons
[ ] 8. FindPartnerPage + TabsCKBConfigPanel、CKBStatsTab、FindPartnerTab 等)
[ ] 9. AdminLayout增加「找伙伴」菜单项若尚无
[ ] 10. UsersPageUserDetailModal 扩展(按需)
```
### 5.3 小程序
```
[ ] 无需变更,保留 yongxu 全部逻辑
```
---
## 六、迁移操作建议
### 6.1 不推荐直接 merge devlop
- dev 与 yongxu 在 miniprogram、部分 soul-api 上存在冲突
- 直接 merge 会覆盖 yongxu 的完善功能
### 6.2 推荐方式
1. **保持当前在 yongxu 分支**
2. **按阶段 1**:在 yongxu 上直接开发 soul-api 补全persons、link-tags、db_book 扩展、ckb-leads
3. **按阶段 2**:用 `git show devlop:path/to/file` 取出 dev 的 soul-admin 文件,手工合并到 yongxu并确认调用的接口已在 soul-api 中存在
4. **每完成一个功能**:过 soul-change-checklist做三端联调验证
### 6.3 冲突文件处理(若必须 diff 参考)
- `miniprogram/*`**以 yongxu 为准**,不采纳 dev 的改动
- `soul-admin/*`:选择性采纳 dev 的 UI/组件,确保调用的 API 已在 yongxu 的 soul-api 中实现
- `soul-api/*`:以 yongxu 为基础缺什么补什么persons、link-tags、ckb-leads 等)
---
## 七、总结
| 项目 | 结论 |
|------|------|
| 基准 | yongxu |
| dev 价值 | 管理端 UI 增强ContentPage、FindPartnerPage、UsersPage |
| dev 问题 | 未配套 soul-api 的 persons、link-tags、ckb-leads 等,导致功能不全 |
| 迁移路径 | 先补 soul-api → 再迁入 soul-admin 新页面 |
| 小程序 | 不改动,保留 yongxu |
---
*文档生成2026-03-09 | 供开发执行参考*

View File

@@ -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.jsbaseUrl 真实后端、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 与合并决策
- 多人在不同分支开发时,合并策略需提前约定(以谁为主、冲突解决规则)
---
*会议纪要由助理橙子生成 | 快照供切换分支后比较与合并使用*

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

View 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_DSNCkbLeadRecord 迁移移除需与老板确认
---
## 四、实施记录
- [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*

View 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、UsersPageRFM、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_DSNCkbLeadRecord 迁移移除需确认 |
| 小程序 | 已补入 @提及 | 见 2026-03-09_合并策略与执行清单.md |
---
*分析完成时间2026-03-09*

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

View File

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

View File

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

View File

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

View File

@@ -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_scoresync-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_scoreREADME-schema-sync 已区分「身份字段」与「不再新增的资料列」。
- **管理端开发工程师**:管理端界面清单与路由/接口已作为需求与验收基准写入《以界面定需求》。
- **小程序开发工程师**:小程序界面清单与接口已作为需求与验收基准;展示优先用户资料。
- **团队共享**:以界面定需求、三端路由隔离、用户/VIP 资料展示规则已沉淀至《以界面定需求》与运营与变更。
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-11.md`*

View File

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

View File

@@ -40,6 +40,13 @@ description: Soul 创业派对管理端开发规范。在 soul-admin/ 下编辑
- **命名**:组件 PascalCase文件与组件名一致`WithdrawalsPage.tsx`);接口/类型用 PascalCase 或小驼峰按习惯。 - **命名**:组件 PascalCase文件与组件名一致`WithdrawalsPage.tsx`);接口/类型用 PascalCase 或小驼峰按习惯。
- **类型**:请求响应用 TypeScript 接口定义(如 `interface Withdrawal { ... }`),可用 `get<WithdrawalsRes>(...)` 泛型。 - **类型**:请求响应用 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. 列表页标准(必守) ## 4. 列表页标准(必守)
@@ -57,7 +64,7 @@ description: Soul 创业派对管理端开发规范。在 soul-admin/ 下编辑
**可选**:导出 CSV、列头排序、批量操作。 **可选**:导出 CSV、列头排序、批量操作。
**禁止**:不得用原生 `alert`加载失败提示;不得调用 `/api/miniprogram/*` **禁止**:不得用原生 `alert`任何提示(包括成功、失败、验证);不得调用 `/api/miniprogram/*`
**检查清单**分页、搜索防抖、刷新、loading、空状态、错误条、仅 admin/db 路径。详见 `开发文档/列表标准与角色分工.md` **检查清单**分页、搜索防抖、刷新、loading、空状态、错误条、仅 admin/db 路径。详见 `开发文档/列表标准与角色分工.md`
@@ -65,7 +72,41 @@ description: Soul 创业派对管理端开发规范。在 soul-admin/ 下编辑
**口诀**:外边包 div/view内部 input width 100%。设置 input 的 padding、背景、边框时用 div 包裹 inputpadding 写在容器上input 仅做文字样式(`width: 100%`)。禁止在 input 自身上设 padding可避免光标截断、布局异常。React`<div className="form-input"><input className="form-input-inner" ... /></div>` **口诀**:外边包 div/view内部 input width 100%。设置 input 的 padding、背景、边框时用 div 包裹 inputpadding 写在容器上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 token401 时跳转登录页并清除 token。 - **鉴权**:登录后 token 存 localStorage`admin_token`);请求头由 client 自动带 Bearer token401 时跳转登录页并清除 token。
- **提现**:列表/统计用 `GET /api/admin/withdrawals`;审核/拒绝用 `PUT /api/admin/withdrawals`(如 action: approve/reject状态与 soul-api 一致(如 pending、processing、success、failed前端展示可映射为「已完成/已拒绝」等文案。 - **提现**:列表/统计用 `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` - **内容/章节**`/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 接口。 - **列表与表格**:管理端列表需有 user_name/userNickname、userAvatar、status、amount 等字段以便通用展示;若 soul-api 返回字段不同,仅在管理端做字段映射,不修改 soul-api 的 miniprogram 接口。

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

View File

@@ -114,7 +114,9 @@ MCP 配置参考:`开发文档/8、部署/Soul-MySQL-MCP配置说明.md`(连
| **插入默认数据** | `INSERT IGNORE INTO xxx (name, sort) VALUES (...)` | 配合 `UNIQUE(name)` 防重复;不指定 id 让 AUTO_INCREMENT 生效 | | **插入默认数据** | `INSERT IGNORE INTO xxx (name, sort) VALUES (...)` | 配合 `UNIQUE(name)` 防重复;不指定 id 让 AUTO_INCREMENT 生效 |
| **脚本位置** | `soul-api/scripts/add-xxx.sql` | 命名清晰,便于部署时按顺序执行 | | **脚本位置** | `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
View File

@@ -1,14 +0,0 @@
# 根目录忽略
.DS_Store
*.zip
.env
.env.*
!.env.*.example
__pycache__/
*.pyc
*.pyo
log/
tmp/
# 各子项目已有 .gitignore此处仅补充分支通用项
node_modules/

BIN
20260226一场.zip Normal file

Binary file not shown.

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

@@ -0,0 +1 @@
NEXT_PUBLIC_API_BASE_URL= http://yishi.com

6
Cunkebao/.env.production Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,11 @@
node_modules/
dist/
build/
yarn.lock
.env
.DS_Store
dist/*
.cursorindexingignore
*.zip
.idea/
.next/

13
Cunkebao/.prettierrc Normal file
View 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"
}

View File

@@ -0,0 +1,8 @@
{
"hash": "efe0acf4",
"configHash": "2bed34b3",
"lockfileHash": "ef01d341",
"browserHash": "91bd3b2c",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

11
Cunkebao/.vscode/extensions.json vendored Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

19
Cunkebao/index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 16,
propList: ['*'],
},
},
};

BIN
Cunkebao/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

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

View 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已加载完成");
}

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

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

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

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

View 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[]; // 传递账号组数据
}

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

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

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

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

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

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

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

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

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

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

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

View 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; // 新增,是否单选模式
}

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

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

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

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

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

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

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

View File

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