first commit

This commit is contained in:
Your Name
2026-09-08 11:40:15 +08:00
commit a5353f7eb5
9568 changed files with 1646214 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Build and Release Folders
bin-debug/
bin-release/
[Oo]bj/
[Bb]in/
# Other files and folders
.settings/
# Executables
*.swf
*.air
*.ipa
*.apk
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important
# information for Eclipse / Flash Builder.
/.idea
/.codex-tasks
/.trellis
/.claude
/.agent
/.shared
/.cursor
/.codex
/.agents
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
/.codegraph
+57
View File
@@ -0,0 +1,57 @@
<!-- TRELLIS:START -->
# Trellis Instructions
These instructions are for AI assistants working in this project.
This project is managed by Trellis. The working knowledge you need lives under `.trellis/`:
- `.trellis/workflow.md` — development phases, when to create tasks, skill routing
- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer)
- `.trellis/workspace/` — per-developer journals and session traces
- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context)
If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command.
If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in:
- `.agents/skills/` — reusable Trellis skills
- `.codex/agents/` — optional custom subagents
## Subagents
- ALWAYS wait for every spawned subagent to reach a terminal status before yielding, acting on partial results, or spawning followups.
- On Codex, this means calling the `wait` tool with the subagent's thread id (requires `multi_agent_v2`). Do NOT infer completion from elapsed time.
- On Claude Code / OpenCode, this means awaiting the Task/agent tool result before continuing.
- NEVER cancel or re-spawn a subagent that hasn't finished. If a subagent appears stuck, raise the wait timeout (Codex default 30s, max 1h) before judging it broken.
- Spawn subagents automatically when:
- Parallelizable work (e.g., install + verify, npm test + typecheck, multiple tasks from plan)
- Long-running or blocking tasks where a worker can run independently
- Isolation for risky changes or checks
### Codex-only — `spawn_agent` parameters
When calling `spawn_agent`, ALWAYS pass `fork_turns="none"`. Without it the child inherits the parent transcript and sees your prior `spawn_agent(...)` records, then applies the "wait for spawned subagents" rule to itself — causing `wait_agent` self-deadlock.
```text
spawn_agent(agent_type="trellis-implement", message="...", fork_turns="none")
```
### Codex-only — multi-subagent close-loop
When `wait` returns a `completed` notification, treat it as an event signal — not as "all done". Run this loop:
1. Maintain an `expected_agents` set of dispatched sub-agent thread IDs.
2. After each `wait` update:
1. Call `list_agents` to inspect ALL live agents' status.
2. For each agent now in a terminal state:
- Verify its promised deliverable exists (e.g. `{task_dir}/research/*.md`).
- Read or summarize as needed.
- `close_agent` to release the slot.
- Remove from `expected_agents`.
3. If `expected_agents` still contains running agents → keep waiting.
4. If `expected_agents` is empty → continue main flow.
3. Never `wait` on an agent that has already reported `completed`.
4. If a `completed` agent is missing its deliverable, treat it as failed — surface that in your report instead of re-waiting.
Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`.
<!-- TRELLIS:END -->
+137
View File
@@ -0,0 +1,137 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Multi-client SaaS platform (基于 likeadmin 脚手架改造) centered on 中医在线问诊 — video consultations (TRTC), prescriptions, medicine library, 企微 (QYWX) integration, and payments. Single repo contains a PHP backend plus four independent frontend clients.
## Top-Level Layout
| Dir | Stack | Purpose |
|-----|-------|---------|
| `server/` | ThinkPHP 8 + PHP 8.0+ | REST API, the single source of truth |
| `admin/` | Vue 3 + Vite + Element Plus + Tailwind | 管理后台 (desktop) |
| `pc/` | Nuxt 3 + Element Plus | 公开 PC 站 (SSR/SSG) |
| `uniapp/` | uni-app (Vue 3, Vite) | H5 / 小程序 / App 多端客户端 |
| `wx/` | uni-app 微信小程序 | 独立 TRTC 微信小程序壳 |
| `TUICallKit*/` | Tencent TRTC UIKit | Vendor SDK source used by clients |
| `docker/` | docker-compose (nginx + php-fpm + mysql) | Local dev stack |
| `doc/`, `ps/` | Supporting docs / psd | Design & reference material |
Each frontend is deployed to `server/public/<client>/` (see `server/route/app.php` fallback routes). Dont rely on a root-level workspace — each client has its own `package.json` and must be built separately.
## Common Commands
### Backend (`server/`)
```
composer install # install deps
php think run --host 0.0.0.0 --port 8080 # dev server (if PHP CLI)
php think <command> # run CLI commands (see app/command/)
php think crontab # trigger cron tasks manually
```
- Docker alternative: `cd docker && docker-compose up -d` (exposes nginx on :80, mysql on :3306).
- SQL migrations live in `server/sql/<version>/*.sql` (versioned by date). Apply manually in order; there is no automatic migration runner. Current version directory: `server/sql/1.9.20260420/`.
### Admin (`admin/`)
```
npm install
npm run dev # Vite dev server
npm run build # production build + node scripts/release.mjs
npm run type-check # vue-tsc --noEmit
npm run lint # ESLint with --fix
npm run clean # node scripts/clean.mjs
```
### PC (`pc/`)
```
npm install
npm run dev # Nuxt dev server on :3000
npm run build # nuxt generate + scripts/build.mjs (static export)
npm run build:ssr # Nuxt SSR build
```
### uniapp (`uniapp/`)
```
npm install
npm run dev:h5 # H5
npm run dev:mp-weixin # 微信小程序
npm run dev:app # App
npm run build:h5 # build + scripts/release.mjs
# other targets: mp-alipay, mp-baidu, mp-qq, mp-toutiao, quickapp-webview...
npm run lint
```
### Running a single test
There is no unit-test harness checked in (no Jest/Vitest/PHPUnit config). When adding tests, wire the framework first; dont assume one exists.
## Backend Architecture (likeadmin conventions)
`server/app/` is a ThinkPHP multi-app project. Three apps with a shared `common/` layer:
- `adminapi/` — admin panel API (consumed by `admin/`)
- `api/` — public/end-user API (consumed by `pc/`, `uniapp/`, `wx/`)
- `index/` — default app fallback
- `common/` — shared models, services, enums, exceptions, listeners
Each app follows the layered pattern:
```
<app>/
├── controller/ # HTTP entry; extends BaseAdminController / BaseApiController
├── logic/ # Business logic; static methods invoked by controllers
├── lists/ # List-query objects (pagination/filter/export encapsulation)
├── validate/ # Request validation rules
├── service/ # App-scoped services
├── http/ # Middleware, route groups
└── config/ # App-specific config
```
Key conventions:
- **Controllers stay thin.** They pull `$this->request->get()/post()`, delegate to a `Logic` class, and wrap the result with `$this->data(...)` / `$this->success(...)` / `$this->fail(...)` from `BaseLikeAdminController` (in `common/controller`).
- **Logic returns plain arrays.** Throw `\app\common\exception\*` for failures — the global exception handler (`app/ExceptionHandle.php`) converts them to JSON envelopes.
- **Models extend `app\common\model\BaseModel`** which auto-completes image paths via `FileService`.
- **Route registration is menu-driven.** Admin routes are auto-resolved from menu entries (see `add_conversion_stats_menu.sql` as an example of wiring a new admin page). Public routes are declared in `app/api/route/` and `server/route/app.php`.
- **Payment / third-party callbacks** bypass auth — declared explicitly in `server/route/app.php` (e.g. wechat-notify, alipay-notify, TRTC recording-notify).
## Admin Architecture (`admin/src/`)
```
src/
├── api/ # One file per domain (stats.ts, order.ts, doctor.ts...)
├── views/ # Page components grouped by domain (mirrors api/)
├── router/
│ ├── index.ts # Dynamic route generation from backend menu
│ └── routes.ts # Static/constant routes + LAYOUT wrapper
├── stores/modules/ # Pinia stores (user, app, tagsView...)
├── components/ # Shared components
├── utils/request/ # Axios instance + interceptors
├── hooks/ # Composables
├── install/ # Global plugin registration (app.use(install))
└── permission.ts # Login/permission guard mounted on router
```
- **Views are dynamically loaded** via `import.meta.glob('/src/views/**/*.vue')` in `router/index.ts`. A view path from the backend menu (e.g. `stats/conversion/index`) resolves to `src/views/stats/conversion/index.vue`. When adding a page, the corresponding menu record must be inserted via SQL (pattern: `server/sql/.../add_*_menu.sql`).
- **API calls go through `utils/request`** which normalizes the response envelope and handles auth/error toasts. Dont use axios directly.
- **Auto-imports**: Element Plus components, `ref`/`reactive`/router composables are auto-imported via `unplugin-auto-import` — no need to import them explicitly (see `auto-imports.d.ts`).
## Cross-Cutting Concerns
- **Versioned SQL migrations.** New schema/menu changes go in `server/sql/<version>/*.sql`. When adding an admin view, pair it with an `add_*_menu.sql` file (see current WIP: `add_conversion_stats_menu.sql` + `admin/src/views/stats/conversion/`).
- **TRTC integration.** Video-call logic is split between `TUICallKit/` (vendor), `admin/src/utils/call-*.ts` and `src/utils/im-*.ts`, and the `api/controller/TrtcController`. Cloud recording is triggered server-side and written back via the `/api/trtc/recording-notify` callback.
- **企微 (QYWX).** `server/app/adminapi/controller/qywx/` + `admin/src/views/qywx/` + `pc/` pages exchange via `easywechat`. OAuth/bind flow uses `admin/src/utils/wecomOauthPostMessage.ts` and `wecomBindGuard.ts`.
- **Multi-cloud storage.** File uploads support Qiniu / Tencent COS / Aliyun OSS — configuration is driven by runtime admin settings, resolved in `FileService`.
## Trellis Workflow
This repo is Trellis-managed (see `.trellis/`). New work should go through:
1. `/trellis:start` — loads session context
2. `/trellis:brainstorm` for complex/vague asks (creates `prd.md`)
3. Research → `task.py init-context` → Implement → Check agents
4. `/trellis:finish-work` before committing
Code-spec files under `.trellis/spec/` are injected into sub-agent contexts automatically. Relevant thinking guides live in `.trellis/spec/guides/`.
## Language & Docs
All user-facing commentary, commit messages, and code comments in this repo use 简体中文 when addressing the product domain; identifiers, API paths, and this file stay in English. The backend still carries original likeadmin attribution headers — do not remove them on edit.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 likeshop技术社区
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
# API 配置
VUE_APP_API_URL=http://localhost:8000/api
# 应用名称
VUE_APP_NAME=医疗小程序
# 应用版本
VUE_APP_VERSION=1.0.0
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
unpackage/dist
*.local
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
TUICallKit
+16
View File
@@ -0,0 +1,16 @@
{ // launch.json 配置了启动调试时相关设置,configurations下节点名称可为 app-plus/h5/mp-weixin/mp-baidu/mp-alipay/mp-qq/mp-toutiao/mp-360/
// launchtype项可配置值为local或remote, local代表前端连本地云函数,remote代表前端连云端云函数
"version": "0.0",
"configurations": [{
"default" :
{
"launchtype" : "local"
},
"mp-weixin" :
{
"launchtype" : "local"
},
"type" : "uniCloud"
}
]
}
+113
View File
@@ -0,0 +1,113 @@
<script setup>
// 2. 获取组件实例,通过 proxy 访问全局属性
import { ref} from "vue";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
import { getCurrentInstance } from 'vue'
// 2. 获取组件实例,通过 proxy 访问全局属性
const { proxy } = getCurrentInstance()
const globalData = {
SDKAppID: 0,
userID: '',
userSig: ''
};
let avatarUrl = ref(""); // 声明为响应式变量
uni.CallManager = new CallManager();
onLaunch(() => {
// iOS 静音键模式下 InnerAudioContext 默认不发声,
// 必须在 onLaunch 全局调用此 API,单实例 obeyMuteSwitch 在 iOS 不可靠
// #ifdef MP-WEIXIN
try {
uni.setInnerAudioOption({
obeyMuteSwitch: false,
mixWithOther: true,
})
} catch (e) {
console.warn('setInnerAudioOption failed:', e)
}
// #endif
uni.login({
provider: 'weixin',
success: function (loginRes) {
if( uni.getStorageSync('token')){
userinfo(loginRes.code);
}else{
wxcode(loginRes.code)
}
// 获取用户信息
}
});
})
const wxcode = async (code) => {
try {
// 通过 proxy 调用全局的 apiUrl
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: {
code: code
},
}, false) // 不显示加载中
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
if(res.code==1 &&res.data.diagnosis){
//video(res.data)
}
console.log('请求结果:', res.data)
} catch (err) {
}
}
const userinfo = async (code) => {
try {
// 通过 proxy 调用全局的 apiUrl
const res = await proxy.apiUrl({
url: '/api/user/info',
method: 'POST',
}, false) // 不显示加载中
if(res.code==-1){
wxcode(code)
}
uni.setStorageSync('userData', res.data)
if(res.code==1 && res.data.diagnosis){
//video(res.data)
}
} catch (err) {
}
}
const video =async (data)=>{
const res = await proxy.apiUrl({
url: '/api/tcm/getPatientSignature',
method: 'GET',
data: {
patient_id: data.diagnosis.patient_id
},
}, false)
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
console.log('SDKAppID',SDKAppID)
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
getApp().globalData.SDKAppID = SDKAppID;
const cc={
sdkAppID: SDKAppID, // 替换为用户自己的 sdkAppID
userID: userId, // 替换为用户自己的 userID
userSig: userSig, // 替换为用户自己的 userSig
globalCallPagePath: "TUICallKit/src/Components/TUICallKit", // 替换为步骤一里注册的全局监听页面
}
uni.setStorageSync('CallManager', userId)
await uni.CallManager.init(cc);
}
</script>
+161
View File
@@ -0,0 +1,161 @@
## 简介
本 demo 演示了如何在 uni-app 项目中集成 [TUICallKit](https://www.npmjs.com/package/@trtc/calls-uikit-wx-uniapp) 音视频通话组件。
## 两种开发方式
本工程同时支持 **HBuilderX****命令行 (vite)** 两套编译链路,二选一即可。
### 方式 A:命令行 (vite)
```bash
npm install # 安装依赖
npm run dev:mp-weixin # watch 模式,产物 dist/dev/mp-weixin
npm run build:mp-weixin # 生产构建,产物 dist/build/mp-weixin
```
构建完成后用微信开发者工具打开对应 `dist/.../mp-weixin` 目录即可。
支持平台:`mp-weixin` / `h5` / `app`(脚本里有 dev/build 两组)。
### 方式 BHBuilderX
HBuilderX 3.4+ 会自动识别 `vite.config.ts` 并切换到 vite 编译模式。
1. 用 HBuilderX 打开工程根目录
2. 菜单「运行」→「运行到小程序模拟器」→「微信开发者工具」
3. 产物会输出到 `unpackage/dist/dev/mp-weixin`
## 环境准备
- 微信 App iOS 最低版本要求:8.0.40
- 微信 App Android 最低版本要求:8.0.40
- 小程序基础库最低版本要求:2.10.0
- 由于小程序测试号不具备 <live-pusher> 和 <live-player> 的使用权限,请使用企业小程序账号申请相关权限进行开发
- 由于微信开发者工具不支持原生组件(即 <live-pusher> 和 <live-player> 标签),需要在真机上进行运行体验
## 快速跑通
第一步:下载源码,编译运行
1. 克隆或者直接下载此仓库源码。
```
git clone https://github.com/tencentyun/TUICallKit.git
```
2. 进入 demo 目录
```
cd ./TUICallKit/uni-app/TUICallKit-Miniprogram/TUICallKit-Vue3
```
3. 安装依赖
```
npm install
```
mac端
```
mkdir -p ./TUICallKit && cp -r node_modules/@trtc/calls-uikit-wx-uniapp/ ./TUICallKit && cp node_modules/@trtc/call-engine-lite-wx/RTCCallEngine.wasm.br ./static
```
windows端
```
xcopy node_modules\@trtc\calls-uikit-wx-uniapp\ .\TUICallKit /i /e
xcopy node_modules\@trtc\call-engine-lite-wx\RTCCallEngine.wasm.br .\static
```
4. HBuilder 中导入项目
<img src="https://web.sdk.qcloud.com/component/trtccalling/images/miniProgram/hbuilder-vue.png" width="400" align="middle" />
5. 修改 `./TUICallKit/uni-app/TUICallKit-Miniprogram/TUICallKit-Vue3/debug/GenerateTestUserSig-es.js` 文件 的 SDKAPPID 以及 SECRETKEY(阅读文末 [开通服务](#开通服务))
<img src="https://qcloudimg.tencent-cloud.cn/raw/49931d68084b2d79f0f69f278894999b.png" width="400" />
6. 运行到【微信开发者工具】,勾选 **【运行时是否压缩代码】**
<img src="https://web.sdk.qcloud.com/component/trtccalling/images/miniProgram/exportWechatTool.png" width="400" align="middle" />
7. 项目导入到微信开发者工具,目录如下图:
<img src="https://qcloudimg.tencent-cloud.cn/raw/a79cd64ac5be8ee099f2d802f1c847c6.png" width="150" align="middle" />
## 示例体验
> **TipsTUICallKit 通话体验,至少需要两台设备,用户 A、B 代表使用不同的设备。**
**用户 AuserId111**
- 步骤 1:在欢迎页,输入用户名(<font color=red>请确保用户名唯一性,不能与其他用户重复</font>)登录,比如:111。
- 步骤 2:根据不同的场景&业务需求,进入不同的场景界面,比如:视频通话。
- 步骤 3:输入要拨打的用户 B 的 userId(例如:222),点击搜索,然后点击呼叫。
| 步骤1 | 步骤2 | 步骤3 |
| :-: | :-: | :-: |
|<img src="https://qcloudimg.tencent-cloud.cn/raw/13bf844da5636f3640da05020800fff9.jpg" width="240"/>|<img src="https://qcloudimg.tencent-cloud.cn/raw/ceed4039c6ce8f9b8ff48deb89199b6e.jpg" width="240">|<img src="https://qcloudimg.tencent-cloud.cn/raw/dc1984904c9790c8a3355ff1c5dada50.jpg" width="240"/>
**用户 BuserId222**
- 步骤 1:在欢迎页,输入用户名(<font color=red>请确保用户名唯一性,不能与其他用户重复</font>)登录,比如:222。
- 步骤 2:进入主页,等待接听来电即可。
## 接入指引
### 步骤一:开通小程序权限
由于 TUICallKit 所使用的小程序标签有更苛刻的权限要求,因此集成 TUICallKit 的第一步就是要开通小程序的类目和标签使用权限,**否则无法使用**,这包括如下步骤:
- 小程序推拉流标签不支持个人小程序,只支持企业类小程序。需要在 [注册](https://developers.weixin.qq.com/community/business/doc/000200772f81508894e94ec965180d) 时填写主体类型为企业,如下图所示:
<img width="480" height="480" src="https://main.qcloudimg.com/raw/a30f04a8983066fb9fdf179229d3ee31.png">
- 小程序推拉流标签使用权限暂时只开放给有限 [类目](https://developers.weixin.qq.com/miniprogram/dev/component/live-pusher.html)。
- 符合类目要求的小程序,需要在 **[微信公众平台](https://mp.weixin.qq.com/)** > **开发** > **开发管理** > **接口设置**中自助开通该组件权限,如下图所示:
<img width="480" height="360" src="https://main.qcloudimg.com/raw/dc6d3c9102bd81443cb27b9810c8e981.png">
### 步骤二:在小程序控制台配置域名
在 **[微信公众平台](https://mp.weixin.qq.com/)** > **开发** > **开发管理** > **开发设置** > **服务器域名**中设置 **request 合法域名** 和 **socket 合法域名**,如下图所示:
- **request 合法域名**
```javascript
https://official.opensso.tencent-cloud.com
https://yun.tim.qq.com
https://cloud.tencent.com
https://webim.tim.qq.com
https://query.tencent-cloud.com
https://web.sdk.qcloud.com
```
- **socket 合法域名**
```javascript
wss://wss.im.qcloud.com
wss://wss.tim.qq.com
```
<img width="480" height="360" src="https://qcloudimg.tencent-cloud.cn/raw/a79ca9726309bb1fdabb9ef8961ce147.png">
### 步骤三:开通服务
TUICallKit 是基于腾讯云 [即时通信 IM](https://cloud.tencent.com/document/product/269/42440) 和 [实时音视频 TRTC](https://cloud.tencent.com/document/product/647/16788) 两项付费 PaaS 服务构建出的音视频通信组件。您可以按照如下步骤开通相关的服务并体验 7 天的免费试用服务:
1. 登录到 [即时通信 IM 控制台](https://console.cloud.tencent.com/im),单击**创建新应用**,在弹出的对话框中输入您的应用名称,并单击**确定**。
<img width="360" height="240" src="https://qcloudimg.tencent-cloud.cn/raw/1105c3c339be4f71d72800fe2839b113.png">
2. 单击刚刚创建出的应用,进入**基本配置**页面,并在页面的右下角找到**开通腾讯实时音视频服务**功能区,单击**免费体验**即可开通 TUICallKit 的 7 天免费试用服务。如果需要正式应用上线,可以单击 [**前往加购**](https://buy.cloud.tencent.com/avc) 即可进入购买页面。
<img width="480" src="https://user-images.githubusercontent.com/72854065/205876830-6c8f119e-8d3c-4f1e-b8d3-0a21788fc47a.png">
> IM 音视频通话能力针对不同的业务需求提供了差异化的付费版本供您选择,您可以在 [IM 购买页](https://buy.cloud.tencent.com/avc) 了解包含功能并选购您适合的版本。
3. 在同一页面找到 **SDKAppID** 和**密钥**并记录下来
- SDKAppID:IM 的应用 ID,用于业务隔离,即不同的 SDKAppID 的通话彼此不能互通。
- SecretkeyIM 的应用密钥,需要和 SDKAppID 配对使用,用于签出合法使用 IM 服务的鉴权用票据 UserSig。
<img width="480" src="https://qcloudimg.tencent-cloud.cn/raw/e435332cda8d9ec7fea21bd95f7a0cba.png">
> 即日起至2022年10月01日0点前,购买音视频通话能力包基础版可获赠解锁相同有效期的小程序端通话功能授权。在活动结束前购买的音视频通话能力包在有效期内不受活动结束影响仍可使用小程序通话功能,活动结束后的新购或续期需选择体验版、进阶版、尊享版来获取小程序通话功能授权,基础版亦可单独加购小程序功能授权进行解锁。
> 单击**免费体验**以后,部分之前使用过 [实时音视频 TRTC]
(https://cloud.tencent.com/document/product/647/16788) 服务的用户会提示:
```
[-100013]:TRTC service is suspended. Please check if the package balance is 0 or the Tencent Cloud accountis in arrears
```
因为新的 IM 音视频通话能力是整合了腾讯云[实时音视频 TRTC](https://cloud.tencent.com/document/product/647/16788) 和 [即时通信 IM](https://cloud.tencent.com/document/product/269/42440) 两个基础的 PaaS 服务,所以当 [实时音视频 TRTC](https://cloud.tencent.com/document/product/647/16788) 的免费额度(10000分钟)已经过期或者耗尽,就会导致开通此项服务失败,这里您可以单击 [TRTC 控制台](https://console.cloud.tencent.com/trtc/app),找到对应 SDKAppID 的应用管理页,示例如图,开通后付费功能后,再次**启用应用**即可正常体验音视频通话能力。
<img width=600px src="https://qcloudimg.tencent-cloud.cn/raw/f74a13a7170cf8894195a1cae6c2f153.png" />
## 附录
- 如果您想要了解TUiCallKit,请阅读 [组件介绍 TUICallKit](https://cloud.tencent.com/document/product/647/78742)
- 如果您想把我们的功能直接嵌入到您的项目中,请阅读 [快速接入 TUICallKit](https://cloud.tencent.com/document/product/647/78912)
- 如果您想要了解详细 API ,请阅读 [ API 概览](https://cloud.tencent.com/document/product/647/78759)
- 如果你遇到了困难,可以先参阅 [常见问题](https://cloud.tencent.com/document/product/647/78912)
- 如果发现了示例代码的 bug,欢迎提交 issue;
+59
View File
@@ -0,0 +1,59 @@
type func = (...args: any[]) => any;
/**
* 广播参数信息
* @interface NotifyEventParams
* @property {string} eventName 事件名
* @property {string} method 调用的方法名
* @property {Record<string, any>} params 业务参数
* @property {func} [callback] 回调函数
*/
interface NotifyEventParams {
eventName: string;
params?: Record<string, any>;
callback?: func;
}
interface TUINotification {
onNotifyEvent(options: NotifyEventParams): void;
}
/**
* @interface TUIBridge
*/
interface TUIBridge {
/**
* 注册广播监听
* @function
* @param {string} eventName 事件名
* @param {string} subKey 事件的具体操作
* @param {TUINotification} notification 事件监听者
* @example
* TUICore.registerEvent('LoginState.LoginSuccess', this);
*/
registerEvent(eventName: string, notification: TUINotification): void;
/**
* 反注册广播监听
* @function
* @param {string} eventName 事件名
* @param {string} subKey 事件的具体操作
* @param {ITUINotification} notification 事件监听者
* @example
* TUICore.unregisterEvent('LoginState.LoginSuccess', this);
*/
unregisterEvent(eventName: string, notification: TUINotification): void;
/**
* 广播通知
* @function
* @param {NotifyEventParams} options 通知内容
* @example
* TUICore.notifyEvent({
* eventName: LoginState.LoginSuccess,
* params: { chat },
* });
*/
notifyEvent(options: NotifyEventParams): void;
}
declare const tuiBridge: TUIBridge;
export { tuiBridge as TUIBridge, tuiBridge as default };
@@ -0,0 +1 @@
const e="LoginState.LoginSuccess";class t{constructor(){this.eventMap=new Map,this.chat=null}registerEvent(e,t){if(console.log(`TUIEventManager.registerEvent eventName:${e}`),!this.eventMap.has(e)){const t=[];this.eventMap.set(e,t)}const n=this.eventMap.get(e)||[];-1===n.indexOf(t)&&(n.push(t),this.renotify(e,t))}unregisterEvent(e,t){if(console.log(`TUIEventManager.unregisterEvent eventName:${e}`),this.eventMap.has(e)){const n=this.eventMap.get(e)||[],s=n.indexOf(t);s>-1&&n.splice(s,1)}}notifyEvent(t){const{eventName:n,params:s}=t;if(console.log("TUIEventManager.notifyEvent options:",t),n===e&&(this.chat=(null==s?void 0:s.chat)||null),this.eventMap.has(n)){(this.eventMap.get(n)||[]).forEach((e=>{e.onNotifyEvent(t)}))}}renotify(t,n){var s;t===e&&(null===(s=this.chat)||void 0===s?void 0:s.isReady())&&(n.onNotifyEvent({eventName:t,params:{chat:this.chat}}),console.log("TUIEventManager.renotify success."))}}class n{constructor(){this.eventManager=new t}static getInstance(){return n.instance||(console.log("TUIBridge.getInstance ok."),n.instance=new n),n.instance}registerEvent(e,t){return this.eventManager.registerEvent(e,t)}unregisterEvent(e,t){return this.eventManager.unregisterEvent(e,t)}notifyEvent(e){return this.eventManager.notifyEvent(e)}}console.log("TUIBridge.VERSION:0.0.1");const s=n.getInstance();export{s as TUIBridge,s as default};
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

@@ -0,0 +1 @@
<svg width="30" height="30" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path d="M4.162 5.677C2.58 7.258 1.967 9.608 2.874 11.652a32.422 32.422 0 0 0 6.733 9.793 32.423 32.423 0 0 0 9.793 6.733c2.044.907 4.394.293 5.975-1.288l2.543-2.543a1.5 1.5 0 0 0-.073-2.19l-4.808-4.207a1.5 1.5 0 0 0-2.048.068l-2.131 2.131a.736.736 0 0 1-.932.095 26.385 26.385 0 0 1-3.9-3.218 26.385 26.385 0 0 1-3.218-3.9.736.736 0 0 1 .095-.932l2.13-2.13a1.5 1.5 0 0 0 .069-2.05L8.895 3.208a1.5 1.5 0 0 0-2.19-.073L4.162 5.677z" fill="#fff"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h30v30H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 631 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="none"><mask id="a" fill="#fff"><path fill-rule="evenodd" d="M9.952 23.334h8.881a1 1 0 0 0 1-1V11.43zM21.364 9.585l1.626-1.958 2.42-1.13a1 1 0 0 1 1.423.907v13.193a1 1 0 0 1-1.423.906l-2.952-1.378-.516-.24a1 1 0 0 1-.578-.907zm-3.5-4.918L2.367 23.334h-.201a1 1 0 0 1-1-1V5.667a1 1 0 0 1 1-1h15.696zM5.833 11.35h4.082v-1.7H5.833z" clip-rule="evenodd"/></mask><path fill="#fff" fill-rule="evenodd" d="M9.952 23.334h8.881a1 1 0 0 0 1-1V11.43zM21.364 9.585l1.626-1.958 2.42-1.13a1 1 0 0 1 1.423.907v13.193a1 1 0 0 1-1.423.906l-2.952-1.378-.516-.24a1 1 0 0 1-.578-.907zm-3.5-4.918L2.367 23.334h-.201a1 1 0 0 1-1-1V5.667a1 1 0 0 1 1-1h15.696zM5.833 11.35h4.082v-1.7H5.833z" clip-rule="evenodd"/><path fill="#fff" d="m9.952 23.334-1.308-1.086-2.313 2.786h3.62zm9.881-11.904h1.7V6.72l-3.008 3.624zm3.157-3.803-.72-1.54-.345.161-.244.294zm-1.626 1.958L20.056 8.5l-.392.472v.614zm4.046-3.087.72 1.54zm1.423.906h1.7zm0 13.193h-1.7zm-1.423.906-.719 1.54zm-2.952-1.378.72-1.54zm-.516-.24-.72 1.54zm-.578-.907h-1.7zM2.368 23.334v1.7h.798l.51-.614zM17.863 4.667l1.308 1.086 2.313-2.786h-3.62zM9.916 11.35v1.7h1.7v-1.7zm-4.083 0h-1.7v1.7h1.7zm4.083-1.7h1.7v-1.7h-1.7zm-4.083 0v-1.7h-1.7v1.7zm13 11.984H9.952v3.4h8.881zm-.7.7a.7.7 0 0 1 .7-.7v3.4a2.7 2.7 0 0 0 2.7-2.7zm0-10.904v10.904h3.4V11.43zm.392-1.086L8.644 22.248l2.616 2.171 9.881-11.903zm3.157-3.802-1.626 1.957 2.616 2.172 1.626-1.958zm3.01-1.585-2.421 1.13 1.438 3.08 2.42-1.129-1.438-3.08zm3.841 2.447c0-1.976-2.052-3.282-3.842-2.447l1.438 3.081a.7.7 0 0 1-.996-.634zm0 13.193V7.404h-3.4v13.193zm-3.842 2.447c1.79.835 3.842-.472 3.842-2.447h-3.4a.7.7 0 0 1 .996-.635zm-2.952-1.378 2.952 1.378 1.438-3.082-2.952-1.377-1.438 3.08zm-.516-.241.516.24 1.438-3.08-.517-.241-1.437 3.08zm-1.559-2.447a2.7 2.7 0 0 0 1.559 2.447l1.438-3.081a.7.7 0 0 1 .403.634zm0-9.393v9.393h3.4V9.585zM3.676 24.42 19.171 5.753 16.555 3.58 1.06 22.248l2.616 2.171zm-1.308-2.785h-.201v3.4h.201zm-.201 0a.7.7 0 0 1 .7.7h-3.4a2.7 2.7 0 0 0 2.7 2.7zm.7.7V5.667h-3.4v16.667zm0-16.667a.7.7 0 0 1-.7.7v-3.4a2.7 2.7 0 0 0-2.7 2.7zm-.7.7h15.696v-3.4H2.166zm7.75 3.283H5.832v3.4h4.083v-3.4zm-1.7 0v1.7h3.4v-1.7zm-2.384 1.7h4.083v-3.4H5.833zm1.7 0v-1.7h-3.4v1.7z" mask="url(#a)"/><path stroke="#fff" stroke-linecap="square" stroke-linejoin="round" stroke-width="2" d="M4.563 25.269 22.14 4.084"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1 @@
<svg width="26" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.916 8.2h.85V4.8H3.984v3.4h4.933zm-7.9-6.533a.15.15 0 0 1 .15-.15h16.667a.15.15 0 0 1 .15.15v16.666a.15.15 0 0 1-.15.15H1.167a.15.15 0 0 1-.15-.15V1.667zm20.198 3.355a.15.15 0 0 1 .087-.136l3.469-1.618a.15.15 0 0 1 .213.135v13.193a.15.15 0 0 1-.213.136l-3.47-1.618a.15.15 0 0 1-.086-.136V5.022z" fill="#22262E" stroke="#22262E" stroke-width="1.7" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 463 B

@@ -0,0 +1 @@
<svg width="30" height="30" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M30 16.05c0-2.235-1.228-4.33-3.314-5.135A32.423 32.423 0 0 0 15 8.751c-4.12 0-8.06.766-11.685 2.164C1.228 11.719 0 13.815 0 16.051v3.597a1.5 1.5 0 0 0 1.6 1.496l6.375-.425a1.5 1.5 0 0 0 1.4-1.496v-3.014c0-.353.245-.659.591-.726A26.382 26.382 0 0 1 15 15.001c1.722 0 3.404.166 5.034.482a.736.736 0 0 1 .591.726v3.014a1.5 1.5 0 0 0 1.4 1.496l6.375.425a1.5 1.5 0 0 0 1.6-1.496V16.05z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg width="30" height="30" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M8.75 7.5a6.25 6.25 0 0 1 11.133-3.902L8.873 16.862a6.28 6.28 0 0 1-.123-1.237V7.5zM6.806 19.352A8.967 8.967 0 0 1 6 15.625V13.75H4v1.875c0 1.963.514 3.806 1.416 5.402l1.39-1.675zm2.406 5.629 1.292-1.558A9 9 0 0 0 24 15.625v-1.874h2v1.875c0 5.738-4.393 10.45-10 10.955V30h-2v-3.42a10.934 10.934 0 0 1-4.788-1.6zm3.086-3.719 8.952-10.784v5.147a6.25 6.25 0 0 1-8.952 5.637z" fill="#fff" style="fill:#fff;fill-opacity:1"/><path d="M6.419 24.084 22.094 5.209" stroke="#fff" style="stroke:#fff;stroke-opacity:1" stroke-width="2" stroke-linecap="square" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 703 B

@@ -0,0 +1 @@
<svg width="30" height="30" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 15.625V13.75h2v1.875a9 9 0 1 0 18 0V13.75h2v1.875c0 5.738-4.393 10.45-10 10.955V30h-2v-3.42c-5.607-.505-10-5.217-10-10.955z" fill="#22262E"/><rect x="8.75" y="1.25" width="12.5" height="20.625" rx="6.25" fill="#22262E"/></svg>

After

Width:  |  Height:  |  Size: 353 B

@@ -0,0 +1 @@
<svg width="30" height="30" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="m10.36 22.842 4.235 3.56c.697.587 1.76.091 1.76-.82V15.62l-5.995 7.223zm13.356-16.09 1.285-1.548A13.955 13.955 0 0 1 29 15c0 5.183-2.817 9.707-7 12.127l-.865.5-1.001-1.731.865-.5C24.59 23.318 27 19.44 27 15c0-3.194-1.248-6.097-3.284-8.248zm-3.278 3.95 1.288-1.553C23.14 10.613 24 12.71 24 15c0 2.869-1.35 5.433-3.445 6.832l-.831.555-1.11-1.663.83-.556C20.938 19.172 22 17.258 22 15c0-1.716-.613-3.232-1.562-4.299zm-4.082-4.22L3.928 21.456H2.252c-.592 0-1.072-.48-1.072-1.072V9.635c0-.591.478-1.07 1.069-1.072l6.073-.017c.251 0 .494-.09.687-.251l5.586-4.697c.697-.586 1.76-.091 1.76.82v2.065z" fill="#fff"/><path d="M4.564 25.268 22.14 4.084" stroke="#fff" stroke-width="2" stroke-linecap="square" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 852 B

@@ -0,0 +1 @@
<svg width="28" height="26" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="m20.135.373.866.5C25.183 3.294 28 7.818 28 13c0 5.183-2.817 9.707-7 12.127l-.865.5-1.001-1.731.865-.5C23.59 21.318 26 17.44 26 13s-2.411-8.319-6-10.395l-.866-.5L20.135.372zm-6.49 1.016a1.071 1.071 0 0 1 1.71.86V23.75c0 .881-1.003 1.386-1.71.86l-6.65-4.945a1.071 1.071 0 0 0-.639-.211H1.252c-.592 0-1.072-.48-1.072-1.072V7.634c0-.59.478-1.07 1.068-1.071l5.11-.017c.23 0 .452-.075.636-.211l6.65-4.946zm5.91 4.78-.831-.556-1.111 1.663.832.556C19.937 8.828 21 10.742 21 13c0 2.259-1.063 4.172-2.555 5.168l-.832.556 1.11 1.663.832-.555C21.65 18.432 23 15.869 23 13c0-2.869-1.35-5.433-3.445-6.832z" fill="#2E333D"/></svg>

After

Width:  |  Height:  |  Size: 739 B

@@ -0,0 +1 @@
<svg width="28" height="28" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M25.018 5.763a2 2 0 0 1 2 2v15.528a2 2 0 0 1-2 2H2.982a2 2 0 0 1-2-2V7.763a2 2 0 0 1 2-2h4.262a2 2 0 0 0 1.494-.67l1.41-1.587a2 2 0 0 1 1.495-.672h4.714a2 2 0 0 1 1.494.671l1.41 1.587a2 2 0 0 0 1.495.671h4.262zM11.723 9.585a5.907 5.907 0 0 1 2.186-.417c1.719 0 3.27.733 4.341 1.903V9.918h1.5v5.001h-1.5v-.005c-.002-2.332-1.932-4.245-4.34-4.245-.58 0-1.13.11-1.633.31l-.554-1.394zM8.25 19.919v-5h1.5c0 2.334 1.93 4.25 4.34 4.25.58 0 1.13-.111 1.633-.31l.554 1.393a5.908 5.908 0 0 1-2.186.417 5.873 5.873 0 0 1-4.341-1.903v1.153h-1.5z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 677 B

@@ -0,0 +1,3 @@
<svg width="56" height="37" viewBox="0 0 56 37" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 0C1.79086 0 0 1.79086 0 4V33C0 35.2091 1.79086 37 4 37H36.8333C39.0425 37 40.8333 35.2091 40.8333 33V4C40.8333 1.79086 39.0425 0 36.8333 0H4ZM7.5 7.5625C6.94771 7.5625 6.5 8.01021 6.5 8.5625V13.125C6.5 13.6773 6.94771 14.125 7.5 14.125H12C12.5523 14.125 13 13.6773 13 13.125V8.5625C13 8.01022 12.5523 7.5625 12 7.5625H7.5ZM45.1667 25.0174V11.8032L54.4897 6.27116C55.1563 5.87562 56 6.35605 56 7.13116V30.0747C56 30.8633 55.1299 31.3416 54.4641 30.919L45.1667 25.0174Z" fill="black" fill-opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 659 B

@@ -0,0 +1,3 @@
<svg width="38" height="54" viewBox="0 0 38 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M29 10.3536C29 4.63544 24.6355 0 19.2516 0H18.7484C13.389 0 9.03459 4.59474 9 10.2868V23.6464C9 29.3645 13.3645 34 18.7484 34H19.2516C24.611 34 28.9654 29.4052 29 23.7132V10.3536ZM17 41.8954C7.53212 40.8995 0.159045 32.9114 0.00253912 23.1854L0 22.8696C0 22.3893 0.389318 22 0.869565 22H2.74948C3.22973 22 3.61905 22.3893 3.61905 22.8696C3.61905 31.5334 10.5267 38.5217 19 38.5217C27.377 38.5217 34.2238 31.6913 34.3783 23.1643L34.381 22.8696C34.381 22.3893 34.7703 22 35.2505 22H37.1304C37.6107 22 38 22.3893 38 22.8696C38 32.738 30.5701 40.8887 21 41.8954V50H29.7619C30.4457 50 31 50.5543 31 51.2381V52.7619C31 53.4457 30.4457 54 29.7619 54H8.23809C7.55431 54 7 53.4457 7 52.7619V51.2381C7 50.5543 7.55431 50 8.2381 50H17V41.8954Z" fill="black" fill-opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 921 B

@@ -0,0 +1,3 @@
<svg width="52" height="42" viewBox="0 0 52 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.1459 0C35.2822 0 36.321 0.642007 36.8292 1.65836L37.8944 3.78885C38.572 5.14399 39.957 6 41.4721 6H49C50.6569 6 52 7.34315 52 9V39C52 40.6569 50.6569 42 49 42H3C1.34315 42 0 40.6569 0 39V9C0 7.34315 1.34315 6 3 6H10.5274C12.0422 6 13.4271 5.14429 14.1048 3.78951L15.1708 1.65836C15.679 0.642007 16.7178 0 17.8541 0H34.1459ZM38 23C38 29.6274 32.6274 35 26 35C19.3726 35 14 29.6274 14 23C14 16.3726 19.3726 11 26 11C32.6274 11 38 16.3726 38 23ZM26 31C30.4183 31 34 27.4183 34 23C34 18.5817 30.4183 15 26 15C21.5817 15 18 18.5817 18 23C18 27.4183 21.5817 31 26 31Z" fill="black" fill-opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 754 B

@@ -0,0 +1 @@
<svg t="1758166707254" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5393" width="200" height="200"><path d="M512 170.666667v85.333333a256 256 0 1 1-223.573333 131.2L213.930667 345.6A341.333333 341.333333 0 1 0 512 170.666667z" fill="#000000" opacity=".3" p-id="5394"></path></svg>

After

Width:  |  Height:  |  Size: 327 B

@@ -0,0 +1,4 @@
<svg width="56" height="57" viewBox="0 0 56 57" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="28" cy="28.8535" r="26" stroke="black" stroke-opacity="0.55" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26 41.8535C26 42.4058 26.4477 42.8535 27 42.8535H29C29.5523 42.8535 30 42.4058 30 41.8535V31.8535H40C40.5523 31.8535 41 31.4058 41 30.8535V28.8535C41 28.3012 40.5523 27.8535 40 27.8535H30V17.8535C30 17.3012 29.5523 16.8535 29 16.8535H27C26.4477 16.8535 26 17.3012 26 17.8535V27.8535H16C15.4477 27.8535 15 28.3012 15 28.8535V30.8535C15 31.4058 15.4477 31.8535 16 31.8535H26V41.8535Z" fill="black" fill-opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

@@ -0,0 +1,4 @@
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9802 18.7267L1.6453 9.69704L0 11.5356L11.977 22L24 11.5373L22.3582 9.69531L11.9802 18.7267Z" fill="#1C66E5"/>
<path d="M24 1.84201L22.3582 0L11.9802 9.03143L1.6453 0.00172581L0 1.84057L11.977 12.305L24 1.84201Z" fill="#1C66E5"/>
</svg>

After

Width:  |  Height:  |  Size: 345 B

@@ -0,0 +1,3 @@
<svg width="52" height="40" viewBox="0 0 52 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M52 4C52 1.79086 50.2091 0 48 0H4C1.79086 0 0 1.79086 0 4V36C0 37.7424 1.11402 39.2245 2.66865 39.7731L32.9472 17.0642L33.1492 16.9196C35.3284 15.432 38.2748 15.6944 40.157 17.5765L52 29.4196V4ZM48 40H9.03277L35.3472 20.2642C35.9105 19.8417 36.6836 19.8658 37.2179 20.3046L37.3285 20.405L51.5794 34.6559C51.7079 34.7844 51.8496 34.8917 52 34.9779V36C52 38.2091 50.2091 40 48 40ZM13 16C10.7909 16 9 14.2091 9 12C9 9.79086 10.7909 8 13 8C15.2091 8 17 9.79086 17 12C17 14.2091 15.2091 16 13 16Z" fill="black" fill-opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 680 B

@@ -0,0 +1,17 @@
<svg width="106" height="106" viewBox="0 0 106 106" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_3459_9975)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.4427 49.9166L44.2609 33.5527C43.1399 32.8521 41.7268 32.8148 40.5707 33.4557C39.4146 34.0967 38.6973 35.3144 38.6973 36.6364V69.3636C38.6973 70.6856 39.4146 71.9033 40.5707 72.5442C41.1201 72.8487 41.7271 73 42.3334 73C43.0032 73 43.6723 72.8153 44.2607 72.4473L70.4425 56.0839C71.5058 55.4194 72.1516 54.254 72.1516 53.0002C72.1518 51.7464 71.506 50.5811 70.4427 49.9166Z" fill="white"/>
<path d="M53 13C30.944 13 13 30.944 13 53C13 75.0558 30.944 93 53 93C75.056 93 93 75.0558 93 53C93 30.944 75.056 13 53 13" stroke="white" stroke-width="6"/>
</g>
<defs>
<filter id="filter0_d_3459_9975" x="0" y="0" width="106" height="106" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3459_9975"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3459_9975" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,5 @@
<svg width="32" height="12" viewBox="0 0 32 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8.6347 11.9983C9.72788 11.9992 10.6193 11.1223 10.6362 10.0292L10.673 7.64717C11.6292 7.36896 13.5482 6.92739 16.0961 6.92739C18.6443 6.92739 20.5804 7.36912 21.5479 7.64767L21.5621 9.97475C21.5689 11.0721 22.4585 11.9591 23.5559 11.9625L29.9833 11.9825C31.0878 11.9859 31.986 11.0933 31.9895 9.98883L32 6.68064C32 4.65824 30.7746 2.85759 28.9507 2.2C26.1675 1.1964 21.6615 0 16 0C10.3386 0 5.83262 1.1964 3.04934 2.2C2.38985 2.43778 1.80885 2.82489 1.33541 3.31805C0.499655 4.18858 0.000161509 5.38952 0 6.68064L0.00557949 9.99594C0.00743508 11.0985 0.90127 11.9916 2.00385 11.9926L8.6347 11.9983Z"
fill="#8F959E" />
</svg>

After

Width:  |  Height:  |  Size: 791 B

+3
View File
@@ -0,0 +1,3 @@
export { useCallListState } from '../states/CallListState';
export { useCallParticipantState } from '../states/CallParticipantState';
export { useDeviceState } from '../states/DeviceState';
+4
View File
@@ -0,0 +1,4 @@
export { useConversationListState } from '../states/ConversationListState';
export { useMessageInputState } from '../states/MessageInputState';
export { useMessageListState } from '../states/MessageListState';
export { useGroupSettingState } from '../states/GroupSettingState';
@@ -0,0 +1,33 @@
<template>
<image :style="avatarStyle" :src="avatarSrc" @error="handleImageError"/>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import defaultAvatarIcon from '../../assets/base/default-avatar.png'
const avatarSrc = ref('');
const props = defineProps({
src: {
type: String,
default: defaultAvatarIcon
},
avatarStyle: {
type: [String, Object],
default: () => ({})
}
})
watch(() => props.src, () => {
avatarSrc.value = props.src;
}, {
immediate: true,
});
function handleImageError() {
avatarSrc.value = defaultAvatarIcon;
}
</script>
<style></style>
@@ -0,0 +1,581 @@
<template>
<!-- 音频/视频通话主容器 -->
<div class="TUICall-container" v-if="callParticipantInfo?.selfInfo?.status !== CallStatus.IDLE">
<!-- 顶部菜单栏 -->
<view class='topBar' v-if="callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED">
<view class="call-duration">
<text>{{ formatDuration(duration) }}</text>
</view>
</view>
<!-- 本地头像遮罩层 -->
<view @click="!isLocalStreamMain && toggleViewSize()" :class="isLocalStreamMain ? 'big-overlay' : 'small-overlay'"
v-if="mediaType === CallMediaType.VIDEO && !isCameraOpen">
<Avatar :avatarStyle="isLocalStreamMain ? bigOverlayAvatarStyle : smallOverlayAvatarStyle"
:src="callParticipantInfo?.selfInfo.avatarUrl || IMG_DEFAULT_AVATAR" />
<view :class="isLocalStreamMain ? 'big-overlay-mask' : 'small-overlay-mask'"></view>
</view>
<!-- 本地流 -->
<div
:class="(mediaType === CallMediaType.AUDIO || !isCameraOpen) ? 'player-audio' : (isLocalStreamMain ? 'big-video' : 'small-video')"
@click="!isLocalStreamMain && toggleViewSize()">
<TRTCPusher :key="pusherId" :id="pusherId" v-if="pusherId" />
</div>
<!-- 远端头像遮罩层 -->
<view :class="!isLocalStreamMain ? 'big-overlay' : 'small-overlay'" @click="isLocalStreamMain && toggleViewSize()"
v-if="mediaType === CallMediaType.AUDIO || (mediaType === CallMediaType.VIDEO && callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED && !callParticipantInfo?.allParticipants[0]?.isCameraOpened)">
<Avatar :avatarStyle="!isLocalStreamMain ? bigOverlayAvatarStyle : smallOverlayAvatarStyle"
:src="callParticipantInfo?.allParticipants[0]?.avatarUrl || IMG_DEFAULT_AVATAR" />
<view :class="!isLocalStreamMain ? 'big-overlay-mask' : 'small-overlay-mask'"></view>
</view>
<!-- 远端流 -->
<view
:class="(mediaType === CallMediaType.AUDIO || callParticipantInfo?.selfInfo?.status === CallStatus.CALLING || !callParticipantInfo?.allParticipants[0]?.isCameraOpened) ? 'player-audio' : (!isLocalStreamMain ? 'big-video' : 'small-video')"
@click="isLocalStreamMain && toggleViewSize()">
<TRTCPlayer :id="`${callParticipantInfo?.allParticipants[0]?.id}_0`" ref="player"
:stream-id="`${callParticipantInfo?.allParticipants[0]?.id}_0`" />
</view>
<!-- 用户信息 -->
<view v-if="mediaType === CallMediaType.AUDIO || callParticipantInfo?.selfInfo?.status === CallStatus.CALLING"
class="voice-invite-message">
<Avatar :avatarStyle="avatarStyle"
:src="callParticipantInfo?.allParticipants[0]?.avatarUrl || IMG_DEFAULT_AVATAR" />
<text class="nick-name">
{{ callParticipantInfo?.allParticipants[0]?.name || callParticipantInfo?.allParticipants[0]?.id }}
</text>
</view>
<!-- 通话状态提示 -->
<div class="tips">
<text v-if="showConnectedTip">已接通</text>
<div v-if="!showConnectedTip && callParticipantInfo.selfInfo.status !== CallStatus.CONNECTED">
<text v-if="callParticipantInfo?.selfInfo?.role !== CallRole.CALLER">
{{ mediaType === CallMediaType.AUDIO ? '邀请你进行语音通话' : '邀请你进行视频通话' }}
</text>
<text v-else>等待对方接受</text>
</div>
</div>
<!-- 主叫呼叫阶段按钮 -->
<view class="footer"
v-if="callParticipantInfo?.selfInfo?.status === CallStatus.CALLING && callParticipantInfo?.selfInfo?.role === CallRole.CALLER">
<view class="btn-operate">
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="CallerHangupHandler">
<image v-if="isCallBtnClickable" :src="IMG_HANGUP" mode="aspectFit" />
<image v-else class="img-loading" :src="IMG_LOADING" mode="aspectFit"></image>
</view>
<text>挂断</text>
</view>
</view>
</view>
<!-- 被叫呼叫阶段按钮 -->
<view class="footer"
v-if="callParticipantInfo?.selfInfo?.status === CallStatus.CALLING && callParticipantInfo?.selfInfo?.role !== CallRole.CALLER">
<view class="btn-operate" style="gap: 40px">
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="reject">
<image :src="IMG_HANGUP" mode="aspectFit" />
</view>
<text>挂断</text>
</view>
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #51C271;" @click="acceptHandler">
<image v-if="isAcceptBtnClickable" :src="IMG_ACCEPT" mode="aspectFit" />
<image v-else class="img-loading" :src="IMG_LOADING" mode="aspectFit"></image>
</view>
<text>接听</text>
</view>
</view>
</view>
<!-- 语音通话接听阶段按钮 -->
<view class="footer"
v-if="mediaType === CallMediaType.AUDIO && callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED">
<view class="btn-operate">
<view class="btn-operate-item" @click="microPhoneHandler">
<view class="call-operate" :style="buttonBgColor(isMicrophoneOpen)">
<image :src="isMicrophoneOpen ? IMG_AUDIO_TRUE : IMG_AUDIO_FALSE" mode="aspectFit"></image>
</view>
<text>{{ isMicrophoneOpen ? '麦克风已开启' : '麦克风已关闭' }}</text>
</view>
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="hangupHandler">
<image mode="aspectFit" v-if="isHangupBtnClickable" :src="IMG_HANGUP" />
<image mode="aspectFit" v-else class="img-loading" :src="IMG_LOADING"></image>
</view>
<text>挂断</text>
</view>
<view class="btn-operate-item" @click="setAudioRoute">
<view class="call-operate" :style="buttonBgColor(isSpeakerOpen)">
<image mode="aspectFit" class="btn-image" :src="isSpeakerOpen ? IMG_SPEAKER_TRUE : IMG_SPEAKER_FALSE">
</image>
</view>
<text>{{ isSpeakerOpen ? '扬声器已开启' : '扬声器已关闭' }}</text>
</view>
</view>
</view>
<!-- 视频通话接听阶段按钮 -->
<view class="footer"
v-if="mediaType === CallMediaType.VIDEO && callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED">
<view class="btn-operate">
<view class="btn-operate-item" @click="microPhoneHandler">
<view class="call-operate" :style="buttonBgColor(isMicrophoneOpen)">
<image :src="isMicrophoneOpen ? IMG_AUDIO_TRUE : IMG_AUDIO_FALSE" mode="aspectFit"></image>
</view>
<text>{{ isMicrophoneOpen ? '麦克风已开启' : '麦克风已关闭' }}</text>
</view>
<view class="btn-operate-item" @click="setAudioRoute">
<view class="call-operate" :style="buttonBgColor(isSpeakerOpen)">
<image mode="aspectFit" class="btn-image" :src="isSpeakerOpen ? IMG_SPEAKER_TRUE : IMG_SPEAKER_FALSE">
</image>
</view>
<text>{{ isSpeakerOpen ? '扬声器已开启' : '扬声器已关闭' }}</text>
</view>
<view class="btn-operate-item" @click="cameraHandler">
<view class="call-operate" :style="buttonBgColor(isCameraOpen)">
<image mode="aspectFit" class="btn-image" :src="isCameraOpen ? IMG_CAMERA_TRUE : IMG_CAMERA_FALSE"></image>
</view>
<text>{{ isCameraOpen ? '摄像头已开启' : '摄像头已关闭' }}</text>
</view>
</view>
<view class="btn-operate">
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="hangupHandler">
<image v-if="isHangupBtnClickable" :src="IMG_HANGUP" mode="aspectFit" />
<image v-else class="img-loading" :src="IMG_LOADING" mode="aspectFit"></image>
</view>
<view v-if="isCameraOpen" class="switch-camera">
<image :src="IMG_SWITCH_CAMERA" @click="switchCamera" mode="aspectFit"/>
</view>
<text>挂断</text>
</view>
</view>
</view>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import { onUnload } from '@dcloudio/uni-app'
import Avatar from '../Avatar/Avatar.vue';
import TRTCPusher from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPusher.vue';
import TRTCPlayer from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPlayer.vue';
import IMG_HANGUP from '../../assets/call/hangup.svg';
import IMG_ACCEPT from '../../assets/call/accept.svg';
import IMG_AUDIO_TRUE from '../../assets/call/microphone-open.svg';
import IMG_AUDIO_FALSE from '../../assets/call/microphone-close.svg';
import IMG_SPEAKER_TRUE from '../../assets/call/speaker-open.svg';
import IMG_SPEAKER_FALSE from '../../assets/call/speaker-close.svg';
import IMG_CAMERA_TRUE from '../../assets/call/camera-open.svg';
import IMG_CAMERA_FALSE from '../../assets/call/camera-close.svg';
import IMG_SWITCH_CAMERA from '../../assets/call/switch-camera.svg';
import IMG_DEFAULT_AVATAR from '../../assets/base/default-avatar.png';
import IMG_LOADING from '../../assets/call/loading.png';
import { formatDuration } from '../../utils/index';
import { CallMediaType, AudioPlayBackDevice } from '@trtc/call-engine-lite-wx';
import { CallStatus, CallRole } from '../../constants/call';
import { useCallListState, useCallParticipantState, useDeviceState } from '../../index';
const {
inviterId,
mediaType,
duration,
pusherId,
accept,
reject,
hangup,
} = useCallListState();
const { callParticipantInfo } = useCallParticipantState();
const {
currentAudioRoute,
openLocalCamera, closeLocalCamera,
openLocalMicrophone, closeLocalMicrophone,
switchCamera, setAudioRoute,
} = useDeviceState();
const isCallBtnClickable = ref((inviterId.value || '').length > 0 ? true : false);
const isAcceptBtnClickable = ref(true);
const isHangupBtnClickable = ref(true);
const isLocalStreamMain = ref(mediaType.value === CallMediaType.AUDIO ? false : true);
const isMicrophoneOpen = computed(() => callParticipantInfo?.value.selfInfo?.isMicrophoneOpened);
const isCameraOpen = computed(() => callParticipantInfo?.value.selfInfo?.isCameraOpened);
const isSpeakerOpen = computed(() => currentAudioRoute.value !== AudioPlayBackDevice.EAR);
const showConnectedTip = ref(false);
const bigOverlayAvatarStyle = ref({
position: 'absolute',
width: '100%',
height: '100%',
objectFit: 'cover',
zIndex: 1
});
const smallOverlayAvatarStyle = ref({
position: 'absolute',
width: '100%',
height: '100%',
objectFit: 'cover',
zIndex: 99
});
const avatarStyle = ref({
width: '100px',
height: '100px',
borderRadius: '12px',
display: 'block',
margin: '140px auto 15px'
});
function toggleViewSize() {
if (mediaType.value === CallMediaType.VIDEO) {
isLocalStreamMain.value = !isLocalStreamMain.value
}
}
async function cameraHandler() {
if (isCameraOpen.value) {
await closeLocalCamera();
} else {
await openLocalCamera();
}
}
async function microPhoneHandler() {
if (isMicrophoneOpen.value) {
await closeLocalMicrophone();
} else {
await openLocalMicrophone();
}
}
async function CallerHangupHandler() {
try {
if (!isCallBtnClickable.value) return;
await hangup();
} catch (error) {
isCallBtnClickable.value = true;
}
}
async function acceptHandler() {
try {
if (!isAcceptBtnClickable.value) {
console.warn(`previous accept is ongoing, please avoid repeat accept`);
return;
}
isAcceptBtnClickable.value = false;
await accept();
isAcceptBtnClickable.value = true;
} catch (error) {
isAcceptBtnClickable.value = true;
}
}
async function hangupHandler() {
try {
if (!isHangupBtnClickable.value) {
console.warn(`previous hangup is ongoing, please avoid repeat hangup`);
return;
}
isHangupBtnClickable.value = false;
await hangup();
isHangupBtnClickable.value = true;
} catch (error) {
isHangupBtnClickable.value = true;
}
}
const buttonBgColor = computed(() => (isActive: boolean) => {
return isActive ? { backgroundColor: '#FFFFFF', } : {
backgroundColor: '#22262E',
opacity: '0.5'
};
})
watch(() => callParticipantInfo?.value?.selfInfo, (newObj, oldObj) => {
const newStatus = newObj?.status;
if (mediaType.value === CallMediaType.VIDEO) {
isLocalStreamMain.value = newStatus === CallStatus.CALLING
}
if (mediaType.value === CallMediaType.AUDIO) {
isLocalStreamMain.value = false;
}
if (newStatus === CallStatus.CONNECTED && oldObj?.status === CallStatus.CALLING) {
showConnectedTip.value = true;
setTimeout(() => {
showConnectedTip.value = false;
}, 1000);
} else {
showConnectedTip.value = false;
}
if (newStatus === CallStatus.IDLE) {
isHangupBtnClickable.value = false;
isAcceptBtnClickable.value = true;
isHangupBtnClickable.value = true;
}
});
watch(inviterId, (newVal, oldVal) => {
isCallBtnClickable.value = (newVal || '').length > 0 ? true : false;
});
onUnload(() => {
const callStatus = callParticipantInfo?.value?.selfInfo?.status;
const callRole = callParticipantInfo?.value?.selfInfo?.role;
if (callStatus === CallStatus.IDLE) return;
// 已接通:页面卸载(返回、切后台、关闭小程序等)不主动 signaling 挂断,医生端可继续面诊;结束请点「挂断」
if (callStatus === CallStatus.CONNECTED) return;
if (callStatus === CallStatus.CALLING) {
if (callRole === CallRole.CALLER) {
hangup();
} else {
reject();
}
}
});
</script>
<style scoped lang="scss">
.TUICall-container {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
bottom: 0;
left: 0;
margin: 0;
background-color: #ffffff;
.topBar {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: center;
width: 100%;
z-index: 10;
.call-duration {
text-align: center;
font-family: PingFang SC;
font-weight: 500;
font-size: 12px;
color: #D5E0F2;
}
}
.big-video {
position: absolute;
width: 100%;
height: 100%;
z-index: 10;
background-color: black;
}
.big-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.big-overlay-avatar {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.big-overlay-mask {
background-color: rgba(0, 0, 0, 0.5);
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 2;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
}
.small-video {
position: absolute;
right: 16px;
top: 100px;
width: 100px;
height: 178px;
z-index: 100;
}
.small-overlay {
position: absolute;
right: 16px;
top: 100px;
width: 100px;
height: 178px;
z-index: 98;
.small-overlay-avatar {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 99;
}
.small-overlay-mask {
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
width: 100%;
height: 100%;
object-fit: cover;
z-index: 100;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
}
.player-audio {
width: 0;
height: 0;
}
.voice-invite-message {
position: fixed;
top: 30%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
text-align: center;
font-family: PingFang SC;
color: #D5E0F2;
font-weight: 500;
z-index: 100;
.nick-name {
font-size: 18px;
text-align: center;
}
}
.tips {
height: 14px;
position: fixed;
top: 70%;
left: 50%;
font-size: 14px;
font-family: PingFang SC;
transform: translate(-50%, -50%);
color: #D5E0F2;
font-weight: 500;
z-index: 100;
}
.footer {
position: absolute;
bottom: 5vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-size: 14px;
color: #f0e9e9;
font-weight: 400;
z-index: 100;
.btn-operate {
display: flex;
flex-direction: initial;
text-align: center;
align-items: center;
justify-content: center;
}
.btn-operate-item {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
text {
font-family: PingFang SC;
font-weight: 400;
font-size: 12px;
color: #D5E0F2;
}
.switch-camera {
position: absolute;
right: 98px;
width: 32px;
top: 60%;
margin-left: 40px;
height: 32px;
image {
width: 100%;
height: 100%;
}
}
.call-operate {
width: 60px;
height: 60px;
border-radius: 50%;
margin: 10px 10vw;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
image {
width: 30px;
height: 30px;
background: none;
}
.img-loading {
animation: rotate 1.5s linear infinite;
width: 30px;
height: 30px
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
text {
font-family: PingFang SC;
font-weight: 400;
font-size: 12px;
color: #D5E0F2;
}
}
}
}
}
</style>
@@ -0,0 +1,208 @@
<template>
<view class="conversation-list">
<!-- 会话列表 -->
<view v-for="conversation in conversationList" :key="conversation.conversationID" class="conversation-item"
@click="handleItemClick(conversation)">
<!-- 头像和未读数 -->
<view class="avatar-container">
<Avatar :avatarStyle='avatarStyle' :src='getConversationAvatar(conversation)'>
</Avatar>
<view v-if="conversation.unreadCount > 0" class="badge">
{{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }}
</view>
</view>
<!-- 会话内容 -->
<view class="content">
<!-- 昵称和时间 -->
<view class="header">
<text class="nickname">{{ getConversationDisplayName(conversation) }}</text>
<text class="time" v-if="conversation.lastMessage?.lastTime">{{
calculateTimestamp(conversation.lastMessage?.lastTime) }}</text>
</view>
<!-- 最后的消息 -->
<view v-if="conversation.lastMessage?.type" class="footer">
<text class="last-message">
{{ getLastMessagePreview(conversation.lastMessage) }}
</text>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { useConversationListState } from '../../index';
import Avatar from '../Avatar/Avatar.vue'
import defaultAvatarIcon from '../../assets/base/default-avatar.png';
import defaultGroupAvatarIcon from '../../assets/base/default-group-avatar.png';
import { handleCallKitSignaling, isCallSignaling } from '../../utils/processCallSignaling';
import { calculateTimestamp } from '../../utils/time';
import { MessageType } from '../../constants/chat'
const avatarStyle = {
width: '48px',
height: '48px',
borderRadius: '4px',
marginRight: '12px'
}
const props = defineProps({
onConversationSelect: {
type: Function,
default: null
}
})
const { conversationList, setActiveConversation } = useConversationListState();
const handleItemClick = (conversation: any) => {
if (props.onConversationSelect) {
props.onConversationSelect(conversation)
} else {
setActiveConversation(conversation.conversationID)
}
};
const getLastMessagePreview = (lastMessage?: any) => {
if (!lastMessage) return '';
switch (lastMessage.type) {
case MessageType.MSG_TEXT:
return lastMessage.payload.text;
case MessageType.MSG_IMAGE:
return '[图片]';
case MessageType.MSG_VIDEO:
return '[视频]';
case MessageType.MSG_CUSTOM:
return handleCustomMessage(lastMessage);
case MessageType.MSG_GRP_TIP:
return '[群提示消息]';
default:
return '[未知消息]';
}
};
const getConversationDisplayName = (conversation: any) => {
// 群组会话:使用群组名称
if (conversation.type === 'GROUP') {
return conversation.groupProfile?.name || conversation.conversationID;
}
// C2C 会话:备注 -> 昵称 -> 用户ID
return conversation.remark || conversation.userProfile?.nick || conversation.userProfile?.userID;
};
const getConversationAvatar = (conversation: any) => {
// 群组会话:使用群组头像
if (conversation.type === 'GROUP') {
return conversation.groupProfile?.avatar || defaultGroupAvatarIcon;
}
// C2C 会话:使用用户头像 -> 默认头像
return conversation.userProfile?.avatar || defaultAvatarIcon;
};
function handleCustomMessage(message) {
if (!isCallSignaling(message)) {
return '[自定义消息]'
}
return handleCallKitSignaling(message).callTip
}
</script>
<style lang="scss" scoped>
.conversation-list {
background-color: #F9FAFC;
height: 100%;
overflow-y: auto;
}
.conversation-item {
display: flex;
padding: 12px 16px;
background-color: #fff;
border-bottom: 1px solid #f0f0f0;
&:active {
background-color: #f9f9f9;
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.nickname {
font-family: PingFang SC;
font-size: 17px;
font-weight: 400;
color: #000000E5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.time {
font-family: PingFang HK;
font-style: Regular;
font-weight: 400;
font-size: 12px;
color: #00000066;
margin-left: 8px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.last-message {
font-family: PingFang SC;
font-weight: 400;
font-style: Regular;
font-size: 14px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.avatar-container {
width: 48px;
height: 48px;
position: relative;
margin-right: 12px;
}
.badge {
position: absolute;
top: -6px;
right: -6px;
background-color: #E54545;
color: white;
font-size: 12px;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
z-index: 1;
line-height: 18px;
}
</style>
@@ -0,0 +1,244 @@
<template>
<view class="modal-overlay" @click="handleClose">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">创建群聊</text>
<view class="close-btn" @click="handleClose">×</view>
</view>
<scroll-view class="form-container" scroll-y>
<view class="form-item">
<text class="form-label">群名称</text>
<input class="form-input" v-model="groupForm.name" placeholder="请输入群名称" />
</view>
<view class="form-item">
<text class="form-label">群ID可选</text>
<input class="form-input" v-model="groupForm.groupID" placeholder="请输入群ID,不填则自动生成" />
</view>
<view class="form-item">
<text class="form-label">群介绍可选</text>
<textarea class="form-textarea" v-model="groupForm.introduction" placeholder="请输入群介绍" />
</view>
<view class="form-item">
<text class="form-label">群头像可选</text>
<input class="form-input" v-model="groupForm.avatar" placeholder="请输入群头像URL" />
</view>
</scroll-view>
<view class="modal-footer">
<button class="cancel-btn" @click="handleClose">取消</button>
<button class="submit-btn" @click="handleSubmit">创建群聊</button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { reactive } from 'vue';
interface Emits {
(e: 'close', value: boolean): void
(e: 'submit', groupInfo: any): void
}
const emit = defineEmits<Emits>()
const groupForm = reactive({
name: '',
groupID: '',
introduction: '',
avatar: ''
})
// 关闭弹窗
const handleClose = () => {
emit('close', false)
resetForm()
}
// 提交表单
const handleSubmit = () => {
if (!groupForm.name.trim()) {
uni.showToast({
title: '请输入群名称',
icon: 'none'
})
return
}
const groupInfo = {
name: groupForm.name,
groupID: groupForm.groupID || '',
introduction: groupForm.introduction || '',
avatar: groupForm.avatar || '',
}
emit('submit', groupInfo)
handleClose()
}
const resetForm = () => {
groupForm.name = ''
groupForm.groupID = ''
groupForm.introduction = ''
groupForm.avatar = ''
}
</script>
<style lang="scss" scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 100%;
max-height: 80vh;
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease-out;
box-sizing: border-box;
overflow: hidden;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 30rpx 24rpx;
border-bottom: 1rpx solid #e8e8e8;
position: relative;
box-sizing: border-box;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #1a1a1a;
flex: 1;
text-align: center;
}
.close-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #999;
position: absolute;
right: 30rpx;
}
.form-container {
flex: 1;
max-height: 80vh;
padding: 30rpx;
overflow: hidden;
box-sizing: border-box;
}
.form-item {
margin-bottom: 32rpx;
box-sizing: border-box;
}
.form-label {
display: block;
font-size: 28rpx;
color: #1a1a1a;
margin-bottom: 16rpx;
font-weight: 500;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
background-color: #f8f9fa;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
min-height: 160rpx;
padding: 24rpx;
background-color: #f8f9fa;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
line-height: 1.5;
box-sizing: border-box;
}
.modal-footer {
display: flex;
gap: 20rpx;
padding: 24rpx 30rpx 40rpx;
border-top: 1rpx solid #e8e8e8;
box-sizing: border-box;
}
.cancel-btn {
flex: 1;
height: 80rpx;
background-color: #f8f9fa;
color: #666;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.submit-btn {
flex: 1;
height: 80rpx;
background-color: #07c160;
color: #fff;
border: none;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.cancel-btn:active {
background-color: #e8e8e8;
}
.submit-btn:active {
background-color: #06a854;
}
</style>
@@ -0,0 +1,421 @@
<template>
<view class="group-members">
<!-- 群成员网格 -->
<view class="members-grid">
<!-- 群成员头像 -->
<view
v-for="(member, index) in displayMembers"
:key="member.userID"
class="member-item"
@click="onMemberClick(member)"
>
<view class="member-avatar-container">
<Avatar
:avatarStyle="avatarStyle"
:src="member.avatar || defaultAvatarIcon"
/>
<!-- 群主标识 -->
<view v-if="member.role === GroupMemberRole.OWNER" class="owner-badge">
<text class="owner-text">群主</text>
</view>
</view>
<text class="member-nick">{{ member.nameCard || member.nick || member.userID }}</text>
</view>
<!-- 添加成员按钮 -->
<view v-if="!props.readonly" class="member-item add-member" @click="onAddMember">
<view class="member-avatar-container">
<view class="add-icon">+</view>
</view>
</view>
<!-- 删除成员按钮 (仅群主可见) -->
<view
v-if="!props.readonly && currentUserRole === GroupMemberRole.OWNER"
class="member-item remove-member"
@click="onRemoveMember"
>
<view class="member-avatar-container">
<view class="remove-icon"></view>
</view>
</view>
</view>
<!-- 折叠/展开按钮 -->
<view
v-if="!props.readonly && totalMembers > displayLimit"
class="toggle-button"
@click="toggleExpanded"
>
<text class="toggle-text">
{{ isExpanded ? '收起' : `查看更多成员(${totalMembers - displayLimit})` }}
</text>
<text class="toggle-icon">{{ isExpanded ? '▲' : '▼' }}</text>
</view>
<!-- 添加成员弹窗 -->
<view v-if="showAddMemberModal" class="modal-overlay" @click="showAddMemberModal = false">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">添加群成员</text>
<text class="close-btn" @click="showAddMemberModal = false">×</text>
</view>
<view class="modal-body">
<UserPicker
ref="userPickerRef"
:maxCount="maxAddCount"
:excludeUserIDs="currentMemberIDs"
:inModal="true"
@confirm="handleAddMembers"
/>
</view>
</view>
</view>
<!-- 删除成员弹窗 -->
<view v-if="showRemoveMemberModal" class="modal-overlay" @click="showRemoveMemberModal = false">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">删除群成员</text>
<text class="close-btn" @click="showRemoveMemberModal = false">×</text>
</view>
<view class="modal-body">
<UserPicker
ref="removeUserPickerRef"
:maxCount="maxRemoveCount"
:dataSource="removableMembers"
mode="remove"
:enableDelete="false"
:enableSearch="false"
:inModal="true"
@confirm="handleRemoveMembers"
/>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { GroupMemberRole } from '../../states/GroupSettingState/types';
import { useGroupSettingState } from '../../states/GroupSettingState/GroupSettingState';
import Avatar from '../Avatar/Avatar.vue';
import UserPicker from '../UserPicker/UserPicker.vue';
import defaultAvatarIcon from '../../assets/base/default-avatar.png';
interface GroupMember {
userID: string;
nick?: string;
avatar?: string;
role?: string;
nameCard?: string;
}
interface Props {
displayLimit?: number;
maxAddCount?: number;
maxRemoveCount?: number;
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
displayLimit: 12,
maxAddCount: 50,
maxRemoveCount: 50,
readonly: false
});
const emit = defineEmits<{
memberClick: [member: GroupMember];
addMembers: [userIDs: string[]];
removeMembers: [userIDs: string[]];
}>();
const {
allMembers,
currentUserRole,
} = useGroupSettingState();
const isExpanded = ref(false);
const showAddMemberModal = ref(false);
const showRemoveMemberModal = ref(false);
const userPickerRef = ref();
const removeUserPickerRef = ref();
const avatarStyle = {
width: '100%',
height: '100%'
};
const totalMembers = computed(() => allMembers.value?.length || 0);
const displayMembers = computed(() => {
const members = allMembers.value || [];
if (props.readonly || isExpanded.value) {
return members;
}
return members.slice(0, props.displayLimit);
});
const removableMembers = computed(() => {
// 过滤掉群主
return allMembers.value?.filter(member => member.role !== GroupMemberRole.OWNER) || [];
});
const currentMemberIDs = computed(() => {
return allMembers.value?.map(member => member.userID) || [];
});
const onMemberClick = (member: GroupMember) => {
emit('memberClick', member);
};
const onAddMember = () => {
showAddMemberModal.value = true;
};
const onRemoveMember = () => {
if (currentUserRole.value !== GroupMemberRole.OWNER) {
uni.showToast({
title: '只有群主可以删除成员',
icon: 'none'
});
return;
}
showRemoveMemberModal.value = true;
};
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
const handleAddMembers = async (userIDs: string[]) => {
try {
await emit('addMembers', userIDs);
showAddMemberModal.value = false;
uni.showToast({
title: '添加成员成功',
icon: 'success'
});
} catch (error) {
console.error('添加成员失败:', error);
uni.showToast({
title: '添加成员失败',
icon: 'none'
});
}
};
const handleRemoveMembers = async (userIDs: string[]) => {
try {
await emit('removeMembers', userIDs);
showRemoveMemberModal.value = false;
uni.showToast({
title: '删除成员成功',
icon: 'success'
});
} catch (error) {
console.error('删除成员失败:', error);
uni.showToast({
title: '删除成员失败',
icon: 'none'
});
}
};
</script>
<style lang="scss" scoped>
.group-members {
background: white;
padding: 0 30rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.members-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
margin-bottom: 20rpx;
}
.member-item {
width: calc((100% - 100rpx) / 6); // 6列布局
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20rpx;
}
.member-avatar-container {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
overflow: hidden;
position: relative;
margin-bottom: 12rpx;
}
.add-member .member-avatar-container,
.remove-member .member-avatar-container {
overflow: visible;
}
.add-icon,
.remove-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
font-weight: 300;
line-height: 1;
border-radius: 50%;
background: #f8f9fa;
color: #999;
transition: all 0.2s ease;
}
.add-icon {
color: #07c160;
background: rgba(7, 193, 96, 0.08);
}
.add-icon:active {
background: rgba(7, 193, 96, 0.15);
transform: scale(0.95);
}
.remove-icon {
color: #fa5151;
background: rgba(250, 81, 81, 0.08);
}
.remove-icon:active {
background: rgba(250, 81, 81, 0.15);
transform: scale(0.95);
}
.owner-badge {
position: absolute;
bottom: 0rpx;
left: 50%;
transform: translateX(-50%);
background: #ff9500;
color: white;
font-size: 18rpx;
padding: 2rpx 6rpx;
border-radius: 6rpx;
white-space: nowrap;
z-index: 10;
line-height: 1;
}
.owner-text {
font-size: 18rpx;
line-height: 1;
}
.member-nick {
font-size: 24rpx;
color: #333;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toggle-button {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 20rpx;
margin-top: 10rpx;
background: #f8f9fa;
border-radius: 12rpx;
transition: background-color 0.2s;
}
.toggle-button:active {
background: #e9ecef;
}
.toggle-text {
font-size: 28rpx;
color: #666;
}
.toggle-icon {
font-size: 24rpx;
color: #999;
}
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 95vw;
max-width: 900rpx;
height: 90vh;
max-height: 950rpx;
background: white;
border-radius: 24rpx;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
flex-shrink: 0;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.close-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #999;
cursor: pointer;
}
.modal-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>
@@ -0,0 +1,721 @@
<template>
<view class="group-settings">
<!-- 顶部群信息 -->
<view class="group-header">
<view class="group-avatar">
<Avatar :avatarStyle="groupAvatarStyle" :src="avatar || defaultGroupAvatarIcon"></Avatar>
</view>
<view class="group-basic-info">
<div class="group-name">{{ groupName || '群聊' }}</div>
<div class="group-id">群ID: {{ groupID || '--' }}</div>
</view>
<view class="edit-name-btn" @click.stop="editGroupName" v-if="currentUserRole === GroupMemberRole.OWNER">
<text class="edit-icon"></text>
</view>
</view>
<!-- 设置项列表 -->
<view class="settings-list">
<!-- 群成员 -->
<view class="setting-item member-section">
<view class="item-left">
<text class="item-label">群成员</text>
</view>
<view class="item-right">
<text class="member-count">{{ memberCount || 0 }}</text>
</view>
</view>
<GroupMembers @addMembers="onAddMembers" @removeMembers="onRemoveMembers" />
<!-- 群公告 -->
<view class="setting-item" @click="showGroupNotice">
<view class="item-left">
<text class="item-label">群公告</text>
</view>
<view class="item-right">
<text class="item-value notice-preview">{{ noticePreview }}</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
<!-- 群类型 -->
<view class="setting-item group-type-item" @click="showGroupType">
<view class="item-left">
<text class="item-label">群类型</text>
</view>
<view class="item-right">
<text class="item-value">{{ groupTypeText }}</text>
</view>
</view>
<!-- 退出群聊/解散群组按钮 -->
<view class="exit-section">
<div class="exit-btn" @click="confirmExit">
{{ currentUserRole === GroupMemberRole.OWNER ? '解散群组' : '退出群聊' }}
</div>
</view>
<!-- 编辑群名称弹窗 -->
<view v-if="showEditGroupName" class="modal-overlay" @click="showEditGroupName = false">
<view class="edit-modal" @click.stop>
<view class="modal-content">
<text class="modal-title">编辑群名称</text>
<input v-model="editGroupNameValue" class="edit-input" maxlength="30" />
<view class="modal-buttons">
<button class="btn-cancel" @click="showEditGroupName = false">取消</button>
<button class="btn-confirm" @click="saveGroupName">保存</button>
</view>
</view>
</view>
</view>
<!-- 编辑群公告弹窗 -->
<view v-if="showEditGroupNotice" class="modal-overlay" @click="showEditGroupNotice = false">
<view class="edit-modal" @click.stop>
<view class="modal-content">
<text class="modal-title">{{ currentUserRole === GroupMemberRole.OWNER ? '编辑群公告' : '群公告' }}</text>
<textarea v-if="currentUserRole === GroupMemberRole.OWNER" v-model="editGroupNoticeValue"
class="edit-textarea" maxlength="130" />
<view v-else class="notice-content">
<text>{{ notification || '暂无公告' }}</text>
</view>
<view class="modal-buttons">
<button class="btn-cancel" @click="showEditGroupNotice = false">取消</button>
<button v-if="currentUserRole === GroupMemberRole.OWNER" class="btn-confirm"
@click="saveGroupNotice">保存</button>
</view>
</view>
</view>
</view>
<!-- 确认退出弹窗 -->
<view v-if="showExitConfirm" class="modal-overlay" @click="showExitConfirm = false">
<view class="confirm-modal" @click.stop>
<view class="modal-content">
<text class="modal-title">{{ currentUserRole === GroupMemberRole.OWNER ? '解散群组' : '退出群聊' }}</text>
<text class="modal-desc">
{{ currentUserRole === GroupMemberRole.OWNER ? '解散后群组将被永久删除,所有成员将被移除' : '退出后将不再接收此群聊的消息' }}
</text>
<view class="modal-buttons">
<button class="btn-cancel" @click="showExitConfirm = false">取消</button>
<button class="btn-confirm danger" @click="handleQuitGroup">
{{ currentUserRole === GroupMemberRole.OWNER ? '解散' : '退出' }}
</button>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { GroupType, GroupInviteType, GroupMemberRole } from '../../states/GroupSettingState/types';
import { useGroupSettingState } from '../../states/GroupSettingState/GroupSettingState';
import Avatar from '../Avatar/Avatar.vue';
import GroupMembers from './GroupMembers.vue';
import defaultGroupAvatarIcon from '../../assets/base/default-group-avatar.png';
const {
// 状态数据
groupID,
groupType,
groupName,
avatar,
notification,
memberCount,
allMembers,
currentUserID,
currentUserRole,
inviteOption,
// 业务方法
quitGroup,
dismissGroup,
getGroupMemberList,
updateGroupProfile,
addGroupMember,
deleteGroupMember
} = useGroupSettingState();
// 弹窗状态
const showExitConfirm = ref(false);
const showEditGroupName = ref(false);
const showEditGroupNotice = ref(false);
// 编辑值
const editGroupNameValue = ref('');
const editGroupNoticeValue = ref('');
const groupAvatarStyle = {
width: '100%',
height: '100%'
}
// 群公告预览
const noticePreview = computed(() => {
const notice = notification.value || '暂无公告';
return notice.length > 15 ? notice.substring(0, 15) + '...' : notice;
});
// 群类型文本
const groupTypeText = computed(() => {
const types = {
[GroupType.PUBLIC]: '公开群',
[GroupType.MEETING]: '会议群',
[GroupType.WORK]: '工作群'
};
return types[groupType.value as GroupType] || '普通群';
});
// 加群方式文本
const joinMethodText = computed(() => {
const methods = {
[GroupInviteType.FREE_ACCESS]: '自由加入',
[GroupInviteType.NEED_PERMISSION]: '需要验证',
[GroupInviteType.DISABLE_APPLY]: '禁止加入'
};
return methods[inviteOption.value as GroupInviteType] || '未知方式';
});
// 方法定义
const editGroupName = () => {
console.log('editGroupName 被调用,currentUserRole:', currentUserRole.value);
if (currentUserRole.value !== GroupMemberRole.OWNER) {
uni.showToast({
title: '只有群主可以修改群名称',
icon: 'none'
});
return;
}
editGroupNameValue.value = '';
showEditGroupName.value = true;
};
const onAddMembers = async (userIDs: string[]) => {
try {
await addGroupMember({ userIDList: userIDs });
} catch (error) {
console.error('添加群成员失败:', error);
throw error;
}
};
const onRemoveMembers = async (userIDs: string[]) => {
try {
await deleteGroupMember({ userIDList: userIDs });
} catch (error) {
console.error('删除群成员失败:', error);
throw error;
}
};
const showGroupNotice = () => {
if (currentUserRole.value === GroupMemberRole.OWNER) {
editGroupNoticeValue.value = notification.value || '';
}
showEditGroupNotice.value = true;
};
// 保存方法
const saveGroupName = async () => {
if (!editGroupNameValue.value.trim()) {
uni.showToast({
title: '群名称不能为空',
icon: 'none'
});
return;
}
try {
await updateGroupProfile({
name: editGroupNameValue.value.trim()
});
uni.showToast({
title: '群名称修改成功',
icon: 'success'
});
showEditGroupName.value = false;
} catch (error) {
console.error('修改群名称失败:', error);
uni.showToast({
title: '修改群名称失败',
icon: 'none'
});
}
};
const saveGroupNotice = async () => {
try {
await updateGroupProfile({
notification: editGroupNoticeValue.value.trim()
});
uni.showToast({
title: '群公告修改成功',
icon: 'success'
});
showEditGroupNotice.value = false;
} catch (error) {
console.error('修改群公告失败:', error);
uni.showToast({
title: '修改群公告失败',
icon: 'none'
});
}
};
const confirmExit = () => {
showExitConfirm.value = true;
};
const handleQuitGroup = async () => {
try {
if (currentUserRole.value === GroupMemberRole.OWNER) {
// 群主调用解散群组
await dismissGroup();
} else {
// 群成员调用退出群组
await quitGroup();
}
uni.showToast({
title: currentUserRole.value === GroupMemberRole.OWNER ? '解散群组成功' : '退出群聊成功',
icon: 'success'
});
// 返回上一页
uni.navigateBack();
} catch (error) {
console.error(currentUserRole.value === GroupMemberRole.OWNER ? '解散群组失败:' : '退出群聊失败:', error);
uni.showToast({
title: currentUserRole.value === GroupMemberRole.OWNER ? '解散群组失败' : '退出群聊失败',
icon: 'none'
});
} finally {
showExitConfirm.value = false;
}
};
</script>
<style lang="scss" scoped>
.group-settings {
background: #F9FAFC;
min-height: 100vh;
padding: 0;
}
/* 群信息头部 */
.group-header {
background: #FFFFFF;
padding: 40rpx 30rpx;
display: flex;
align-items: center;
border-bottom: 1rpx solid #e5e5e5;
position: relative;
}
.group-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 16rpx;
overflow: hidden;
margin-right: 30rpx;
background: white;
border: 1rpx solid #e5e5e5;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
}
.group-basic-info {
flex: 1;
}
.group-basic-info .group-name {
font-size: 36rpx;
font-weight: 600;
color: #000;
margin-bottom: 12rpx;
line-height: 1.2;
}
.group-basic-info .group-id {
font-size: 26rpx;
color: #999;
font-weight: 400;
}
.edit-name-btn {
position: absolute;
top: 40rpx;
right: 30rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
}
.edit-name-btn:active {
background-color: #e9ecef;
transform: scale(0.95);
}
.edit-icon {
font-size: 28rpx;
}
/* 设置项列表 */
.settings-list {
background: white;
margin-bottom: 20rpx;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
margin-top: 16rpx;
/* 8px 间距 */
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
background: white;
position: relative;
min-height: 96rpx;
transition: background-color 0.2s ease;
}
.setting-item:active {
background-color: #f8f8f8;
}
.setting-item:last-child {
border-bottom: none;
}
.item-left .item-label {
font-family: PingFang SC;
font-weight: 400;
font-size: 28rpx;
line-height: 100%;
letter-spacing: 0px;
color: #000;
}
.item-right {
display: flex;
align-items: center;
gap: 20rpx;
}
.item-value {
font-size: 30rpx;
color: #999;
max-width: 400rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.notice-preview {
max-width: 320rpx;
}
.item-arrow {
color: #c7c7cc;
font-size: 28rpx;
font-weight: normal;
opacity: 0.6;
}
/* 群成员区域 */
.member-section {
border-bottom: none !important;
padding-bottom: 0;
}
/* 群类型和我的群昵称之间无间隙 */
.setting-item:not(.member-section):not(.group-type-item) {
margin-bottom: 0;
}
/* 群类型和退出群聊之间增加间隙 */
.group-type-item {
margin-bottom: 16rpx;
/* 8px 间距 */
}
.member-section .item-right {
display: flex;
align-items: center;
gap: 20rpx;
}
.member-count {
font-size: 30rpx;
color: #999;
}
/* 退出群聊区域 */
.exit-section {
background: white;
padding: 20rpx 0;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
margin-bottom: 40rpx;
border: none;
/* 移除边框 */
}
.exit-btn {
width: 100%;
padding: 32rpx 30rpx;
background: transparent;
border: none;
color: #E54545;
font-family: PingFang SC;
font-weight: 400;
font-size: 28rpx;
/* 14px */
line-height: 100%;
letter-spacing: 0px;
text-align: center;
transition: background-color 0.2s ease;
}
.exit-btn:active {
background-color: #f8f8f8;
}
/* 确认弹窗 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 60rpx 40rpx;
backdrop-filter: blur(4rpx);
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.confirm-modal {
background: white;
border-radius: 24rpx;
width: 580rpx;
max-width: 90vw;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.2);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(40rpx);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-content {
padding: 64rpx 48rpx 48rpx;
text-align: center;
}
.modal-title {
display: block;
font-size: 38rpx;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 24rpx;
line-height: 1.3;
}
.modal-desc {
display: block;
font-size: 32rpx;
color: #666;
line-height: 1.5;
margin-bottom: 48rpx;
}
.modal-buttons {
display: flex;
border-top: 1rpx solid #f0f0f0;
}
.modal-buttons button {
flex: 1;
padding: 32rpx 0;
border: none;
background: transparent;
font-size: 34rpx;
font-weight: 500;
position: relative;
line-height: 1;
transition: all 0.2s ease;
}
.modal-buttons button::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.05);
opacity: 0;
transition: opacity 0.2s ease;
}
.modal-buttons button:active::after {
opacity: 1;
}
.btn-cancel {
color: #666;
border-right: 1rpx solid #f0f0f0;
}
.btn-confirm {
color: #007aff;
font-weight: 600;
}
.btn-confirm.danger {
color: #ff453a;
}
/* 编辑弹窗样式 */
.edit-modal {
background: white;
border-radius: 24rpx;
width: 580rpx;
max-width: 90vw;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.2);
animation: slideUp 0.3s ease-out;
}
.edit-input {
height: 100rpx;
width: 100%;
padding: 0 32rpx;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
font-size: 34rpx;
margin: 32rpx 0;
background: #fafafa;
box-sizing: border-box;
transition: all 0.3s ease;
color: #333;
}
.edit-input:focus {
border-color: #007aff;
background: white;
outline: none;
box-shadow: 0 0 0 4rpx rgba(0, 122, 255, 0.1);
}
.edit-textarea {
width: 100%;
min-height: 260rpx;
padding: 32rpx;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
font-size: 34rpx;
margin: 32rpx 0;
background: #fafafa;
resize: none;
box-sizing: border-box;
line-height: 1.6;
transition: all 0.3s ease;
color: #333;
}
.edit-textarea:focus {
border-color: #007aff;
background: white;
outline: none;
box-shadow: 0 0 0 4rpx rgba(0, 122, 255, 0.1);
}
.notice-content {
width: 100%;
min-height: 260rpx;
padding: 32rpx;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
font-size: 34rpx;
margin: 32rpx 0;
background: #f8f9fa;
color: #666;
line-height: 1.6;
box-sizing: border-box;
border-radius: 16rpx;
}
/* 响应式设计 */
@media (max-width: 375px) {
.group-header {
padding: 30rpx 20rpx;
}
.group-avatar {
width: 100rpx;
height: 100rpx;
}
.group-basic-info .group-name {
font-size: 32rpx;
}
.setting-item {
padding: 25rpx;
}
.item-left .item-label {
font-size: 30rpx;
}
.item-value {
font-size: 26rpx;
}
}
</style>
@@ -0,0 +1,31 @@
.panel-box {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
margin: 10px;
}
.panel-item {
background-color: #F9FAFC;
width: 64px;
height: 64px;
border-radius: 14px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 4px;
}
.panel-icon {
width: 30px;
height: 30px;
}
.panel-text {
font-family: PingFang SC;
font-weight: 400;
font-size: 12px;
color: #00000066;
opacity: 0.8;
}
@@ -0,0 +1,49 @@
<template>
<div class="panel-box">
<div class="panel-item" @click="handleClick">
<image class="panel-icon" :src="CameraPickerIcon"></image>
</div>
<text class="panel-text">拍照</text>
</div>
</template>
<script lang="ts" setup>
import { useMessageInputState } from '../../../index';
import CameraPickerIcon from '../../../assets/chat/camera-picker.svg'
import { MessageContentType } from '../../../constants/chat'
const { sendMessage } = useMessageInputState();
const emit = defineEmits(['closePanel']);
const handleClick = () => {
uni.chooseMedia({
count: 1,
mediaType: ['image', 'video'],
sizeType: ['original', 'compressed'],
sourceType: ['camera'],
camera: 'back',
success: function (res: any) {
emit('closePanel');
if (res.type === MessageContentType.IMAGE) {
sendMessage({
type: MessageContentType.IMAGE,
content: res
})
}
if (res.type === MessageContentType.VIDEO) {
sendMessage({
type: MessageContentType.VIDEO,
content: res
})
}
},
fail: function (err: any) {
console.error('chooseMedia failed:', err);
}
});
}
</script>
<style lang="scss" scoped>
@import './AttachmentPicker.module.scss';
</style>
@@ -0,0 +1,48 @@
<template>
<div class="panel-box">
<div class="panel-item" @click="handleClick">
<image class="panel-icon" :src="PhotoPickerIcon"></image>
</div>
<text class="panel-text">图片</text>
</div>
</template>
<script lang="ts" setup>
import { useMessageInputState } from '../../../states/MessageInputState';
import PhotoPickerIcon from '../../../assets/chat/photo-picker.svg'
import { MessageContentType } from '../../../constants/chat'
const { sendMessage } = useMessageInputState();
const emit = defineEmits(['closePanel']);
const handleClick = () => {
uni.chooseMedia({
count: 1,
mediaType: ['image', 'video'],
sizeType: ['original', 'compressed'],
sourceType: ['album'],
success: function (res: any) {
emit('closePanel');
if (res.type === MessageContentType.IMAGE) {
sendMessage({
type: MessageContentType.IMAGE,
content: res
})
}
if (res.type === MessageContentType.VIDEO) {
sendMessage({
type: MessageContentType.VIDEO,
content: res
})
}
},
fail: function (err: any) {
console.error('chooseMedia failed:', err);
}
});
}
</script>
<style lang="scss" scoped>
@import './AttachmentPicker.module.scss';
</style>
@@ -0,0 +1,42 @@
<template>
<div class="panel-box">
<div class="panel-item" @click="handleCall">
<image class="panel-icon" :src="CallVideoIcon"></image>
</div>
<text class="panel-text">视频通话</text>
</div>
</template>
<script lang="ts" setup>
import { useConversationListState } from '../../../states/ConversationListState';
import { removeC2C } from '../../../utils'
import CallVideoIcon from '../../../assets/chat/call-video.svg'
import { TUIBridge } from '../../../TUIBridge';
import { EVENT } from '../../../constants/event';
const emit = defineEmits(['closePanel', 'showUserPicker']);
const { activeConversation } = useConversationListState();
const handleCall = () => {
emit('closePanel');
if (activeConversation.value?.type === 'GROUP') {
// 群聊:触发显示用户选择器事件
emit('showUserPicker', { type: 2 });
} else {
// 单聊:直接呼叫
const userID = removeC2C(activeConversation.value.conversationID);
TUIBridge.notifyEvent({
eventName: EVENT.ON_CALLS,
params: {
userIDList: [userID],
type: 2,
},
});
}
};
</script>
<style lang="scss" scoped>
@import './AttachmentPicker.module.scss';
</style>
@@ -0,0 +1,43 @@
<template>
<div class="panel-box">
<div class="panel-item" @click="handleCall">
<image class="panel-icon" :src="CallVoiceIcon"></image>
</div>
<text class="panel-text">语音通话</text>
</div>
</template>
<script lang="ts" setup>
import { useConversationListState } from '../../../states/ConversationListState';
import { removeC2C } from '../../../utils'
import CallVoiceIcon from '../../../assets/chat/call-voice.svg'
import { TUIBridge } from '../../../TUIBridge';
import { EVENT } from '../../../constants/event';
const emit = defineEmits(['closePanel', 'showUserPicker']);
const { activeConversation } = useConversationListState();
const handleCall = () => {
emit('closePanel');
if (activeConversation.value?.type === 'GROUP') {
// 群聊:触发显示用户选择器事件
emit('showUserPicker', { type: 1 });
} else {
// 单聊:直接呼叫
const userID = removeC2C(activeConversation.value.conversationID);
TUIBridge.notifyEvent({
eventName: EVENT.ON_CALLS,
params: {
userIDList: [userID],
type: 1,
},
});
}
};
</script>
<style lang="scss" scoped>
@import './AttachmentPicker.module.scss';
</style>
@@ -0,0 +1,328 @@
<template>
<!-- 消息输入容器-->
<div class="message-input-container" :style="{ paddingBottom: keyboardHeight > 0 ? keyboardHeight + 'px' : '' }">
<!-- 正常状态可以发送消息 -->
<view class="input-container" v-if="canSendMessage">
<!-- 消息输入框 -->
<input class="message-input" type="text" :value="inputRawValue" confirm-type="send" @confirm="sendInputMessage"
@input="updateRawValue($event.target.value)" :adjust-position="false"
:style="{ borderColor: inputRawValue ? '#006eff' : 'transparent' }" />
<!-- 更多功能按钮当输入框为空时显示 -->
<view class="icon-btn" v-if="!inputRawValue" @click="toggleMorePanel">
<image :src="MorePanelIcon"></image>
</view>
<!-- 发送按钮当输入框有内容时显示 -->
<view class="send-btn" v-if="inputRawValue" @click="sendInputMessage">
<text>发送</text>
</view>
</view>
<!-- 退出群聊状态显示提示信息 -->
<view class="input-container disabled-container" v-else>
<view class="disabled-message">
<text class="disabled-text">你已退出群聊无法发送消息</text>
</view>
</view>
<!-- 更多功能面板 -->
<view class="more-panel" v-if="showMorePanel" @touchmove.stop.prevent>
<view class="panel-content">
<ImagePicker @closePanel="closePanel" />
<PhotoPicker @closePanel="closePanel" />
<VideoCallPicker @closePanel="closePanel" @showUserPicker="handleShowUserPicker" />
<VoiceCallPicker @closePanel="closePanel" @showUserPicker="handleShowUserPicker" />
</view>
</view>
<!-- 用户选择器弹窗 -->
<view v-if="showUserPicker" class="user-picker-modal">
<view class="modal-mask" @click="closeUserPicker"></view>
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">选择通话用户</text>
<view class="close-btn" @click="closeUserPicker">×</view>
</view>
<UserPicker :maxCount="1" :dataSource="filteredGroupMembers" :enableDelete="false" :inModal="true"
@confirm="handleUserSelect" />
</view>
</view>
</div>
</template>
<script lang="ts">
export default {
options: {
virtualHost: true,
}
}
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useMessageInputState } from '../../states/MessageInputState';
import { useConversationListState } from '../../states/ConversationListState';
import { useGroupSettingState } from '../../states/GroupSettingState';
import VideoCallPicker from './AttachmentPicker/VideoCallPicker.vue';
import VoiceCallPicker from './AttachmentPicker/voiceCallPicker.vue';
import ImagePicker from './AttachmentPicker/CameraPicker.vue';
import PhotoPicker from './AttachmentPicker/PhotoPicker.vue';
import UserPicker from '../UserPicker/UserPicker.vue';
import MorePanelIcon from '../../assets/chat/more-panel.svg';
import { TUIBridge } from '../../TUIBridge';
import { EVENT } from '../../constants/event';
import { MessageContentType } from '../../constants/chat'
const { inputRawValue, updateRawValue, sendMessage } = useMessageInputState();
const { activeConversation } = useConversationListState();
const { allMembers, isInGroup, currentUserID, getGroupMemberList } = useGroupSettingState();
const showMorePanel = ref(false);
const showUserPicker = ref(false);
const currentCallType = ref(1);
const keyboardHeight = ref(0);
// 判断是否可以发送消息
const canSendMessage = computed(() => {
// 如果不是群聊,可以发送消息
if (activeConversation.value?.type !== 'GROUP') {
return true;
}
// 如果是群聊,检查是否在群里
return isInGroup.value !== false;
});
// 过滤掉自己的群成员列表
const filteredGroupMembers = computed(() => {
return (allMembers.value || []).filter(member => member.userID !== currentUserID.value);
});
const toggleMorePanel = () => {
showMorePanel.value = !showMorePanel.value;
};
const closePanel = () => {
showMorePanel.value = false;
};
const sendInputMessage = async () => {
if (!inputRawValue.value) return;
sendMessage({
type: MessageContentType.TEXT,
content: inputRawValue.value
});
inputRawValue.value = '';
};
const handleShowUserPicker = ({ type }) => {
currentCallType.value = type;
showUserPicker.value = true;
};
const handleUserSelect = (selectedUserIDs: string[]) => {
if (selectedUserIDs.length > 0) {
TUIBridge.notifyEvent({
eventName: EVENT.ON_CALLS,
params: {
userIDList: selectedUserIDs,
type: currentCallType.value,
// groupID: activeConversation.value.groupID,
},
});
}
closeUserPicker();
};
const closeUserPicker = () => {
showUserPicker.value = false;
};
onMounted(async () => {
if (activeConversation.value?.type === 'GROUP') {
await getGroupMemberList({ count: 100 });
}
// Listen for keyboard height changes
uni.onKeyboardHeightChange((res) => {
keyboardHeight.value = res.height;
if (res.height > 0) {
// Close more panel when keyboard pops up
showMorePanel.value = false;
uni.$emit('TUIChat:keyboardHeightChange', res.height);
}
});
})
onUnmounted(() => {
uni.offKeyboardHeightChange();
})
</script>
<style lang="scss" scoped>
.message-input-container {
position: relative;
/* iOS安全区域适配 - 稍微增加间距 */
padding-bottom: calc(constant(safe-area-inset-bottom) * 0.4);
padding-bottom: calc(env(safe-area-inset-bottom) * 0.4);
/* 添加背景色覆盖安全区域 */
background-color: #FFFFFF;
}
.input-container {
width: 100%;
height: 48px;
background-color: #FFFFFF;
border-top: 1px solid #e5e5e5;
display: flex;
align-items: center;
z-index: 100;
/* 移除额外间距,只保留外层容器的安全区域适配 */
padding-bottom: 0;
}
.icon-btn {
width: 28px;
height: 28px;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
image {
width: 100%;
height: 100%;
}
}
.message-input {
flex: 1;
height: 36px;
padding: 0 15px;
background-color: #F0F2F7;
border-radius: 4px;
font-size: 16px;
border: 1px solid transparent;
margin: 0 10px;
}
.disabled-container {
justify-content: center;
align-items: center;
background-color: #f8f9fa;
/* iOS安全区域适配 - 稍微增加间距 */
padding-bottom: calc(constant(safe-area-inset-bottom) * 0.55);
padding-bottom: calc(env(safe-area-inset-bottom) * 0.55);
}
.disabled-message {
padding: 12px 16px;
text-align: center;
}
.disabled-text {
font-size: 14px;
color: #999;
line-height: 1.4;
}
.send-btn {
min-width: 60px;
height: 36px;
padding: 0 12px;
display: flex;
justify-content: center;
align-items: center;
background-color: #006eff;
color: #fff;
border-radius: 4px;
font-size: 14px;
margin: 0 5px;
}
.more-panel {
width: 100%;
height: 125px;
background-color: #fff;
z-index: 99;
/* iOS安全区域适配 - 稍微增加间距 */
padding-bottom: calc(constant(safe-area-inset-bottom) * 0.55);
padding-bottom: calc(env(safe-area-inset-bottom) * 0.55);
}
.panel-content {
height: 100%;
padding: 10px 0;
display: flex;
flex-wrap: wrap;
border-top: 1px solid #eee;
justify-content: center;
padding: 10px;
}
.user-picker-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
width: 95%;
max-width: 500px;
height: 90vh;
max-height: 800px;
background-color: #fff;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e5e5e5;
background-color: #fff;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.close-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #999;
cursor: pointer;
border-radius: 50%;
transition: background-color 0.2s;
}
.close-btn:hover {
background-color: #f5f5f5;
}
</style>
@@ -0,0 +1,46 @@
<template>
<view class="bubble" :class="{ 'bubble-me': message?.flow === 'out' }">
<div v-if="isCallSignaling(message)" @click="rePlayCall(handleCallKitSignaling(message).callType)"
style="display: flex; align-items: center;">
<image class="callMessage-icon"
:src="handleCallKitSignaling(message).callType === 1 ? CallVoiceIcon : CallVideoIcon" />
<text class="text">{{ handleCallKitSignaling(message).callTip }}</text>
</div>
<div v-else>
<text class="text">{{ message?.payload }}</text>
</div>
</view>
</template>
<script lang="ts" setup>
import { useMessageListState } from '../../../index';
import CallVoiceIcon from '../../../assets/chat/voice-call-message.svg';
import CallVideoIcon from '../../../assets/chat/call-video.svg';
import { removeC2C } from '../../../utils';
import { handleCallKitSignaling, isCallSignaling } from '../../../utils/processCallSignaling';
import { TUIBridge } from '../../../TUIBridge';
import { EVENT } from '../../../constants/event';
const { activeConversationID } = useMessageListState();
const props = defineProps({
message: {
type: Object,
},
})
async function rePlayCall(callType) {
const userID = removeC2C(activeConversationID.value)
TUIBridge.notifyEvent({
eventName: EVENT.ON_CALLS,
params: {
userIDList: [userID],
type: callType,
},
});
}
</script>
<style lang="scss" scoped>
@import './Message.module.scss';
</style>
@@ -0,0 +1,131 @@
<template>
<view class="group-tip-message">
<text class="tip-text">{{ renderText() }}</text>
</view>
</template>
<script lang="ts" setup>
import { handleCallKitSignaling, isCallSignaling } from '../../../utils/processCallSignaling';
interface Props {
message: any;
}
const props = defineProps<Props>();
const renderText = () => {
const messageContent = props.message.getMessageContent()
if (messageContent.businessID === 'group_create') {
return `${messageContent.showName || ''} 创建群组`;
}
if (isCallSignaling(props.message) && props.message.conversationType === 'GROUP') {
return handleCallKitSignaling(props.message);
}
return resolveGroupTipMessage(props.message).text;
};
const resolveGroupTipMessage = (message: any) => {
const ret: {
text: string;
} = {
text: '',
};
let showName: string = message?.nick || message?.payload?.userIDList?.join(',');
if (message?.payload?.memberList?.length > 0) {
showName = '';
message?.payload?.memberList?.map((user: any) => {
const _showName = user?.nick || user?.userID;
showName += `${_showName},`;
return user;
});
showName = showName?.slice(0, -1);
}
switch (message.payload.operationType) {
case 1: // GRP_TIP_MBR_JOIN
ret.text = `${showName} 加入群组`;
break;
case 2: // GRP_TIP_MBR_QUIT
ret.text = `${showName} 退出群组`;
break;
case 3: // GRP_TIP_MBR_KICKED_OUT
ret.text = `${showName} 被踢出群组`;
break;
case 4: // GRP_TIP_MBR_SET_ADMIN
ret.text = `${showName} 成为管理员`;
break;
case 5: // GRP_TIP_MBR_CANCELED_ADMIN
ret.text = `${showName} 被撤销管理员`;
break;
case 6: // GRP_TIP_GRP_PROFILE_UPDATED
ret.text = handleGroupProfileUpdated(message);
break;
case 7: // GRP_TIP_MBR_PROFILE_UPDATED
message.payload.memberList.forEach((member: any) => {
if (member.muteTime > 0) {
ret.text = `${showName} 被禁言`;
} else {
ret.text = `${showName} 被取消禁言`;
}
});
break;
default:
ret.text = `[群提示消息]`;
break;
}
return ret;
}
const handleGroupProfileUpdated = (message: any) => {
const { nick, payload } = message;
const { newGroupProfile, memberList, operatorID } = payload;
let text = '';
const showName: string = nick || operatorID;
const key: string = Object.keys(newGroupProfile)[0];
switch (key) {
case 'muteAllMembers':
if (newGroupProfile[key]) {
text = `管理员 ${showName} 开启全员禁言`;
} else {
text = `管理员 ${showName} 取消全员禁言`;
}
break;
case 'ownerID':
if (memberList && memberList.length > 0) {
text = `${memberList[0].nick || memberList[0].userID} 成为新的群主`;
}
break;
case 'groupName':
text = `${showName} 修改群名为 ${newGroupProfile[key]}`;
break;
case 'notification':
text = `${showName} 发布新公告`;
break;
default:
break;
}
return text;
}
</script>
<style scoped>
.group-tip-message {
display: flex;
justify-content: center;
align-items: center;
padding: 16rpx 32rpx;
margin: 8rpx 0;
}
.tip-text {
font-size: 24rpx;
color: #999;
text-align: center;
line-height: 1.4;
max-width: 80%;
word-break: break-all;
}
</style>
@@ -0,0 +1,91 @@
<template>
<div class="image-message">
<image :src="message?.payload.imageInfoArray[0]?.url" :style="imgStyle" @click="previewImage" />
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
const props = defineProps({
message: {
type: Object,
},
})
const imgStyle = ref({});
const previewImage = (e: Event) => {
const currentUrl = props.message?.payload.imageInfoArray[0]?.url;
if (!currentUrl) return;
uni.previewImage({
current: currentUrl,
urls: [currentUrl],
indicator: 'default',
loop: false,
});
}
const calculateImageSize = (width: number, height: number) => {
const maxWidth = 200;
const maxHeight = 200;
if (width > maxWidth) {
height = (maxWidth / width) * height;
width = maxWidth;
}
if (height > maxHeight) {
width = (maxHeight / height) * width;
height = maxHeight;
}
return { width, height };
}
const setImageStyle = (width: number, height: number) => {
imgStyle.value = {
width: `${width}px`,
height: `${height}px`
};
}
const getImageSizeFromProps = () => {
const imageInfo = props.message?.payload.imageInfoArray[0];
if (imageInfo?.width && imageInfo?.height) {
return { width: imageInfo.width, height: imageInfo.height };
}
return null;
}
const detectImageSize = () => {
uni.getImageInfo({
src: props.message?.payload.imageInfoArray[0]?.url,
success: (res) => {
const { width, height } = calculateImageSize(res.width, res.height);
setImageStyle(width, height);
},
fail: () => {
setImageStyle(200, 200);
}
});
}
onMounted(() => {
const sizeFromProps = getImageSizeFromProps();
if (sizeFromProps) {
const { width, height } = calculateImageSize(sizeFromProps.width, sizeFromProps.height);
setImageStyle(width, height);
} else {
detectImageSize();
}
});
</script>
<style lang="scss" scoped>
.image-message {
image {
border-radius: 8px;
object-fit: contain;
}
}
</style>
@@ -0,0 +1,24 @@
.bubble {
background-color: #F0F2F7;
border-radius: 0px 10px 10px 10px;
padding: 15rpx;
position: relative;
&.bubble-me {
border-radius: 10px 0 10px 10px;
background-color: #CCE2FF;
}
.callMessage-icon {
width: 20px;
height: 20px;
margin-right: 6px;
}
.text {
font-size: 14px;
font-weight: 400;
color: #333;
}
}
@@ -0,0 +1,26 @@
<template>
<view class="bubble" :class="{ 'bubble-me': message?.flow === 'out' }">
<text class="text" :style="{ wordBreak: shouldBreakWord ? 'break-all' : 'normal' }">
{{ message.payload.text }}
</text>
</view>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps({
message: {
type: Object,
},
})
const shouldBreakWord = computed(() => {
return props.message?.payload?.text?.length > 50
})
</script>
<style lang="scss" scoped>
@import './Message.module.scss';
</style>
@@ -0,0 +1,190 @@
<template>
<view class="video-message">
<!-- 视频预览图 -->
<view class="video-preview" @click="handleVideoClick">
<image class="video-poster" :src="message.payload.snapshotUrl" mode="aspectFill" />
<!-- 播放按钮图标 -->
<view class="play-icon">
<image :src="playControlIcon" mode="aspectFit" />
</view>
<!-- 视频时长 -->
<view class="video-duration">
{{ formatDuration(message.payload.videoSecond) }}
</view>
</view>
<!-- 全屏播放器 -->
<view class="fullscreen-container" v-if="isPlaying">
<video id="videoPlayer" :src="message.payload.remoteVideoUrl" :autoplay="true" :controls="true"
:show-fullscreen-btn="false" :show-center-play-btn="false" @ended="handleVideoEnd"
@pause="handleVideoPause" @fullscreenchange="handleFullscreenChange" class="fullscreen-video"
:enable-progress-gesture="true" objectFit="contain" />
<!-- 关闭按钮 -->
<view class="video-controls">
<view class="close-btn" @click="handleCloseVideo">
<view class="close-icon"></view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import playControlIcon from '../../../assets/chat/play-control.svg';
const props = defineProps({
message: {
type: Object,
required: true
},
});
const isPlaying = ref(false);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
};
const handleVideoClick = () => {
isPlaying.value = true;
setTimeout(() => {
const videoContext = uni.createVideoContext('videoPlayer', this);
videoContext.requestFullScreen({
direction: 0 // 0表示正常竖屏
});
}, 100);
};
const handleVideoPause = () => {
};
const handleVideoEnd = () => {
isPlaying.value = false;
};
const handleFullscreenChange = (e: any) => {
if (!e.detail.fullScreen) {
isPlaying.value = false;
}
};
const handleCloseVideo = () => {
const videoContext = uni.createVideoContext('videoPlayer', this);
videoContext.pause();
videoContext.exitFullScreen();
isPlaying.value = false;
};
</script>
<style lang="scss" scoped>
.video-message {
position: relative;
width: 200px;
height: 200px;
border-radius: 8px;
overflow: hidden;
background-color: black;
.video-preview {
position: relative;
width: 100%;
height: 100%;
}
.video-poster {
width: 100%;
height: 100%;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
image {
width: 100%;
height: 100%;
}
}
.video-duration {
position: absolute;
right: 8px;
bottom: 8px;
padding: 2px 6px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
font-size: 12px;
border-radius: 4px;
}
.fullscreen-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
background-color: black;
}
.fullscreen-video {
width: 100%;
height: 100%;
}
.video-controls {
position: absolute;
top: 0;
left: 0;
width: 100%;
padding: 15px;
box-sizing: border-box;
z-index: 1000;
/* 确保关闭按钮在控制条上方 */
}
.close-btn {
width: 24px;
height: 24px;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.close-icon {
width: 16px;
height: 16px;
position: relative;
}
.close-icon::before,
.close-icon::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 2px;
background-color: white;
border-radius: 1px;
}
.close-icon::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.close-icon::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
}
</style>
@@ -0,0 +1,325 @@
<template>
<div class="message-container">
<!-- 消息列表滚动区域 -->
<scroll-view ref="scrollViewRef" scroll-y class="message-list" :scroll-into-view="currentMessage"
scroll-with-animation @scrolltolower="handleScrollToBottom" @scrolltoupper="handleScrollToTop">
<!-- 下拉加载提示 -->
<view v-if="isLoadingOlderMessage || showNoMoreTip" class="loading-tip">
<text>{{ isLoadingOlderMessage ? '加载中...' : '没有更多消息了' }}</text>
</view>
<!-- 消息列表 -->
<view v-for="(message, index) in messageList">
<!-- tips 消息 -->
<GroupTipMessage v-if="shouldRenderAsGroupTip(message)" :message="message" />
<!-- 消息时间分割线 -->
<MessageTimestamp v-if="!shouldRenderAsGroupTip(message)" :currTime="message.time"
:prevTime="index > 0 ? messageList[index - 1].time : 0" />
<!-- 非群 tips 消息 -->
<view v-if="isSupportedMessageType(message)" :id="`msg-${message.ID}`" class="message-item"
:class="{ 'message-item-me': message?.flow === 'out' }">
<!-- 消息头像 -->
<Avatar :avatarStyle="avatarStyle" :src="message?.avatar || DefaultAvatarIcon" />
<!-- 消息内容 -->
<view class="message-content">
<!-- 群聊用户名显示 -->
<view v-if="activeConversation?.type === 'GROUP' && message?.flow === 'in'" class="sender-name">
{{ getDisplayName(message) }}
</view>
<!-- 文本消息 -->
<TextMessage v-if="message?.type === MessageType.MSG_TEXT" :message="message" />
<!-- 通话消息 -->
<CustomMessage v-if="message?.type === MessageType.MSG_CUSTOM" :message="message" />
<!-- 图片消息 -->
<ImageMessage v-if="message?.type === MessageType.MSG_IMAGE" :message="message" />
<!-- 视频消息 -->
<VideoMessage v-if="message?.type === MessageType.MSG_VIDEO" :message="message" />
</view>
<!-- 消息状态 -->
<MessageStatus class="message-status" :message="message" />
</view>
</view>
</scroll-view>
<!-- 新消息提示条当有新消息且不在底部时显示 -->
<view v-if="showNewMessageTip" class="new-message-tip" @click="handleNewMessageTipClick">
<image :src="NewMessageTipIcon"></image>
{{ newMessageTipText }}
</view>
</div>
</template>
<script lang="ts">
export default {
options: {
virtualHost: true,
}
}
</script>
<script lang="ts" setup>
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import Avatar from '../Avatar/Avatar.vue'
import TextMessage from './Message/TextMessage.vue';
import CustomMessage from './Message/CustomMessage.vue';
import ImageMessage from './Message/ImageMessage.vue';
import VideoMessage from './Message/VideoMessage.vue';
import GroupTipMessage from './Message/GroupTipMessage.vue';
import MessageStatus from './MessageStatus/MessageStatus.vue';
import MessageTimestamp from './MessageTimeDivider/MessageTimeDivider.vue';
import DefaultAvatarIcon from '../../assets/base/default-avatar.png';
import NewMessageTipIcon from '../../assets/chat/new-message.svg';
import { useMessageListState, useConversationListState } from '../../chat';
import { isCallSignaling } from '../../utils/processCallSignaling';
import { MessageType } from '../../constants/chat'
const { setActiveConversation, activeConversation } = useConversationListState()
const { messageList, loadMoreOlderMessage, hasMoreOlderMessage } = useMessageListState();
const scrollViewRef = ref();
const showNewMessageTip = ref(false);
const newMessageCount = ref(0);
const autoScroll = ref(true);
const isLoadingOlderMessage = ref(false);
const showNoMoreTip = ref(false);
const historyFirstMessageID = ref<string>('');
const currentMessage = ref('');
const avatarStyle = ref({
width: '40px',
height: '40px',
borderRadius: '5px'
})
// 获取显示的用户名
const getDisplayName = (message: any) => {
const nameCard = message?.nameCard || '';
const senderNick = message?.nick || '';
const senderUserID = message?.from || '';
// 如果是群聊,按优先级显示:nameCard > nick > userID
if (activeConversation.value?.type === 'GROUP') {
return nameCard || senderNick || senderUserID;
}
// 如果不是群聊,只显示昵称
return senderNick || senderUserID;
};
const isSupportedMessageType = (message) => {
const type = message?.type
return [
MessageType.MSG_TEXT,
MessageType.MSG_CUSTOM,
MessageType.MSG_IMAGE,
MessageType.MSG_VIDEO
].includes(type) && !shouldRenderAsGroupTip(message)
}
const newMessageTipText = computed(() => {
return `${newMessageCount.value}条新消息`
})
const scrollToBottom = () => {
if (messageList.value?.length) {
const lastMessage = messageList.value[messageList.value.length - 1]
const targetId = `msg-${lastMessage.ID}`
// Clear and re-set to ensure scroll triggers even when value is the same
if (currentMessage.value === targetId) {
currentMessage.value = ''
setTimeout(() => {
currentMessage.value = targetId
}, 50)
} else {
currentMessage.value = targetId
}
}
}
const handleNewMessageTipClick = () => {
showNewMessageTip.value = false
newMessageCount.value = 0
autoScroll.value = true
scrollToBottom()
}
const handleScrollToTop = async (e) => {
if (isLoadingOlderMessage.value) return;
if (!hasMoreOlderMessage.value) {
showNoMoreTip.value = true;
setTimeout(() => {
showNoMoreTip.value = false;
}, 1000);
return;
}
isLoadingOlderMessage.value = true;
const currentFirstMessageID = messageList.value?.[0]?.ID || '';
await loadMoreOlderMessage();
historyFirstMessageID.value = currentFirstMessageID;
isLoadingOlderMessage.value = false;
}
const handleScrollToBottom = () => {
showNewMessageTip.value = false
}
onMounted(() => {
scrollToBottom()
// Listen for keyboard pop-up to auto scroll to bottom
uni.$on('TUIChat:keyboardHeightChange', () => {
scrollToBottom()
})
})
onUnmounted(() => {
setActiveConversation('')
uni.$off('TUIChat:keyboardHeightChange')
})
watch(messageList, (newVal, oldVal) => {
if (!newVal?.length) return
const lastMessage = newVal[newVal.length - 1]
const isMyMessage = lastMessage?.flow === 'out'
if (isMyMessage) {
scrollToBottom()
} else {
if (autoScroll.value) {
// 新消息如果在底部一个屏幕范围内,自动滚动
scrollToBottom()
showNewMessageTip.value = false
newMessageCount.value = 0
} else {
// 否则显示新消息提示
newMessageCount.value += 1
showNewMessageTip.value = true
}
}
}, { deep: true });
const shouldRenderAsGroupTip = (message: any) => {
// 创建群消息
if (message.type === MessageType.MSG_CUSTOM && message.getMessageContent().businessID === 'group_create') {
return true;
}
// 群通话消息
if (
message.type === MessageType.MSG_CUSTOM
&& isCallSignaling(message)
&& message.conversationType === "GROUP"
) {
return true;
}
// 群提示消息
if ( message.type === MessageType.MSG_GRP_TIP ) {
return true
}
return false;
};
</script>
<style lang="scss" scoped>
.message-container {
background-color: #F9FAFC;
display: flex;
flex: 1;
min-height: 0;
width: 100%;
height: 100%;
}
.loading-tip {
display: flex;
justify-content: center;
align-items: center;
padding: 12px 0;
color: #999;
font-size: 14px;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes rotating {
from {
transform: rotate(0deg)
}
to {
transform: rotate(360deg)
}
}
.new-message-tip {
position: absolute;
bottom: 50px;
right: 10px;
background-color: #FFFFFF;
color: #1C66E5;
padding: 8px 16px;
border-radius: 3px;
font-size: 12px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
white-space: nowrap;
image {
width: 12px;
height: 11px;
margin-right: 5px;
}
}
.message-list {
flex: 1;
overflow: auto;
margin: 8px;
overscroll-behavior: contain;
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
color: transparent;
}
-webkit-overflow-scrolling: touch;
}
.message-item {
display: flex;
margin: 7px 0;
&.message-item-me {
flex-direction: row-reverse;
}
}
.message-status {
position: relative;
}
.message-content {
margin: 0 20rpx;
max-width: 70%;
}
.sender-name {
font-size: 12px;
color: #999;
margin-bottom: 4px;
line-height: 1.2;
}
</style>
@@ -0,0 +1,68 @@
<template>
<view v-if="message?.flow === 'out' && message?.status !== 'success'" class="message-status" :class="positionClass">
<view v-if="message?.status === 'unSend'" class="status-loading">
<image :src="IMG_LOADING" />
</view>
<view v-if="message?.status === 'fail'" class="status-fail">
<text>!</text>
</view>
</view>
</template>
<script lang="ts" setup>
import IMG_LOADING from '../../../assets/chat/message-loading.svg';
const { message } = defineProps({
message: {
type: Object,
required: true
}
})
</script>
<style lang="scss" scoped>
.message-status {
position: absolute;
bottom: 14px;
right: -4px;
}
.status-loading {
width: 20px;
height: 20px;
image {
width: 100%;
height: 100%;
animation: rotating 1s linear infinite;
}
}
.status-fail {
width: 16px;
height: 16px;
background-color: #ff4d4f;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
text {
color: white;
font-size: 12px;
font-weight: bold;
}
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,91 @@
<template>
<div
v-if="timestampShowFlag"
class="message-timestamp"
>
{{ timestampShowContent }}
</div>
</template>
<script setup lang="ts">
import { toRefs, ref, watch } from 'vue';
import { calculateTimestamp } from '../../../utils/time';
const props = defineProps({
currTime: {
type: Number,
default: 0,
},
prevTime: {
type: Number,
default: 0,
},
});
const { currTime, prevTime } = toRefs(props);
const timestampShowFlag = ref(false);
const timestampShowContent = ref('');
const handleItemTime = (currTime: number, prevTime: number) => {
timestampShowFlag.value = false;
if (currTime <= 0) return '';
const minDiffToShow = 5 * 60;
// 第一条消息必显示
if (!prevTime || prevTime <= 0) {
timestampShowFlag.value = true;
return calculateTimestamp(currTime);
}
// 计算时间差(秒)
const diff = currTime - prevTime;
// 超过5分钟显示
if (diff >= minDiffToShow) {
timestampShowFlag.value = true;
return calculateTimestamp(currTime);
}
// 跨天必显示(即使间隔<5分钟)
const currDate = new Date(currTime * 1000);
const prevDate = new Date(prevTime * 1000);
if (currDate.getDate() !== prevDate.getDate() ||
currDate.getMonth() !== prevDate.getMonth() ||
currDate.getFullYear() !== prevDate.getFullYear()) {
timestampShowFlag.value = true;
return calculateTimestamp(currTime);
}
return '';
};
watch(
() => [currTime.value, prevTime.value],
(newVal: any, oldVal: any) => {
if (newVal?.toString() === oldVal?.toString()) {
return;
} else {
timestampShowContent.value = handleItemTime(
currTime.value,
prevTime.value,
);
}
},
{
immediate: true,
},
);
</script>
<style lang="scss" scoped>
.message-timestamp {
width: 100%;
margin: 15px 0;
color: #BBBBBB;
font-size: 12px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
</style>
@@ -0,0 +1,117 @@
// @ts-nocheck
import type { UIKitModalOptions, UIKitModalResult } from './type';
import { TUICallKitServer } from '../../states/TUICallService/index';
const URL_REGEX = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/i;
const FULL_URL_REGEX = /(https?:\/\/[^\s]+)/g;
function isDevEnvironment(): boolean {
const accountInfo = uni.getAccountInfoSync?.();
const envVersion = accountInfo?.miniProgram?.envVersion;
return envVersion === 'develop' || envVersion === 'trial';
}
function extractUrlFromContent(content: string): string | null {
if (!content || typeof content !== 'string') {
return null;
}
if (content.includes('<a ') || content.includes('<a>')) {
const hrefMatch = content.match(/href=["']([^"']+)["']/);
return hrefMatch ? hrefMatch[1] : null;
}
if (URL_REGEX.test(content.trim())) {
return content.trim();
}
const urlMatch = content.match(FULL_URL_REGEX);
return urlMatch ? urlMatch[0] : null;
}
function processContent(content: string): string {
if (!content || typeof content !== 'string') {
return '';
}
let text = content.replace(/<[^>]+>/g, '');
text = text
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"');
text = text.replace(/\s+/g, ' ').trim();
const url = extractUrlFromContent(content);
if (url && !text.includes(url)) {
return text ? `${text}\n${url}` : url;
}
return text;
}
function reportModalView(options: UIKitModalOptions): void {
try {
const tim = TUICallKitServer?.getTim?.();
if (tim && typeof tim.callExperimentalAPI === 'function') {
const reportData = {
id: options.id,
title: options.title,
type: options.type,
content: options.content,
platform: 'miniprogram',
};
tim.callExperimentalAPI('reportModalView', JSON.stringify(reportData));
}
} catch (error) {
console.warn('[UIKitModal] reportModalView failed:', error);
}
}
const createUIKitModal = (options: UIKitModalOptions): Promise<UIKitModalResult> => {
return new Promise((resolve) => {
reportModalView(options);
if (!isDevEnvironment()) {
resolve({ action: 'confirm' });
return;
}
const url = extractUrlFromContent(options.content);
const processedContent = processContent(options.content);
const hasLink = !!url;
uni.showModal({
title: options.title || '',
content: processedContent,
cancelText: '取消',
confirmText: hasLink ? '复制链接' : '确认',
success: (res) => {
const action = res.confirm ? 'confirm' : 'cancel';
if (res.confirm) {
if (hasLink && url) {
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' });
},
});
}
options.onConfirm?.();
} else {
options.onCancel?.();
}
resolve({ action, raw: res });
},
fail: () => {
options.onCancel?.();
resolve({ action: 'cancel' });
},
});
});
};
export const UIKitModal = {
openModal: (config: UIKitModalOptions) => createUIKitModal(config),
};
@@ -0,0 +1,2 @@
export { UIKitModal } from './UIKitModal';
export type { UIKitModalOptions, UIKitModalResult, ModalType } from './type';
@@ -0,0 +1,16 @@
// @ts-nocheck
export type ModalType = 'info' | 'warning' | 'error' | 'success';
export interface UIKitModalOptions {
id: number;
title: string;
content: string;
type: ModalType;
onConfirm?: () => void;
onCancel?: () => void;
}
export interface UIKitModalResult {
action: 'confirm' | 'cancel';
raw?: UniApp.ShowModalRes;
}
@@ -0,0 +1,697 @@
<template>
<view class="user-picker">
<!-- 搜索栏 -->
<view v-if="enableSearch" class="search-bar">
<view class="search-input-container">
<input class="search-input" :placeholder="searchPlaceholder" v-model="searchKeyword" @confirm="handleSearch" />
<view class="search-icon" @click="handleSearch">🔍</view>
</view>
</view>
<!-- 用户列表区域 -->
<view class="list-container">
<scroll-view class="user-list" scroll-y :style="listStyle">
<!-- 空状态 -->
<view v-if="allUsers.length === 0" class="empty-state">
<text class="empty-text">{{ emptyText }}</text>
</view>
<!-- 已搜索到的用户列表 -->
<view v-for="user in allUsers" :key="user.userID" class="user-item" @click="toggleUserSelection(user)">
<view v-if="!readOnly" class="checkbox">
<view class="checkbox-icon" :class="{ 'checked': selectedUsers.has(user.userID) }">
<text v-if="selectedUsers.has(user.userID)" class="checkmark"></text>
</view>
</view>
<!-- 用户头像 -->
<view class="user-avatar">
<image :src="user.avatar || defaultAvatarIcon" class="avatar-img" />
</view>
<!-- 用户信息 -->
<view class="user-info">
<text class="user-nick">{{ user.nick || user.userID }}</text>
<text class="user-id">{{ user.userID }}</text>
</view>
<!-- 删除按钮 -->
<view v-if="enableDelete" class="delete-btn" @click.stop="removeUser(user.userID)">
<text>×</text>
</view>
</view>
</scroll-view>
</view>
<!-- 底部操作栏-->
<view v-if="!readOnly" class="bottom-bar">
<view class="selected-count">
已选 {{ selectedUsers.size }}
</view>
<button class="confirm-btn" :class="{ 'disabled': selectedUsers.size === 0 }" @click="handleConfirm"
:disabled="selectedUsers.size === 0">
确定
</button>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted, watch } from 'vue';
import TUIChatEngine from '../../states/chat-uikit-engine-lite';
import defaultAvatarIcon from '../../assets/base/default-avatar.png';
interface User {
userID: string
nick?: string
avatar?: string
}
interface Props {
maxCount?: number
readOnly?: boolean
enableSearch?: boolean
enableDelete?: boolean
dataSource?: User[]
excludeUserIDs?: string[]
mode?: 'select' | 'remove' // 模式:select-选择模式(排除指定用户),remove-删除模式(显示所有用户)
inModal?: boolean // 是否在弹窗中使用
}
const emit = defineEmits<{
confirm: [selectedUserIDs: string[]]
}>()
const props = withDefaults(defineProps<Props>(), {
maxCount: Number.POSITIVE_INFINITY,
readOnly: false,
enableSearch: true,
enableDelete: true,
dataSource: [],
excludeUserIDs: () => [],
mode: 'select',
inModal: false
})
const searchKeyword = ref('')
const allUsers = ref<User[]>([])
const selectedUsers = reactive(new Set<string>())
// 根据模式决定是否应用排除逻辑
const shouldApplyExclude = computed(() => props.mode === 'select')
const listStyle = computed(() => {
if (props.inModal) {
// 在弹窗中:使用 calc 计算可用高度,避免大片空白
return {
height: 'calc(90vh - 160rpx - 120rpx)' // 90vh - 搜索栏(160rpx) - 底部操作栏(120rpx)
}
} else {
// 全屏模式:使用整个视口高度
return {
height: 'calc(100vh - 128rpx - 120rpx)' // 减去搜索栏(128rpx)和底部操作栏(120rpx)的高度
}
}
})
const searchPlaceholder = computed(() => {
if (props.mode === 'remove') {
return '搜索群成员昵称或ID'
}
return props.dataSource.length > 0 ? '搜索群成员昵称或ID' : '输入用户ID进行搜索添加'
})
const emptyText = computed(() => {
if (props.mode === 'remove') {
return '暂无群成员'
}
return props.dataSource.length > 0 ? '暂无群成员' : '暂无用户,请搜索添加'
})
onMounted(() => {
allUsers.value = shouldApplyExclude.value
? props.dataSource?.filter(user => !props.excludeUserIDs.includes(user.userID))
: [...props.dataSource]
})
// 监听 dataSource 变化
watch(() => props.dataSource, (newDataSource) => {
allUsers.value = shouldApplyExclude.value
? newDataSource.filter(user => !props.excludeUserIDs.includes(user.userID))
: [...newDataSource]
}, { deep: true })
// 监听 excludeUserIDs 变化
watch(() => props.excludeUserIDs, () => {
allUsers.value = shouldApplyExclude.value
? props.dataSource.filter(user => !props.excludeUserIDs.includes(user.userID))
: [...props.dataSource]
}, { deep: true })
// 监听 mode 变化
watch(() => props.mode, () => {
allUsers.value = shouldApplyExclude.value
? props.dataSource.filter(user => !props.excludeUserIDs.includes(user.userID))
: [...props.dataSource]
}, { deep: true })
const searchUsers = async (keyword: string): Promise<User[]> => {
if (!keyword.trim()) {
// 如果没有关键词,根据模式返回相应的数据源
return shouldApplyExclude.value
? props.dataSource.filter(user => !props.excludeUserIDs.includes(user.userID))
: [...props.dataSource]
}
// 如果有dataSource,在dataSource中搜索
if (props.dataSource.length > 0) {
const filteredUsers = props.dataSource.filter(user => {
const matchesKeyword = user.userID.includes(keyword) ||
(user.nick && user.nick.includes(keyword))
// 根据模式决定是否应用排除逻辑
return shouldApplyExclude.value
? !props.excludeUserIDs.includes(user.userID) && matchesKeyword
: matchesKeyword
})
if (filteredUsers.length === 0) {
uni.showToast({
title: '未找到匹配的群成员',
icon: 'none'
})
}
return filteredUsers
} else {
// 如果没有dataSource,进行全局搜索
const currentUserID = TUIChatEngine.getMyUserID();
// 在选择模式下,检查是否搜索自己或排除列表中的用户
if (shouldApplyExclude.value && (keyword === currentUserID || props.excludeUserIDs.includes(keyword))) {
uni.showToast({
title: keyword === currentUserID ? '不能搜索自己' : '用户已在群中',
icon: 'none'
})
return []
}
// 在删除模式下,只检查是否搜索自己
if (!shouldApplyExclude.value && keyword === currentUserID) {
uni.showToast({
title: '不能删除自己',
icon: 'none'
})
return []
}
try {
const res = await TUIChatEngine.TUIUser.getUserProfile({ userIDList: [keyword] })
if (res.data.length > 0) {
const { nick = '', avatar = '' } = res.data[0]
return [
{
userID: keyword,
nick,
avatar: avatar || defaultAvatarIcon
}
]
} else {
uni.showToast({
title: '用户不存在',
icon: 'none'
})
return []
}
} catch (error) {
console.error('搜索用户失败:', error)
uni.showToast({
title: '搜索失败',
icon: 'none'
})
return []
}
}
}
const handleSearch = async () => {
const keyword = searchKeyword.value.trim()
try {
const results = await searchUsers(keyword)
if (props.dataSource.length > 0) {
// 有dataSource时,直接显示搜索结果(过滤模式)
allUsers.value = results
} else {
// 无dataSource时,添加模式
if (results.length > 0) {
// 检查是否已经存在该用户
const existingUser = allUsers.value.find(user => user.userID === keyword)
if (!existingUser) {
// 将新用户添加到现有列表中
allUsers.value = [...allUsers.value, ...results]
} else {
uni.showToast({
title: '用户已存在',
icon: 'none'
})
}
}
}
// 搜索完成后清除输入框内容
searchKeyword.value = ''
} catch (error) {
console.error('搜索用户失败:', error)
}
}
const toggleUserSelection = (user: User) => {
if (props.readOnly) return
if (selectedUsers.has(user.userID)) {
selectedUsers.delete(user.userID)
} else {
if (selectedUsers.size >= props.maxCount) {
uni.showToast({
title: `最多只能选择 ${props.maxCount} 个用户`,
icon: 'none'
})
return
}
if (props.maxCount === 1) {
selectedUsers.clear()
}
selectedUsers.add(user.userID)
}
}
const removeUser = (userID: string) => {
allUsers.value = allUsers.value.filter(user => user.userID !== userID)
if (selectedUsers.has(userID)) {
selectedUsers.delete(userID)
}
}
const handleConfirm = async () => {
if (selectedUsers.size === 0) return
const selectedUserIDs = Array.from(selectedUsers)
emit('confirm', selectedUserIDs)
}
defineExpose({
handleConfirm
})
</script>
<style lang="scss" scoped>
.user-picker {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
}
.user-picker[in-modal="true"] {
height: 90vh;
max-height: 800px;
}
.search-bar {
padding: 24rpx;
background-color: #fff;
border-bottom: 1rpx solid #e8e8e8;
flex-shrink: 0;
}
.search-input-container {
position: relative;
display: flex;
align-items: center;
}
.search-input {
flex: 1;
height: 80rpx;
padding: 0 80rpx 0 30rpx;
background-color: #f0f2f7;
border-radius: 40rpx;
font-size: 28rpx;
border: 1rpx solid #e0e0e0;
}
.search-icon {
position: absolute;
right: 30rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #666;
cursor: pointer;
}
.list-container {
flex: 1;
background-color: #fff;
display: flex;
flex-direction: column;
min-height: 0;
}
.user-list {
width: 100%;
background-color: #fff;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 300rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
.user-item {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
position: relative;
transition: background-color 0.2s;
}
.user-item:active {
background-color: #f8f9fa;
}
.checkbox {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.checkbox-icon {
width: 36rpx;
height: 36rpx;
border: 2rpx solid #dcdfe6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&.checked {
border-color: #07c160;
background-color: #07c160;
}
}
.checkmark {
color: #fff;
font-size: 20rpx;
font-weight: bold;
}
.user-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
overflow: hidden;
margin-right: 20rpx;
background-color: #f0f2f5;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
}
.user-nick {
font-size: 32rpx;
color: #1a1a1a;
font-weight: 500;
margin-bottom: 8rpx;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400rpx;
}
.user-id {
font-size: 24rpx;
color: #8c8c8c;
line-height: 1.2;
}
.delete-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 36rpx;
font-weight: bold;
border-radius: 50%;
transition: all 0.2s;
}
.delete-btn:active {
background-color: #ffebee;
color: #ff4444;
}
.bottom-bar {
height: 120rpx;
background-color: #fff;
border-top: 1rpx solid #e8e8e8;
display: flex;
align-items: center;
padding: 0 30rpx;
flex-shrink: 0;
position: sticky;
bottom: 0;
z-index: 10;
}
.selected-count {
font-size: 28rpx;
color: #666;
flex: 1;
}
.confirm-btn {
background-color: #07c160;
color: #fff;
border: none;
border-radius: 8rpx;
font-size: 28rpx;
height: 72rpx;
padding: 0 40rpx;
transition: all 0.2s;
&.disabled {
background-color: #dcdfe6;
color: #c0c4cc;
}
}
.confirm-btn:not(.disabled):active {
background-color: #06a854;
transform: scale(0.98);
}
/* 群聊表单弹出层样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 100%;
max-height: 80vh;
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease-out;
box-sizing: border-box;
overflow: hidden;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 30rpx 24rpx;
border-bottom: 1rpx solid #e8e8e8;
position: relative;
box-sizing: border-box;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #1a1a1a;
flex: 1;
text-align: center;
}
.close-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #999;
position: absolute;
right: 30rpx;
}
.form-container {
flex: 1;
max-height: 60vh;
padding: 30rpx;
box-sizing: border-box;
overflow: hidden;
}
.form-item {
margin-bottom: 32rpx;
box-sizing: border-box;
}
.form-label {
display: block;
font-size: 28rpx;
color: #1a1a1a;
margin-bottom: 16rpx;
font-weight: 500;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
background-color: #f8f9fa;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.form-picker {
width: 100%;
height: 80rpx;
background-color: #f8f9fa;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
display: flex;
align-items: center;
padding: 0 24rpx;
box-sizing: border-box;
cursor: pointer;
}
.picker-value {
font-size: 28rpx;
color: #1a1a1a;
width: 100vw;
height: 100%;
display: flex;
align-items: center;
}
.form-textarea {
width: 100%;
min-height: 160rpx;
padding: 24rpx;
background-color: #f8f9fa;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
line-height: 1.5;
box-sizing: border-box;
}
.modal-footer {
display: flex;
gap: 20rpx;
padding: 24rpx 30rpx 40rpx;
border-top: 1rpx solid #e8e8e8;
box-sizing: border-box;
}
.cancel-btn {
flex: 1;
height: 80rpx;
background-color: #f8f9fa;
color: #666;
border: 1rpx solid #e8e8e8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.submit-btn {
flex: 1;
height: 80rpx;
background-color: #07c160;
color: #fff;
border: none;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.cancel-btn:active {
background-color: #e8e8e8;
}
.submit-btn:active {
background-color: #06a854;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
export enum CallStatus {
IDLE = 'idle',
CALLING = 'calling',
CONNECTED = 'connected',
}
export enum CallRole {
UNKNOWN = 'unknown',
CALLEE = 'callee',
CALLER = 'caller',
}
export enum DeviceStatus {
UNKNOWN = 'unknown',
ON = 'on',
OFF = 'off',
}
+25
View File
@@ -0,0 +1,25 @@
export const UI_PLATFORM = {
UNI_MINI_APP: '45'
}
export enum MessageType {
MSG_TEXT = 'TIMTextElem',
MSG_CUSTOM = 'TIMCustomElem',
MSG_IMAGE = 'TIMImageElem',
MSG_VIDEO = 'TIMVideoFileElem',
MSG_GRP_TIP = 'TIMGroupTipElem'
}
export enum MessageContentType {
TEXT = 'text',
IMAGE = 'image',
VIDEO = 'video',
FILE = 'file',
MENTION = 'mention',
EMOJI = 'emoji',
}
export enum CONV_TYPE {
C2C = 'C2C',
GROUP = 'GROUP'
}
@@ -0,0 +1,7 @@
export const EVENT = {
LOGIN_SUCCESS: 'LoginState.LoginSuccess',
LOGOUT_SUCCESS: 'LoginState.LogoutSuccess',
ACTIVE_CONV_UPDATED: 'active_conv_updated',
SEND_MESSAGE: 'send_message',
ON_CALLS: 'On_Calls',
}
@@ -0,0 +1,30 @@
import LibGenerateTestUserSig from './lib-generate-test-usersig-es.min.js';
let SDKAPPID = 1600132918;
let SECRETKEY = 'd93e9195b247e42e1cd707e450b678a90115c93877df91b1f2649778ca442c37';
/**
* Expiration time for the signature, it is recommended not to set it too short.
* Time unit: seconds
* Default time: 7 x 24 x 60 x 60 = 604800 = 7 days
*/
const EXPIRETIME = 7 * 24 * 60 * 60;
function genTestUserSig({ userID, SDKAppID, SecretKey }) {
if (SDKAppID) SDKAPPID = SDKAppID;
if (SecretKey) SECRETKEY = SecretKey;
const generator = new LibGenerateTestUserSig(SDKAPPID, SECRETKEY, EXPIRETIME);
const userSig = generator.genTestUserSig(userID);
return {
SDKAppID: SDKAPPID,
userSig,
};
}
export {
genTestUserSig,
SDKAPPID,
SECRETKEY,
};
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
import { useLoginState } from './states/LoginState';
export * from './chat';
export * from './call'
export {
useLoginState,
};
+14
View File
@@ -0,0 +1,14 @@
{
"name": "tuikit-atomicx-uniapp-wx-standard",
"version": "1.1.9",
"main": "index.js",
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@tencentcloud/lite-chat": "^1.6.3",
"@tencentcloud/trtc-component-uniapp": "^1.0.5",
"@trtc/call-engine-lite-wx": "~3.4.10"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
import { ref, watch } from 'vue';
import { CallMediaType, AudioPlayBackDevice } from '@trtc/call-engine-lite-wx';
import {
TUIStore,
StoreName,
TUICallKitServer,
NAME,
} from './TUICallService/index';
const inviterId = ref<string>('');
const mediaType = ref<CallMediaType>(CallMediaType.UNKNOWN);
const duration = ref<number>(0);
const pusherId = ref<string>(TUIStore.getData(StoreName.CALL, NAME.PUSHER_ID));
TUIStore?.watch(StoreName.CALL, {
[NAME.CALL_MEDIA_TYPE]: _handleCallMediaTypeChange,
[NAME.DURATION]: _handleCallDurationChange,
[NAME.CALL_INFO]: _handleCallInfoChange,
[NAME.PUSHER_ID]: _handlePusherIdChange,
});
function _handleCallMediaTypeChange(value: CallMediaType) {
mediaType.value = value;
}
function _handleCallDurationChange(value: Number) {
duration.value = value;
}
function _handleCallInfoChange(obj: any) {
if (inviterId.value === obj.inviterId) return;
inviterId.value = obj.inviterId || '';
}
function _handlePusherIdChange(value: string) {
pusherId.value = value;
}
const calls = async (params: any) => await TUICallKitServer.calls(params);
const accept = async () => await TUICallKitServer.accept();
const reject = async () => await TUICallKitServer.reject();
const hangup = async () => await TUICallKitServer.hangup();
// // 群通话: "邀请他人", "中途加入"
// const inviteUser = async (params) => await TUICallKitServer.inviteUser(params);
// const join = async (params) => await TUICallKitServer.join(params);
const setSelfInfo = async (params) => await TUICallKitServer.setSelfInfo(params);
const setSoundMode = async (type) => await TUICallKitServer.setSoundMode(type);
function useCallListState() {
return {
// state
inviterId,
mediaType,
duration,
pusherId,
// actions
calls,
accept,
reject,
hangup,
setSoundMode,
setSelfInfo,
};
}
export { useCallListState };
@@ -0,0 +1,87 @@
import { ref } from 'vue';
import { ICallParticipantInfo, IUserInfo } from './type';
import { TUIStore, StoreName, NAME, CallStatus, CallRole } from './TUICallService/index';
const callParticipantInfo = ref<ICallParticipantInfo>({
selfInfo: { status: CallStatus.IDLE },
allParticipants: [],
speakerVolumes: [],
networkQualities: [],
});
TUIStore?.watch(StoreName.CALL, {
[NAME.CALL_ROLE]: _handleCallRoleChange,
[NAME.CALL_STATUS]: _handleCallStatusChange,
[NAME.LOCAL_USER_INFO]: _handleLocalUserInfoChange,
[NAME.REMOTE_USER_INFO_LIST]: _handleRemoteUserInfoListChange,
});
function _handleCallRoleChange(value: CallRole) {
callParticipantInfo.value.selfInfo.role = value;
}
function _handleCallStatusChange(value: CallStatus) {
console.log(`callStatus change: ${value}`);
callParticipantInfo.value.selfInfo.status = value;
if (value === CallStatus.IDLE) {
handleCallStatusToIdle();
}
if (value === CallStatus.CALLING || value === CallStatus.CONNECTED) {
handleCallStatusToCalling();
}
}
function _handleLocalUserInfoChange(value) {
const { userId, nick = '', avatar = '', remark = '', isAudioAvailable, isVideoAvailable, volume } = value;
const selfInfo: IUserInfo = {
id: userId || '',
name: nick,
avatarUrl: avatar,
remark: remark,
isMicrophoneOpened: isAudioAvailable,
isCameraOpened: isVideoAvailable,
};
callParticipantInfo.value.selfInfo = { ...callParticipantInfo.value.selfInfo, ...selfInfo };
}
function _handleRemoteUserInfoListChange(remoteUserInfoList) {
callParticipantInfo.value.allParticipants = remoteUserInfoList.map(obj => {
const { userId, nick = '', avatar = '', remark = '', isAudioAvailable, isVideoAvailable } = obj || {};
const selfInfo: IUserInfo = {
id: userId || '',
name: nick,
avatarUrl: avatar,
remark: remark,
isMicrophoneOpened: isAudioAvailable,
isCameraOpened: isVideoAvailable,
};
return selfInfo;
});
}
function getRoute(): string {
// @ts-ignore
const pages = getCurrentPages();
return pages[pages.length - 1].route;
}
function handleCallStatusToCalling(): void {
if (!wx.$globalCallPagePath || getRoute() === wx.$globalCallPagePath) return;
wx.navigateTo({
url: `/${wx.$globalCallPagePath}`,
fail: () => console.error('navigateTo fail!')
});
}
function handleCallStatusToIdle(): void {
if (!wx.$globalCallPagePath || getRoute() !== wx.$globalCallPagePath) return;
wx.navigateBack({
fail: () => console.error('navigateBack fail!')
});
}
function useCallParticipantState() {
return {
callParticipantInfo,
};
}
export { useCallParticipantState };
@@ -0,0 +1,118 @@
import { ref } from 'vue';
import type { Ref } from 'vue';
import TUIChatEngine, {
TUIStore,
StoreName,
TUIConversationService,
TUIGroupService,
IConversationModel as ConversationModel,
} from '../chat-uikit-engine-lite';
import type { CreateGroupParams } from './types';
interface ConversationListState {
conversationList: Ref<ConversationModel[] | undefined>;
activeConversation: Ref<ConversationModel | undefined>;
totalUnRead: Ref<number>;
netStatus: Ref<
| typeof TUIChatEngine.TYPES.NET_STATE_CONNECTED
| typeof TUIChatEngine.TYPES.NET_STATE_CONNECTING
| typeof TUIChatEngine.TYPES.NET_STATE_DISCONNECTED
>;
}
interface ConversationListAction {
setActiveConversation: (conversationID: string) => void;
setCurrentConversation: (conversation: ConversationModel | undefined) => void;
createC2CConversation: (userID: string) => Promise<ConversationModel>;
createGroupConversation: (options: CreateGroupParams) => Promise<ConversationModel>;
setConversationList: (conversationList: ConversationModel[]) => void;
setTotalUnRead: (totalUnRead: number) => void;
}
type UseConversationListStateReturn = Omit<
ConversationListState & ConversationListAction,
'setConversationList' | 'setTotalUnRead' | 'getConversation' | 'setCurrentConversation'
>;
const conversationListRef = ref<ConversationModel[] | undefined>(undefined);
const activeConversationRef = ref<ConversationModel | undefined>(undefined);
const totalUnReadRef = ref<number>(0);
const setConversationList = (conversationList: ConversationModel[]) => {
let totalUnread = 0;
conversationList.forEach(conversation => {
totalUnread += conversation.unreadCount || 0;
});
conversationListRef.value = conversationList;
setTotalUnRead(totalUnread);
};
const setTotalUnRead = (totalUnRead: number) => {
totalUnReadRef.value = totalUnRead;
};
const setCurrentConversation = (conversation: ConversationModel | undefined) => {
activeConversationRef.value = conversation;
};
const setActiveConversation = async (conversationID: string) => {
const currentActiveConversation = activeConversationRef.value;
if (conversationID !== currentActiveConversation?.conversationID) {
await TUIConversationService.switchConversation(conversationID);
}
};
const createC2CConversation = async (userID: string) => {
const response = await TUIConversationService.getConversationProfile(`C2C${userID}`);
return response.data.conversation;
};
const createGroupConversation = async (options: CreateGroupParams) => {
const { groupID, ...otherOptions } = options;
const params: CreateGroupParams = otherOptions;
if (options.type !== TUIChatEngine.TYPES.GRP_COMMUNITY) {
params.groupID = groupID || '';
}
const res = await TUIGroupService.createGroup(params);
const { type } = res.data.group;
if (type === TUIChatEngine.TYPES.GRP_AVCHATROOM) {
await TUIGroupService.joinGroup({
groupID: res.data.group.groupID,
applyMessage: '',
});
}
const response = await TUIConversationService.getConversationProfile(`GROUP${res.data.group.groupID}`);
const conversation = response.data.conversation;
return conversation;
};
function initConversationWatcher() {
TUIStore.watch(StoreName.CONV, {
conversationList: (list: ConversationModel[]) => {
setConversationList(list);
},
currentConversation: (conversation: ConversationModel | null) => {
setCurrentConversation(conversation ?? undefined);
},
});
}
initConversationWatcher();
function useConversationListState(): UseConversationListStateReturn {
return {
conversationList: conversationListRef,
activeConversation: activeConversationRef,
totalUnRead: totalUnReadRef,
setActiveConversation,
createC2CConversation,
createGroupConversation
};
}
export { useConversationListState };
export default useConversationListState;
@@ -0,0 +1 @@
export * from './ConversationListState';
@@ -0,0 +1,19 @@
export interface GroupMemberItem {
userID: string;
role?: string;
memberCustomField?: any[];
}
export interface CreateGroupParams {
name: string;
type: string;
groupID?: string;
introduction?: string;
notification?: string;
avatar?: string;
maxMemberNum?: number;
joinOption: string;
memberList?: GroupMemberItem[];
groupCustomField?: any[];
isSupportTopic?: boolean;
}
@@ -0,0 +1,60 @@
import { ref } from 'vue';
import { AudioPlayBackDevice } from '@trtc/call-engine-lite-wx';
import {
TUIStore,
StoreName,
TUICallKitServer,
NAME,
} from './TUICallService/index';
import { DeviceStatus } from '../constants/call';
const microphoneStatus = ref<DeviceStatus>(DeviceStatus.OFF);
const cameraStatus = ref<DeviceStatus>(DeviceStatus.OFF);
const isFrontCamera = ref<boolean>(true);
const currentAudioRoute = ref<string>(AudioPlayBackDevice.EAR);
// const localVideoQuality = ref();
const openLocalCamera = async () => await TUICallKitServer.openCamera('localVideo');
const closeLocalCamera = async () => await TUICallKitServer.closeCamera();
const switchCamera = async () => await TUICallKitServer.switchCamera();
const openLocalMicrophone = async () => await TUICallKitServer.openMicrophone();
const closeLocalMicrophone = async () => await TUICallKitServer.closeMicrophone();
const setAudioRoute = async () => await TUICallKitServer.setSoundMode();
TUIStore?.watch(StoreName.CALL, {
[NAME.CAMERA_POSITION]: _handleCameraPositionChange,
[NAME.IS_EAR_PHONE]: _handleIsEarPhoneChange,
});
function _handleCameraPositionChange(value: boolean) {
isFrontCamera.value = value;
}
function _handleIsEarPhoneChange(value: boolean) {
currentAudioRoute.value = value ? AudioPlayBackDevice.EAR : AudioPlayBackDevice.SPEAKER;
}
function useDeviceState() {
return {
// state
microphoneStatus,
cameraStatus,
// localVideoQuality,
isFrontCamera,
currentAudioRoute,
// captureVolume,
// networkInfo,
// actions
openLocalMicrophone,
closeLocalMicrophone,
openLocalCamera,
closeLocalCamera,
switchCamera,
setAudioRoute,
};
}
export { useDeviceState };
@@ -0,0 +1,787 @@
/**
* 群组设置状态管理
* @module GroupSettingState
* @description 管理群组设置相关的状态和操作,包括群组信息管理、成员管理、权限控制等功能
*/
import { ref } from 'vue';
import {
TUIChatEngine,
TUIStore,
StoreName,
TUIGroupService,
} from '../chat-uikit-engine-lite';
import { GroupMemberRole, GroupType, GroupPermission, GroupInviteType } from './types';
import type {
GroupMember,
GroupSettingState,
GetGroupMemberListParams,
UpdateGroupProfileParams,
SetGroupMemberNameCardParams,
GroupPermissionUtils,
AddGroupMemberParams,
DeleteGroupMemberParams,
ChangeGroupOwnerParams,
AddGroupMemberResult,
} from './types';
import type { IConversationModel } from '../chat-uikit-engine-lite';
/**
* 群组设置业务操作接口
* @interface IGroupSettingBusinessAction
* @description 定义群组设置相关的业务操作方法,继承权限工具接口
*/
interface IGroupSettingBusinessAction extends GroupPermissionUtils {
setChatPinned: (value: boolean) => Promise<void>;
setChatMuted: (value: boolean) => Promise<void>;
getGroupMemberProfile: (userID: string, groupID?: string) => Promise<GroupMember>;
getGroupMemberList: (params?: GetGroupMemberListParams) => Promise<GroupMember[]>;
updateGroupProfile: (params: UpdateGroupProfileParams) => Promise<void>;
addGroupMember: (params: AddGroupMemberParams) => Promise<AddGroupMemberResult>;
deleteGroupMember: (params: DeleteGroupMemberParams) => Promise<void>;
changeGroupOwner: (params: ChangeGroupOwnerParams) => Promise<void>;
setGroupMemberNameCard: (params: SetGroupMemberNameCardParams) => Promise<void>;
dismissGroup: (groupID?: string) => Promise<void>;
quitGroup: (groupID?: string) => Promise<void>;
}
const currentConversationRef = ref<IConversationModel | undefined>(undefined);
const groupIDRef = ref<string | undefined>(undefined);
const groupTypeRef = ref<GroupType | undefined>(undefined);
const groupNameRef = ref<string | undefined>(undefined);
const avatarRef = ref<string | undefined>(undefined);
const introductionRef = ref<string | undefined>(undefined);
const notificationRef = ref<string | undefined>(undefined);
const isMutedRef = ref<boolean | undefined>(undefined);
const isPinnedRef = ref<boolean | undefined>(undefined);
const groupOwnerRef = ref<GroupMember | undefined>(undefined);
const adminMembersRef = ref<GroupMember[] | undefined>([]);
const allMembersRef = ref<GroupMember[] | undefined>([]);
const memberCountRef = ref<number | undefined>(undefined);
const maxMemberCountRef = ref<number | undefined>(undefined);
const currentUserIDRef = ref<string | undefined>(undefined);
const currentUserRoleRef = ref<GroupMemberRole | undefined>(undefined);
const nameCardRef = ref<string | undefined>(undefined);
const isInGroupRef = ref<boolean | undefined>(undefined);
const inviteOptionRef = ref<GroupInviteType | undefined>(undefined);
/**
* 获取单个群成员资料
* @memberof module:GroupSettingState
* @description 获取指定群组中特定成员的资料信息,用于获取单个成员的详细信息
* @param {string} userID - 用户ID
* @param {string} [groupID] - 群组ID,不传则使用当前群组ID
* @returns {Promise<GroupMember>} 群成员信息
* @throws {Error} 当群组ID或用户ID为空时抛出错误
* @example
* ```typescript
* const { getGroupMemberProfile } = useGroupSettingState();
*
* // 获取自己的群成员信息
* try {
* const myProfile = await getGroupMemberProfile('user123');
* console.log('我的群昵称:', myProfile.nameCard);
* } catch (error) {
* console.error('获取成员信息失败:', error);
* }
* ```
*/
async function getGroupMemberProfile(userID: string, groupID?: string): Promise<GroupMember> {
const targetGroupID = groupID || groupIDRef.value;
if (!targetGroupID || !userID) {
throw new Error('getGroupMemberProfile::groupID and userID are required');
}
const result = await TUIGroupService.getGroupMemberProfile({
groupID: targetGroupID,
userIDList: [userID],
});
if (result?.data?.memberList && result.data.memberList.length > 0) {
const member = result.data.memberList[0];
return {
userID: member.userID,
nick: member.nick || member.userID,
avatar: member.avatar || '',
role: member.role as GroupMemberRole,
joinTime: member.joinTime,
muteUntil: member.muteUntil || '',
memberCustomField: member.memberCustomField || '',
nameCard: member.nameCard || '',
};
}
throw new Error('getGroupMemberProfile::member not found');
}
/**
* 获取群组成员列表
* @memberof module:GroupSettingState
* @description 获取指定群组的成员列表,支持分页加载和数据合并
* @param {GetGroupMemberListParams} [params] - 获取成员列表的参数
* @param {string} [params.groupID] - 群组ID,不传则使用当前群组ID
* @param {number} [params.count=100] - 获取成员数量,默认100
* @param {number} [params.offset=0] - 偏移量,用于分页,默认0
* @returns {Promise<GroupMember[]>} 群组成员列表
* @throws {Error} 当群组ID为空或获取失败时抛出错误
* @example
* ```typescript
* const { getGroupMemberList } = useGroupSettingState();
*
* // 获取群组成员列表
* try {
* const members = await getGroupMemberList({
* groupID: 'group123',
* count: 50,
* offset: 0
* });
* console.log('群组成员:', members);
* } catch (error) {
* console.error('获取成员列表失败:', error);
* }
*
* // 分页加载更多成员
* const moreMembers = await getGroupMemberList({
* count: 50,
* offset: 50
* });
* ```
*/
async function getGroupMemberList(params?: GetGroupMemberListParams): Promise<GroupMember[]> {
const targetGroupID = params?.groupID || groupIDRef.value;
if (!targetGroupID) {
throw new Error('getGroupMemberList::groupID is required');
}
const result = await TUIGroupService.getGroupMemberList({
groupID: targetGroupID,
count: params?.count || 100,
offset: params?.offset || 0,
});
if (result?.data?.memberList) {
const newMembers: GroupMember[] = result.data.memberList.map((member: any) => ({
userID: member.userID,
nick: member.nick || member.userID,
avatar: member.avatar || '',
role: member.role as GroupMemberRole,
joinTime: member.joinTime,
muteUntil: member.muteUntil || '',
memberCustomField: member.memberCustomField || '',
nameCard: member.nameCard || '',
}));
const currentMembers = allMembersRef.value || [];
const offset = params?.offset || 0;
let updatedMembers: GroupMember[];
if (offset === 0) {
// First load or refresh - replace all members
updatedMembers = newMembers;
} else {
// Pagination load - merge with existing members
updatedMembers = [...currentMembers];
// Handle overlapping and new data
newMembers.forEach((newMember, index) => {
const targetIndex = offset + index;
if (targetIndex < updatedMembers.length) {
// Replace existing member at this position
updatedMembers[targetIndex] = newMember;
} else {
// Append new member to the end
updatedMembers.push(newMember);
}
});
}
// Separate owner, admins from the updated members list
const owner = updatedMembers.find(member => member.role === GroupMemberRole.OWNER);
const admins = updatedMembers.filter(member => member.role === GroupMemberRole.ADMIN);
// Update refs with merged data
if (owner) {
groupOwnerRef.value = owner;
}
adminMembersRef.value = admins;
allMembersRef.value = updatedMembers;
return updatedMembers;
}
throw new Error('getGroupMemberList::getGroupMemberList failed');
}
/**
* 更新群组资料
* @memberof module:GroupSettingState
* @description 更新群组的基本信息,包括群名称、头像、简介、公告等,并进行参数验证
* @param {UpdateGroupProfileParams} params - 更新群组资料的参数
* @param {string} [params.groupID] - 群组ID,不传则使用当前群组ID
* @param {string} [params.name] - 群组名称,长度限制1-30字符
* @param {string} [params.avatar] - 群组头像URL,长度限制500字符以内
* @param {string} [params.introduction] - 群组简介,长度限制130字符以内
* @param {string} [params.notification] - 群组公告,长度限制130字符以内
* @returns {Promise<void>} 更新群组资料的Promise
* @throws {Error} 当参数验证失败或更新失败时抛出错误
* @example
* ```typescript
* const { updateGroupProfile } = useGroupSettingState();
*
* // 更新群组资料
* try {
* await updateGroupProfile({
* groupID: 'group123',
* name: '新的群组名称',
* introduction: '这是一个学习交流群',
* notification: '欢迎大家积极讨论',
* avatar: 'https://example.com/avatar.jpg'
* });
* console.log('群组资料更新成功');
* } catch (error) {
* console.error('更新群组资料失败:', error);
* }
*
* // 只更新群名称
* await updateGroupProfile({
* name: '技术交流群'
* });
* ```
*/
async function updateGroupProfile(params: UpdateGroupProfileParams): Promise<void> {
const targetGroupID = params.groupID || groupIDRef.value;
if (!targetGroupID) {
throw new Error('updateGroupProfile::groupID is required');
}
const updateParams: UpdateGroupProfileParams = { groupID: targetGroupID };
// Validate and process name
if (params.name !== undefined) {
if (typeof params.name !== 'string') {
throw new Error('updateGroupProfile::name must be a string');
}
if (params.name.length === 0) {
throw new Error('updateGroupProfile::name cannot be empty');
}
if (params.name.length > 30) {
throw new Error('updateGroupProfile::name must be less than or equal to 25 characters');
}
if (params.name === groupNameRef.value) {
throw new Error('updateGroupProfile::name cannot be the same as current value');
}
updateParams.name = params.name;
}
// Validate and process introduction
if (params.introduction !== undefined) {
if (typeof params.introduction !== 'string') {
throw new Error('updateGroupProfile::introduction must be a string');
}
if (params.introduction.length > 130) {
throw new Error('updateGroupProfile::introduction must be less than 100 characters');
}
if (params.introduction === introductionRef.value) {
throw new Error('updateGroupProfile::introduction cannot be the same as current value');
}
updateParams.introduction = params.introduction;
}
// Validate and process notification
if (params.notification !== undefined) {
if (typeof params.notification !== 'string') {
throw new Error('updateGroupProfile::notification must be a string');
}
if (params.notification.length > 130) {
throw new Error('updateGroupProfile::notification must be less than 100 characters');
}
if (params.notification === notificationRef.value) {
throw new Error('updateGroupProfile::notification cannot be the same as current value');
}
updateParams.notification = params.notification;
}
// Validate and process avatar
if (params.avatar !== undefined) {
if (typeof params.avatar !== 'string') {
throw new Error('updateGroupProfile::avatar must be a string');
}
if (params.avatar.length > 500) {
throw new Error('updateGroupProfile::avatar must be less than 500 characters');
}
if (params.avatar === avatarRef.value) {
throw new Error('updateGroupProfile::avatar cannot be the same as current value');
}
updateParams.avatar = params.avatar;
}
await TUIGroupService.updateGroupProfile(updateParams as any);
// Update local state
if (params.name !== undefined) {
groupNameRef.value = params.name;
}
if (params.introduction !== undefined) {
introductionRef.value = params.introduction;
}
if (params.notification !== undefined) {
notificationRef.value = params.notification;
}
if (params.avatar !== undefined) {
avatarRef.value = params.avatar;
}
}
/**
* 添加群组成员
* @memberof module:GroupSettingState
* @description 向群组中添加新成员,添加成功后自动刷新成员列表
* @param {AddGroupMemberParams} params - 添加成员的参数
* @param {string} [params.groupID] - 群组ID,不传则使用当前群组ID
* @param {string[]} params.userIDList - 要添加的用户ID列表
* @returns {Promise<AddGroupMemberResult>} 添加成员的结果,包含成功和失败的用户信息
* @throws {Error} 当群组ID为空或添加失败时抛出错误
* @example
* ```typescript
* const { addGroupMember } = useGroupSettingState();
*
* // 添加群组成员
* try {
* const result = await addGroupMember({
* groupID: 'group123',
* userIDList: ['user1', 'user2', 'user3']
* });
* console.log('添加成功的用户:', result.successUserIDList);
* console.log('添加失败的用户:', result.failureUserIDList);
* } catch (error) {
* console.error('添加群组成员失败:', error);
* }
* ```
*/
async function addGroupMember(params: AddGroupMemberParams): Promise<AddGroupMemberResult> {
const targetGroupID = params.groupID || groupIDRef.value;
if (!targetGroupID) {
throw new Error('addGroupMember::groupID is required');
}
const result: AddGroupMemberResult = await TUIGroupService.addGroupMember({
groupID: targetGroupID,
userIDList: params.userIDList,
});
getGroupMemberList({ count: 100 });
return result;
}
/**
* 删除群组成员
* @memberof module:GroupSettingState
* @description 从群组中移除指定成员,删除成功后自动更新本地成员列表
* @param {DeleteGroupMemberParams} params - 删除成员的参数
* @param {string} [params.groupID] - 群组ID,不传则使用当前群组ID
* @param {string[]} params.userIDList - 要删除的用户ID列表
* @returns {Promise<void>} 删除成员的Promise
* @throws {Error} 当群组ID为空或删除失败时抛出错误
* @example
* ```typescript
* const { deleteGroupMember } = useGroupSettingState();
*
* // 删除群组成员
* try {
* await deleteGroupMember({
* groupID: 'group123',
* userIDList: ['user1', 'user2']
* });
* console.log('群组成员删除成功');
* } catch (error) {
* console.error('删除群组成员失败:', error);
* }
* ```
*/
async function deleteGroupMember(params: DeleteGroupMemberParams): Promise<void> {
const targetGroupID = params.groupID || groupIDRef.value;
if (!targetGroupID) {
throw new Error('deleteGroupMember::groupID is required');
}
await TUIGroupService.deleteGroupMember({
groupID: targetGroupID,
userIDList: params.userIDList,
});
const newAllMembers = allMembersRef.value?.filter(member => !params.userIDList.includes(member.userID));
const newAdminMembers = adminMembersRef.value?.filter(member => !params.userIDList.includes(member.userID));
allMembersRef.value = newAllMembers;
adminMembersRef.value = newAdminMembers;
}
/**
* 转让群主
* @memberof module:GroupSettingState
* @description 将群主身份转让给指定的群成员,只有群主才能执行此操作
* @param {ChangeGroupOwnerParams} params - 转让群主的参数
* @param {string} [params.groupID] - 群组ID,不传则使用当前群组ID
* @param {string} params.newOwnerID - 新群主的用户ID
* @returns {Promise<void>} 转让群主的Promise
* @throws {Error} 当转让失败时抛出错误
* @example
* ```typescript
* const { changeGroupOwner } = useGroupSettingState();
*
* // 转让群主
* try {
* await changeGroupOwner({
* groupID: 'group123',
* newOwnerID: 'user123'
* });
* console.log('群主转让成功');
* } catch (error) {
* console.error('转让群主失败:', error);
* }
* ```
*/
async function changeGroupOwner(params: ChangeGroupOwnerParams): Promise<void> {
const targetGroupID = params.groupID || groupIDRef.value;
if (!targetGroupID) {
return;
}
await TUIGroupService.changeGroupOwner({
groupID: targetGroupID,
newOwnerID: params.newOwnerID,
});
}
/**
* 设置群组成员名片
* @memberof module:GroupSettingState
* @description 设置指定群成员的群名片(群昵称),通常用于设置自己的群名片
* @param {SetGroupMemberNameCardParams} params - 设置成员名片的参数
* @param {string} [params.groupID] - 群组ID,不传则使用当前群组ID
* @param {string} [params.userID] - 用户ID,不传则使用当前用户ID
* @param {string} params.nameCard - 群名片内容
* @returns {Promise<void>} 设置成员名片的Promise
* @throws {Error} 当设置失败时抛出错误
* @example
* ```typescript
* const { setGroupMemberNameCard } = useGroupSettingState();
*
* // 设置自己的群名片
* try {
* await setGroupMemberNameCard({
* groupID: 'group123',
* nameCard: '技术负责人-小王'
* });
* console.log('群名片设置成功');
* } catch (error) {
* console.error('设置群名片失败:', error);
* }
*
* // 设置其他成员的群名片(需要管理员权限)
* await setGroupMemberNameCard({
* userID: 'user123',
* nameCard: '产品经理-小李'
* });
* ```
*/
async function setGroupMemberNameCard(params: SetGroupMemberNameCardParams): Promise<void> {
const targetGroupID = params.groupID || groupIDRef.value;
if (!targetGroupID || !currentUserIDRef.value) {
return;
}
nameCardRef.value = params.nameCard;
await TUIGroupService.setGroupMemberNameCard({
groupID: targetGroupID,
userID: params.userID || currentUserIDRef.value,
nameCard: params.nameCard,
});
}
/**
* 解散群组
* @memberof module:GroupSettingState
* @description 解散指定的群组,只有群主才能执行此操作,解散后群组将被永久删除
* @param {string} [groupID] - 群组ID,不传则使用当前群组ID
* @returns {Promise<void>} 解散群组的Promise
* @throws {Error} 当群组ID为空或解散失败时抛出错误
* @example
* ```typescript
* const { dismissGroup } = useGroupSettingState();
*
* // 解散群组
* try {
* await dismissGroup('group123');
* console.log('群组解散成功');
* } catch (error) {
* console.error('解散群组失败:', error);
* }
*
* // 解散当前群组
* await dismissGroup();
* ```
*/
async function dismissGroup(groupID?: string): Promise<void> {
const targetGroupID = groupID || groupIDRef.value;
if (!targetGroupID) {
throw new Error('dismissGroup::groupID is required');
}
await TUIGroupService.dismissGroup(targetGroupID);
}
/**
* 退出群组
* @memberof module:GroupSettingState
* @description 退出指定的群组,退出后将不再接收群组消息
* @param {string} [groupID] - 群组ID,不传则使用当前群组ID
* @returns {Promise<void>} 退出群组的Promise
* @throws {Error} 当群组ID为空或退出失败时抛出错误
* @example
* ```typescript
* const { quitGroup } = useGroupSettingState();
*
* // 退出群组
* try {
* await quitGroup('group123');
* console.log('退出群组成功');
* } catch (error) {
* console.error('退出群组失败:', error);
* }
*
* // 退出当前群组
* await quitGroup();
* ```
*/
async function quitGroup(groupID?: string): Promise<void> {
const targetGroupID = groupID || groupIDRef.value;
if (!targetGroupID) {
throw new Error('quitGroup::groupID is required');
}
await TUIGroupService.quitGroup(targetGroupID);
}
/**
* 重置状态
* @memberof module:GroupSettingState
* @description 重置所有群组设置相关的状态数据为初始值
* @example
* ```typescript
* // 当切换到非群组会话或退出群组时,自动调用重置状态
* // 通常不需要手动调用此函数
* reset();
* ```
*/
function reset() {
currentConversationRef.value = undefined;
groupIDRef.value = undefined;
groupTypeRef.value = undefined;
groupNameRef.value = undefined;
avatarRef.value = undefined;
introductionRef.value = undefined;
notificationRef.value = undefined;
isMutedRef.value = undefined;
isPinnedRef.value = undefined;
groupOwnerRef.value = undefined;
adminMembersRef.value = [];
allMembersRef.value = [];
memberCountRef.value = undefined;
maxMemberCountRef.value = undefined;
currentUserIDRef.value = undefined;
currentUserRoleRef.value = undefined;
nameCardRef.value = undefined;
isInGroupRef.value = undefined;
inviteOptionRef.value = undefined;
}
/**
* 初始化数据监听器
* @memberof module:GroupSettingState
* @description 初始化TUIStore数据变化监听器,实现群组状态的自动同步更新
* @example
* ```typescript
* // 自动调用,监听以下数据变化:
* // - 当前会话变化:自动更新群组信息和用户状态
* // - 群组资料变化:同步更新群组ID等信息
* // - 会话切换:自动重置或更新相关状态
* initWatcher();
* ```
*/
function initWatcher() {
TUIStore.watch(StoreName.CONV, {
currentConversation: (conversation: IConversationModel) => {
if (conversation && conversation.type === 'GROUP') {
const prevConversationID = currentConversationRef.value?.conversationID;
if (prevConversationID !== conversation.conversationID) {
allMembersRef.value = undefined;
adminMembersRef.value = undefined;
groupOwnerRef.value = undefined;
}
currentConversationRef.value = conversation;
const {
groupProfile: {
groupID,
name,
avatar,
introduction,
memberCount,
maxMemberCount,
notification,
type,
inviteOption,
selfInfo: {
role,
userID,
nameCard,
},
},
isMuted,
isPinned,
operationType,
} = conversation;
groupIDRef.value = groupID;
groupTypeRef.value = type as GroupType;
groupNameRef.value = name;
avatarRef.value = avatar;
introductionRef.value = introduction;
isMutedRef.value = isMuted;
isPinnedRef.value = isPinned;
memberCountRef.value = memberCount;
currentUserIDRef.value = userID;
currentUserRoleRef.value = role;
nameCardRef.value = nameCard;
maxMemberCountRef.value = maxMemberCount;
notificationRef.value = notification;
// inviteOption
switch (inviteOption) {
case TUIChatEngine.TYPES.JOIN_OPTIONS_FREE_ACCESS:
inviteOptionRef.value = GroupInviteType.FREE_ACCESS;
break;
case TUIChatEngine.TYPES.JOIN_OPTIONS_NEED_PERMISSION:
inviteOptionRef.value = GroupInviteType.NEED_PERMISSION;
break;
case TUIChatEngine.TYPES.JOIN_OPTIONS_DISABLE_INVITE:
inviteOptionRef.value = GroupInviteType.DISABLE_APPLY;
break;
default:
inviteOptionRef.value = GroupInviteType.DISABLE_APPLY;
}
if ([4, 5, 8].includes(operationType)) {
isInGroupRef.value = false;
} else if (operationType === 0) {
isInGroupRef.value = true;
}
} else {
reset();
}
},
});
TUIStore.watch(StoreName.GRP, {
groupProfile: (groupProfile: unknown) => {
if (typeof groupProfile === 'object' && groupProfile && 'groupID' in groupProfile) {
groupIDRef.value = groupProfile.groupID as string;
}
},
});
}
initWatcher();
/**
* 群组设置状态管理Hook
* @memberof module:GroupSettingState
* @description 提供群组设置相关的状态和操作方法,包括群组信息管理、成员管理、权限控制等功能
* @returns {GroupSettingState & IGroupSettingBusinessAction} 群组设置状态和操作方法
* @example
* ```typescript
* import { useGroupSettingState } from './GroupSettingState';
*
* // 在组件中使用
* const {
* // 状态数据
* groupID,
* groupName,
* groupOwner,
* allMembers,
* currentUserRole,
*
* // 权限检查
* hasPermission,
* canOperateOnMember,
*
* // 业务操作
* updateGroupProfile,
* addGroupMember,
* deleteGroupMember,
* setGroupMemberRole,
* setMuteAllMember,
* dismissGroup
* } = useGroupSettingState();
*
* // 更新群组信息
* await updateGroupProfile({
* name: '新群名称',
* introduction: '群组简介'
* });
*
* // 添加群成员
* await addGroupMember({
* userIDList: ['user1', 'user2']
* });
*
* // 检查权限
* if (hasPermission(GroupPermission.DELETE_MEMBER)) {
* // 显示删除成员按钮
* }
*
* // 检查是否可以操作某个成员
* const member = allMembers.value?.find(m => m.userID === 'user123');
* if (member && canOperateOnMember(member)) {
* // 显示管理操作
* }
* ```
*/
function useGroupSettingState(): GroupSettingState & IGroupSettingBusinessAction {
return {
// State
groupID: groupIDRef,
groupType: groupTypeRef,
groupName: groupNameRef,
avatar: avatarRef,
introduction: introductionRef,
notification: notificationRef,
groupOwner: groupOwnerRef,
adminMembers: adminMembersRef,
allMembers: allMembersRef,
memberCount: memberCountRef,
maxMemberCount: maxMemberCountRef,
currentUserID: currentUserIDRef,
currentUserRole: currentUserRoleRef,
nameCard: nameCardRef,
isInGroup: isInGroupRef,
inviteOption: inviteOptionRef,
// Business actions
getGroupMemberProfile,
getGroupMemberList,
updateGroupProfile,
addGroupMember,
deleteGroupMember,
changeGroupOwner,
setGroupMemberNameCard,
dismissGroup,
quitGroup,
};
}
export {
useGroupSettingState,
GroupPermission,
GroupType,
GroupMemberRole,
GroupInviteType,
};
export type {
GroupMember,
};
@@ -0,0 +1,11 @@
export {
useGroupSettingState,
GroupPermission,
GroupType,
GroupMemberRole,
GroupInviteType,
} from './GroupSettingState';
export type {
GroupMember,
} from './GroupSettingState';
@@ -0,0 +1,177 @@
import type { Ref } from 'vue';
// ==================== Enum Definitions ====================
enum GroupMemberRole {
OWNER = 'Owner',
ADMIN = 'Admin',
COMMON = 'Member',
}
enum GroupType {
WORK = 'Private',
PUBLIC = 'Public',
MEETING = 'ChatRoom',
AVCHATROOM = 'AVChatRoom',
COMMUNITY = 'Community',
}
enum GroupInviteType {
FREE_ACCESS = 'FREE_ACCESS',
NEED_PERMISSION = 'NEED_PERMISSION',
DISABLE_APPLY = 'DISABLE_APPLY',
}
enum GroupPermission {
// Basic permissions
VIEW_GROUP_INFO = 'VIEW_GROUP_INFO',
VIEW_MEMBER_LIST = 'VIEW_MEMBER_LIST',
// Group profile permissions
EDIT_GROUP_PROFILE_NAME = 'EDIT_GROUP_PROFILE_NAME',
EDIT_GROUP_PROFILE_AVATAR = 'EDIT_GROUP_PROFILE_AVATAR',
EDIT_GROUP_PROFILE_INTRODUCTION = 'EDIT_GROUP_PROFILE_INTRODUCTION',
EDIT_GROUP_PROFILE_NOTIFICATION = 'EDIT_GROUP_PROFILE_NOTIFICATION',
EDIT_GROUP_PROFILE_ELSE = 'EDIT_GROUP_PROFILE_ELSE',
// Member management permissions
REMOVE_MEMBER = 'REMOVE_MEMBER',
SET_MEMBER_ROLE = 'SET_MEMBER_ROLE',
// Mute permissions
MUTE_MEMBER = 'MUTE_MEMBER',
MUTE_ALL_MEMBERS = 'MUTE_ALL_MEMBERS',
// Group management permissions
TRANSFER_OWNERSHIP = 'TRANSFER_OWNERSHIP',
DISMISS_GROUP = 'DISMISS_GROUP',
QUIT_GROUP = 'QUIT_GROUP',
}
// ==================== Interface Definitions ====================
interface GroupMember {
userID: string;
nick: string;
avatar: string;
role: GroupMemberRole;
joinTime: number;
muteUntil: string;
memberCustomField: string;
}
interface GroupSettingState {
groupID: Ref<string | undefined>;
groupType: Ref<GroupType | undefined>;
groupName: Ref<string | undefined>;
avatar: Ref<string | undefined>;
introduction: Ref<string | undefined>;
notification: Ref<string | undefined>;
nameCard: Ref<string | undefined>;
isMuted: Ref<boolean | undefined>;
isPinned: Ref<boolean | undefined>;
groupOwner: Ref<GroupMember | undefined>;
adminMembers: Ref<GroupMember[] | undefined>;
allMembers: Ref<GroupMember[] | undefined>;
memberCount: Ref<number | undefined>;
maxMemberCount: Ref<number | undefined>;
currentUserID: Ref<string | undefined>;
currentUserRole: Ref<GroupMemberRole | undefined>;
isMuteAllMembers: Ref<boolean | undefined>;
isInGroup: Ref<boolean | undefined>;
inviteOption: Ref<GroupInviteType | undefined>;
}
// ==================== Permission Related Types ====================
// Complete permission matrix type - ensures all permissions are explicitly configured
type CompletePermissionMatrix = {
[_GroupTypeKey in GroupType]: {
[_Role in GroupMemberRole]: {
[_Permission in GroupPermission]: boolean;
};
};
};
// ==================== Method Parameter Types ====================
interface GetGroupMemberListParams {
// count max is 100
count?: number;
groupID?: string;
role?: string;
offset?: number;
}
interface UpdateGroupProfileParams {
groupID?: string;
name?: string;
avatar?: string;
introduction?: string;
notification?: string;
}
interface AddGroupMemberParams {
userIDList: string[];
groupID?: string;
}
interface AddGroupMemberResult {
data: {
successUserIDList: string[];
failureUserIDList: string[];
existedUserIDList: string[];
};
}
interface DeleteGroupMemberParams {
userIDList: string[];
groupID?: string;
}
interface SetGroupMemberRoleParams {
userID: string;
role: string;
groupID?: string;
}
interface ChangeGroupOwnerParams {
newOwnerID: string;
groupID?: string;
}
interface SetGroupMemberMuteTimeParams {
userID: string;
time: number;
groupID?: string;
}
interface SetGroupMemberNameCardParams {
nameCard: string;
// If userID is not provided, the nameCard of the current user will be modified
userID?: string;
groupID?: string;
}
export {
GroupType,
GroupMemberRole,
GroupPermission,
GroupInviteType,
};
export type {
GroupMember,
GroupSettingState,
GroupPermissionUtils,
GetGroupMemberListParams,
UpdateGroupProfileParams,
AddGroupMemberParams,
AddGroupMemberResult,
DeleteGroupMemberParams,
SetGroupMemberRoleParams,
ChangeGroupOwnerParams,
SetGroupMemberMuteTimeParams,
SetGroupMemberNameCardParams,
CompletePermissionMatrix,
};
+128
View File
@@ -0,0 +1,128 @@
import { ref } from 'vue';
import type { LoginParams, LoginUserInfo, SetSelfInfoParams } from '../types/login';
import TencentCloudChat from '@tencentcloud/lite-chat/basic';
import conversationPlugin from '@tencentcloud/lite-chat/plugins/conversation';
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
import TUIChatEngine from './chat-uikit-engine-lite';
import { UI_PLATFORM } from '../constants/chat';
import { EVENT } from "../constants/event";
import { TUIBridge } from '../TUIBridge/index';
const loginUserInfo = ref<LoginUserInfo | null>(null);
let chat: ReturnType<typeof TencentCloudChat.create> | null = null;
async function login(options: LoginParams) {
console.log('[loginState login] options', options)
if (!options.userId || !options.userSig || !options.sdkAppId) {
throw new Error('[loginState login] params error');
}
const { userId, userSig, sdkAppId } = options;
try {
chat = TencentCloudChat.create({
SDKAppID: sdkAppId,
scene: UI_PLATFORM.UNI_MINI_APP,
})
chat.use(conversationPlugin);
chat.use(messageEnhancerPlugin);
chat.use(richMediaMessagePlugin);
chat.use(groupPlugin);
await TUIChatEngine.login({
SDKAppID: sdkAppId,
userID: userId,
userSig,
chat
})
const params = {
chat: chat,
userID: userId,
userSig,
SDKAppID: sdkAppId,
};
TUIBridge.notifyEvent({ eventName: EVENT.LOGIN_SUCCESS, params });
await getMyProfile(userId);
} catch (error) {
console.error('[loginState login] error', error);
throw error;
}
}
async function getMyProfile(userID) {
const result = await chat?.getUserProfile({ userIDList: [userID] });
const localUserInfo = result.data[0];
if (!localUserInfo.value) {
loginUserInfo.value = {
userId: localUserInfo.userID,
userName: localUserInfo.nick,
avatarUrl: localUserInfo.avatar,
customInfo: localUserInfo.profileCustomField,
};
}
}
async function setSelfInfo(options: SetSelfInfoParams): Promise<void> {
const currentLoginUserInfo = loginUserInfo.value || {} as LoginUserInfo;
if (!chat) {
throw Error('[loginState setSelfInfo] not login');
} else {
const profileCustomField: any[] = [];
if (options.customInfo) {
Object.keys(options.customInfo).forEach((key) => {
let customKey = key;
if (!key.includes('Tag_Profile_Custom')) {
customKey = `Tag_Profile_Custom_${key}`;
}
profileCustomField.push({
key: customKey,
value: options.customInfo?.[key],
});
});
}
try {
const res = await chat.updateMyProfile({
nick: options.userName,
avatar: options.avatarUrl,
profileCustomField,
});
loginUserInfo.value = {
...currentLoginUserInfo,
...options,
};
return res;
} catch (error) {
console.error('[loginState setSelfInfo] error', error);
throw error;
}
}
}
async function logout() {
try {
console.log('[loginState logout]')
await chat?.logout();
await chat?.destroy();
TUIBridge.notifyEvent({ eventName: EVENT.LOGOUT_SUCCESS, params: {} });
chat = null;
} catch (error) {
console.error('[loginState logout] error', error);
}
}
export function useLoginState() {
return {
loginUserInfo,
login,
logout,
setSelfInfo
};
}
export default useLoginState;
@@ -0,0 +1,149 @@
import { ref } from 'vue';
import type { Ref } from 'vue';
import { StoreName, TUIChatService, TUIStore } from '../chat-uikit-engine-lite';
import { MessageContentType } from './type';
import { convertInputContentToEditorNode } from './utils';
import type { InputContent } from './type';
/**
* Message Input Store
*
* This store manages the state and operations related to the chat message input, including:
* - Raw input value (text or structured content)
* - Editor instance management
* - Content manipulation (insertion, updating, etc.)
* - Message sending functionality
*/
interface MessageInputState {
inputRawValue: Ref<string | InputContent[]>;
isPeerTyping: Ref<boolean>;
}
interface MessageInputAction {
updateRawValue: (value: string | InputContent[]) => void;
setEditorInstance: (editor) => void;
setContent: (value: string | InputContent[]) => void;
insertContent: (value: string | InputContent[], focus?: boolean) => void;
focusEditor: () => void;
blurEditor: () => void;
sendMessage: (msg?: string | InputContent[]) => void;
}
const editor = ref(null);
const inputRawValue = ref<string | InputContent[]>('');
const isPeerTyping = ref(false);
/* =====================================================
* MessageInputActions begin
* ===================================================== */
const updateRawValue = (value: string | InputContent[]) => {
if (typeof value !== 'string' && !Array.isArray(value)) {
console.warn('Invalid input type for updateRawValue');
return;
}
if (typeof value === 'string') {
inputRawValue.value = value.trim();
} else {
inputRawValue.value = value?.length > 0 ? value : '';
}
TUIChatService.enterTypingState();
setTimeout(() => {
TUIChatService.leaveTypingState();
}, 3000);
};
const setEditorInstance = (instance) => {
if (editor.value) {
editor.value.destroy();
}
editor.value = instance;
};
const setContent = (content: string | InputContent[]) => {
if (!editor.value) {
return;
}
if (typeof content === 'string') {
editor.value.commands.setContent(content, true);
} else {
const editorContent = content.map(convertInputContentToEditorNode);
editor.value.commands.setContent(editorContent, true);
}
editor.value.commands.focus();
};
const insertContent = (content: string | InputContent[], focus = true) => {
if (!editor.value) {
return;
}
if (typeof content === 'string') {
editor.value.commands.insertContent(content);
} else {
const editorContent = content.map(convertInputContentToEditorNode);
editor.value.commands.insertContent(editorContent);
}
if (focus) {
editor.value.commands.focus();
}
};
const focusEditor = () => {
editor.value?.commands.focus();
};
const blurEditor = () => {
editor.value?.commands.blur();
};
const sendMessage = async (options) => {
const { type, content } = options
if (type === MessageContentType.TEXT) {
await TUIChatService.sendTextMessage({
payload: { text: content },
})
}
if (type === MessageContentType.IMAGE) {
await TUIChatService.sendImageMessage({
payload: { file: content },
});
}
if (type === MessageContentType.VIDEO) {
await TUIChatService.sendVideoMessage({
payload: { file: content },
});
}
};
/* =====================================================
* MessageInputActions end
* ===================================================== */
function useMessageInputState(): MessageInputState & MessageInputAction {
return {
inputRawValue,
isPeerTyping,
updateRawValue,
setEditorInstance,
setContent,
insertContent,
focusEditor,
blurEditor,
sendMessage,
};
}
function initWatcher() {
TUIStore.watch(StoreName.CHAT, {
typingStatus: (typingStatus) => {
isPeerTyping.value = typingStatus;
},
});
}
initWatcher();
export { useMessageInputState, MessageContentType };
export type { InputContent };
@@ -0,0 +1 @@
export * from './MessageInputState';
@@ -0,0 +1,38 @@
enum MessageContentType {
TEXT = 'text',
IMAGE = 'image',
VIDEO = 'video',
FILE = 'file',
MENTION = 'mention',
EMOJI = 'emoji',
}
type ContentTypeMap = {
[key in MessageContentType]: key extends MessageContentType.TEXT
? string
: key extends MessageContentType.IMAGE
? File
: key extends MessageContentType.VIDEO
? File
: key extends MessageContentType.FILE
? File
: key extends MessageContentType.MENTION
? string[]
: key extends MessageContentType.EMOJI
? { url: string; key: string; text: string }
: never;
};
interface InputContent<T extends MessageContentType = MessageContentType> {
type: T;
content: ContentTypeMap[T];
}
export {
MessageContentType,
};
export type {
ContentTypeMap,
InputContent,
};
@@ -0,0 +1,45 @@
import { MessageContentType } from './type';
import type { InputContent } from './type';
function convertInputContentToEditorNode(item: InputContent) {
switch (item.type) {
case MessageContentType.TEXT:
return {
type: 'text',
text: item.content,
};
case MessageContentType.IMAGE: {
const imageFile = item.content as File;
const imageUrl = URL.createObjectURL(imageFile);
return {
type: MessageContentType.IMAGE,
attrs: {
src: imageUrl,
alt: imageFile?.name,
fileData: imageFile,
title: imageFile?.name,
},
};
}
case MessageContentType.EMOJI: {
const emoticonContent = item.content as { url: string; key: string; text: string };
return {
type: MessageContentType.EMOJI,
attrs: {
src: emoticonContent.url,
alt: emoticonContent.key,
title: emoticonContent.text,
},
};
}
default:
return {
type: 'text',
text: String(item.content),
};
}
}
export {
convertInputContentToEditorNode,
};
@@ -0,0 +1,166 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
import {
TUIStore,
StoreName,
TUIChatService,
} from '../chat-uikit-engine-lite';
import type { IMessageModel as MessageModel } from '../chat-uikit-engine-lite';
interface MessageListState {
activeConversationID: Ref<string | undefined>;
messageList: Ref<readonly MessageModel[] | undefined>;
hasMoreOlderMessage: Ref<boolean | undefined>;
hasMoreNewerMessage: Ref<boolean | undefined>;
enableReadReceipt: Ref<boolean | undefined>;
isDisableScroll: Ref<boolean | undefined>;
recalledMessageIDSet: Ref<Set<string>>;
highlightMessageIDSet: Ref<Set<string>>;
}
interface MessageListBusinessAction {
loadMoreOlderMessage: () => Promise<void>;
// TODO
// loadMoreNewerMessage: () => Promise<void>;
setEnableReadReceipt: (enableReadReceipt: boolean | undefined) => void;
setIsDisableScroll: (isDisableScroll: boolean) => void;
highlightMessage: ({ messageID, duration }: { messageID: string; duration: number }) => void;
}
// internal state
let prevConversationID: string | undefined;
// initial state
const activeConversationID = ref<string | undefined>(undefined);
const messageList = ref<readonly MessageModel[] | undefined>(undefined);
const hasMoreOlderMessage = ref<boolean | undefined>(undefined);
const hasMoreNewerMessage = ref<boolean | undefined>(undefined);
const enableReadReceipt = ref<boolean | undefined>(undefined);
const isDisableScroll = ref<boolean | undefined>(undefined);
const recalledMessageIDSet = ref<Set<string>>(new Set());
const highlightMessageIDSet = ref<Set<string>>(new Set());
function initialState() {
activeConversationID.value = undefined;
messageList.value = undefined;
hasMoreOlderMessage.value = undefined;
hasMoreNewerMessage.value = undefined;
enableReadReceipt.value = undefined;
isDisableScroll.value = undefined;
recalledMessageIDSet.value = new Set();
highlightMessageIDSet.value = new Set();
}
// business actions
function setEnableReadReceipt(_enableReadReceipt: boolean | undefined): void {
enableReadReceipt.value = _enableReadReceipt;
}
function setIsDisableScroll(_isDisableScroll: boolean): void {
isDisableScroll.value = _isDisableScroll;
}
function highlightMessage({ messageID, duration }: { messageID: string; duration: number }): void {
highlightMessageIDSet.value.add(messageID);
setTimeout(() => {
highlightMessageIDSet.value.delete(messageID);
}, duration);
}
function useMessageListState(): MessageListState & MessageListBusinessAction {
return {
// state
activeConversationID,
messageList,
hasMoreOlderMessage,
hasMoreNewerMessage,
enableReadReceipt,
isDisableScroll,
recalledMessageIDSet,
highlightMessageIDSet,
// actions
loadMoreOlderMessage: TUIChatService.getMessageList,
// TODO
// loadMoreNewerMessage: TUIChatService.getMessageList,
setEnableReadReceipt,
setIsDisableScroll,
highlightMessage,
};
}
const initMessageListWatcher = () => {
/** watcher binding */
function onMessageListUpdated(_messageList: MessageModel[]) {
if (!activeConversationID.value) {
return;
}
function createOptimizedMessage(originalMessage: MessageModel): MessageModel {
// use spread operator to copy all own properties, but will lose the prototype chain methods
return {
...originalMessage,
// 🎯 re-create frequently changing properties to ensure React can detect changes
status: originalMessage.status,
progress: originalMessage.progress,
isRevoked: originalMessage.isRevoked,
isDeleted: originalMessage.isDeleted,
isPeerRead: originalMessage.isPeerRead,
// 🎯 re-create read receipt info object
readReceiptInfo: originalMessage.readReceiptInfo
? {
...originalMessage.readReceiptInfo,
readCount: originalMessage.readReceiptInfo.readCount,
unreadCount: originalMessage.readReceiptInfo.unreadCount,
isPeerRead: originalMessage.readReceiptInfo.isPeerRead,
}
: originalMessage.readReceiptInfo,
getMessageContent: () => originalMessage.getMessageContent(),
deleteMessage: () => originalMessage.deleteMessage(),
revokeMessage: () => originalMessage.revokeMessage(),
resendMessage: () => originalMessage.resendMessage(),
getSignalingInfo: () => originalMessage.getSignalingInfo(),
modifyMessage: options => originalMessage.modifyMessage(options),
quoteMessage: () => originalMessage.quoteMessage(),
replyMessage: () => originalMessage.replyMessage(),
};
}
const optimizedMessageList = _messageList
.map(createOptimizedMessage);
messageList.value = optimizedMessageList;
// resolve recalled message ID for quoted message
messageList.value.forEach((message) => {
if (message.isRevoked) {
recalledMessageIDSet.value.add(message.ID);
}
});
}
function onMessageListLoadStateUpdated(isCompleted: boolean) {
hasMoreOlderMessage.value = !isCompleted;
}
function onActiveConversationIDUpdated(conversationID: string) {
if (!conversationID || conversationID !== prevConversationID) {
initialState();
activeConversationID.value = conversationID;
}
prevConversationID = conversationID || undefined;
}
TUIStore.watch(StoreName.CONV, {
currentConversationID: onActiveConversationIDUpdated,
});
TUIStore.watch(StoreName.CHAT, {
messageList: onMessageListUpdated,
isCompleted: onMessageListLoadStateUpdated,
});
};
initMessageListWatcher();
export { useMessageListState };
@@ -0,0 +1 @@
export * from './MessageListState';
@@ -0,0 +1,46 @@
import { UIKitModal } from '../../../components/UIKitModal'
const MINI_MODAL_ERROR_CODES = [
-1001,
-1002,
101002,
];
const MINI_MODAL_ERROR_MAP = {
'-1001': {
id: 10001,
content: '您的应用还未开通音视频通话能力'
},
'-1002': {
id: 10002,
content: '您暂不支持使用该能力,请前往购买页购买开通'
},
'101002': {
id: 10014,
content: '发起通话失败,用户 ID 无效,请确认该用户已注册'
}
}
export function handleModalError(error) {
if (!error || !error?.code) {
return;
}
handleMiniModalError(error);
}
function handleMiniModalError(error) {
if (!MINI_MODAL_ERROR_CODES.includes(error.code)) {
return;
}
const errorInfo = MINI_MODAL_ERROR_MAP[error.code.toString()];
if (errorInfo) {
UIKitModal.openModal({
id: errorInfo.id,
content: errorInfo.content,
title: '错误',
type: 'error'
});
}
}
@@ -0,0 +1,47 @@
import { TUIBridge } from '../../../TUIBridge/index';
import { LOG_LEVEL } from '@trtc/call-engine-lite-wx';
import { COMPONENT, NAME, EVENT } from '../const/index';
export default class ChatCombine {
static instance: ChatCombine;
private _callService: any;
constructor(options) {
this._callService = options.callService;
TUIBridge.registerEvent(EVENT.LOGIN_SUCCESS, this);
TUIBridge.registerEvent(EVENT.LOGOUT_SUCCESS, this);
TUIBridge.registerEvent(EVENT.ON_CALLS, this);
}
static getInstance(options) {
if (!ChatCombine.instance) {
ChatCombine.instance = new ChatCombine(options);
}
return ChatCombine.instance;
}
/**
* tuicore notify event manager
* @param {String} eventName event name
* @param {String} subKey sub key
* @param {Any} options tuicore event parameters
*/
public async onNotifyEvent(options?: any) {
try {
if (options?.eventName === EVENT.LOGIN_SUCCESS) {
const { chat, userID, userSig, SDKAppID } = options?.params || {};
await this._callService?.init({ tim: chat, userID, userSig, sdkAppID: SDKAppID, isFromChat: true, component: COMPONENT.TIM_CALL_KIT });
this._callService?.setIsFromChat(true);
this._callService?.setLogLevel(LOG_LEVEL.NORMAL);
}
if (options?.eventName === EVENT.LOGOUT_SUCCESS) {
await this._callService?.destroyed();
}
if (options?.eventName === EVENT.ON_CALLS) {
options?.params && this._callService.calls(options.params);
}
} catch (error) {
console.error(`${NAME.PREFIX}TUICore onNotifyEvent failed, error: ${error}.`);
}
}
}
@@ -0,0 +1,346 @@
import { ITUIStore } from '../interface/ITUIStore';
import { TUICallEvent } from '@trtc/call-engine-lite-wx';
import { IUserInfo } from '../interface/ICallService';
import { CallMediaType } from '@trtc/call-engine-lite-wx';
import { StoreName, CallStatus, NAME, CallRole, ErrorCode, ErrorMessage, NETWORK_QUALITY_THRESHOLD } from '../const/index';
import { CallTips, t } from '../locales/index';
import { initAndCheckRunEnv } from './miniProgram';
import { getRemoteUserProfile, analyzeEventData, deleteRemoteUser } from './utils';
import TuiStore from '../TUIStore/tuiStore';
const TUIStore: ITUIStore = TuiStore.getInstance();
export default class EngineEventHandler {
static instance: EngineEventHandler;
private _callService: any;
constructor(options) {
this._callService = options.callService;
}
static getInstance(options) {
if (!EngineEventHandler.instance) {
EngineEventHandler.instance = new EngineEventHandler(options);
}
return EngineEventHandler.instance;
}
public addListenTuiCallEngineEvent() {
const callEngine = this._callService?.getTUICallEngineInstance();
if (!callEngine) {
console.warn(`${NAME.PREFIX}add engine event listener failed, engine is empty.`);
return;
}
callEngine.on(TUICallEvent.ERROR, this._handleError, this);
callEngine.on(TUICallEvent.ON_CALL_RECEIVED, this._handleNewInvitationReceived, this); // 收到邀请事件
TUICallEvent?.ON_CALL_BEGIN && callEngine.on(TUICallEvent.ON_CALL_BEGIN, this._handleOnCallBegin, this); // 主叫收到被叫接通事件
callEngine.on(TUICallEvent.USER_ENTER, this._handleUserEnter, this); // 有用户进房事件
callEngine.on(TUICallEvent.USER_LEAVE, this._handleUserLeave, this); // 有用户离开通话事件
callEngine.on(TUICallEvent.REJECT, this._handleInviteeReject, this); // 主叫收到被叫的拒绝通话事件
callEngine.on(TUICallEvent.NO_RESP, this._handleNoResponse, this); // 主叫收到被叫的无应答事件
callEngine.on(TUICallEvent.LINE_BUSY, this._handleLineBusy, this); // 主叫收到被叫的忙线事件
callEngine.on(TUICallEvent.ON_CALL_NOT_CONNECTED, this._handleCallNotConnected, this); // 主被叫在通话未建立时, 收到的取消事件
callEngine.on(TUICallEvent.ON_USER_INVITING, this._handleOnUserInviting, this); // 通话存在邀请他人时, 通话里的所有人都会抛出
callEngine.on(TUICallEvent.KICKED_OUT, this._handleKickedOut, this); // 未开启多端登录时, 多端登录收到的被踢事件
// @ts-ignore
// TUICallEvent.ON_USER_NETWORK_QUALITY_CHANGED && callEngine.on(TUICallEvent.ON_USER_NETWORK_QUALITY_CHANGED, this._handleNetworkQuality, this); // 用户网络质量
callEngine.on(TUICallEvent.USER_VIDEO_AVAILABLE, this._handleUserVideoAvailable, this);
callEngine.on(TUICallEvent.USER_AUDIO_AVAILABLE, this._handleUserAudioAvailable, this);
callEngine.on(TUICallEvent.CALL_END, this._handleCallingEnd, this); // 主被叫在通话结束时, 收到的通话结束事件
}
public removeListenTuiCallEngineEvent() {
const callEngine = this._callService?.getTUICallEngineInstance();
callEngine.off(TUICallEvent.ERROR, this._handleError, this);
callEngine.off(TUICallEvent.ON_CALL_RECEIVED, this._handleNewInvitationReceived, this);
TUICallEvent?.ON_CALL_BEGIN && callEngine.off(TUICallEvent.ON_CALL_BEGIN, this._handleOnCallBegin, this);
callEngine.off(TUICallEvent.USER_ENTER, this._handleUserEnter, this);
callEngine.off(TUICallEvent.USER_LEAVE, this._handleUserLeave, this);
callEngine.off(TUICallEvent.REJECT, this._handleInviteeReject, this);
callEngine.off(TUICallEvent.NO_RESP, this._handleNoResponse, this);
callEngine.off(TUICallEvent.LINE_BUSY, this._handleLineBusy, this);
callEngine.off(TUICallEvent.ON_CALL_NOT_CONNECTED, this._handleCallNotConnected, this);
callEngine.off(TUICallEvent.ON_USER_INVITING, this._handleOnUserInviting, this);
callEngine.off(TUICallEvent.KICKED_OUT, this._handleKickedOut, this);
// @ts-ignore
// TUICallEvent.ON_USER_NETWORK_QUALITY_CHANGED && callEngine.off(TUICallEvent.ON_USER_NETWORK_QUALITY_CHANGED, this._handleNetworkQuality, this);
callEngine.off(TUICallEvent.USER_VIDEO_AVAILABLE, this._handleUserVideoAvailable, this);
callEngine.off(TUICallEvent.USER_AUDIO_AVAILABLE, this._handleUserAudioAvailable, this);
callEngine.off(TUICallEvent.CALL_END, this._handleCallingEnd, this);
}
private _callerChangeToConnected() {
const callRole = TUIStore.getData(StoreName.CALL, NAME.CALL_ROLE);
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
if (callStatus === CallStatus.CALLING && callRole === CallRole.CALLER) {
TUIStore.update(StoreName.CALL, NAME.CALL_STATUS, CallStatus.CONNECTED);
this._callService?.startTimer();
}
}
private _unNormalEventsManager(event: any, eventName: TUICallEvent): void {
console.log(`${NAME.PREFIX}${eventName} event data: ${JSON.stringify(event)}.`);
const isGroup = TUIStore.getData(StoreName.CALL, NAME.IS_GROUP);
const remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
switch (eventName) {
case TUICallEvent.REJECT:
case TUICallEvent.LINE_BUSY: {
const { userID: userId } = analyzeEventData(event);
let callTipsKey = eventName === TUICallEvent.REJECT ? CallTips.OTHER_SIDE_REJECT_CALL : CallTips.OTHER_SIDE_LINE_BUSY;
let userListNeedToShow = '';
if (isGroup) {
userListNeedToShow = (remoteUserInfoList.find(obj => obj.userId === userId) || {}).displayUserInfo || userId;
callTipsKey = eventName === TUICallEvent.REJECT ? CallTips.REJECT_CALL : CallTips.IN_BUSY;
}
TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: { key: callTipsKey, options: { userList: userListNeedToShow } } });
userId && deleteRemoteUser([userId]);
if (TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST).length === 0) {
this._callService?._resetCallStore();
}
break;
}
case TUICallEvent.NO_RESP: {
const { userIDList = [] } = analyzeEventData(event);
const callTipsKey = isGroup ? CallTips.TIMEOUT : CallTips.CALL_TIMEOUT;
const userInfoList: string[] = userIDList.map(userId => {
const userInfo: IUserInfo = remoteUserInfoList.find(obj => obj.userId === userId) || {};
return userInfo.displayUserInfo || userId;
});
TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: { key: callTipsKey, options: { userList: userInfoList.join() } } });
userIDList.length > 0 && deleteRemoteUser(userIDList);
break;
}
case TUICallEvent.ON_CALL_NOT_CONNECTED: {
this._callService?._resetCallStore();
break;
}
}
}
private _handleError(event: any): void {
const { code, message } = event || {};
const index = Object.values(ErrorCode).indexOf(code);
let callTips = '';
if (index !== -1) {
const key = Object.keys(ErrorCode)[index];
callTips = t(ErrorMessage[key]);
callTips && TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: ErrorMessage[key], type: NAME.ERROR });
}
console.error(`${NAME.PREFIX}_handleError, errorCode: ${code}; errorMessage: ${callTips || message}.`);
}
private async _handleNewInvitationReceived(event: any) {
console.log(`${NAME.PREFIX}onCallReceived event data: ${JSON.stringify(event)}.`);
const { callerId = '', callMediaType, inviteData = {}, calleeIdList = [], chatGroupID: groupID = '', roomID, strRoomID } = analyzeEventData(event);
const currentUserInfo: IUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
const remoteUserIdList: string[] = [callerId, ...calleeIdList.filter((userId: string) => userId !== currentUserInfo.userId)];
const type = callMediaType || inviteData.callType;
const callTipsKey = type === CallMediaType.AUDIO ? CallTips.CALLEE_CALLING_AUDIO_MSG : CallTips.CALLEE_CALLING_VIDEO_MSG;
let updateStoreParams = {
[NAME.CALL_ROLE]: CallRole.CALLEE,
[NAME.IS_GROUP]: (!!groupID || calleeIdList.length > 1),
[NAME.CALL_STATUS]: CallStatus.CALLING,
[NAME.CALL_MEDIA_TYPE]: type,
[NAME.CALL_TIPS]: callTipsKey,
[NAME.CALLER_USER_INFO]: { userId: callerId },
[NAME.GROUP_ID]: groupID,
};
initAndCheckRunEnv();
this._callService.openCamera(type === CallMediaType.VIDEO);
this._callService.openMicrophone()
const deviceMap = {
microphone: true,
camera: type === CallMediaType.VIDEO,
};
this._callService._preDevicePermission = await this._callService._tuiCallEngine.deviceCheck(deviceMap);
TUIStore.updateStore(updateStoreParams, StoreName.CALL);
const remoteUserInfoList = await getRemoteUserProfile(remoteUserIdList, this._callService?.getTim());
const [userInfo] = remoteUserInfoList.filter((userInfo: IUserInfo) => userInfo.userId === callerId);
remoteUserInfoList.length > 0 && TUIStore.updateStore({
[NAME.REMOTE_USER_INFO_LIST]: remoteUserInfoList,
[NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST]: remoteUserInfoList,
[NAME.CALLER_USER_INFO]: {
userId: callerId,
nick: userInfo?.nick || '',
avatar: userInfo?.avatar || '',
displayUserInfo: userInfo?.remark || userInfo?.nick || callerId,
},
}, StoreName.CALL);
}
private async _handleOnCallBegin(event: any): Promise<void> {
this._callerChangeToConnected();
TUIStore.update(StoreName.CALL, NAME.CALL_TIPS, { text: 'answered', duration: 2000 });
await this._callService.openMicrophone();
console.log(`${NAME.PREFIX}accept event data: ${JSON.stringify(event)}.`);
// 通知聊天页:视频/语音通话已接通,可关闭等待计时器
try {
uni.$emit('TUICall:callStart', event);
} catch (_) {}
}
private async _handleUserEnter(event: any): Promise<void> {
const { userID: userId, data } = analyzeEventData(event);
// 对方进入房间,通话已接通,通知聊天页关闭等待计时器
try {
uni.$emit('TUICall:callStart', event);
} catch (_) {}
if (userId && TUIStore.getData(StoreName.CALL, NAME.CALL_ROLE === CallRole.CALLEE)) {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.CALLING) {
this._callService._handleAcceptResponse({});
}
}
this._callerChangeToConnected();
await this._addUserToRemoteUserInfoList(userId);
let remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
remoteUserInfoList = remoteUserInfoList.map((obj: IUserInfo) => {
if (obj.userId === userId) obj.isEnter = true;
return obj;
});
if (remoteUserInfoList.length > 0) {
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoList);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
}
console.log(`${NAME.PREFIX}userEnter event data: ${JSON.stringify(event)}.`);
}
private _handleUserLeave(event: any): void {
console.log(`${NAME.PREFIX}userLeave event data: ${JSON.stringify(event)}.`);
const { data, userID: userId } = analyzeEventData(event);
if (TUIStore.getData(StoreName.CALL, NAME.IS_GROUP)) {
const remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
const userListNeedToShow: string = (remoteUserInfoList.find(obj => obj.userId === userId) || {}).displayUserInfo || userId;
TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: { key: CallTips.END_CALL, options: { userList: userListNeedToShow } } });
}
userId && deleteRemoteUser([userId]);
}
private _handleInviteeReject(event: any): void {
this._unNormalEventsManager(event, TUICallEvent.REJECT);
}
private _handleNoResponse(event: any): void {
this._unNormalEventsManager(event, TUICallEvent.NO_RESP);
}
private _handleLineBusy(event: any): void {
this._unNormalEventsManager(event, TUICallEvent.LINE_BUSY);
}
private _handleCallNotConnected(event: any): void {
this._unNormalEventsManager(event, TUICallEvent.ON_CALL_NOT_CONNECTED);
}
private async _handleOnUserInviting(event: any) {
const { userID: userId } = analyzeEventData(event);
if (!userId) return;
if (userId !== TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO).userId) {
await this._addUserToRemoteUserInfoList(userId);
}
}
private _handleCallingEnd(event: any): void {
console.log(`${NAME.PREFIX}callEnd event data: ${JSON.stringify(event)}.`);
this._callService?._resetCallStore();
// 通知聊天页等:视频通话已结束,可关闭等待计时器等
try {
uni.$emit('TUICall:callEnd', event);
} catch (_) {}
}
private _handleKickedOut(event: any): void {
console.log(`${NAME.PREFIX}kickOut event data: ${JSON.stringify(event)}.`);
this._callService?.kickedOut && this._callService?.kickedOut(event);
TUIStore.update(StoreName.CALL, NAME.CALL_TIPS, CallTips.KICK_OUT);
this._callService?._resetCallStore();
}
// private _handleNetworkQuality(event) {
// const { networkQualityList = [] } = analyzeEventData(event);
// TUIStore.update(StoreName.CALL, NAME.NETWORK_STATUS, networkQualityList);
// const isGroup = TUIStore.getData(StoreName.CALL, NAME.IS_GROUP);
// const localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
// const remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
// if(!isGroup) {
// const isRemoteNetworkPoor = networkQualityList.find(user => remoteUserInfoList[0]?.userId === user?.userId && user?.quality >= NETWORK_QUALITY_THRESHOLD);
// if(isRemoteNetworkPoor) {
// TUIStore.update(StoreName.CALL, NAME.CALL_TIPS, CallTips.REMOTE_NETWORK_IS_POOR);
// return;
// };
// const isLocalNetworkPoor = networkQualityList.find(user => localUserInfo?.userId === user?.userId && user?.quality >= NETWORK_QUALITY_THRESHOLD);
// if(isLocalNetworkPoor) {
// TUIStore.update(StoreName.CALL, NAME.CALL_TIPS, CallTips.LOCAL_NETWORK_IS_POOR);
// return;
// }
// }
// }
private async _startRemoteView(userId: string) {
if (!userId) {
console.warn(`${NAME.PREFIX}_startRemoteView userID is empty`);
return;
}
try {
const displayMode = TUIStore.getData(StoreName.CALL, NAME.DISPLAY_MODE);
await this._callService?.getTUICallEngineInstance().startRemoteView({ userID: userId, videoViewDomID: `${userId}_0`, options: { objectFit: displayMode } });
} catch (error: any) {
console.error(`${NAME.PREFIX}_startRemoteView error: ${error}.`);
return Promise.reject(error);
}
}
private _setRemoteUserInfoAudioVideoAvailable(isAvailable: boolean, type: string, userId: string) {
let remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
remoteUserInfoList = remoteUserInfoList.map((obj: IUserInfo) => {
if (obj.userId === userId) {
if (type === NAME.AUDIO) {
return { ...obj, isAudioAvailable: isAvailable };
}
if (type === NAME.VIDEO) {
return { ...obj, isVideoAvailable: isAvailable };
}
}
return obj;
});
if (remoteUserInfoList.length > 0) {
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoList);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
}
}
private async _handleUserVideoAvailable(event: any): Promise<any> {
const { userID: userId, isVideoAvailable } = analyzeEventData(event);
console.log(`${NAME.PREFIX}_handleUserVideoAvailable event data: ${JSON.stringify(event)}.`);
this._setRemoteUserInfoAudioVideoAvailable(isVideoAvailable, NAME.VIDEO, userId);
try {
isVideoAvailable && await this._startRemoteView(userId);
} catch (error) {
console.error(`${NAME.PREFIX}_startRemoteView failed, error: ${error}.`);
}
}
private _handleUserAudioAvailable(event: any): void {
const { userID: userId, isAudioAvailable } = analyzeEventData(event);
console.log(`${NAME.PREFIX}_handleUserAudioAvailable event data: ${JSON.stringify(event)}.`);
this._setRemoteUserInfoAudioVideoAvailable(isAudioAvailable, NAME.AUDIO, userId);
}
private async _addUserToRemoteUserInfoList(userId: string) {
let remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
const isInRemoteUserList = remoteUserInfoList.find(item => item?.userId === userId);
if (!isInRemoteUserList) {
remoteUserInfoList.push({ userId });
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoList);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
const [userInfo] = await getRemoteUserProfile([userId], this._callService?.getTim());
remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
remoteUserInfoList.forEach((obj) => {
if (obj?.userId === userId) {
obj = Object.assign(obj, userInfo);
}
});
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoList);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
}
}
}
@@ -0,0 +1,543 @@
import { IUserInfo, ISelfInfoParams, IInitParams } from '../interface/ICallService';
import { CallMediaType, CameraPosition, AudioPlayBackDevice, LOG_LEVEL, devicePermissions } from '@trtc/call-engine-lite-wx';
import { ICallsParam, IInviteUserParam } from '@trtc/call-engine-lite-wx';
import { StoreName, CallStatus, NAME, CALL_DATA_KEY, CallRole, COMPONENT, FeatureButton, ButtonState } from '../const/index';
import { TUICallEngine } from '@trtc/call-engine-lite-wx';
import { beforeCall, handlePackageError } from './miniProgram';
import { CallTips, t } from '../locales/index';
import { handleModalError } from './UIKitModal';
import { handleRepeatedCallError, performanceNow } from '../utils/common-utils';
import { getRemoteUserProfile, noDevicePermissionToast, setLocalUserInfoAudioVideoAvailable } from './utils';
import { ITUIStore } from '../interface/index';
import TuiStore from '../TUIStore/tuiStore';
import ChatCombine from './chatCombine';
import EngineEventHandler from './engineEventHandler';
const TUIStore: ITUIStore = TuiStore.getInstance();
const version = '<@VERSION@>';
export { TUIStore };
const defaultOfflinePushInfo = { title: '', description: t('you have a new call') };
export default class TUICallService {
static instance: TUICallService;
public _tuiCallEngine: any;
private _tim: any = null;
private _timerId: any = -1;
private _startTimeStamp: number = performanceNow();
private _isFromChat: boolean = false;
private _offlinePushInfo = null;
private _permissionCheckTimer: any = null;
private _chatCombine: any = null;
private _engineEventHandler: any = null;
constructor() {
console.log(`${NAME.PREFIX}version: ${version}`);
this._watchTUIStore();
this._engineEventHandler = EngineEventHandler.getInstance({ callService: this });
this._chatCombine = ChatCombine.getInstance({ callService: this });
}
static getInstance() {
if (!TUICallService.instance) {
TUICallService.instance = new TUICallService();
}
return TUICallService.instance;
}
public async init(params: IInitParams) {
try {
if (this._tuiCallEngine) return;
// @ts-ignore
let { userID, tim, userSig, sdkAppID, SDKAppID, isFromChat, component = COMPONENT.TUI_CALL_KIT } = params;
this._tim = tim;
console.log(`${NAME.PREFIX}init sdkAppId: ${sdkAppID || SDKAppID}, userId: ${userID}`);
this._tuiCallEngine = TUICallEngine.createInstance({
tim,
sdkAppID: sdkAppID || SDKAppID, // 兼容传入 SDKAppID 的问题
callkitVersion: version,
isFromChat: isFromChat || false,
component,
scene: 'wx-uniapp-vue3-lite',
});
this._addListenTuiCallEngineEvent();
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { userId: userID });
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { userId: userID });
await this._tuiCallEngine.login({ userID, userSig, assetsPath: '' }); // web && mini
const uiConfig = TUIStore.getData(StoreName.CALL, NAME.CUSTOM_UI_CONFIG);
this._tuiCallEngine?.reportLog?.({
name: 'TUICallkit.init',
data: {
uiConfig,
}
});
} catch (error) {
console.error(`${NAME.PREFIX}init failed, error: ${error}.`);
throw error;
}
}
// component destroy
public async destroyed() {
try {
const currentCallStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
if (currentCallStatus !== CallStatus.IDLE) {
throw new Error(`please destroyed when status is idle, current status: ${currentCallStatus}`);
}
if (this._tuiCallEngine) {
this._removeListenTuiCallEngineEvent();
await this._tuiCallEngine.destroyInstance();
this._tuiCallEngine = null;
this._unwatchTUIStore();
}
} catch (error) {
console.error(`${NAME.PREFIX}destroyed failed, error: ${error}.`);
throw error;
}
}
// ===============================【通话操作】===============================
public async calls(callsParams: ICallsParam) {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) !== CallStatus.IDLE) return; // avoid double click when application stuck
try {
TUIStore.update(StoreName.CALL, NAME.PUSHER_ID, NAME.NEW_PUSHER);
const { userIDList, type, chatGroupID, offlinePushInfo } = callsParams;
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) !== CallStatus.IDLE) return;
const remoteUserInfoList = userIDList.map(userId => ({ userId }));
await this._updateCallStoreBeforeCall(type, remoteUserInfoList, chatGroupID);
callsParams.offlinePushInfo = { ...defaultOfflinePushInfo, ...offlinePushInfo };
const response = await this._tuiCallEngine.calls(callsParams);
await this._updateCallStoreAfterCall(userIDList, response);
} catch (error: any) {
handleModalError(error);
this._handleCallError(error, 'calls');
}
}
// public async inviteUser(params: IInviteUserParam) {
// if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.IDLE) return; // avoid double click when application stuck
// try {
// const { userIDList } = params;
// let inviteUserInfoList = await getRemoteUserProfile(userIDList, this.getTim());
// const remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
// const userIDListNotInRemoteUserInfoList = userIDList.filter(userId => {
// return !remoteUserInfoList.some(remoteUserInfo => remoteUserInfo.userId === userId);
// });
// if (userIDListNotInRemoteUserInfoList.length === 0) {
// return;
// }
// TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, [...remoteUserInfoList, ...inviteUserInfoList]);
// TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, [...remoteUserInfoList, ...inviteUserInfoList]);
// this._tuiCallEngine && await this._tuiCallEngine.inviteUser(params);
// } catch (error: any) {
// console.error(`${NAME.PREFIX}inviteUser failed, error: ${error}.`);
// }
// }
// public async join(params) {
// if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.CONNECTED) return; // avoid double click when application stuck
// try {
// const response = await this._tuiCallEngine.join(params);
// TUIStore.update(StoreName.CALL, NAME.IS_CLICKABLE, true);
// this.startTimer();
// const updateStoreParams = {
// [NAME.CALL_ROLE]: CallRole.CALLEE,
// [NAME.IS_GROUP]: true,
// [NAME.CALL_STATUS]: CallStatus.CONNECTED,
// [NAME.CALL_MEDIA_TYPE]: TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE) || CallMediaType.AUDIO, // default audio callMediaType
// };
// TUIStore.updateStore(updateStoreParams, StoreName.CALL);
// this.setSoundMode(params.type === CallMediaType.AUDIO ? AudioPlayBackDevice.EAR : AudioPlayBackDevice.SPEAKER);
// const localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
// TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { ...localUserInfo, isEnter: true });
// TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { ...localUserInfo, isEnter: true });
// await this.openMicrophone();
// setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO);
// } catch (error) {
// this._handleCallError(error, 'join');
// }
// }
// ===============================【其它对外接口】===============================
public getTUICallEngineInstance(): any {
return this?._tuiCallEngine || null;
}
public setLogLevel(level: LOG_LEVEL) {
this?._tuiCallEngine?.setLogLevel(level);
}
public enableFloatWindow(enable: boolean) {
TUIStore.update(StoreName.CALL, NAME.ENABLE_FLOAT_WINDOW, enable);
}
public async setSelfInfo(params: ISelfInfoParams) {
const { nickName, avatar } = params;
try {
await this._tuiCallEngine.setSelfInfo(nickName, avatar);
} catch (error) {
console.error(`${NAME.PREFIX}setSelfInfo failed, error: ${error}.`);
}
}
public async enableMuteMode(enable: boolean) {
try {
} catch (error) {
console.warn(`${NAME.PREFIX}enableMuteMode failed, error: ${error}.`);
}
}
// =============================【实验性接口】=============================
public callExperimentalAPI(jsonStr: string) {
const jsonObj = JSON.parse(jsonStr);
if (jsonObj === jsonStr) return;
const { api, params } = jsonObj;
if (!api || !params) return;
try {
switch(api) {
case 'forceUseV2API':
const { enable } = params;
TUIStore.update(StoreName.CALL, NAME.IS_FORCE_USE_V2_API, !!enable);
break;
default:
break;
}
} catch (error) {
this._tuiCallEngine?.reportLog?.({ name: 'TUICallKit.callExperimentalAPI.fail', data: { error } });
}
}
// =============================【内部按钮操作方法】=============================
public async accept() {
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
this._tuiCallEngine?.reportLog?.({
name: 'TUICallKit.accept.start',
data: { callStatus },
});
if (callStatus === CallStatus.CONNECTED) return; // avoid double click when application stuck, especially for miniProgram
try {
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
const deviceMap = {
microphone: true,
camera: callMediaType === CallMediaType.VIDEO,
};
const currentDevicePermission = await this._tuiCallEngine.deviceCheck(deviceMap);
if (!currentDevicePermission) {
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
if (typeof devicePermissions === 'function') {
await devicePermissions(callMediaType);
}
}
TUIStore.update(StoreName.CALL, NAME.PUSHER_ID, NAME.NEW_PUSHER);
const response = await this._tuiCallEngine.accept();
await this._handleAcceptResponse(response);
} catch (error) {
this._tuiCallEngine?.reportLog?.({
name: 'TUICallKit.accept.fail',
level: 'error',
error,
});
if (handleRepeatedCallError(error)) return;
handleModalError(error);
noDevicePermissionToast(error, CallMediaType.AUDIO, this._tuiCallEngine);
this._resetCallStore();
}
}
private async _handleAcceptResponse(response) {
if (response) {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.CONNECTED) return;
// 小程序接通时会进行授权弹框, 状态需要放在 accept 后, 否则先接通后再拉起权限设置
TUIStore.update(StoreName.CALL, NAME.CALL_STATUS, CallStatus.CONNECTED);
TUIStore.update(StoreName.CALL, NAME.IS_CLICKABLE, true);
this.startTimer();
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
const isCameraDefaultStateClose = this._getFeatureButtonDefaultState(FeatureButton.Camera) === ButtonState.Close;
(callMediaType === CallMediaType.VIDEO) && !isCameraDefaultStateClose && await this.openCamera(NAME.LOCAL_VIDEO);
this.setSoundMode(callMediaType === CallMediaType.AUDIO ? AudioPlayBackDevice.EAR : AudioPlayBackDevice.SPEAKER);
const localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { ...localUserInfo, isEnter: true });
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { ...localUserInfo, isEnter: true });
setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO); // web && mini default open audio
}
}
public async hangup() {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.IDLE) return; // avoid double click when application stuck
await this._tuiCallEngine.hangup();
this._resetCallStore();
}
public async reject() {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.IDLE) return; // avoid double click when application stuck
await this._tuiCallEngine.reject();
this._resetCallStore();
}
public async openCamera(videoViewDomID: string) {
try {
const currentPosition = TUIStore.getData(StoreName.CALL, NAME.CAMERA_POSITION);
const isFrontCamera = currentPosition === CameraPosition.FRONT ? true : false;
this._tuiCallEngine.openCamera(videoViewDomID, isFrontCamera);
setLocalUserInfoAudioVideoAvailable(true, NAME.VIDEO);
} catch (error: any) {
noDevicePermissionToast(error, CallMediaType.VIDEO, this._tuiCallEngine);
console.error(`${NAME.PREFIX}openCamera error: ${error}.`);
}
}
public async closeCamera() {
try {
await this._tuiCallEngine.closeCamera();
setLocalUserInfoAudioVideoAvailable(false, NAME.VIDEO);
} catch (error: any) {
console.error(`${NAME.PREFIX}closeCamera error: ${error}.`);
}
}
public async openMicrophone() {
try {
await this._tuiCallEngine.openMicrophone();
setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO);
} catch (error: any) {
console.error(`${NAME.PREFIX}openMicrophone failed, error: ${error}.`);
}
}
public async closeMicrophone() {
try {
await this._tuiCallEngine.closeMicrophone();
setLocalUserInfoAudioVideoAvailable(false, NAME.AUDIO);
} catch (error: any) {
console.error(`${NAME.PREFIX}closeMicrophone failed, error: ${error}.`);
}
}
public switchScreen(userId: string) {
if(!userId) return;
TUIStore.update(StoreName.CALL, NAME.BIG_SCREEN_USER_ID, userId);
}
public async switchCamera() {
const currentPosition = TUIStore.getData(StoreName.CALL, NAME.CAMERA_POSITION);
const targetPosition = currentPosition === CameraPosition.BACK ? CameraPosition.FRONT : CameraPosition.BACK;
try {
await this._tuiCallEngine.switchCamera(targetPosition);
TUIStore.update(StoreName.CALL, NAME.CAMERA_POSITION, targetPosition);
} catch (error) {
console.error(`${NAME.PREFIX}_switchCamera failed, error: ${error}.`);
}
}
public setSoundMode(type?: string): void {
try {
let isEarPhone = TUIStore.getData(StoreName.CALL, NAME.IS_EAR_PHONE);
const soundMode = type || (isEarPhone ? AudioPlayBackDevice.SPEAKER : AudioPlayBackDevice.EAR); // UI 层切换时传参数
this._tuiCallEngine?.selectAudioPlaybackDevice(soundMode);
if (type) {
isEarPhone = type === AudioPlayBackDevice.EAR;
} else {
isEarPhone = !isEarPhone;
}
TUIStore.update(StoreName.CALL, NAME.IS_EAR_PHONE, isEarPhone);
} catch (error) {
console.error(`${NAME.PREFIX}setSoundMode failed, error: ${error}.`);
}
}
// ==========================【TUICallEngine 事件处理】==========================
private _addListenTuiCallEngineEvent() {
this._engineEventHandler.addListenTuiCallEngineEvent();
}
private _removeListenTuiCallEngineEvent() {
this._engineEventHandler.removeListenTuiCallEngineEvent();
}
// 处理用户异常退出的情况, 小程序 ”右滑“、"左上角退出"; web 页面关闭浏览器或关闭 tab 页面
public async handleExceptionExit(event?: any) {
try {
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
const callRole = TUIStore.getData(StoreName.CALL, NAME.CALL_ROLE);
this._tuiCallEngine?.reportLog?.({ name: 'TUICallkit.handleExceptionExit', data: { callStatus, callRole } });
if (callStatus === CallStatus.IDLE) return;
// 在呼叫状态下,被叫调用 reject,主叫调用 hangup
if (callStatus === CallStatus.CALLING) {
if (callRole === CallRole.CALLER) {
await this?.hangup();
} else {
await this?.reject();
}
}
if (callStatus === CallStatus.CONNECTED) {
await this?.hangup();
}
this?._resetCallStore();
} catch (error) {
console.error(`${NAME.PREFIX} handleExceptionExit failed, error: ${error}.`);
}
if (event) {
event.returnValue = '';
}
}
// 通话时长更新
public startTimer(): void {
if (this._timerId === -1) {
this._startTimeStamp = performanceNow();
this._timerId = setInterval(() => this._updateCallDuration(), 1000);
}
}
private _stopTimer(): void {
if (this._timerId !== -1) {
clearInterval(this._timerId);
this._timerId = -1;
}
}
// =========================【private methods for service use】=========================
// 处理 “呼叫” 抛出的异常
private _handleCallError(error: any, methodName?: string) {
this._permissionCheckTimer && clearInterval(this._permissionCheckTimer);
if (handleRepeatedCallError(error)) return;
noDevicePermissionToast(error, CallMediaType.AUDIO, this._tuiCallEngine);
console.error(`${NAME.PREFIX}${methodName} failed, error: ${error}.`);
this._resetCallStore();
throw error;
}
private async _updateCallStoreBeforeCall(type: number, remoteUserInfoList: IUserInfo[], groupID?: string): Promise<void> {
let callTips = CallTips.CALLER_CALLING_MSG;
let updateStoreParams: any = {
[NAME.CALL_MEDIA_TYPE]: type,
[NAME.CALL_ROLE]: CallRole.CALLER,
[NAME.REMOTE_USER_INFO_LIST]: remoteUserInfoList,
[NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST]: remoteUserInfoList,
[NAME.IS_GROUP]: (!!groupID || remoteUserInfoList.length > 1),
[NAME.CALL_TIPS]: callTips,
[NAME.GROUP_ID]: groupID
};
const callStatus = await beforeCall(type, this); // 如果没有权限, 此时为 false. 因此需要在 call 后设置为 calling. 和 web 存在差异
console.log(`${NAME.PREFIX}mini beforeCall return callStatus: ${callStatus}.`);
TUIStore.updateStore({ ...updateStoreParams, [NAME.CALL_STATUS]: callStatus }, StoreName.CALL);
const remoteUserInfoLists = await getRemoteUserProfile(remoteUserInfoList.map(obj => obj.userId), this.getTim());
if (remoteUserInfoLists.length > 0) {
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoLists);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoLists);
}
const deviceMap = {
microphone: true,
camera: type === CallMediaType.VIDEO,
};
let hasDevicePermission = await this._tuiCallEngine.deviceCheck(deviceMap);
if (!hasDevicePermission) {
this._permissionCheckTimer && clearInterval(this._permissionCheckTimer);
this._permissionCheckTimer = setInterval(async () => {
hasDevicePermission = await this._tuiCallEngine.deviceCheck(deviceMap);
if (hasDevicePermission && this._permissionCheckTimer) {
clearInterval(this._permissionCheckTimer);
TUIStore.update(StoreName.CALL, NAME.CALL_STATUS, CallStatus.CALLING);
}
}, 500);
}
}
private async _updateCallStoreAfterCall(userIdList: string[], response: any) {
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
let localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
TUIStore.update(StoreName.CALL, NAME.CALL_INFO, { mediaType: callMediaType, inviterId: localUserInfo.userId });
if (response) {
TUIStore.update(StoreName.CALL, NAME.IS_CLICKABLE, true);
this.setSoundMode(callMediaType === CallMediaType.AUDIO ? AudioPlayBackDevice.EAR : AudioPlayBackDevice.SPEAKER);
const isCameraDefaultStateClose = this._getFeatureButtonDefaultState(FeatureButton.Camera) === ButtonState.Close;
if((callMediaType === CallMediaType.VIDEO) && !isCameraDefaultStateClose) {
await this.openCamera(NAME.LOCAL_VIDEO);
}
localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { ...localUserInfo, isEnter: true });
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { ...localUserInfo, isEnter: true });
setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO); // web && mini, default open audio
} else {
this._permissionCheckTimer && clearInterval(this._permissionCheckTimer);
this._permissionCheckTimer = null;
this._resetCallStore();
}
}
private _getFeatureButtonDefaultState(buttonName: FeatureButton) {
const { button: buttonConfig } = TUIStore.getData(StoreName.CALL, NAME.CUSTOM_UI_CONFIG);
return buttonConfig?.[buttonName]?.state;
}
private _updateCallDuration(): void {
TUIStore.update(StoreName.CALL, NAME.DURATION, Math.round((performanceNow() - this._startTimeStamp) / 1000));
}
private _resetCallStore() {
this._stopTimer();
// localUserInfo, language 在通话结束后不需要清除
// callStatus 清除需要通知; isMinimized 也需要通知(basic-vue3 中切小窗关闭后, 再呼叫还是小窗, 因此需要通知到组件侧)
// isGroup 也不清除(engine 先抛 cancel 事件, 再抛 reject 事件)
// displayMode、videoResolution 也不能清除, 组件不卸载, 这些属性也需保留, 否则采用默认值.
// enableFloatWindow 不清除:开启/关闭悬浮窗功能。
let notResetOrNotifyKeys = Object.keys(CALL_DATA_KEY).filter((key) => {
switch (CALL_DATA_KEY[key]) {
case NAME.CALL_STATUS:
case NAME.LANGUAGE:
case NAME.IS_GROUP:
case NAME.ENABLE_FLOAT_WINDOW:
case NAME.LOCAL_USER_INFO:
case NAME.IS_FORCE_USE_V2_API:
case NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN: {
return false;
}
default: {
return true;
}
}
});
notResetOrNotifyKeys = notResetOrNotifyKeys.map(key => CALL_DATA_KEY[key]);
TUIStore.reset(StoreName.CALL, notResetOrNotifyKeys);
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
callStatus !== CallStatus.IDLE && TUIStore.reset(StoreName.CALL, [NAME.CALL_STATUS], true); // callStatus reset need notify
TUIStore.reset(StoreName.CALL, [NAME.IS_MINIMIZED], true); // isMinimized reset need notify
TUIStore.reset(StoreName.CALL, [NAME.IS_EAR_PHONE], true); // isEarPhone reset need notify
TUIStore.reset(StoreName.CALL, [NAME.PUSHER_ID], true); // pusher unload reset need notify
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, {
...TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO),
isVideoAvailable: false,
isAudioAvailable: false,
});
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, {
...TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN),
isVideoAvailable: false,
isAudioAvailable: false,
});
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, []);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, []);
TUIStore.update(StoreName.CALL, NAME.CAMERA_POSITION, CameraPosition.FRONT);
TUIStore.update(StoreName.CALL, NAME.CALL_INFO, {});
}
// =========================【监听 TUIStore 中的状态及处理】=========================
private _handleCallStatusChange = async (value: CallStatus) => {
try {
if (value === CallStatus.CALLING) {
} else {
// 状态变更通知
if (value === CallStatus.CONNECTED) {
const isGroup = TUIStore.getData(StoreName.CALL, NAME.IS_GROUP);
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
const remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
TUIStore.update(StoreName.CALL, NAME.CALL_TIPS, '');
if (!isGroup && callMediaType === CallMediaType.VIDEO) {
this.switchScreen(remoteUserInfoList[0].domId);
}
}
}
} catch (error) {
console.warn(`${NAME.PREFIX}handleCallStatusChange, ${error}.`);
}
};
private _watchTUIStore() {
TUIStore?.watch(StoreName.CALL, {
[NAME.CALL_STATUS]: this._handleCallStatusChange,
});
}
private _unwatchTUIStore() {
TUIStore?.unwatch(StoreName.CALL, {
[NAME.CALL_STATUS]: this._handleCallStatusChange,
});
}
// =========================【set、get methods】=========================
public getTim() {
if (this._tim) return this._tim;
if (!this._tuiCallEngine) {
console.warn(`${NAME.PREFIX}getTim warning: _tuiCallEngine Instance is not available.`);
return null;
}
return this._tuiCallEngine?.tim || this._tuiCallEngine?.getTim(); // mini support getTim interface
}
public setIsFromChat(isFromChat: boolean) {
this._isFromChat = isFromChat;
}
}
@@ -0,0 +1,66 @@
import { CallMediaType } from '@trtc/call-engine-lite-wx';
import { CallStatus } from '../const/index';
export function initialUI() {
// 收起键盘
// @ts-ignore
wx.hideKeyboard && wx.hideKeyboard({
complete: () => {},
});
};
// 检测运行时环境, 当是微信开发者工具时, 提示用户需要手机调试
export function checkRunPlatform() {
// @ts-ignore
const systemInfo = wx.getSystemInfoSync();
if (systemInfo.platform === 'devtools') {
// 当前运行在微信开发者工具里
// @ts-ignore
wx.showModal({
icon: 'none',
title: '运行环境提醒',
content: '微信开发者工具不支持原生推拉流组件(即 <live-pusher> 和 <live-player> 标签),请使用真机调试或者扫码预览。',
showCancel: false,
});
}
};
export function initAndCheckRunEnv() {
initialUI(); // miniProgram 收起键盘, 隐藏 tabBar
checkRunPlatform(); // miniProgram 检测运行时环境
}
export async function beforeCall(type: CallMediaType, that: any) {
try {
initAndCheckRunEnv();
// 检查设备权限
const deviceMap = {
microphone: true,
camera: type === CallMediaType.VIDEO,
};
const hasDevicePermission = await that._tuiCallEngine.deviceCheck(deviceMap); // miniProgram 检查设备权限
return hasDevicePermission ? CallStatus.CALLING : CallStatus.IDLE;
} catch (error) {
console.debug(error);
return CallStatus.IDLE;
}
}
// 套餐问题提示, 小程序最低需要群组通话版, 1v1 通话版本使用 TRTC 就会报错
export function handlePackageError(error) {
if (error?.code === -1002) {
// @ts-ignore
wx.showModal({
icon: 'none',
title: 'error',
content: error?.message || '',
showCancel: false,
});
}
}
export function handleNoPusherCapabilityError(){
// @ts-ignore
wx.showModal({
icon: 'none',
title: '权限提示',
content: '当前小程序 appid 不具备 <live-pusher> 和 <live-player> 的使用权限,您将无法正常使用实时通话能力,请使用企业小程序账号申请权限后再进行开发体验',
showCancel: false,
});
}
@@ -0,0 +1,199 @@
import { CallMediaType } from '@trtc/call-engine-lite-wx';
import { NAME, StoreName } from '../const/index';
import { handleNoDevicePermissionError } from '../utils/common-utils';
import { IUserInfo } from '../interface/ICallService';
import { ITUIStore } from '../interface/ITUIStore';
import { CallTips, t } from '../locales/index';
import TuiStore from '../TUIStore/tuiStore';
// @ts-ignore
const TUIStore: ITUIStore = TuiStore.getInstance();
// 设置默认的 UserInfo 信息
export function setDefaultUserInfo(userId: string, domId?: string): IUserInfo {
const userInfo: IUserInfo = {
userId,
nick: '',
avatar: '',
remark: '',
displayUserInfo: '',
isAudioAvailable: false,
isVideoAvailable: false,
isEnter: false,
domId: domId || userId,
};
return domId ? userInfo : { ...userInfo, isEnter: false }; // localUserInfo 没有 isEnter, remoteUserInfoList 有 isEnter
}
// 获取个人用户信息
// export async function getMyProfile(myselfUserId: string, tim: any): Promise<IUserInfo> {
// let localUserInfo: IUserInfo = setDefaultUserInfo(myselfUserId, NAME.LOCAL_VIDEO);
// try {
// if (!tim) return localUserInfo;
// const res = await tim.getMyProfile();
// const currentLocalUserInfo = TUIStore?.getData(StoreName.CALL, NAME.LOCAL_USER_INFO); // localUserInfo may have been updated
// if (res?.code === 0) {
// localUserInfo = {
// ...localUserInfo,
// ...currentLocalUserInfo,
// userId: res?.data?.userID,
// nick: res?.data?.nick,
// avatar: res?.data?.avatar,
// displayUserInfo: res?.data?.nick || res?.data?.userID,
// };
// }
// return localUserInfo;
// } catch (error) {
// console.error(`${NAME.PREFIX}getMyProfile failed, error: ${error}.`);
// return localUserInfo;
// }
// }
// 获取远端用户列表信息
export async function getRemoteUserProfile(userIdList: Array<string>, tim: any): Promise<any> {
let remoteUserInfoList: IUserInfo[] = userIdList.map((userId: string) => setDefaultUserInfo(userId));
try {
if (!tim) return remoteUserInfoList;
if (tim?.getFriendProfile) {
// const res = await tim.getFriendProfile({ userIDList: userIdList });
// if (res.code === 0) {
// const { friendList = [], failureUserIDList = [] } = res.data;
// let unFriendList: IUserInfo[] = failureUserIDList.map((obj: any) => obj.userID);
// if (failureUserIDList.length > 0) {
// const res = await tim.getUserProfile({ userIDList: failureUserIDList.map((obj: any) => obj.userID) });
// if (res?.code === 0) {
// unFriendList = res?.data || [];
// }
// }
// const currentRemoteUserInfoList = TUIStore?.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST); // remoteUserInfoList may have been updated
// const tempFriendIdList: string[] = friendList.map((obj: any) => obj.userID);
// const tempUnFriendIdList: string[] = unFriendList.map((obj: any) => obj.userID);
// remoteUserInfoList = userIdList.map((userId: string) => {
// const defaultUserInfo: IUserInfo = setDefaultUserInfo(userId);
// const friendListIndex: number = tempFriendIdList.indexOf(userId);
// const unFriendListIndex: number = tempUnFriendIdList.indexOf(userId);
// let remark = '';
// let nick = '';
// let displayUserInfo = '' ;
// let avatar = '';
// if (friendListIndex !== -1) {
// remark = friendList[friendListIndex]?.remark || '';
// nick = friendList[friendListIndex]?.profile?.nick || '';
// displayUserInfo = remark || nick || defaultUserInfo.userId || '';
// avatar = friendList[friendListIndex]?.profile?.avatar || '';
// }
// if (unFriendListIndex !== -1) {
// nick = unFriendList[unFriendListIndex]?.nick || '';
// displayUserInfo = nick || defaultUserInfo.userId || '';
// avatar = unFriendList[unFriendListIndex]?.avatar || '';
// }
// const userInfo = currentRemoteUserInfoList.find(subObj => subObj.userId === userId) || {};
// return { ...defaultUserInfo, ...userInfo, remark, nick, displayUserInfo, avatar };
// });
// }
return remoteUserInfoList;
} else {
const res = await tim.getUserProfile({ userIDList: userIdList });
const currentRemoteUserInfoList = TUIStore?.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST); // remoteUserInfoList may have been updated
remoteUserInfoList = userIdList.map((userId: string) => {
const defaultUserInfo: IUserInfo = setDefaultUserInfo(userId);
const userInfo = currentRemoteUserInfoList.find(subObj => subObj.userId === userId) || {};
const userData = (res.data || []).find(obj => obj.userID === userId) || {};
return {
...defaultUserInfo,
...userInfo,
nick: userData?.nick || '',
displayUserInfo: userData?.nick || userId,
avatar: userData?.avatar || '',
};
});
return remoteUserInfoList;
}
} catch (error) {
console.error(`${NAME.PREFIX}getRemoteUserProfile failed, error: ${error}.`);
return remoteUserInfoList;
}
}
// 获取群组[offset, count + offset]区间成员
// export async function getGroupMemberList(groupID: string, tim: any, count, offset) {
// let groupMemberList = [];
// try {
// const res = await tim.getGroupMemberList({ groupID, count, offset });
// if (res.code === 0) {
// return res.data.memberList || groupMemberList;
// }
// } catch(error) {
// console.error(`${NAME.PREFIX}getGroupMember failed, error: ${error}.`);
// return groupMemberList;
// }
// }
// 获取 IM 群信息
// export async function getGroupProfile(groupID: string, tim: any): Promise<any> {
// let groupProfile = {};
// try {
// const res = await tim.getGroupProfile({ groupID });
// return res.data.group || groupProfile;
// } catch(error) {
// console.warn(`${NAME.PREFIX}getGroupProfile failed, error: ${error}.`);
// return groupProfile;
// }
// }
/**
* web and miniProgram call engine throw event data structure are different
* @param {any} event call engine throw out data
* @returns {any} data
*/
export function analyzeEventData(event: any): any {
return event || {};
}
/**
* delete user from remoteUserInfoList
* @param {string[]} userIdList to be deleted userIdList
* @param {ITUIStore} TUIStore TUIStore instance
*/
export function deleteRemoteUser(userIdList: string[]): void {
if (userIdList.length === 0) return;
let remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
userIdList.forEach((userId) => {
remoteUserInfoList = remoteUserInfoList.filter((obj: IUserInfo) => obj.userId !== userId);
});
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoList);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
}
/**
* update the no device permission toast
* @param {any} error error
* @param {CallMediaType} type call midia type
* @param {any} tuiCallEngine TUICallEngine instance
*/
export function noDevicePermissionToast(error, type: CallMediaType, tuiCallEngine: any) {
let toastInfoKey = '';
if (handleNoDevicePermissionError(error)) {
if (type === CallMediaType.AUDIO) {
toastInfoKey = CallTips.NO_MICROPHONE_DEVICE_PERMISSION;
}
if (type === CallMediaType.VIDEO) {
toastInfoKey = CallTips.NO_CAMERA_DEVICE_PERMISSION;
}
toastInfoKey && TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: toastInfoKey, type: NAME.ERROR });
console.error(`${NAME.PREFIX}call failed, error: ${error.message}.`);
}
}
/**
* set localUserInfo audio/video available
* @param {boolean} isAvailable is available
* @param {string} type callMediaType 'audio' | 'video'
* @param {ITUIStore} TUIStore TUIStore instance
*/
export function setLocalUserInfoAudioVideoAvailable(isAvailable: boolean, type: string) {
let localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
if (type === NAME.AUDIO) {
localUserInfo = { ...localUserInfo, isAudioAvailable: isAvailable };
}
if (type === NAME.VIDEO) {
localUserInfo = { ...localUserInfo, isVideoAvailable: isAvailable };
}
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, localUserInfo);
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, localUserInfo);
}
@@ -0,0 +1,71 @@
import { CallMediaType, CameraPosition } from '@trtc/call-engine-lite-wx';
import { CallStatus, CallRole, NAME } from '../const/index';
import { ICallStore } from '../interface/ICallStore';
import { t } from '../locales/index';
import { deepClone } from "../utils/common-utils";
export default class CallStore {
public defaultStore: ICallStore = {
callStatus: CallStatus.IDLE,
callRole: CallRole.UNKNOWN,
callMediaType: CallMediaType.UNKNOWN,
localUserInfo: { userId: '' },
localUserInfoExcludeVolume: { userId: '' },
remoteUserInfoList: [],
remoteUserInfoExcludeVolumeList: [],
callerUserInfo: { userId: '' },
isGroup: false,
duration: 0, // 通话时长
callTips: '', // 通话提示的信息. 例如: '等待谁接听', 'xxx 拒绝通话', 'xxx 挂断通话'
toastInfo: { text: '' }, // 远端用户挂断、拒绝、超时、忙线等的 toast 提示信息
isMinimized: false, // 用来记录当前是否悬浮窗模式
enableFloatWindow: false, // 开启/关闭悬浮窗功能,设置为false,通话界面左上角的悬浮窗按钮会隐藏
bigScreenUserId: '', // 当前大屏幕显示的 userID 用户
language: 'zh-cn', // en, zh-cn
isClickable: false, // 是否可点击, 用于按钮增加 loading 效果,不可点击
// deviceList: { cameraList: [], microphoneList: [], currentCamera: {}, currentMicrophone: {} },
showPermissionTip: false,
netWorkQualityList: [], // 显示网络状态差的提示
isMuteSpeaker: false,
callID: '', // callEngine 3.1 support
groupID: '',
roomID: 0,
cameraPosition: CameraPosition.FRONT, // 前置或后置,值为front, back
// 小程序相关属性
isEarPhone: false, // 是否是听筒, 默认: false
pusherId: '', // 重新渲染 live-Pusher 的标识位
// translate function
translate: t,
callInfo: {},
};
public store: ICallStore = deepClone(this.defaultStore);
public prevStore: ICallStore = deepClone(this.defaultStore);
public update(key: keyof ICallStore, data: any): void {
switch (key) {
case NAME.CALL_TIPS:
const preData = this.getData(key);
(this.prevStore[key] as any) = preData;
default:
(this.store[key] as any) = data as any;
}
}
public getData(key: string | undefined): any {
if (!key) return this.store;
return this.store[key as keyof ICallStore];
}
// reset call store
public reset(keyList: Array<string> = []) {
if (keyList.length === 0) {
keyList = Object.keys(this.store);
}
const resetToDefault = keyList.reduce((acc, key) => ({ ...acc, [key]: this.defaultStore[key as keyof ICallStore] }), {});
this.store = {
...this.defaultStore,
...this.store,
...resetToDefault,
};
}
}
@@ -0,0 +1,157 @@
import { ITUIStore, IOptions, Task } from '../interface/ITUIStore';
import { StoreName, NAME } from '../const/index';
import CallStore from './callStore';
import { isString, isNumber, isBoolean } from '../utils/common-utils';
export default class TUIStore implements ITUIStore {
static instance: TUIStore;
public task: Task;
private storeMap: Partial<Record<StoreName, any>>;
private timerId: number = -1;
constructor() {
this.storeMap = {
[StoreName.CALL]: new CallStore(),
};
// todo 此处后续优化结构后调整
this.task = {} as Task; // 保存监听回调列表
}
/**
* 获取 TUIStore 实例
* @returns {TUIStore}
*/
static getInstance() {
if (!TUIStore.instance) {
TUIStore.instance = new TUIStore();
}
return TUIStore.instance;
}
/**
* UI 组件注册监听回调
* @param {StoreName} storeName store 名称
* @param {IOptions} options 监听信息
* @param {Object} params 扩展参数
* @param {String} params.notifyRangeWhenWatch 注册时监听时的通知范围, 'all' - 通知所有注册该 key 的监听; 'myself' - 通知本次注册该 key 的监听; 默认不通知
*/
watch(storeName: StoreName, options: IOptions, params?: any) {
if (!this.task[storeName]) {
this.task[storeName] = {};
}
const watcher = this.task[storeName];
Object.keys(options).forEach((key) => {
const callback = options[key];
if (!watcher[key]) {
watcher[key] = new Map();
}
watcher[key].set(callback, 1);
const { notifyRangeWhenWatch } = params || {};
// 注册监听后, 通知所有注册该 key 的监听,使用 'all' 时要特别注意是否对其他地方的监听产生影响
if (notifyRangeWhenWatch === NAME.ALL) {
this.notify(storeName, key);
}
// 注册监听后, 仅通知自己本次监听该 key 的数据
if (notifyRangeWhenWatch === NAME.MYSELF) {
const data = this.getData(storeName, key);
callback.call(this, data);
}
});
}
/**
* UI 取消组件监听回调
* @param {StoreName} storeName store 名称
* @param {IOptions} options 监听信息,包含需要取消的回掉等
*/
unwatch(storeName: StoreName, options: IOptions) {
// todo 该接口暂未支持,unwatch掉同一类,如仅传入store注销掉该store下的所有callback,同样options仅传入key注销掉该key下的所有callback
// options的callback function为必填参数,后续修改
if (!this.task[storeName]) {
return;
};
// if (isString(options)) {
// // 移除所有的监听
// if (options === '*') {
// const watcher = this.task[storeName];
// Object.keys(watcher).forEach(key => {
// watcher[key].clear();
// });
// } else {
// console.warn(`${NAME.PREFIX}unwatch warning: options is ${options}`);
// }
// return;
// }
const watcher = this.task[storeName];
Object.keys(options).forEach((key: string) => {
watcher[key].delete(options[key]);
});
}
/**
* 通用 store 数据更新,messageList 的变更需要单独处理
* @param {StoreName} storeName store 名称
* @param {string} key 变更的 key
* @param {unknown} data 变更的数据
*/
update(storeName: StoreName, key: string, data: unknown) {
// 基本数据类型时, 如果相等, 就不进行更新, 减少不必要的 notify
if (isString(data) || isNumber(data) || isBoolean(data)) {
const currentData = this.storeMap[storeName]['store'][key]; // eslint-disable-line
if (currentData === data) return;
}
this.storeMap[storeName]?.update(key, data);
this.notify(storeName, key);
}
/**
* 获取 Store 数据
* @param {StoreName} storeName store 名称
* @param {string} key 待获取的 key
* @returns {Any}
*/
getData(storeName: StoreName, key: string) {
return this.storeMap[storeName]?.getData(key);
}
/**
* UI 组件注册监听回调
* @param {StoreName} storeName store 名称
* @param {string} key 变更的 key
*/
private notify(storeName: StoreName, key: string) {
if (!this.task[storeName]) {
return;
}
const watcher = this.task[storeName];
if (watcher[key]) {
const callbackMap = watcher[key];
const data = this.getData(storeName, key);
for (const [callback] of callbackMap.entries()) {
callback.call(this, data);
}
}
}
public reset(storeName: StoreName, keyList: Array<string> = [], isNotificationNeeded = false) {
if (storeName in this.storeMap) {
const store = this.storeMap[storeName];
// reset all
if (keyList.length === 0) {
keyList = Object.keys(store?.store);
}
store.reset(keyList);
if (isNotificationNeeded) {
keyList.forEach((key) => {
this.notify(storeName, key);
});
}
}
}
// 批量修改多个 key-value
public updateStore(params: any, name?: StoreName): void {
const storeName = name ? name : StoreName.CALL;
Object.keys(params).forEach((key) => {
this.update(storeName, key, params[key]);
});
}
}
@@ -0,0 +1,48 @@
import { CallMediaType } from "@trtc/call-engine-lite-wx";
/**
* @property {String} call 1v1 通话 + 群组通话
* @property {String} CUSTOM 自定义 Store
*/
export enum StoreName {
CALL = 'call',
CUSTOM = 'custom'
}
/**
* @property {String} caller 主叫
* @property {String} callee 被叫
*/
export enum CallRole {
UNKNOWN = 'unknown',
CALLEE = 'callee',
CALLER = 'caller',
}
/**
* @property {String} idle 空闲
* @property {String} calling 呼叫等待中
* @property {String} connected 通话中
*/
export enum CallStatus {
IDLE = 'idle',
CALLING = 'calling',
CONNECTED = 'connected',
}
// 支持的语言
export enum LanguageType {
EN = 'en',
'ZH-CN' = 'zh-cn',
JA_JP = 'ja_JP',
}
export enum FeatureButton {
Camera = 'camera',
Microphone = 'microphone',
SwitchCamera = 'switchCamera',
InviteUser = 'inviteUser',
}
export enum ButtonState {
Open = 'open',
Close = 'close',
}
@@ -0,0 +1,14 @@
// 错误码
export const ErrorCode: any = {
MICROPHONE_UNAVAILABLE: 60003,
CAMERA_UNAVAILABLE: 60004,
BAN_DEVICE: 60005,
ERROR_BLACKLIST: 20007,
} as const;
export const ErrorMessage: any = {
MICROPHONE_UNAVAILABLE: 'microphone-unavailable',
CAMERA_UNAVAILABLE: 'camera-unavailable',
BAN_DEVICE: 'ban-device',
ERROR_BLACKLIST: 'blacklist-user-tips'
} as const;
@@ -0,0 +1,86 @@
export * from './call';
export * from './error';
export const CALL_DATA_KEY: any = {
CALL_STATUS: 'callStatus',
CALL_ROLE: 'callRole',
CALL_MEDIA_TYPE: 'callMediaType',
LOCAL_USER_INFO: 'localUserInfo',
LOCAL_USER_INFO_EXCLUDE_VOLUMN: 'localUserInfoExcludeVolume',
REMOTE_USER_INFO_LIST: 'remoteUserInfoList',
REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST: 'remoteUserInfoExcludeVolumeList',
CALLER_USER_INFO: 'callerUserInfo',
IS_GROUP: 'isGroup',
DURATION: 'duration',
CALL_TIPS: 'callTips',
TOAST_INFO: 'toastInfo',
IS_MINIMIZED: 'isMinimized',
ENABLE_FLOAT_WINDOW: 'enableFloatWindow',
BIG_SCREEN_USER_ID: 'bigScreenUserId',
LANGUAGE: 'language',
IS_CLICKABLE: 'isClickable',
DISPLAY_MODE: 'displayMode',
IS_EAR_PHONE: 'isEarPhone',
SHOW_PERMISSION_TIP: 'SHOW_PERMISSION_TIP',
NETWORK_STATUS: 'NetWorkStatus',
GROUP_ID: 'groupID',
CALL_INFO: 'callInfo',
PUSHER_ID: 'pusherId',
};
export const PUSHER_ID = {
INITIAL_PUSHER: 'initialPusher',
NEW_PUSHER: 'newPusher'
};
export const CHAT_DATA_KEY: any = {
"INNER_ATTR_KIT_INFO": "inner_attr_kit_info",
};
export const NAME = {
PREFIX: '【CallService】',
AUDIO: 'audio',
VIDEO: 'video',
LOCAL_VIDEO: 'localVideo',
ERROR: 'error',
TIMEOUT: 'timeout',
BOOLEAN: 'boolean',
STRING: 'string',
NUMBER: 'number',
OBJECT: 'object',
FUNCTION: 'function',
UNDEFINED: "undefined",
UNKNOWN: 'unknown',
ALL: 'all',
MYSELF: 'myself',
CAMERA_POSITION: 'cameraPosition',
...PUSHER_ID,
...CALL_DATA_KEY,
...CHAT_DATA_KEY,
};
export const AudioCallIcon = 'https://web.sdk.qcloud.com/component/TUIKit/assets/call.png';
export const VideoCallIcon = 'https://web.sdk.qcloud.com/component/TUIKit/assets/call-video-reverse.svg';
export const MAX_NUMBER_ROOM_ID = 2147483647;
export const DEFAULT_BLUR_LEVEL = 3;
export const NETWORK_QUALITY_THRESHOLD = 4;
export enum PLATFORM {
// eslint-disable-next-line no-unused-vars
MAC = 'mac',
// eslint-disable-next-line no-unused-vars
WIN = 'win',
};
export enum COMPONENT {
// eslint-disable-next-line no-unused-vars
TUI_CALL_KIT = 14,
// eslint-disable-next-line no-unused-vars
TIM_CALL_KIT = 15,
};
export const EVENT = {
LOGIN_SUCCESS: 'LoginState.LoginSuccess',
LOGOUT_SUCCESS: 'logout_success',
ACTIVE_CONV_UPDATED: 'active_conv_updated',
SEND_MESSAGE: 'send_message',
ON_CALLS: 'On_Calls',
}
@@ -0,0 +1,23 @@
import TUICallService, { TUIStore } from './CallService/index';
import {
StoreName,
NAME,
CallRole,
CallStatus,
FeatureButton,
} from './const/index';
import { t } from './locales/index';
// 实例化
const TUICallKitServer = TUICallService.getInstance();
// 输出产物
export {
TUIStore,
StoreName,
TUICallKitServer,
NAME,
CallStatus,
CallRole,
t,
FeatureButton,
};
@@ -0,0 +1,47 @@
import { CallMediaType } from '@trtc/call-engine-lite-wx';
import { CallStatus, CallRole } from '../const/index';
type SDKAppID = { SDKAppID: number; } | { sdkAppID: number; };
export interface IInitParamsBase {
userID: string;
userSig: string;
tim?: any;
isFromChat?: boolean;
component?: number;
scene?: string;
}
export type IInitParams = IInitParamsBase & SDKAppID;
// userInfo interface
export interface IUserInfo {
userId: string;
nick?: string;
avatar?: string;
remark?: string;
displayUserInfo?: string; // 远端用户信息展示: remark -> nick -> userId, 简化 UI 组件; 本地用户信息展示: nick -> userId
isAudioAvailable?: boolean; // 用来设置: 麦克风是否打开
isVideoAvailable?: boolean; // 用来设置: 摄像头是否打开
volume?: number;
isEnter?: boolean; // 远端用户, 用来控制预览远端是否显示 loading 效果; 本地用户, 用来控制 "呼叫"、"接通" 接通后显示的 loading 效果
domId?: string; // 播放流 dom 节点, localUserInfo 的 domId = 'localVideo'; remoteUserInfo 的 domId 就是 userId
}
export interface ISelfInfoParams {
nickName: string;
avatar: string;
}
export interface INetWorkQuality {
userId: string;
quality: number
}
export interface ICallInfo {
callId?: string;
roomId?: string;
inviterId?: string;
inviteeIds?: Array<string>;
chatGroupId?: string;
mediaType?: CallMediaType;
result?: string;
startTime?: number;
duration?: number;
}
@@ -0,0 +1,44 @@
import { CallMediaType, CameraPosition } from '@trtc/call-engine-lite-wx';
import { CallStatus, CallRole } from '../const/index';
import { IUserInfo, INetWorkQuality } from './index';
export interface IToastInfo {
text: string;
type?: string; // 默认 info 通知, 取值: 'info' | 'warn' | 'success' | 'error'
}
export interface ICallStore {
callStatus: CallStatus; // 当前的通话状态, 默认: 'idle'
callRole: CallRole; // 通话角色, 默认: 'callee'
callMediaType: CallMediaType; // 通话类型
localUserInfo: IUserInfo; // 自己的信息, 默认: { userId: '' }
localUserInfoExcludeVolume: IUserInfo; // 不包含音量的当前用户信息
remoteUserInfoList: Array<IUserInfo>; // 远端用户信息列表, 默认: []
remoteUserInfoExcludeVolumeList: Array<IUserInfo>; // 不包含音量的远端用户信息列表
// 被叫在未接通时,展示主叫的 userId、头像。但是如果主叫进入通话后再挂断,此时被叫无法知道主叫的信息了。
// 因为目前 store 中仅提供了 remoteUserInfoList 数据,主叫离开后,被叫就没有主叫的信息了。因此考虑在 store 中增加 callerUserInfo 字段。
callerUserInfo: IUserInfo;
isGroup: boolean; // 是否是群组通话, 默认: false
duration: number; // 通话时长, 默认: 0
callTips: string; // 通话提示的信息. 例如: '等待谁接听', 'xxx 拒绝通话', 'xxx 挂断通话'
toastInfo: IToastInfo; // 远端用户挂断、拒绝、超时、忙线等的 toast 提示信息
isMinimized: boolean; // 当前是否悬浮窗模式, 默认: false
enableFloatWindow: boolean, // 开启/关闭悬浮窗功能,默认: false
bigScreenUserId: string, // 当前大屏幕显示的 userID 用户
language: string; // 当前语言
isClickable: boolean; // 按钮是否可点击(呼叫后, '挂断' 按钮不可点击, 发送信令后才可以点击)
showPermissionTip: boolean; // 设备权限弹窗是否展示(如果有麦克风权限为 false,如果没有麦克风也没有摄像头权限为 true)
netWorkQualityList: Array<INetWorkQuality>; // 通话中用户的网络状态
// deviceList: TDeviceList;
callID: string; // callEngine v3.1 support
groupID: string;
roomID: number | string;
cameraPosition: CameraPosition; // 前置或后置,值为front, back
isMuteSpeaker: boolean;
// 小程序相关属性
isEarPhone: boolean; // 是否是听筒, 默认: false
pusherId: string; // 重新渲染 live-Pusher 的标识位, 必须, 接通前如果没有权限, 授权后需要用新的 pusher, 否则进不了房间
// translate function
translate: Function,
callInfo: Object;
}
@@ -0,0 +1,79 @@
import { StoreName } from '../const/index';
// 此处 Map 的 value = 1 为占位作用
export type Task = Record<StoreName, Record<string, Map<(data?: unknown) => void, 1>>>
export interface IOptions {
[key: string]: (newData?: any) => void;
}
/**
* @class TUIStore
* @property {ICustomStore} customStore 自定义 store,可根据业务需要通过以下 API 进行数据操作。
*/
export interface ITUIStore {
task: Task;
/**
* UI 组件注册监听
* @function
* @param {StoreName} storeName store 名称
* @param {IOptions} options UI 组件注册的监听信息
* @param {Object} params 扩展参数
* @param {String} params.notifyRangeWhenWatch 注册时监听时的通知范围
* @example
* // UI 层监听会话列表更新通知
* let onConversationListUpdated = function(conversationList) {
* console.warn(conversationList);
* }
* TUIStore.watch(StoreName.CONV, {
* conversationList: onConversationListUpdated,
* })
*/
watch(storeName: StoreName, options: IOptions, params?: any): void;
/**
* UI 组件取消监听回调
* @function
* @param {StoreName} storeName store 名称
* @param {IOptions} options 监听信息,包含需要取消的回调等
* @example
* // UI 层取消监听会话列表更新通知
* TUIStore.unwatch(StoreName.CONV, {
* conversationList: onConversationListUpdated,
* })
*/
unwatch(storeName: StoreName, options: IOptions | string): void;
/**
* 获取 store 中的数据
* @function
* @param {StoreName} storeName store 名称
* @param {String} key 需要获取的 key
* @private
*/
getData(storeName: StoreName, key: string): any;
/**
* 更新 store
* - 需要使用自定义 store 时可以用此 API 更新自定义数据
* @function
* @param {StoreName} storeName store 名称
* @param {String} key 需要更新的 key
* @example
* // UI 层更新自定义 Store 数据
* TUIStore.update(StoreName.CUSTOM, 'customKey', 'customData')
*/
update(storeName: StoreName, key: string, data: unknown): void;
/**
* 重置 store 内数据
* @function
* @param {StoreName} storeName store 名称
* @param {Array<string>} keyList 需要 reset 的 keyList
* @param {boolean} isNotificationNeeded 是否需要触发更新
* @private
*/
reset: (storeName: StoreName, keyList?: Array<string>, isNotificationNeeded?: boolean) => void;
/**
* 修改多个 key-value
* @param {Object} params 多个 key-value 组成的 object
* @param {StoreName} storeName store 名称
*/
updateStore: (params: any, name?: StoreName) => void;
}
@@ -0,0 +1,3 @@
export * from './ICallService';
export * from './ICallStore';
export * from './ITUIStore';
@@ -0,0 +1,49 @@
import { TUIStore } from '../CallService/index';
import { NAME, StoreName } from '../const/index';
import { zh } from './zh-cn';
import { interpolate, isString, isPlainObject } from '../utils/common-utils';
export const CallTips: any = {
OTHER_SIDE_REJECT_CALL: 'other side reject call',
REJECT_CALL: 'reject call',
OTHER_SIDE_LINE_BUSY: 'other side line busy',
IN_BUSY: 'in busy',
CALL_TIMEOUT: 'call timeout',
END_CALL: 'end call',
TIMEOUT: 'timeout',
KICK_OUT: 'kick out',
CALLER_CALLING_MSG: 'caller calling message',
CALLER_GROUP_CALLING_MSG: 'wait to be called',
CALLEE_CALLING_VIDEO_MSG: 'callee calling video message',
CALLEE_CALLING_AUDIO_MSG: 'callee calling audio message',
NO_MICROPHONE_DEVICE_PERMISSION: 'no microphone access',
NO_CAMERA_DEVICE_PERMISSION: 'no camera access',
LOCAL_NETWORK_IS_POOR: 'The network is poor during your current call',
REMOTE_NETWORK_IS_POOR: 'The other user network is poor during the current call',
};
export const languageData: languageDataType = {
'zh-cn': zh,
};
// language translate
export function t(args): string {
const language = 'zh-cn';
let translationContent = '';
if (isString(args)) {
translationContent = languageData?.[language]?.[args] || '';
} else if (isPlainObject(args)) {
const { key, options } = args;
translationContent = languageData?.[language]?.[key] || '';
translationContent = interpolate(translationContent, options);
}
return translationContent;
}
interface languageItemType {
[key: string]: string;
}
interface languageDataType {
[key: string]: languageItemType;
}

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