Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	soul-admin/src/api/ckb.ts
#	soul-admin/src/pages/content/PersonAddEditModal.tsx
#	soul-api/internal/model/person.go
#	开发文档/1、需求/以界面定需求.md
#	开发文档/1、需求/需求汇总.md
This commit is contained in:
Alex-larget
2026-03-18 21:10:02 +08:00
460 changed files with 92262 additions and 3962 deletions

View File

@@ -0,0 +1,38 @@
# 产品经理 经验记录 - 2026-03-18
## 文档归档与需求口径(界面驱动)
### 需求基准(验收口径)
- **需求以界面为准**:以 `开发文档/1、需求/以界面定需求.md` 的“界面清单 + 业务逻辑对齐”为验收基准。
- **需求清单与变更记录**
- 需求清单:`开发文档/1、需求/需求汇总.md`
- 近期讨论/决议:`开发文档/10、项目管理/运营与变更.md`
- 里程碑执行层:`开发文档/10、项目管理/项目落地推进表.md`
### 归档原则(避免文档发散)
- 新增/改版功能必须同步更新:
- 《以界面定需求》(界面与主要接口)
- 《需求汇总》(需求清单条目:日期/描述/状态/备注)
- 《运营与变更》(决议与实现摘要)
- 旧方案文档若已过时:在 `开发文档/README.md` 的“已移除文档”里登记清理原因,避免重复讨论。
### 功能需求整理(按产品域)
- **内容阅读与付费**预览与解锁规则、VIP 全章免费、余额支付与微信支付链路、阅读统计与埋点。
- **代付分享**:发起人支付后分享;好友打开阅读页自动领取并解锁;发起人可查看领取进度与明细。
- **推广分销与提现**:分润规则可配置(会员 20%/非会员 10%、内容 90%)、提现闭环(申请→审核/打款→回写→订阅消息)。
- **找伙伴/存客宝**@mention/#标签自动创建与同步;留资与匹配流程;限频与风控边界。
### 分享场景强约束(验收必测)
- **好友分享 vs 朋友圈分享singlePage**
- 朋友圈进入可能是单页模式,页面能力不完整
- 验收必须覆盖:单页模式不触发支付/自动领取等强动作,且明确引导“前往小程序”进入完整版
## 超级个体开通后自动创建@人与资料引导(会议决议)
### 业务目标与规则
- **自动创建 @人**:用户开通超级个体后,管理端「链接人与事」自动创建一条 Person 记录,展示名与用户**当前昵称一致**。
- **资料完善拦截**:支付超级个体前若昵称/头像为默认值,必须引导至仅头像+昵称的引导页完成修改;开通后进入权益/成功页也需再次检测兜底。
### 待确认
- 昵称变更后的同步规则:是否强制同步更新 Person.name是否需要保留历史别名/展示区分。

View File

@@ -0,0 +1,41 @@
# 后端工程师 经验记录 - 2026-03-18
## 功能需求口径整理(按接口契约与风险)
### 需求基准(后端视角)
-`开发文档/1、需求/以界面定需求.md` 的“界面→接口”映射为准:
- 小程序只用 `/api/miniprogram/*`
- 管理端只用 `/api/admin/*``/api/db/*``/api/orders`
### 核心功能域(必须稳定)
- **阅读与权限**
- 未授权只返回预览;授权返回全文
- VIP 全章免费信号必须后端折叠输出,前端只认统一字段(避免各端各自判断)
- **支付链路**
- 下单→支付→回调→解锁/分润,必须具备幂等与可追溯(订单号、来源、日志)
- **代付分享(发起人支付,好友领取)**
- 发起人支付后产生可分享 requestSn
- 好友领取必须并发安全(名额扣减原子、重复领取幂等)
- 权益归属必须正确(代付场景 beneficiaryUserID=发起人)
- **推广/分润/提现**
- 分润规则可配置,计算口径一致
- 提现流转:申请→审核/打款→回写状态→订阅消息
### 分享场景风险点(联调/验收必测)
- **朋友圈 singlePage**:属于前端能力限制,但后端要做到:
- 接口幂等(前端重试/重复进入会更频繁)
- 错误码与提示文案清晰(便于前端引导“前往小程序”)
### 文档归档(后端相关)
- 里程碑推进表:`开发文档/10、项目管理/项目落地推进表.md`
- 测试流程与回归口径:`scripts/test/功能测试流程.md`
## 超级个体开通后自动创建@人Person与资料完善 flags
### 幂等与建模建议
- 自动创建 Person 建议以业务主键(`userId`)作为**幂等键**,避免仅依赖昵称导致重名/改名混乱。
- 倾向在 `persons` 增加 `user_id`(并做唯一索引/约束),后续昵称变更时可按 `user_id` 同步更新 `name`
### 端上资料完善判断
- 默认头像/昵称判定不建议只靠前端字符串规则;后端可在用户资料/登录态接口返回明确布尔值(如 `profileNeedComplete` / `isDefaultAvatar` / `isDefaultNickname`),小程序仅消费并跳转引导页。

View File

@@ -0,0 +1,24 @@
# 团队 经验记录 - 2026-03-18
## 分享链路统一规则:兼容朋友圈 singlePage单页模式
### 背景
微信“朋友圈分享”点进小程序页可能是 **singlePage**,能力不完整;如果不做判断,容易出现支付/登录/领取等链路断裂,引发转化损失与投诉。
### 团队级决议(跨端共识)
- **任何“分享进入”的关键流程**(支付、代付、领取、绑定等)都要:
- **识别 singlePage**`wx.getSystemInfoSync().mode === 'singlePage'`(并允许通过 `app.globalData.isSinglePageMode` 兜底标记)
- **做能力降级**:单页模式不执行强动作(支付/自动领取/自动登录等)
- **给出明确引导**:提示用户点击底部 **「前往小程序」** 进入完整版后再操作
### 落地建议
- UI/交互层:统一封装 `ensureFullAppMode()`(或页面内统一判断),避免每个按钮散落实现导致漏判
- 测试层:新增用例覆盖“朋友圈 singlePage 打开 + 点击关键按钮”应出现引导而非报错/卡死
## 超级个体开通后自动创建@人与资料完善拦截(跨端共识)
### 团队级决议
- “可被 @ 的人”统一走 Person 体系,避免为超级个体另建一套 mention 逻辑。
- 幂等键应绑定业务主键(优先 `userId`),展示名同步为昵称(具体同步规则由产品确认)。
- 默认资料判定尽量由后端提供明确 flags前端仅做跳转与阻断并保留兜底规则。

View File

@@ -0,0 +1,34 @@
# 小程序开发工程师 经验记录 - 2026-03-18
## 分享链路:好友/朋友圈单页模式兼容(强约束)
### 场景
- **好友分享**:从会话/好友点进来,页面能力完整,可登录、可支付、可领取。
- **朋友圈分享**:点进来可能是 **singlePage单页模式**,页面能力不完整(常见限制:登录/支付/部分交互不可靠)。
### 结论(必须执行)
- **凡涉及分享链路的功能**(购买、代付、领取、登录等),必须先判断是否处于单页模式:
- `const isSinglePage = (wx.getSystemInfoSync()?.mode === 'singlePage') || app.globalData.isSinglePageMode`
- **单页模式下能力降级**
- 禁止直接发起支付、禁止隐式自动领取等强动作
- 通过弹窗/提示文案 **引导用户点击底部「前往小程序」进入完整版**后再操作
- **非单页模式**:正常执行登录/支付/领取等完整链路
### 推荐实现点位(模板)
- 打开页:若参数表示“代付/领取/支付”等强动作,单页模式直接 `wx.showModal` 提示并 return
- 按钮点击:支付/领取/登录等入口,统一在 handler 最前面做单页模式拦截
### 关联模块
- 阅读页 `pages/read/read`、代付/支付相关页面
## 超级个体支付前资料完善拦截(头像+昵称引导)
### 结论
- 复用 `pages/avatar-nickname`(仅头像+昵称)作为“资料完善引导页”。
- 在两处做强校验并跳转:
- **支付超级个体之前**:在“去支付/确认支付”按钮入口先校验默认头像/昵称,不通过则 `navigateTo('/pages/avatar-nickname/avatar-nickname')` 并 return。
- **开通成功后**:在权益页/成功回调再校验一次兜底,确保用户最终资料不为默认。
### 口径
- 默认判断优先使用后端返回 flags`profileNeedComplete`),前端仅做兜底规则(空昵称、昵称以“微信用户”开头、头像为空/命中默认头像域名等)。

View File

@@ -0,0 +1,15 @@
# 开发助理 经验记录 - 2026-03-18
## 开发文档归档整理(统一入口与索引一致性)
### 发现与修复
- `开发文档/README.md` 索引引用了 `开发文档/10、项目管理/项目落地推进表.md`,但该文件缺失。
- 已补齐:新建 `开发文档/10、项目管理/项目落地推进表.md`,用于记录里程碑、风险与下一步。
### 归档建议(持续维护规则)
- 需求基准:`以界面定需求.md`
- 需求清单:`需求汇总.md`
- 决议与变更:`运营与变更.md`
- 执行层推进:`项目落地推进表.md`
- 每次变更后按上述 4 份文档联动更新,避免“清单/决议/执行层”脱节。

View File

@@ -51,6 +51,8 @@
| 2026-03-17 | 小程序 | 业务规则 | - | 代付统一到代付页gift=1&ref 打开 read 时 redirectTo 代付页,禁止在阅读页代付 |
| 2026-03-17 | 软件测试 | 流程定稿 | testing SKILL | 功能测试流程:成功 ☑、失败列问题、最终报告scripts/test/功能测试流程.md、测试报告-环境与用例清单.md |
| 2026-03-17 | 后端、团队 | 架构/最佳实践 | api-dev SKILL | Redis 缓存parts/hot/recommended/stats/config/章节 content容灾回退 DBOSS 上传;/health 返回 database/redis 状态 |
| 2026-03-18 | 小程序、团队 | 业务规则/最佳实践 | - | 分享链路兼容好友/朋友圈 singlePage单页模式能力降级不支付/不自动领取),引导点击底部“前往小程序”进入完整版 |
| 2026-03-18 | 产品、后端、管理端、测试 | 文档归档/需求口径 | - | 文档归档整理:以《以界面定需求》为基准,各角色重整“功能需求+验收口径+风险点”并写入各自经验库;补齐《项目落地推进表》 |
---
@@ -61,4 +63,4 @@
---
**最后更新**2026-03-17会议收尾源码优化完成与测试流程定稿
**最后更新**2026-03-18

View File

@@ -24,9 +24,11 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析 | 已完成 |
| 2026-03-17 | 会议:稳定版源码质量优化;验收标准功能不变、三端联调通过 | 待续 |
| 2026-03-18 | 文档归档整理:以《以界面定需求》为基准,重整需求口径/验收点/分享 singlePage 约束,写入产品经验库 | 已完成 |
| 2026-03-18 | 会议:超级个体开通后自动创建@人与支付前资料引导(头像+昵称) | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-17
**最后更新**2026-03-18

View File

@@ -36,9 +36,11 @@ soul-apiGo + Gin + GORM + MySQL提供三组路由`/api/miniprogram/*`
| 2026-03-17 | 会议:稳定版源码质量优化;敏感配置生产强制校验、新增 /api/admin/user/track、AdminWithdrawTest 环境限制 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化 10 项全部完成;开发环境测试 10 通过 2 跳过 | 已完成 |
| 2026-03-17 | 性能优化会议Redis 缓存接入parts/hot/recommended/stats/config/章节 content、容灾回退 DBOSS 上传接入;/health 返回 database/redis 状态 | 已完成 |
| 2026-03-18 | 文档归档整理:按界面→接口→规则口径重整后端功能需求与风险点,写入角色经验库 | 已完成 |
| 2026-03-18 | 会议:超级个体开通后自动创建@人Person 绑定 userId 幂等)与资料完善 flags 方案 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-17
**最后更新**2026-03-18

View File

@@ -29,9 +29,11 @@ Soul 创业派对全项目架构与约定路由隔离miniprogram/admin/db
| 2026-03-16 | TipTap Mention 需 data-label 规则;链接人与事与存客宝对接优化会议收尾 | 已完成 |
| 2026-03-17 | 代付美团式流程与权益归属约定:读页→代付页→分享;权益/分佣归发起人PayNotify beneficiaryUserID | 已完成 |
| 2026-03-17 | 性能优化与 Redis 缓存方案落地Redis 容灾回退 DB、OSS 上传容灾;/health 返回 database/redis 状态 | 已完成 |
| 2026-03-18 | 吸收经验:分享进入链路需兼容朋友圈 singlePage单页模式不执行支付/自动领取等强动作并引导“前往小程序” | 已完成 |
| 2026-03-18 | 会议:超级个体开通后自动创建@人统一走 Person幂等键绑定 userId默认资料 flags 后端输出 | 已完成 |
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
---
**最后更新**2026-03-17
**最后更新**2026-03-18

View File

@@ -39,9 +39,11 @@
| 2026-03-17 | 代付页营销:章节标题+20%内容预览;我的代付列表点击进详情;页面协调 | 已完成 |
| 2026-03-17 | 会议:稳定版源码质量优化;删除 payment.js、goToMatch 重复、备份文件config 读取、totalSections 动态化 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化 5 项全部完成;开发环境测试通过 | 已完成 |
| 2026-03-18 | 吸收经验:分享链路需兼容好友/朋友圈 singlePage单页模式能力降级并引导“前往小程序”进入完整版 | 已完成 |
| 2026-03-18 | 会议:支付超级个体前/开通后资料默认校验,跳转 avatar-nickname 引导页(仅头像+昵称) | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-17
**最后更新**2026-03-18

View File

@@ -30,7 +30,9 @@
| 2026-03-17 | 会议:稳定版源码质量优化;每项小回归、全部完成后完整三端联调 | 待续 |
| 2026-03-17 | 会议收尾:功能测试流程定稿、测试报告模板、开发环境 10 通过 2 跳过 | 已完成 |
| 2026-03-17 | 性能优化会议test_upload.py 6 用例;/health 可验证 database/redis部署后回归缓存接口 | 已完成 |
| 2026-03-18 | 文档归档整理:按界面驱动口径统一验收;补充分享 singlePage 降级与引导为必测项 | 已完成 |
| 2026-03-18 | 会议新增用例资料默认阻断支付、Person 自动创建幂等、昵称变更同步回归) | 已完成 |
---
**最后更新**2026-03-17
**最后更新**2026-03-18

View File

@@ -41,11 +41,13 @@
| 2026-03-17 | 会议稳定版源码质量优化UserDetailModal 改 /api/admin/user/track、RichEditor HTML 转义 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化已落地;开发环境测试通过 | 已完成 |
| 2026-03-17 | 性能优化会议OSS 配置后上传自动优先 OSS失败回退本地无需前端改动 | 已完成 |
| 2026-03-18 | 文档归档整理:按《以界面定需求》重整管理端功能需求与验收口径,写入角色经验库 | 已完成 |
| 2026-03-18 | 会议:超级个体开通后自动创建@人;管理端可选展示 userId/来源以便排查重名 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-17
**最后更新**2026-03-18
> soul-admin 构建仍有 DistributionPage Order.description 类型错误(与本次迁移无关),待修复。

View File

@@ -0,0 +1,29 @@
# 管理端开发工程师 经验记录 - 2026-03-18
## 功能需求整理(以界面定需求 → 管理端任务清单)
### 需求基准
- 管理端界面与接口以 `开发文档/1、需求/以界面定需求.md` 第三节为准。
- 允许路径:`/api/admin/*``/api/db/*``/api/orders` 等;禁止调用 `/api/miniprogram/*`
### 主要功能域(稳定版基线)
- **登录与鉴权**JWT Bearer鉴权失败应清 token 并回登录(避免假登录态)。
- **数据概览**:用户/订单/收入、趋势与最近列表。
- **内容管理**:章节树/内容编辑/API 文档入口(以稳定版为主,迁移新版时不覆盖核心逻辑)。
- **用户管理**:列表/搜索/详情VIP 设置(到期必填);用户余额与行为轨迹。
- **订单管理**:支付方式(微信/余额/代付);筛选/退款;用户/推荐人信息展示。
- **提现管理**:审核/打款/状态流转;测试接口在 release 环境不可用。
- **系统设置**:免费章节、推广设置、站点与 OSS 配置(如有 region 等字段需与后端契约一致)。
### 新版差异(迁移待办口径)
- 迁移与否以 `开发文档/迁移完成度与待办清单.md` 为准:优先补齐“运行时配置/审核模式/auditMode”相关的界面隐藏与配置读取一致性。
### 分享链路验收提醒(管理端侧)
- 虽然 singlePage 属于小程序端场景,但管理端涉及“配置/开关/文案”时,需要给小程序提供可配置的引导文案或开关(若业务要求),否则前端只能硬编码。
## 超级个体开通后自动创建@人(链接人与事)
### 管理端影响面
- 自动创建的 Person 记录会出现在「链接人与事」列表中;如后端新增 `persons.user_id`,管理端可选增加一列“绑定用户/来源”,便于运营排查与避免重名困扰。
- mention 展示依赖 TipTap 的 `data-label`:只要后端/数据层保证 `data-label=昵称`,管理端预览与编辑侧显示即可稳定。

View File

@@ -0,0 +1,34 @@
# 软件测试 经验记录 - 2026-03-18
## 文档归档后测试口径统一(按“界面→接口→规则”验收)
### 验收基准(先看文档再测)
- 《以界面定需求》:`开发文档/1、需求/以界面定需求.md`
- 《需求清单》:`开发文档/1、需求/需求汇总.md`
- 《变更与决议》:`开发文档/10、项目管理/运营与变更.md`
- 《测试流程》:`scripts/test/功能测试流程.md`
- 《里程碑推进》:`开发文档/10、项目管理/项目落地推进表.md`
### 分享场景新增必测点(高优先级)
- **好友分享**:进入页面能力完整,支付/登录/领取均可用。
- **朋友圈分享singlePage**
- 进入后页面能力可能不完整
- 关键按钮点击应 **不执行强动作**(支付/自动领取/自动登录等)
- 必须出现明确引导:点击底部 **「前往小程序」** 进入完整版
### 回归覆盖建议(与分享强相关的链路)
- 代付分享:发起人支付→分享→好友打开→自动/手动领取→解锁正文→重复进入幂等
- 支付与回调:微信/余额两条路,状态一致,失败/取消提示一致
- 路径隔离:小程序只调 `/api/miniprogram/*`;管理端不调 miniprogram
## 新增用例:超级个体开通后自动创建@人与支付前资料引导
### 资料引导拦截
- 默认资料(昵称/头像)→ 点击支付超级个体 → 必须跳转 `avatar-nickname` 引导页并阻断支付
- 引导页保存成功 → 返回原流程继续支付(不丢失上下文)
- 已完善资料 → 不拦截支付
### Person 自动创建(幂等)
- 支付成功回调重复触发/用户重复进入成功页 → Person 记录不应重复创建
- 昵称变更后同步策略回归(若实现“跟随昵称”)

View File

@@ -0,0 +1,130 @@
# 会议纪要 - 2026-03-18 | 超级个体开通后自动创建@人与资料引导
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-18 (记录时间以当天为准)
- **议题**
- 小程序用户开通「超级个体」后,管理端「链接人与事」需自动创建一个与用户昵称一致的 `@人`
- 小程序新增校验:用户昵称/头像为默认值时,跳转到仅修改头像+昵称的引导页;且在支付超级个体之前也必须先完成此检查
- **触发方式**:开个会
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
---
## 各角色发言
### 【产品经理】
- **目标**:让超级个体开通后立刻具备「可被内容 @」的入口,形成“内容→人→转化”的闭环;同时在付费前强制用户完善头像昵称,避免默认资料影响信任与转化。
- **业务规则**
- 自动创建的 `@人` 展示名 **必须与当前用户昵称一致**(后续昵称变更需要同步策略)。
- 资料引导页只包含头像+昵称两项,用户完成后回到原流程继续支付/使用。
- **验收**
- 开通成功后,管理端「链接人与事」列表出现该用户昵称对应的记录;且可在内容编辑中被搜索/插入为 mention。
- 支付超级个体前若昵称/头像为默认值,必须跳转引导页并阻断支付。
### 【后端开发】
- **建议实现点**:在“超级个体开通成功”的后端闭环(支付回调/开通接口最终落库点)触发一次“确保 Person 存在”的逻辑。
- **数据与幂等**
- 仅用 `name=nickname` 作为唯一键会遇到重名与改名问题;建议在 `persons` 增加 `user_id`(或等价字段)作为绑定关系,做到幂等与可追溯。
- 若暂不加字段,也至少保证:同名已存在则复用,不重复创建。
- **接口契约方向**
- 小程序资料默认判定若后端统一输出更稳:建议在用户资料接口/登录态接口返回 `profileNeedComplete` / `isDefaultAvatar` / `isDefaultNickname`,小程序只负责跳转与阻断支付。
### 【管理端开发工程师】
- 「链接人与事」现有列表与 Person CRUD 已具备承载自动创建记录的能力;若增加 `user_id` 字段,可在列表中加一列“来源/绑定用户”便于运营排查。
- 内容编辑插入 mention 已依赖 `data-label` 显示名规则:只要后端/存储保证 `data-label=昵称`,即可正确展示。
### 【小程序开发工程师】
- 工程上已有独立页 `pages/avatar-nickname/avatar-nickname`(仅头像+昵称),可复用。
- 需要补两处拦截:
- **支付前**:在“去支付/确认支付”按钮入口做一次默认资料校验,未通过则跳转引导页并 return。
- **开通后**:在超级个体权益页/开通成功回调后再做一次校验,若仍为默认则跳引导页(兼容用户开通后才意识到资料不完善)。
- 默认判定若只靠前端字符串规则易漂移,建议后端提供明确布尔值;前端可做兜底规则(空昵称、昵称以“微信用户”开头、头像为空/命中默认头像域名等)。
### 【测试人员】
- 新增用例需覆盖:
- 资料默认 → 触发引导页 → 保存成功 → 回到支付流程继续完成支付
- 资料已完善 → 不拦截支付
- 开通成功后自动创建 Person重复支付回调/重复点击导致的幂等(不应创建多条)
- 昵称变更后:自动创建记录是否更新展示名(按决议验收)
---
## 讨论过程
- 讨论了 Person 的现有语义(用于文章 mention 与 CKB 线索承接),并确认“超级个体开通后自动创建 @人”应落在同一条 Person 体系内,避免另起一套表导致前后端两套 mention 逻辑分裂。
- 对“唯一键”的讨论:仅用昵称无法保证幂等与长期一致性,因此倾向新增 `persons.user_id` 绑定用户;并在昵称变化时做同步更新策略。
- 对“默认资料判定”的讨论:前端硬编码规则不稳,后端输出明确 flags 最稳;前端保留兜底。
---
## 会议决议
1. **自动创建 @人Person触发点**:在“超级个体开通成功”的后端落库闭环触发 `ensurePersonForUser(userId)`;幂等保证不重复创建。
2. **Person 与用户绑定**:优先方案为 `persons` 增加 `user_id` 字段(并加唯一约束/索引),以 `user_id` 为幂等键;`name` 作为展示名,与用户昵称保持同步策略。
3. **小程序资料引导**:复用 `pages/avatar-nickname`,在“支付前入口”与“开通后进入权益页/成功回调”两处增加默认资料校验与跳转。
4. **默认资料判定口径**:后端优先提供明确 flags`profileNeedComplete` / `isDefaultNickname` / `isDefaultAvatar`),小程序仅消费;前端可保留兜底规则。
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 后端开发 | 在超级个体开通成功链路增加 `ensurePersonForUser`;为 `persons` 增加 `user_id` 并做幂等;必要时补“昵称变更同步” | 高 | 2026-03-20 |
| 管理端开发工程师 | 链接人与事列表可选增加 `userId/来源` 展示与筛选;确认插入 mention 时 `data-label` 始终为昵称 | 中 | 2026-03-21 |
| 小程序开发工程师 | 支付前与开通后两处增加资料默认校验;不通过则跳转 `avatar-nickname` 引导页并阻断后续动作 | 高 | 2026-03-20 |
| 产品经理 | 明确“昵称变更后的同步规则/是否允许重名冲突显示”;补充验收标准文案 | 中 | 2026-03-19 |
| 测试人员 | 补充用例:资料引导阻断支付 + 幂等创建 Person + 昵称变更同步回归 | 中 | 联调前 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | `persons.user_id` 是否需要唯一索引?重名用户在列表展示如何区分? | 后端/产品 | (待补充) |
| 2 | 昵称变更是否必须同步更新 Person.name若同步是否保留历史别名 | 产品/后端 | (待补充) |
| 3 | 默认头像/昵称判定的最终口径(后端 flags vs 前端兜底规则)以哪一套为准? | 后端/小程序 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 超级个体开通后要立刻具备“可被内容 @”入口;支付前资料完善是转化关键拦截点(头像/昵称)。
### 后端开发
- 自动创建 Person 必须做幂等,建议以 `user_id` 绑定,避免仅靠昵称造成重名/改名混乱;默认资料判定尽量由后端输出 flags前端只消费。
### 管理端开发工程师
- Person 体系可承载自动创建记录mention 显示依赖 `data-label`,需确保展示名与数据一致。
### 小程序开发工程师
- 复用 `avatar-nickname` 引导页,在支付前/开通后两处做资料校验与跳转;后端 flags 优先,前端规则兜底。
### 测试人员
- 新增强约束:资料未完善必须阻断支付;自动创建 Person 要验幂等与昵称变更同步。
### 团队共享
- “可被 @ 的人”统一走 Person 体系幂等键优先绑定业务主键userId展示名同步为昵称默认资料判定由后端输出布尔 flags 更稳定。
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-18.md`*

View File

@@ -79,3 +79,4 @@ YYYY-MM-DD_会议主题.md
| 2026-03-17 | 稳定版源码质量优化方案讨论与开发安排 | 产品、后端、管理端、小程序、测试 | [2026-03-17_稳定版源码质量优化方案讨论与开发安排.md](2026-03-17_稳定版源码质量优化方案讨论与开发安排.md) |
| 2026-03-17 | 会议收尾:源码优化完成与测试流程定稿 | 产品、后端、管理端、小程序、测试、助理橙子 | [2026-03-17_会议收尾-源码优化完成与测试流程定稿.md](2026-03-17_会议收尾-源码优化完成与测试流程定稿.md) |
| 2026-03-17 | 性能优化与 Redis 缓存方案落地 | 后端、管理端、小程序、测试、助理橙子 | [2026-03-17_性能优化与Redis缓存方案落地.md](2026-03-17_性能优化与Redis缓存方案落地.md) |
| 2026-03-18 | 超级个体开通后自动创建@人与资料引导 | 产品、后端、管理端、小程序、测试 | [2026-03-18_超级个体开通后自动创建@人与资料引导.md](2026-03-18_超级个体开通后自动创建@人与资料引导.md) |

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

View File

@@ -0,0 +1,101 @@
import React, { useRef, useEffect } from "react";
import { CloseOutlined } from "@ant-design/icons";
import styles from "./VideoPlayer.module.scss";
interface VideoPlayerProps {
/** 视频URL */
videoUrl: string;
/** 是否显示播放器 */
visible: boolean;
/** 关闭回调 */
onClose: () => void;
/** 视频标题 */
title?: string;
}
const VideoPlayer: React.FC<VideoPlayerProps> = ({
videoUrl,
visible,
onClose,
title = "操作视频",
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (visible && videoRef.current) {
// 播放器打开时播放视频
videoRef.current.play().catch(err => {
console.error("视频播放失败:", err);
});
// 阻止背景滚动
document.body.style.overflow = "hidden";
} else if (videoRef.current) {
// 播放器关闭时暂停视频
videoRef.current.pause();
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [visible]);
// 点击遮罩层关闭
const handleMaskClick = (e: React.MouseEvent<HTMLDivElement>) => {
// 如果点击的是遮罩层本身(不是视频容器),则关闭
if (e.target === e.currentTarget) {
handleClose();
}
};
const handleClose = () => {
if (videoRef.current) {
videoRef.current.pause();
}
onClose();
};
// 阻止事件冒泡
const handleContentClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
};
if (!visible) {
return null;
}
return (
<div
ref={containerRef}
className={styles.modalMask}
onClick={handleMaskClick}
>
<div className={styles.videoContainer} onClick={handleContentClick}>
<div className={styles.header}>
<span className={styles.title}>{title}</span>
<button className={styles.closeButton} onClick={handleClose}>
<CloseOutlined />
</button>
</div>
<div className={styles.videoWrapper}>
<video
ref={videoRef}
src={videoUrl}
controls
className={styles.video}
playsInline
webkit-playsinline="true"
x5-playsinline="true"
x5-video-player-type="h5"
x5-video-player-fullscreen="true"
>
</video>
</div>
</div>
</div>
);
};
export default VideoPlayer;

View File

@@ -0,0 +1,56 @@
.floatingButton {
position: fixed;
right: 20px;
bottom: 80px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 9998;
transition: all 0.3s ease;
animation: float 3s ease-in-out infinite;
&:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.5);
}
&:active {
transform: scale(0.95);
}
.icon {
font-size: 28px;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
}
// 移动端适配
@media (max-width: 768px) {
right: 16px;
bottom: 70px;
width: 50px;
height: 50px;
.icon {
font-size: 24px;
}
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}

View File

@@ -0,0 +1,68 @@
import React, { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
import { PlayCircleOutlined } from "@ant-design/icons";
import VideoPlayer from "./VideoPlayer";
import { getVideoUrlByRoute } from "./videoConfig";
import styles from "./index.module.scss";
interface FloatingVideoHelpProps {
/** 是否显示悬浮窗,默认为 true */
visible?: boolean;
/** 自定义样式类名 */
className?: string;
}
const FloatingVideoHelp: React.FC<FloatingVideoHelpProps> = ({
visible = true,
className,
}) => {
const location = useLocation();
const [showPlayer, setShowPlayer] = useState(false);
const [currentVideoUrl, setCurrentVideoUrl] = useState<string | null>(null);
// 根据当前路由获取视频URL
useEffect(() => {
const videoUrl = getVideoUrlByRoute(location.pathname);
setCurrentVideoUrl(videoUrl);
}, [location.pathname]);
const handleClick = () => {
if (currentVideoUrl) {
setShowPlayer(true);
} else {
// 如果没有对应的视频,可以显示提示
console.warn("当前路由没有对应的操作视频");
}
};
const handleClose = () => {
setShowPlayer(false);
};
// 如果没有视频URL不显示悬浮窗
if (!visible || !currentVideoUrl) {
return null;
}
return (
<>
<div
className={`${styles.floatingButton} ${className || ""}`}
onClick={handleClick}
title="查看操作视频"
>
<PlayCircleOutlined className={styles.icon} />
</div>
{showPlayer && currentVideoUrl && (
<VideoPlayer
videoUrl={currentVideoUrl}
visible={showPlayer}
onClose={handleClose}
/>
)}
</>
);
};
export default FloatingVideoHelp;

View File

@@ -0,0 +1,110 @@
/**
* 路由到视频URL的映射配置
* key: 路由路径(支持正则表达式)
* value: 视频URL
*/
interface VideoConfig {
[route: string]: string;
}
// 视频URL配置
const videoConfig: VideoConfig = {
// 首页
"/": "/videos/home.mp4",
"/mobile/home": "/videos/home.mp4",
// 工作台
"/workspace": "/videos/workspace.mp4",
"/workspace/auto-like": "/videos/auto-like-list.mp4",
"/workspace/auto-like/new": "/videos/auto-like-new.mp4",
"/workspace/auto-like/record": "/videos/auto-like-record.mp4",
"/workspace/auto-group": "/videos/auto-group-list.mp4",
"/workspace/auto-group/new": "/videos/auto-group-new.mp4",
"/workspace/group-push": "/videos/group-push-list.mp4",
"/workspace/group-push/new": "/videos/group-push-new.mp4",
"/workspace/moments-sync": "/videos/moments-sync-list.mp4",
"/workspace/moments-sync/new": "/videos/moments-sync-new.mp4",
"/workspace/ai-assistant": "/videos/ai-assistant.mp4",
"/workspace/ai-analyzer": "/videos/ai-analyzer.mp4",
"/workspace/traffic-distribution": "/videos/traffic-distribution-list.mp4",
"/workspace/traffic-distribution/new": "/videos/traffic-distribution-new.mp4",
"/workspace/contact-import": "/videos/contact-import-list.mp4",
"/workspace/contact-import/form": "/videos/contact-import-form.mp4",
"/workspace/ai-knowledge": "/videos/ai-knowledge-list.mp4",
"/workspace/ai-knowledge/new": "/videos/ai-knowledge-new.mp4",
// 我的
"/mobile/mine": "/videos/mine.mp4",
"/mobile/mine/devices": "/videos/devices.mp4",
"/mobile/mine/wechat-accounts": "/videos/wechat-accounts.mp4",
"/mobile/mine/content": "/videos/content.mp4",
"/mobile/mine/traffic-pool": "/videos/traffic-pool.mp4",
"/mobile/mine/recharge": "/videos/recharge.mp4",
"/mobile/mine/setting": "/videos/setting.mp4",
// 场景
"/mobile/scenarios": "/videos/scenarios.mp4",
"/mobile/scenarios/plan": "/videos/scenarios-plan.mp4",
};
/**
* 根据路由路径获取对应的视频URL
* @param routePath 当前路由路径
* @returns 视频URL如果没有匹配则返回 null
*/
export function getVideoUrlByRoute(routePath: string): string | null {
// 精确匹配
if (videoConfig[routePath]) {
return videoConfig[routePath];
}
// 模糊匹配(支持动态路由参数)
// 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like/edit/:id
const routeKeys = Object.keys(videoConfig);
for (const key of routeKeys) {
// 将配置中的 :id 等参数转换为正则表达式
const regexPattern = key.replace(/:\w+/g, "[^/]+");
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(routePath)) {
return videoConfig[key];
}
}
// 前缀匹配(作为兜底方案)
// 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like
const sortedKeys = routeKeys.sort((a, b) => b.length - a.length); // 按长度降序排列
for (const key of sortedKeys) {
if (routePath.startsWith(key)) {
return videoConfig[key];
}
}
return null;
}
/**
* 添加或更新视频配置
* @param route 路由路径
* @param videoUrl 视频URL
*/
export function setVideoConfig(route: string, videoUrl: string): void {
videoConfig[route] = videoUrl;
}
/**
* 批量添加视频配置
* @param config 视频配置对象
*/
export function setVideoConfigs(config: VideoConfig): void {
Object.assign(videoConfig, config);
}
/**
* 获取所有视频配置
* @returns 视频配置对象
*/
export function getAllVideoConfigs(): VideoConfig {
return { ...videoConfig };
}
export default videoConfig;

View File

@@ -0,0 +1,11 @@
import request from "@/api/request";
// 获取好友列表
export function getFriendList(params: {
page: number;
limit: number;
deviceIds?: string; // 逗号分隔
keyword?: string;
}) {
return request("/v1/friend", params, "GET");
}

View File

@@ -0,0 +1,27 @@
export interface FriendSelectionItem {
id: number;
wechatId: string;
nickname: string;
avatar: string;
[key: string]: any;
}
// 组件属性接口
export interface FriendSelectionProps {
selectedOptions?: FriendSelectionItem[];
onSelect: (friends: FriendSelectionItem[]) => void;
deviceIds?: number[];
enableDeviceFilter?: boolean;
placeholder?: string;
className?: string;
visible?: boolean; // 新增
onVisibleChange?: (visible: boolean) => void; // 新增
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: number[],
selectedItems: FriendSelectionItem[],
) => void; // 新增
}

View File

@@ -0,0 +1,246 @@
.inputWrapper {
position: relative;
}
.selectedListRow {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.selectedListRowContent {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.selectedListRowContentText {
flex: 1;
}
.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,140 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import { FriendSelectionProps } from "./data";
import SelectionPopup from "./selectionPopup";
export default function FriendSelection({
selectedOptions = [],
onSelect,
deviceIds = [],
enableDeviceFilter = true,
placeholder = "选择微信好友",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: FriendSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 内部弹窗交给 selectionPopup 处理
// 受控弹窗逻辑
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 || selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个好友`;
};
// 删除已选好友
const handleRemoveFriend = (id: number) => {
if (readonly) return;
onSelect((selectedOptions || []).filter(v => v.id !== id));
};
// 弹窗确认回调
const handleConfirm = (
selectedIds: number[],
selectedItems: typeof selectedOptions,
) => {
onSelect(selectedItems);
if (onConfirm) onConfirm(selectedIds, selectedItems);
setRealVisible(false);
};
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(friend => (
<div key={friend.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<Avatar src={friend.avatar || friend.friendAvatar} />
<div className={style.selectedListRowContentText}>
<div>{friend.nickname || friend.friendName}</div>
<div>{friend.wechatId}</div>
</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={() => handleRemoveFriend(friend.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible && !readonly}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions || []}
onSelect={onSelect}
deviceIds={deviceIds}
enableDeviceFilter={enableDeviceFilter}
readonly={readonly}
onConfirm={handleConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,245 @@
import React, { useCallback, useEffect, useState } from "react";
import { Popup, Checkbox } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { getFriendList } from "./api";
import style from "./index.module.scss";
import type { FriendSelectionItem } from "./data";
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: FriendSelectionItem[];
onSelect: (friends: FriendSelectionItem[]) => void;
deviceIds?: number[];
enableDeviceFilter?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: number[],
selectedItems: FriendSelectionItem[],
) => void;
}
const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible,
onVisibleChange,
selectedOptions,
onSelect,
deviceIds = [],
enableDeviceFilter = true,
readonly = false,
onConfirm,
}) => {
const [friends, setFriends] = useState<FriendSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalFriends, setTotalFriends] = useState(0);
const [loading, setLoading] = useState(false);
const [tempSelectedOptions, setTempSelectedOptions] = useState<
FriendSelectionItem[]
>([]);
// 获取好友列表API
const fetchFriends = useCallback(
async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
if (enableDeviceFilter && deviceIds.length > 0) {
params.deviceIds = deviceIds.join(",");
}
const response = await getFriendList(params);
if (response && response.list) {
setFriends(response.list);
setTotalFriends(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取好友列表失败:", error);
} finally {
setLoading(false);
}
},
[deviceIds, enableDeviceFilter],
);
// 处理好友选择
const handleFriendToggle = (friend: FriendSelectionItem) => {
if (readonly) return;
const newSelectedFriends = tempSelectedOptions.some(f => f.id === friend.id)
? tempSelectedOptions.filter(f => f.id !== friend.id)
: tempSelectedOptions.concat(friend);
setTempSelectedOptions(newSelectedFriends);
};
// 全选当前页
const handleSelectAllCurrentPage = (checked: boolean) => {
if (readonly) return;
if (checked) {
// 全选:添加当前页面所有未选中的好友
const currentPageFriends = friends.filter(
friend => !tempSelectedOptions.some(f => f.id === friend.id),
);
setTempSelectedOptions(prev => [...prev, ...currentPageFriends]);
} else {
// 取消全选:移除当前页面的所有好友
const currentPageFriendIds = friends.map(f => f.id);
setTempSelectedOptions(prev =>
prev.filter(f => !currentPageFriendIds.includes(f.id)),
);
}
};
// 检查当前页是否全选
const isCurrentPageAllSelected =
friends.length > 0 &&
friends.every(friend => tempSelectedOptions.some(f => f.id === friend.id));
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(
tempSelectedOptions.map(v => v.id),
tempSelectedOptions,
);
}
// 更新实际选中的选项
onSelect(tempSelectedOptions);
onVisibleChange(false);
};
// 弹窗打开时初始化
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchFriends(1, "");
}
}, [visible, selectedOptions]); // 只在弹窗开启时请求
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return; // 弹窗关闭或搜索词为空时不请求
const timer = setTimeout(() => {
setCurrentPage(1);
fetchFriends(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible) return; // 弹窗关闭或第一页时不请求
fetchFriends(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
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={() => fetchFriends(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>
) : friends.length > 0 ? (
<div className={style.friendListInner}>
{friends.map(friend => (
<div key={friend.id} className={style.friendItem}>
<Checkbox
checked={tempSelectedOptions.some(f => f.id === friend.id)}
onChange={() => !readonly && handleFriendToggle(friend)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
<div className={style.friendInfo}>
<div className={style.friendAvatar}>
{friend.avatar ? (
<img
src={friend.avatar}
alt={friend.nickname}
className={style.avatarImg}
/>
) : (
friend.nickname.charAt(0)
)}
</div>
<div className={style.friendDetail}>
<div className={style.friendName}>{friend.nickname}</div>
<div className={style.friendId}>
ID: {friend.wechatId}
</div>
{friend.customer && (
<div className={style.friendCustomer}>
: {friend.customer}
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{deviceIds.length === 0
? "请先选择设备"
: searchQuery
? `没有找到包含"${searchQuery}"的好友`
: "没有找到好友"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
};
export default SelectionPopup;

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取群组列表
export function getGroupList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/chatroom", params, "GET");
}

View File

@@ -0,0 +1,43 @@
// 群组接口类型
export interface WechatGroup {
id: string;
chatroomId: string;
name: string;
avatar: string;
ownerWechatId: string;
ownerNickname: string;
ownerAvatar: string;
}
export interface GroupSelectionItem {
id: string;
avatar: string;
chatroomId?: string;
createTime?: number;
identifier?: string;
name: string;
ownerAlias?: string;
ownerAvatar?: string;
ownerNickname?: string;
ownerWechatId?: string;
[key: string]: any;
}
// 组件属性接口
export interface GroupSelectionProps {
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: WechatGroup[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void; // 新增
}

View File

@@ -0,0 +1,206 @@
.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;
}
.selectedListRow {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.selectedListRowContent {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.selectedListRowContentText {
flex: 1;
}
.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;
}
.groupList {
flex: 1;
overflow-y: auto;
}
.groupListInner {
border-top: 1px solid #f0f0f0;
}
.groupItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.groupInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.groupAvatar {
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;
}
.groupDetail {
flex: 1;
}
.groupName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.groupId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.groupOwner {
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;
}

View File

@@ -0,0 +1,126 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import SelectionPopup from "./selectionPopup";
import { GroupSelectionProps } from "./data";
export default function GroupSelection({
selectedOptions,
onSelect,
onSelectDetail,
placeholder = "选择群聊",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: GroupSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 删除已选群聊
const handleRemoveGroup = (id: string) => {
if (readonly) return;
onSelect(selectedOptions.filter(g => g.id !== id));
};
// 受控弹窗逻辑
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} 个群聊`;
};
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(group => (
<div key={group.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<Avatar src={group.avatar} />
<div className={style.selectedListRowContentText}>
<div>{group.name}</div>
<div>{group.chatroomId}</div>
</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={() => handleRemoveGroup(group.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
onSelectDetail={onSelectDetail}
readonly={readonly}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from "react";
import { Popup, Checkbox } from "antd-mobile";
import { getGroupList } 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 { GroupSelectionItem } from "./data";
// 群组接口类型
interface WechatGroup {
id: string;
name: string;
avatar: string;
chatroomId?: string;
ownerWechatId?: string;
ownerNickname?: string;
ownerAvatar?: string;
}
// 弹窗属性接口
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: WechatGroup[]) => void;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void;
}
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
onSelectDetail,
readonly = false,
onConfirm,
}: SelectionPopupProps) {
const [groups, setGroups] = useState<WechatGroup[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalGroups, setTotalGroups] = useState(0);
const [loading, setLoading] = useState(false);
const [tempSelectedOptions, setTempSelectedOptions] = useState<
GroupSelectionItem[]
>([]);
// 获取群聊列表API
const fetchGroups = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const response = await getGroupList(params);
if (response && response.list) {
setGroups(response.list);
setTotalGroups(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取群聊列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理群聊选择
const handleGroupToggle = (group: GroupSelectionItem) => {
if (readonly) return;
const newSelectedGroups = tempSelectedOptions.some(g => g.id === group.id)
? tempSelectedOptions.filter(g => g.id !== group.id)
: tempSelectedOptions.concat(group);
setTempSelectedOptions(newSelectedGroups);
};
// 全选当前页
const handleSelectAllCurrentPage = (checked: boolean) => {
if (readonly) return;
if (checked) {
// 全选:添加当前页面所有未选中的群组
const currentPageGroups = groups.filter(
group => !tempSelectedOptions.some(g => g.id === group.id),
);
setTempSelectedOptions(prev => [...prev, ...currentPageGroups]);
} else {
// 取消全选:移除当前页面的所有群组
const currentPageGroupIds = groups.map(g => g.id);
setTempSelectedOptions(prev =>
prev.filter(g => !currentPageGroupIds.includes(g.id)),
);
}
};
// 检查当前页是否全选
const isCurrentPageAllSelected =
groups.length > 0 &&
groups.every(group => tempSelectedOptions.some(g => g.id === group.id));
// 确认选择
const handleConfirm = () => {
// 用户点击确认时才更新实际的selectedOptions
onSelect(tempSelectedOptions);
// 如果有 onSelectDetail 回调,传递完整的群聊对象
if (onSelectDetail) {
const selectedGroupObjs = groups.filter(group =>
tempSelectedOptions.some(g => g.id === group.id),
);
onSelectDetail(selectedGroupObjs);
}
if (onConfirm) {
onConfirm(
tempSelectedOptions.map(g => g.id),
tempSelectedOptions,
);
}
onVisibleChange(false);
};
// 弹窗打开时初始化数据(只执行一次)
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchGroups(1, "");
} else {
// 弹窗关闭时重置状态
setTempSelectedOptions([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchGroups(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible) return;
fetchGroups(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
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={() => fetchGroups(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.groupList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : groups.length > 0 ? (
<div className={style.groupListInner}>
{groups.map(group => (
<div key={group.id} className={style.groupItem}>
<Checkbox
checked={tempSelectedOptions.some(g => g.id === group.id)}
onChange={() => !readonly && handleGroupToggle(group)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
<div className={style.groupInfo}>
<div className={style.groupAvatar}>
{group.avatar ? (
<img
src={group.avatar}
alt={group.name}
className={style.avatarImg}
/>
) : (
group.name.charAt(0)
)}
</div>
<div className={style.groupDetail}>
<div className={style.groupName}>{group.name}</div>
<div className={style.groupId}>
ID: {group.chatroomId}
</div>
{group.ownerNickname && (
<div className={style.groupOwner}>
: {group.ownerNickname}
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的群聊`
: "没有找到群聊"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -0,0 +1,329 @@
.container {
width: 100%;
}
.inputWrapper {
position: relative;
margin-bottom: 12px;
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
z-index: 1;
pointer-events: none;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.clearBtn {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #999;
font-size: 16px;
z-index: 1;
}
}
.selectedGroupsList {
display: flex;
flex-direction: column;
gap: 16px;
}
.groupCard {
background: #fff;
border-radius: 12px;
padding: 16px;
border: 1px solid #e5e6eb;
}
.groupHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.groupInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.groupAvatar {
width: 48px;
height: 48px;
border-radius: 8px;
flex-shrink: 0;
}
.groupDetails {
flex: 1;
min-width: 0;
}
.groupName {
font-size: 16px;
font-weight: 500;
color: #222;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groupId {
font-size: 14px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deleteGroupBtn {
color: #ff4d4f;
font-size: 18px;
padding: 4px;
min-width: auto;
height: auto;
}
.membersSection {
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.membersLabel {
font-size: 14px;
color: #666;
margin-bottom: 12px;
font-weight: 500;
}
.membersList {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.memberItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
position: relative;
width: 70px;
}
.memberAvatar {
width: 56px;
height: 56px;
border-radius: 50%;
position: relative;
}
.removeMemberBtn {
position: absolute;
top: -4px;
right: -4px;
width: 20px;
height: 20px;
min-width: 20px;
padding: 0;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 50%;
color: #ff4d4f;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.memberName {
font-size: 12px;
color: #222;
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.addMemberBtn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border: 1px dashed #d9d9d9;
border-radius: 50%;
background: #fafafa;
color: #999;
font-size: 20px;
cursor: pointer;
transition: all 0.2s;
gap: 4px;
span {
font-size: 12px;
}
&:active {
background: #f0f0f0;
border-color: #1677ff;
color: #1677ff;
}
}
.memberSelectionPopup {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
.popupHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.popupTitle {
font-size: 18px;
font-weight: 600;
color: #222;
}
.closeBtn {
color: #1677ff;
font-size: 16px;
}
.searchBox {
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.searchInputWrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.searchInput {
flex: 1;
background: #f5f5f5;
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
}
.clearSearchBtn {
position: absolute;
right: 8px;
width: 20px;
height: 20px;
min-width: 20px;
padding: 0;
color: #999;
display: flex;
align-items: center;
justify-content: center;
}
.searchBtn {
min-width: 60px;
height: 32px;
}
.memberList {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.memberListItem {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
&:last-child {
border-bottom: none;
}
&.selected {
.memberListItemName {
color: #1677ff;
}
}
}
.memberListItemAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.memberListItemName {
flex: 1;
font-size: 16px;
color: #222;
}
.checkmark {
color: #1677ff;
font-size: 18px;
font-weight: bold;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.loadingText {
font-size: 14px;
color: #999;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.emptyText {
font-size: 14px;
color: #999;
}

View File

@@ -0,0 +1,438 @@
import React, { useState, useEffect } from "react";
import { SearchOutlined, DeleteOutlined, PlusOutlined, CloseOutlined } from "@ant-design/icons";
import { Button, Input, Popup } from "antd-mobile";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import GroupSelection from "../GroupSelection";
import { GroupSelectionItem } from "../GroupSelection/data";
import request from "@/api/request";
// 群成员接口
export interface GroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
}
// 带成员的群选项
export interface GroupWithMembers extends GroupSelectionItem {
members?: GroupMember[];
groupId?: string; // 用于关联成员和群
}
interface GroupSelectionWithMembersProps {
selectedGroups: GroupWithMembers[];
onSelect: (groups: GroupWithMembers[]) => void;
placeholder?: string;
className?: string;
readonly?: boolean;
}
// 获取群成员列表
const getGroupMembers = async (
groupId: string,
page: number = 1,
limit: number = 100,
keyword: string = "",
): Promise<GroupMember[]> => {
try {
const params: any = {
page,
limit,
groupId,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const response = await request("/v1/kefu/wechatChatroom/members", params, "GET");
// request 拦截器会返回 res.data.data ?? res.data
// 对于 { code: 200, data: { list: [...] } } 的返回,拦截器会返回 { list: [...] }
const memberList = response?.list || response?.data?.list || [];
// 映射接口返回的数据结构到我们的接口
return memberList.map((item: any) => ({
id: String(item.id),
nickname: item.nickname || "",
wechatId: item.wechatId || "",
avatar: item.avatar || "",
gender: undefined, // 接口未返回,暂时设为 undefined
role: undefined, // 接口未返回,暂时设为 undefined
}));
} catch (error) {
console.error("获取群成员失败:", error);
return [];
}
};
const GroupSelectionWithMembers: React.FC<GroupSelectionWithMembersProps> = ({
selectedGroups,
onSelect,
placeholder = "选择聊天群",
className = "",
readonly = false,
}) => {
const [groupSelectionVisible, setGroupSelectionVisible] = useState(false);
const [memberSelectionVisible, setMemberSelectionVisible] = useState<{
visible: boolean;
groupId: string;
}>({ visible: false, groupId: "" });
const [allMembers, setAllMembers] = useState<Record<string, GroupMember[]>>({});
const [selectedMembers, setSelectedMembers] = useState<Record<string, GroupMember[]>>({});
const [loadingMembers, setLoadingMembers] = useState(false);
const [memberSearchKeyword, setMemberSearchKeyword] = useState("");
// 存储完整成员列表(用于搜索时切换回完整列表)
const [fullMembersCache, setFullMembersCache] = useState<Record<string, GroupMember[]>>({});
// 处理群选择
const handleGroupSelect = (groups: GroupSelectionItem[]) => {
const groupsWithMembers: GroupWithMembers[] = groups.map(group => {
const existing = selectedGroups.find(g => g.id === group.id);
return {
...group,
members: existing?.members || [],
};
});
onSelect(groupsWithMembers);
setGroupSelectionVisible(false);
};
// 删除群
const handleRemoveGroup = (groupId: string) => {
if (readonly) return;
const newGroups = selectedGroups.filter(g => g.id !== groupId);
const newSelectedMembers = { ...selectedMembers };
delete newSelectedMembers[groupId];
setSelectedMembers(newSelectedMembers);
onSelect(newGroups);
};
// 打开成员选择弹窗
const handleOpenMemberSelection = async (groupId: string) => {
if (readonly) return;
setMemberSelectionVisible({ visible: true, groupId });
setMemberSearchKeyword(""); // 重置搜索关键词
// 如果还没有加载过该群的成员列表,则加载所有成员(不使用搜索关键词)
if (!allMembers[groupId] && !fullMembersCache[groupId]) {
setLoadingMembers(true);
try {
const members = await getGroupMembers(groupId, 1, 100, "");
setAllMembers(prev => ({ ...prev, [groupId]: members }));
setFullMembersCache(prev => ({ ...prev, [groupId]: members })); // 缓存完整列表
} catch (error) {
console.error("加载群成员失败:", error);
} finally {
setLoadingMembers(false);
}
} else if (fullMembersCache[groupId] && !allMembers[groupId]) {
// 如果有缓存但没有显示列表,恢复完整列表
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
}
};
// 关闭成员选择弹窗
const handleCloseMemberSelection = () => {
setMemberSelectionVisible({ visible: false, groupId: "" });
setMemberSearchKeyword(""); // 重置搜索关键词
};
// 手动触发搜索
const handleSearchMembers = async () => {
const groupId = memberSelectionVisible.groupId;
if (!groupId) return;
const keyword = memberSearchKeyword.trim();
// 如果搜索关键词为空,使用缓存的完整列表
if (!keyword) {
if (fullMembersCache[groupId] && fullMembersCache[groupId].length > 0) {
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
}
return;
}
// 有搜索关键词时,调用 API 搜索
setLoadingMembers(true);
try {
const members = await getGroupMembers(groupId, 1, 100, keyword);
setAllMembers(prev => ({ ...prev, [groupId]: members }));
} catch (error) {
console.error("搜索群成员失败:", error);
} finally {
setLoadingMembers(false);
}
};
// 清空搜索
const handleClearSearch = () => {
setMemberSearchKeyword("");
const groupId = memberSelectionVisible.groupId;
if (groupId && fullMembersCache[groupId] && fullMembersCache[groupId].length > 0) {
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
}
};
// 选择成员
const handleSelectMember = (groupId: string, member: GroupMember) => {
if (readonly) return;
const currentMembers = selectedMembers[groupId] || [];
const isSelected = currentMembers.some(m => m.id === member.id);
let newSelectedMembers = { ...selectedMembers };
if (isSelected) {
newSelectedMembers[groupId] = currentMembers.filter(m => m.id !== member.id);
} else {
newSelectedMembers[groupId] = [...currentMembers, member];
}
setSelectedMembers(newSelectedMembers);
// 更新群数据
const updatedGroups = selectedGroups.map(group => {
if (group.id === groupId) {
return {
...group,
members: newSelectedMembers[groupId] || [],
};
}
return group;
});
onSelect(updatedGroups);
};
// 移除成员
const handleRemoveMember = (groupId: string, memberId: string) => {
if (readonly) return;
const currentMembers = selectedMembers[groupId] || [];
const newMembers = currentMembers.filter(m => m.id !== memberId);
const newSelectedMembers = { ...selectedMembers };
newSelectedMembers[groupId] = newMembers;
setSelectedMembers(newSelectedMembers);
// 更新群数据
const updatedGroups = selectedGroups.map(group => {
if (group.id === groupId) {
return {
...group,
members: newMembers,
};
}
return group;
});
onSelect(updatedGroups);
};
// 同步 selectedGroups 到 selectedMembers
useEffect(() => {
const membersMap: Record<string, GroupMember[]> = {};
selectedGroups.forEach(group => {
if (group.members && group.members.length > 0) {
membersMap[group.id] = group.members;
}
});
setSelectedMembers(membersMap);
}, [selectedGroups.length]);
// 获取显示文本
const getDisplayText = () => {
if (selectedGroups.length === 0) return "";
return `已选择${selectedGroups.length}个群聊`;
};
const currentGroupMembers = allMembers[memberSelectionVisible.groupId] || [];
const currentSelectedMembers = selectedMembers[memberSelectionVisible.groupId] || [];
return (
<div className={`${style.container} ${className}`}>
{/* 输入框 */}
<div
className={style.inputWrapper}
onClick={() => !readonly && setGroupSelectionVisible(true)}
>
<SearchOutlined className={style.inputIcon} />
<Input
placeholder={placeholder}
value={getDisplayText()}
readOnly
className={style.input}
/>
{!readonly && selectedGroups.length > 0 && (
<Button
fill="none"
size="small"
className={style.clearBtn}
onClick={e => {
e.stopPropagation();
setSelectedMembers({});
onSelect([]);
}}
>
<DeleteOutlined />
</Button>
)}
</div>
{/* 已选群列表 */}
{selectedGroups.length > 0 && (
<div className={style.selectedGroupsList}>
{selectedGroups.map(group => (
<div key={group.id} className={style.groupCard}>
{/* 群信息 */}
<div className={style.groupHeader}>
<div className={style.groupInfo}>
<Avatar src={group.avatar} className={style.groupAvatar} />
<div className={style.groupDetails}>
<div className={style.groupName}>{group.name}</div>
<div className={style.groupId}>ID: {group.chatroomId || group.id}</div>
</div>
</div>
{!readonly && (
<Button
fill="none"
size="small"
className={style.deleteGroupBtn}
onClick={() => handleRemoveGroup(group.id)}
>
<DeleteOutlined />
</Button>
)}
</div>
{/* 成员选择区域 */}
<div className={style.membersSection}>
<div className={style.membersLabel}>
({group.members?.length || 0})
</div>
<div className={style.membersList}>
{group.members?.map(member => (
<div key={member.id} className={style.memberItem}>
<Avatar src={member.avatar} className={style.memberAvatar} />
<div className={style.memberName}>{member.nickname}</div>
{!readonly && (
<Button
fill="none"
size="small"
className={style.removeMemberBtn}
onClick={() => handleRemoveMember(group.id, member.id)}
>
<DeleteOutlined />
</Button>
)}
</div>
))}
{!readonly && (
<div
className={style.addMemberBtn}
onClick={() => handleOpenMemberSelection(group.id)}
>
<PlusOutlined />
<span></span>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* 群选择弹窗 */}
<GroupSelection
selectedOptions={selectedGroups as GroupSelectionItem[]}
onSelect={handleGroupSelect}
placeholder={placeholder}
visible={groupSelectionVisible}
onVisibleChange={setGroupSelectionVisible}
showInput={false}
showSelectedList={false}
/>
{/* 成员选择弹窗 */}
<Popup
visible={memberSelectionVisible.visible}
onMaskClick={handleCloseMemberSelection}
position="bottom"
bodyStyle={{
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<div className={style.memberSelectionPopup}>
<div className={style.popupHeader}>
<div className={style.popupTitle}></div>
<Button
fill="none"
size="small"
onClick={handleCloseMemberSelection}
className={style.closeBtn}
>
</Button>
</div>
<div className={style.searchBox}>
<div className={style.searchInputWrapper}>
<Input
placeholder="搜索成员昵称或微信号"
value={memberSearchKeyword}
onChange={val => setMemberSearchKeyword(val)}
onEnterPress={handleSearchMembers}
className={style.searchInput}
/>
{memberSearchKeyword && (
<Button
fill="none"
size="small"
className={style.clearSearchBtn}
onClick={handleClearSearch}
>
<CloseOutlined />
</Button>
)}
</div>
<Button
color="primary"
size="small"
onClick={handleSearchMembers}
loading={loadingMembers}
className={style.searchBtn}
>
</Button>
</div>
<div className={style.memberList}>
{loadingMembers ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : currentGroupMembers.length > 0 ? (
currentGroupMembers.map(member => {
const isSelected = currentSelectedMembers.some(m => m.id === member.id);
return (
<div
key={member.id}
className={`${style.memberListItem} ${isSelected ? style.selected : ""}`}
onClick={() => handleSelectMember(memberSelectionVisible.groupId, member)}
>
<Avatar src={member.avatar} className={style.memberListItemAvatar} />
<div className={style.memberListItemName}>{member.nickname}</div>
{isSelected && <div className={style.checkmark}></div>}
</div>
);
})
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}></div>
</div>
)}
</div>
</div>
</Popup>
</div>
);
};
export default GroupSelectionWithMembers;

View File

@@ -0,0 +1,87 @@
.listContainer {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.listItem {
flex-shrink: 0;
width: 100%;
}
.loadMoreButtonContainer {
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
flex-shrink: 0;
}
.noMoreText {
text-align: center;
color: #999;
font-size: 14px;
padding: 16px;
flex-shrink: 0;
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #999;
flex: 1;
min-height: 200px;
}
.emptyIcon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.emptyText {
font-size: 14px;
color: #999;
}
.pullToRefresh {
height: 100%;
overflow: auto;
}
// 自定义滚动条样式
.listContainer::-webkit-scrollbar {
width: 4px;
}
.listContainer::-webkit-scrollbar-track {
background: transparent;
}
.listContainer::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.listContainer::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
// 响应式设计
@media (max-width: 768px) {
.listContainer {
padding: 0 8px;
}
.loadMoreButtonContainer {
padding: 12px;
}
.noMoreText {
padding: 12px;
}
}

View File

@@ -0,0 +1,195 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
PullToRefresh,
InfiniteScroll,
Button,
SpinLoading,
} from "antd-mobile";
import styles from "./InfiniteList.module.scss";
interface InfiniteListProps<T> {
// 数据相关
data: T[];
loading?: boolean;
hasMore?: boolean;
loadingText?: string;
noMoreText?: string;
// 渲染相关
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string | number;
// 事件回调
onLoadMore?: () => Promise<void> | void;
onRefresh?: () => Promise<void> | void;
// 样式相关
className?: string;
itemClassName?: string;
containerStyle?: React.CSSProperties;
// 功能开关
enablePullToRefresh?: boolean;
enableInfiniteScroll?: boolean;
enableLoadMoreButton?: boolean;
// 自定义高度
height?: string | number;
minHeight?: string | number;
}
const InfiniteList = <T extends any>({
data,
loading = false,
hasMore = true,
loadingText = "加载中...",
noMoreText = "没有更多了",
renderItem,
keyExtractor = (_, index) => index,
onLoadMore,
onRefresh,
className = "",
itemClassName = "",
containerStyle = {},
enablePullToRefresh = true,
enableInfiniteScroll = true,
enableLoadMoreButton = false,
height = "100%",
minHeight = "200px",
}: InfiniteListProps<T>) => {
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// 处理下拉刷新
const handleRefresh = useCallback(async () => {
if (!onRefresh) return;
setRefreshing(true);
try {
await onRefresh();
} catch (error) {
console.error("Refresh failed:", error);
} finally {
setRefreshing(false);
}
}, [onRefresh]);
// 处理加载更多
const handleLoadMore = useCallback(async () => {
if (!onLoadMore || loadingMore || !hasMore) return;
setLoadingMore(true);
try {
await onLoadMore();
} catch (error) {
console.error("Load more failed:", error);
} finally {
setLoadingMore(false);
}
}, [onLoadMore, loadingMore, hasMore]);
// 点击加载更多按钮
const handleLoadMoreClick = useCallback(() => {
handleLoadMore();
}, [handleLoadMore]);
// 容器样式
const containerStyles: React.CSSProperties = {
height,
minHeight,
...containerStyle,
};
// 渲染列表项
const renderListItems = () => {
return data.map((item, index) => (
<div
key={keyExtractor(item, index)}
className={`${styles.listItem} ${itemClassName}`}
>
{renderItem(item, index)}
</div>
));
};
// 渲染加载更多按钮
const renderLoadMoreButton = () => {
if (!enableLoadMoreButton || !hasMore) return null;
return (
<div className={styles.loadMoreButtonContainer}>
<Button
size="small"
loading={loadingMore}
onClick={handleLoadMoreClick}
disabled={loading || !hasMore}
>
{loadingMore ? loadingText : "点击加载更多"}
</Button>
</div>
);
};
// 渲染无更多数据提示
const renderNoMoreText = () => {
if (hasMore || data.length === 0) return null;
return <div className={styles.noMoreText}>{noMoreText}</div>;
};
// 渲染空状态
const renderEmptyState = () => {
if (data.length > 0 || loading) return null;
return (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>📝</div>
<div className={styles.emptyText}></div>
</div>
);
};
const content = (
<div
className={`${styles.listContainer} ${className}`}
style={containerStyles}
>
{renderListItems()}
{renderLoadMoreButton()}
{renderNoMoreText()}
{renderEmptyState()}
{/* 无限滚动组件 */}
{enableInfiniteScroll && (
<InfiniteScroll
loadMore={handleLoadMore}
hasMore={hasMore}
threshold={100}
/>
)}
</div>
);
// 如果启用下拉刷新包装PullToRefresh
if (enablePullToRefresh && onRefresh) {
return (
<PullToRefresh
onRefresh={handleRefresh}
refreshing={refreshing}
className={styles.pullToRefresh}
>
{content}
</PullToRefresh>
);
}
return content;
};
export default InfiniteList;

View File

@@ -0,0 +1,52 @@
import React, { useEffect } from "react";
import { SpinLoading } from "antd-mobile";
import styles from "./layout.module.scss";
interface LayoutProps {
loading?: boolean;
children?: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({
children,
header,
footer,
loading = false,
}) => {
// 移动端100vh兼容
useEffect(() => {
const setRealHeight = () => {
document.documentElement.style.setProperty(
"--real-vh",
`${window.innerHeight * 0.01}px`,
);
};
setRealHeight();
window.addEventListener("resize", setRealHeight);
return () => window.removeEventListener("resize", setRealHeight);
}, []);
return (
<div
className={styles.container}
style={{ height: "calc(var(--real-vh, 1vh) * 100)" }}
>
{header && <header>{header}</header>}
<main>
{loading ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
<div className={styles.loadingText}>...</div>
</div>
) : (
children
)}
</main>
{footer && <footer>{footer}</footer>}
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,48 @@
import React from "react";
import { SpinLoading } from "antd-mobile";
import styles from "./layout.module.scss";
interface LayoutProps {
loading?: boolean;
children?: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
}
const LayoutFiexd: React.FC<LayoutProps> = ({
header,
children,
footer,
loading = false,
}) => {
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
<div className="header">{header}</div>
<div
className="content"
style={{
flex: 1,
overflow: "auto",
}}
>
{loading ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
<div className={styles.loadingText}>...</div>
</div>
) : (
children
)}
</div>
<div className="footer">{footer}</div>
</div>
);
};
export default LayoutFiexd;

View File

@@ -0,0 +1,28 @@
.container {
display: flex;
height: 100vh;
flex-direction: column;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.container main {
flex: 1;
overflow: auto;
}
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
background: rgba(255, 255, 255, 0.8);
}
.loadingText {
margin-top: 16px;
color: #666;
font-size: 14px;
text-align: center;
}

View File

@@ -0,0 +1,53 @@
import React from "react";
import ReactECharts from "echarts-for-react";
interface LineChartProps {
title?: string;
xData: string[];
yData: number[];
height?: number | string;
}
const LineChart: React.FC<LineChartProps> = ({
title = "",
xData,
yData,
height = 200,
}) => {
const option = {
title: {
text: title,
left: "center",
textStyle: { fontSize: 16 },
},
tooltip: { trigger: "axis" },
xAxis: {
type: "category",
data: xData,
boundaryGap: false,
},
yAxis: {
type: "value",
boundaryGap: ["10%", "10%"], // 上下留白
min: (value: any) => value.min - 10, // 下方多留一点空间
max: (value: any) => value.max + 10, // 上方多留一点空间
minInterval: 1,
axisLabel: { margin: 12 },
},
series: [
{
data: yData,
type: "line",
smooth: true,
symbol: "circle",
lineStyle: { color: "#1677ff" },
itemStyle: { color: "#1677ff" },
},
],
grid: { left: 40, right: 24, top: 40, bottom: 32 },
};
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
};
export default LineChart;

View File

@@ -0,0 +1,57 @@
import React from "react";
import ReactECharts from "echarts-for-react";
import { getChartColor } from "@/utils/chartColors";
interface LineChartProps {
title?: string;
xData: string[];
yData: any[];
height?: number | string;
}
const LineChart: React.FC<LineChartProps> = ({
title = "",
xData,
yData,
height = 200,
}) => {
const option = {
title: {
text: title,
left: "center",
textStyle: { fontSize: 16 },
},
tooltip: { trigger: "axis" },
xAxis: {
type: "category",
data: xData,
boundaryGap: false,
},
yAxis: {
type: "value",
boundaryGap: ["10%", "10%"], // 上下留白
min: (value: any) => value.min - 10, // 下方多留一点空间
max: (value: any) => value.max + 10, // 上方多留一点空间
minInterval: 1,
axisLabel: { margin: 12 },
},
series: [
...yData.map((item, index) => {
const color = getChartColor(index);
return {
data: item,
type: "line",
smooth: true,
symbol: "circle",
lineStyle: { color },
itemStyle: { color },
};
}),
],
grid: { left: 40, right: 24, top: 40, bottom: 32 },
};
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
};
export default LineChart;

View File

@@ -0,0 +1,57 @@
import React from "react";
import { TabBar } from "antd-mobile";
import { PieOutline, UserOutline } from "antd-mobile-icons";
import { HomeOutlined, TeamOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
const tabs = [
{
key: "home",
title: "首页",
icon: <HomeOutlined />,
path: "/",
},
{
key: "scenarios",
title: "场景获客",
icon: <TeamOutlined />,
path: "/scenarios",
},
{
key: "workspace",
title: "工作台",
icon: <PieOutline />,
path: "/workspace",
},
{
key: "mine",
title: "我的",
icon: <UserOutline />,
path: "/mine",
},
];
interface MeauMobileProps {
activeKey: string;
}
const MeauMobile: React.FC<MeauMobileProps> = ({ activeKey }) => {
const navigate = useNavigate();
return (
<TabBar
style={{ background: "#fff" }}
activeKey={activeKey}
onChange={key => {
const tab = tabs.find(t => t.key === key);
if (tab && tab.path) navigate(tab.path);
}}
>
{tabs.map(item => (
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
))}
</TabBar>
);
};
export default MeauMobile;

View File

@@ -0,0 +1,154 @@
.twoColumnModal {
.ant-modal-body {
padding: 0;
}
}
.container {
display: flex;
height: 500px;
border: 1px solid #e8e8e8;
}
.leftColumn {
flex: 1;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
}
.rightColumn {
width: 300px;
display: flex;
flex-direction: column;
background: #fafafa;
}
.searchWrapper {
padding: 16px;
border-bottom: 1px solid #e8e8e8;
.ant-input {
border-radius: 6px;
}
}
.memberList {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.memberItem {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #f5f5f5;
}
&.selected {
background-color: #e6f7ff;
}
.ant-checkbox {
margin-right: 12px;
}
}
.memberInfo {
margin-left: 12px;
flex: 1;
}
.memberName {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 2px;
}
.memberId {
font-size: 12px;
color: #999;
}
.selectedHeader {
padding: 16px;
border-bottom: 1px solid #e8e8e8;
font-weight: 500;
color: #333;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
}
.singleTip {
font-size: 12px;
color: #999;
font-weight: normal;
}
.selectedList {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.selectedItem {
display: flex;
align-items: center;
padding: 8px 16px;
background: #fff;
margin: 4px 8px;
border-radius: 6px;
border: 1px solid #e8e8e8;
}
.selectedInfo {
margin-left: 8px;
flex: 1;
}
.selectedName {
font-size: 13px;
color: #333;
}
.removeBtn {
color: #999;
font-size: 16px;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: #ff4d4f;
background: #fff2f0;
}
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: #999;
font-size: 14px;
}
.emptySelected {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: #999;
font-size: 14px;
}

View File

@@ -0,0 +1,185 @@
import React, { useState } from 'react';
import { Modal, Input, Avatar, Button, Checkbox } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import styles from './TwoColumnMemberSelection.module.scss';
interface Member {
id: string;
nickname: string;
avatar: string;
}
interface TwoColumnMemberSelectionProps {
visible: boolean;
members: Member[];
onCancel: () => void;
onConfirm: (selectedIds: string[]) => void;
title?: string;
allowMultiple?: boolean;
}
const TwoColumnMemberSelection: React.FC<TwoColumnMemberSelectionProps> = ({
visible,
members,
onCancel,
onConfirm,
title = '选择成员',
allowMultiple = true,
}) => {
const [selectedMembers, setSelectedMembers] = useState<Member[]>([]);
const [searchQuery, setSearchQuery] = useState('');
// 过滤成员
const filteredMembers = members.filter(member =>
member.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
member.id.toLowerCase().includes(searchQuery.toLowerCase())
);
// 处理搜索
const handleSearch = (value: string) => {
setSearchQuery(value);
};
// 选择成员
const handleSelectMember = (member: Member) => {
const isSelected = selectedMembers.some(m => m.id === member.id);
if (allowMultiple) {
if (isSelected) {
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
} else {
setSelectedMembers([...selectedMembers, member]);
}
} else {
// 单选模式
if (isSelected) {
setSelectedMembers([]);
} else {
setSelectedMembers([member]);
}
}
};
// 移除已选成员
const handleRemoveMember = (memberId: string) => {
setSelectedMembers(selectedMembers.filter(m => m.id !== memberId));
};
// 确认选择
const handleConfirmSelection = () => {
const selectedIds = selectedMembers.map(m => m.id);
onConfirm(selectedIds);
setSelectedMembers([]);
setSearchQuery('');
};
// 取消选择
const handleCancelSelection = () => {
setSelectedMembers([]);
setSearchQuery('');
onCancel();
};
return (
<Modal
title={title}
open={visible}
onCancel={handleCancelSelection}
width={800}
footer={[
<Button key="cancel" onClick={handleCancelSelection}>
</Button>,
<Button
key="confirm"
type="primary"
onClick={handleConfirmSelection}
disabled={selectedMembers.length === 0}
>
</Button>,
]}
className={styles.twoColumnModal}
>
<div className={styles.container}>
{/* 左侧:成员列表 */}
<div className={styles.leftColumn}>
<div className={styles.searchWrapper}>
<Input
placeholder="请输入昵称或微信号"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
prefix={<SearchOutlined />}
allowClear
/>
</div>
<div className={styles.memberList}>
{filteredMembers.length > 0 ? (
filteredMembers.map(member => {
const isSelected = selectedMembers.some(m => m.id === member.id);
return (
<div
key={member.id}
className={`${styles.memberItem} ${isSelected ? styles.selected : ''}`}
onClick={() => handleSelectMember(member)}
>
<Checkbox checked={isSelected} />
<Avatar src={member.avatar} size={40}>
{member.nickname?.charAt(0)}
</Avatar>
<div className={styles.memberInfo}>
<div className={styles.memberName}>{member.nickname}</div>
<div className={styles.memberId}>{member.id}</div>
</div>
</div>
);
})
) : (
<div className={styles.empty}>
{searchQuery ? `没有找到包含"${searchQuery}"的成员` : '暂无成员'}
</div>
)}
</div>
</div>
{/* 右侧:已选成员 */}
<div className={styles.rightColumn}>
<div className={styles.selectedHeader}>
({selectedMembers.length})
{!allowMultiple && <span className={styles.singleTip}></span>}
</div>
<div className={styles.selectedList}>
{selectedMembers.length > 0 ? (
selectedMembers.map(member => (
<div key={member.id} className={styles.selectedItem}>
<Avatar src={member.avatar} size={32}>
{member.nickname?.charAt(0)}
</Avatar>
<div className={styles.selectedInfo}>
<div className={styles.selectedName}>{member.nickname}</div>
</div>
<Button
type="text"
size="small"
onClick={() => handleRemoveMember(member.id)}
className={styles.removeBtn}
>
×
</Button>
</div>
))
) : (
<div className={styles.emptySelected}>
</div>
)}
</div>
</div>
</div>
</Modal>
);
};
export default TwoColumnMemberSelection;

View File

@@ -0,0 +1,51 @@
import React, { useState } from 'react';
import { Modal, Checkbox, Avatar, List, Button } from 'antd';
interface MemberSelectionProps {
visible: boolean;
members: { id: string; nickname: string; avatar: string }[];
onCancel: () => void;
onConfirm: (selectedIds: string[]) => void;
}
const MemberSelection: React.FC<MemberSelectionProps> = ({ visible, members, onCancel, onConfirm }) => {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const handleToggle = (id: string) => {
const newSelectedIds = selectedIds.includes(id)
? selectedIds.filter(memberId => memberId !== id)
: [...selectedIds, id];
setSelectedIds(newSelectedIds);
};
const handleConfirm = () => {
onConfirm(selectedIds);
setSelectedIds([]);
};
return (
<Modal
title="选择要删除的成员"
visible={visible}
onCancel={onCancel}
onOk={handleConfirm}
okText="删除"
cancelText="取消"
>
<List
dataSource={members}
renderItem={member => (
<List.Item key={member.id} onClick={() => handleToggle(member.id)} style={{ cursor: 'pointer' }}>
<List.Item.Meta
avatar={<Avatar src={member.avatar} />}
title={member.nickname}
/>
<Checkbox checked={selectedIds.includes(member.id)} />
</List.Item>
)}
/>
</Modal>
);
};
export default MemberSelection;

View File

@@ -0,0 +1,62 @@
import React, { useEffect, useState } from "react";
import { NavBar } from "antd-mobile";
import { ArrowLeftOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { getSafeAreaHeight } from "@/utils/common";
interface NavCommonProps {
title: string | React.ReactNode;
backFn?: () => void;
right?: React.ReactNode;
left?: React.ReactNode;
}
const NavCommon: React.FC<NavCommonProps> = ({
title,
backFn,
right,
left,
}) => {
const navigate = useNavigate();
const [paddingTop, setPaddingTop] = useState("0px");
useEffect(() => {
setPaddingTop(getSafeAreaHeight() + "px");
}, []);
return (
<div
style={{
paddingTop: paddingTop,
background: "#fff",
}}
>
<NavBar
back={null}
left={
left ? (
left
) : (
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => {
if (backFn) {
backFn();
} else {
navigate(-1);
}
}}
/>
</div>
)
}
right={right}
>
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
{title}
</span>
</NavBar>
</div>
);
};
export default NavCommon;

View File

@@ -0,0 +1,52 @@
import React from "react";
import { NavBar, Button } from "antd-mobile";
import { PlusOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
interface PlaceholderPageProps {
title: string;
showBack?: boolean;
showAddButton?: boolean;
addButtonText?: string;
}
const PlaceholderPage: React.FC<PlaceholderPageProps> = ({
title,
showBack = true,
showAddButton = false,
addButtonText = "新建",
}) => {
return (
<Layout
header={
<NavBar
back={showBack}
style={{ background: "#fff" }}
onBack={showBack ? () => window.history.back() : undefined}
left={
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
{title}
</div>
}
right={
showAddButton ? (
<Button size="small" color="primary">
<PlusOutlined />
<span style={{ marginLeft: 4, fontSize: 12 }}>
{addButtonText}
</span>
</Button>
) : undefined
}
/>
}
>
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
<h3>{title}</h3>
<p>...</p>
</div>
</Layout>
);
};
export default PlaceholderPage;

View File

@@ -0,0 +1,34 @@
import request from "@/api/request";
// 请求参数接口
export interface Request {
keyword: string;
/**
* 条数
*/
limit: string;
/**
* 分页
*/
page: string;
[property: string]: any;
}
// 获取流量池包列表
export function getPoolPackages(params: Request) {
return request("/v1/traffic/pool/getPackage", params, "GET");
}
// 保留原接口以兼容现有代码
export function getPoolList(params: {
page?: string;
pageSize?: string;
keyword?: string;
addStatus?: string;
deviceId?: string;
packageId?: string;
userValue?: string;
[property: string]: any;
}) {
return request("/v1/traffic/pool", params, "GET");
}

View File

@@ -0,0 +1,61 @@
// 流量池包接口类型
export interface PoolPackageItem {
id: number;
name: string;
description: string;
createTime: string;
num: number;
}
// 原流量池接口类型(保留以兼容现有代码)
export interface PoolItem {
id: number;
identifier: string;
mobile: string;
wechatId: string;
fromd: string;
status: number;
createTime: string;
companyId: number;
sourceId: string;
type: number;
nickname: string;
avatar: string;
gender: number;
phone: string;
alias: string;
packages: any[];
tags: any[];
}
export interface PoolSelectionItem {
id: string;
avatar?: string;
name: string;
wechatId?: string;
mobile?: string;
nickname?: string;
createTime?: string;
description?: string;
num?: number;
[key: string]: any;
}
// 组件属性接口
export interface PoolSelectionProps {
selectedOptions: PoolSelectionItem[];
onSelect: (Pools: PoolSelectionItem[]) => void;
onSelectDetail?: (Pools: PoolPackageItem[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: PoolSelectionItem[],
) => void;
}

View File

@@ -0,0 +1,206 @@
.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;
}
.selectedListRow {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.selectedListRowContent {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.selectedListRowContentText {
flex: 1;
}
.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;
}
.groupList {
flex: 1;
overflow-y: auto;
}
.groupListInner {
border-top: 1px solid #f0f0f0;
}
.groupItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.groupInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.groupAvatar {
width: 40px;
height: 40px;
border-radius: 8px;
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;
}
.groupDetail {
flex: 1;
}
.groupName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.groupId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.groupOwner {
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;
}

View File

@@ -0,0 +1,127 @@
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 { PoolSelectionProps } from "./data";
export default function PoolSelection({
selectedOptions,
onSelect,
onSelectDetail,
placeholder = "选择流量池",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: PoolSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 删除已选流量池项
const handleRemoveItem = (id: string) => {
if (readonly) return;
onSelect(selectedOptions.filter(item => item.id !== id));
};
// 受控弹窗逻辑
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} 个流量池项`;
};
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(item => (
<div key={item.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<div className={style.groupAvatar}>
{(item.nickname || item.name || "").charAt(0)}
</div>
<div className={style.selectedListRowContentText}>
<div>{item.nickname || item.name}</div>
<div>{item.wechatId || item.mobile}</div>
</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={() => handleRemoveItem(item.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
onSelectDetail={onSelectDetail}
readonly={readonly}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,258 @@
import React, { useState, useEffect } from "react";
import { Popup, Checkbox } from "antd-mobile";
import { getPoolPackages, Request } 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 { PoolSelectionItem, PoolPackageItem } from "./data";
// 弹窗属性接口
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: PoolSelectionItem[];
onSelect: (items: PoolSelectionItem[]) => void;
onSelectDetail?: (items: PoolPackageItem[]) => void;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: PoolSelectionItem[],
) => void;
}
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
onSelectDetail,
readonly = false,
onConfirm,
}: SelectionPopupProps) {
const [poolPackages, setPoolPackages] = useState<PoolPackageItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [loading, setLoading] = useState(false);
const [tempSelectedOptions, setTempSelectedOptions] = useState<
PoolSelectionItem[]
>([]);
// 获取流量池包列表API
const fetchPoolPackages = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: Request = {
page: String(page),
limit: "20",
keyword: keyword.trim(),
};
const response = await getPoolPackages(params);
if (response && response.list) {
setPoolPackages(response.list);
setTotalItems(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取流量池包列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理流量池包选择
const handlePackageToggle = (item: PoolPackageItem) => {
if (readonly) return;
// 将PoolPackageItem转换为GroupSelectionItem格式
const selectionItem: PoolSelectionItem = {
id: String(item.id),
name: item.name,
description: item.description,
createTime: item.createTime,
num: item.num,
// 保留原始数据
originalData: item,
};
const newSelectedItems = tempSelectedOptions.some(
g => g.id === String(item.id),
)
? tempSelectedOptions.filter(g => g.id !== String(item.id))
: tempSelectedOptions.concat(selectionItem);
setTempSelectedOptions(newSelectedItems);
// 如果有 onSelectDetail 回调,传递完整的流量池包对象
if (onSelectDetail) {
const selectedItemObjs = poolPackages.filter(packageItem =>
newSelectedItems.some(g => g.id === String(packageItem.id)),
);
onSelectDetail(selectedItemObjs);
}
};
// 全选当前页
const handleSelectAllCurrentPage = (checked: boolean) => {
if (readonly) return;
if (checked) {
// 全选:添加当前页面所有未选中的流量池包
const currentPagePackages = poolPackages.filter(
packageItem =>
!tempSelectedOptions.some(p => p.id === String(packageItem.id)),
);
const newSelectionItems = currentPagePackages.map(item => ({
id: String(item.id),
name: item.name,
description: item.description,
createTime: item.createTime,
num: item.num,
originalData: item,
}));
setTempSelectedOptions(prev => [...prev, ...newSelectionItems]);
} else {
// 取消全选:移除当前页面的所有流量池包
const currentPagePackageIds = poolPackages.map(p => String(p.id));
setTempSelectedOptions(prev =>
prev.filter(p => !currentPagePackageIds.includes(p.id)),
);
}
};
// 检查当前页是否全选
const isCurrentPageAllSelected =
poolPackages.length > 0 &&
poolPackages.every(packageItem =>
tempSelectedOptions.some(p => p.id === String(packageItem.id)),
);
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(
tempSelectedOptions.map(item => item.id),
tempSelectedOptions,
);
}
// 更新实际选中的选项
onSelect(tempSelectedOptions);
onVisibleChange(false);
};
// 弹窗打开时初始化数据(只执行一次)
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchPoolPackages(1, "");
}
}, [visible, selectedOptions]);
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchPoolPackages(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible) return;
fetchPoolPackages(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
return (
<Popup
visible={visible}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择流量池包"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索流量池包"
loading={loading}
onRefresh={() => fetchPoolPackages(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.groupList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : poolPackages.length > 0 ? (
<div className={style.groupListInner}>
{poolPackages.map(item => (
<div key={item.id} className={style.groupItem}>
<Checkbox
checked={tempSelectedOptions.some(
g => g.id === String(item.id),
)}
onChange={() => !readonly && handlePackageToggle(item)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
<div className={style.groupInfo}>
<div className={style.groupAvatar}>
{item.name ? item.name.charAt(0) : "?"}
</div>
<div className={style.groupDetail}>
<div className={style.groupName}>{item.name}</div>
<div className={style.groupId}>
: {item.description || "无描述"}
</div>
<div className={style.groupOwner}>
: {item.createTime}
</div>
<div className={style.groupOwner}>
: {item.num}
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的流量池包`
: "没有找到流量池包"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -0,0 +1,88 @@
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #666;
display: flex;
align-items: center;
gap: 12px;
}
.selectAllCheckbox {
margin-right: 0;
.ant-checkbox-wrapper {
font-size: 14px;
}
&.ant-checkbox-wrapper-disabled {
.ant-checkbox-disabled + span {
color: #d9d9d9;
}
}
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.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;
border: 1px solid #d9d9d9;
color: #333;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
border-color: #1677ff;
color: #1677ff;
}
&:disabled {
background: #f5f5f5;
color: #ccc;
cursor: not-allowed;
}
}
.pageInfo {
font-size: 14px;
color: #222;
margin: 0 8px;
min-width: 60px;
text-align: center;
}

View File

@@ -0,0 +1,88 @@
import React from "react";
import { Button, Checkbox } from "antd";
import style from "./footer.module.scss";
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
interface PopupFooterProps {
currentPage: number;
totalPages: number;
loading: boolean;
selectedCount: number;
onPageChange: (page: number) => void;
onCancel: () => void;
onConfirm: () => void;
// 全选功能相关
isAllSelected?: boolean;
onSelectAll?: (checked: boolean) => void;
singleSelect?: boolean;
}
const PopupFooter: React.FC<PopupFooterProps> = ({
currentPage,
totalPages,
loading,
selectedCount,
onPageChange,
onCancel,
onConfirm,
isAllSelected = false,
onSelectAll,
singleSelect = false,
}) => {
return (
<>
{/* 分页栏 */}
<div className={style.paginationRow}>
{onSelectAll && (
<div className={style.totalCount}>
<Checkbox
checked={isAllSelected}
onChange={e => onSelectAll(e.target.checked)}
className={style.selectAllCheckbox}
>
</Checkbox>
</div>
)}
<div className={style.paginationControls}>
<Button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
>
<ArrowLeftOutlined />
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
>
<ArrowRightOutlined />
</Button>
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{singleSelect
? selectedCount > 0
? "已选择设备"
: "未选择设备"
: `已选择 ${selectedCount} 条记录`}
</div>
<div className={style.footerBtnGroup}>
<Button color="primary" variant="filled" onClick={onCancel}>
</Button>
<Button type="primary" onClick={onConfirm}>
</Button>
</div>
</div>
</>
);
};
export default PopupFooter;

View File

@@ -0,0 +1,51 @@
.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: 5px;
padding: 16px;
}
.popupSearchInputWrap {
position: relative;
flex: 1;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
z-index: 10;
font-size: 18px;
}
.refreshBtn {
width: 36px;
height: 36px;
}
.loadingIcon {
animation: spin 1s linear infinite;
font-size: 16px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

Some files were not shown because too many files have changed in this diff Show More