jisti-meet服务开启
This commit is contained in:
@@ -1,263 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
# 开发环境完整部署配置
|
||||
services:
|
||||
|
||||
# ================== 基础服务 ==================
|
||||
|
||||
# PostgreSQL 数据库
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: ul-postgres
|
||||
environment:
|
||||
POSTGRES_DB: urban_lifeline
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres123
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./urbanLifelineServ/.bin/database/postgres/sql:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- ul-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ul-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- ul-network
|
||||
command: redis-server --appendonly yes
|
||||
|
||||
# Nacos 注册中心
|
||||
nacos:
|
||||
image: nacos/nacos-server:v2.3.0
|
||||
container_name: ul-nacos
|
||||
environment:
|
||||
MODE: standalone
|
||||
SPRING_DATASOURCE_PLATFORM: mysql
|
||||
PREFER_HOST_MODE: hostname
|
||||
JVM_XMS: 512m
|
||||
JVM_XMX: 512m
|
||||
JVM_XMN: 256m
|
||||
ports:
|
||||
- "8848:8848"
|
||||
- "9848:9848"
|
||||
volumes:
|
||||
- nacos_data:/home/nacos/data
|
||||
networks:
|
||||
- ul-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8848/nacos"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# ================== 后端服务 ==================
|
||||
|
||||
# Gateway 网关服务
|
||||
gateway:
|
||||
build:
|
||||
context: ./urbanLifelineServ/gateway
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: ul-gateway
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: dev
|
||||
NACOS_SERVER_ADDR: nacos:8848
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./urbanLifelineServ/gateway:/app
|
||||
- maven_cache:/root/.m2
|
||||
networks:
|
||||
- ul-network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nacos:
|
||||
condition: service_healthy
|
||||
command: mvn spring-boot:run
|
||||
|
||||
# 认证服务
|
||||
auth-service:
|
||||
build:
|
||||
context: ./urbanLifelineServ/auth
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: ul-auth
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: dev
|
||||
NACOS_SERVER_ADDR: nacos:8848
|
||||
POSTGRES_HOST: postgres
|
||||
REDIS_HOST: redis
|
||||
volumes:
|
||||
- ./urbanLifelineServ/auth:/app
|
||||
- maven_cache:/root/.m2
|
||||
networks:
|
||||
- ul-network
|
||||
depends_on:
|
||||
- postgres
|
||||
- nacos
|
||||
|
||||
# 系统服务
|
||||
system-service:
|
||||
build:
|
||||
context: ./urbanLifelineServ/system
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: ul-system
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: dev
|
||||
NACOS_SERVER_ADDR: nacos:8848
|
||||
POSTGRES_HOST: postgres
|
||||
volumes:
|
||||
- ./urbanLifelineServ/system:/app
|
||||
- maven_cache:/root/.m2
|
||||
networks:
|
||||
- ul-network
|
||||
depends_on:
|
||||
- postgres
|
||||
- nacos
|
||||
|
||||
# ================== 前端服务 ==================
|
||||
|
||||
# 共享包服务(Module Federation Remote)
|
||||
shared:
|
||||
build:
|
||||
context: ./urbanLifelineWeb
|
||||
dockerfile: packages/shared/Dockerfile.dev
|
||||
container_name: ul-shared
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./urbanLifelineWeb/packages/shared:/app
|
||||
- /app/node_modules
|
||||
- pnpm_store:/root/.local/share/pnpm/store
|
||||
networks:
|
||||
- ul-network
|
||||
environment:
|
||||
- VITE_PORT=5000
|
||||
- CHOKIDAR_USEPOLLING=true # 支持 Docker 内文件监听
|
||||
command: pnpm dev
|
||||
|
||||
# 主应用(Portal)
|
||||
portal:
|
||||
build:
|
||||
context: ./urbanLifelineWeb
|
||||
dockerfile: packages/portal/Dockerfile.dev
|
||||
container_name: ul-portal
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./urbanLifelineWeb/packages/portal:/app
|
||||
- ./urbanLifelineWeb/packages/shared:/shared
|
||||
- /app/node_modules
|
||||
- pnpm_store:/root/.local/share/pnpm/store
|
||||
networks:
|
||||
- ul-network
|
||||
environment:
|
||||
- VITE_PORT=3000
|
||||
- VITE_API_BASE_URL=http://nginx/api
|
||||
- VITE_SHARED_REMOTE=http://nginx/shared
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
depends_on:
|
||||
- shared
|
||||
command: pnpm dev
|
||||
|
||||
# 招投标应用
|
||||
app-bidding:
|
||||
build:
|
||||
context: ./urbanLifelineWeb
|
||||
dockerfile: packages/app-bidding/Dockerfile.dev
|
||||
container_name: ul-app-bidding
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./urbanLifelineWeb/packages/app-bidding:/app
|
||||
- ./urbanLifelineWeb/packages/shared:/shared
|
||||
- /app/node_modules
|
||||
- pnpm_store:/root/.local/share/pnpm/store
|
||||
networks:
|
||||
- ul-network
|
||||
environment:
|
||||
- VITE_PORT=3001
|
||||
- VITE_API_BASE_URL=http://nginx/api
|
||||
- VITE_SHARED_REMOTE=http://nginx/shared
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
depends_on:
|
||||
- shared
|
||||
command: pnpm dev
|
||||
|
||||
# 智能客服应用
|
||||
app-customer-service:
|
||||
build:
|
||||
context: ./urbanLifelineWeb
|
||||
dockerfile: packages/app-customer-service/Dockerfile.dev
|
||||
container_name: ul-app-cs
|
||||
ports:
|
||||
- "3002:3002"
|
||||
volumes:
|
||||
- ./urbanLifelineWeb/packages/app-customer-service:/app
|
||||
- ./urbanLifelineWeb/packages/shared:/shared
|
||||
- /app/node_modules
|
||||
- pnpm_store:/root/.local/share/pnpm/store
|
||||
networks:
|
||||
- ul-network
|
||||
environment:
|
||||
- VITE_PORT=3002
|
||||
- VITE_API_BASE_URL=http://nginx/api
|
||||
- VITE_SHARED_REMOTE=http://nginx/shared
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
depends_on:
|
||||
- shared
|
||||
command: pnpm dev
|
||||
|
||||
# ================== 统一网关 ==================
|
||||
|
||||
# Nginx 统一入口
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: ul-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./docker/nginx/ssl:/etc/nginx/ssl:ro
|
||||
networks:
|
||||
- ul-network
|
||||
depends_on:
|
||||
- gateway
|
||||
- portal
|
||||
- app-bidding
|
||||
- app-customer-service
|
||||
- shared
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
ul-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
nacos_data:
|
||||
maven_cache:
|
||||
pnpm_store:
|
||||
393
docs/Jitsi-Meet-Docker部署指南.md
Normal file
393
docs/Jitsi-Meet-Docker部署指南.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# 🎥 Jitsi Meet Docker 部署与使用指南
|
||||
|
||||
## 📋 架构说明
|
||||
|
||||
本项目已将Jitsi Meet完整集成到Docker Compose中,包含以下4个服务:
|
||||
|
||||
| 服务 | 容器名 | 端口 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| **jitsi-web** | urban-lifeline-jitsi-web | 8280 (HTTP)<br>8443 (HTTPS) | Web前端服务 |
|
||||
| **jitsi-prosody** | urban-lifeline-jitsi-prosody | 5222/5347/5280 (内部) | XMPP信令服务器 |
|
||||
| **jitsi-jicofo** | urban-lifeline-jitsi-jicofo | 无需暴露 | 会议控制服务 |
|
||||
| **jitsi-jvb** | urban-lifeline-jitsi-jvb | 10000/udp<br>4443/tcp | 视频桥接服务 |
|
||||
|
||||
**数据持久化目录**:
|
||||
```
|
||||
F:\Project\urbanLifeline\.data\docker\jitsi\
|
||||
├── web/ # Web配置
|
||||
├── prosody/ # XMPP配置
|
||||
├── jicofo/ # Jicofo配置
|
||||
├── jvb/ # JVB配置
|
||||
└── transcripts/ # 转录文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 步骤1:启动Jitsi Meet服务
|
||||
|
||||
```bash
|
||||
# 进入Docker Compose目录
|
||||
cd F:\Project\urbanLifeline\urbanLifelineServ\.bin\docker\urbanlifeline
|
||||
|
||||
# 启动Jitsi Meet(仅启动jitsi相关服务)
|
||||
docker-compose up -d jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb
|
||||
|
||||
# 查看服务状态
|
||||
docker-compose ps
|
||||
|
||||
# 查看日志(排查问题用)
|
||||
docker-compose logs -f jitsi-web
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
NAME STATUS PORTS
|
||||
urban-lifeline-jitsi-web Up (healthy) 0.0.0.0:8280->80/tcp, 0.0.0.0:8443->443/tcp
|
||||
urban-lifeline-jitsi-prosody Up (healthy) 5222/tcp, 5347/tcp, 5280/tcp
|
||||
urban-lifeline-jitsi-jicofo Up (healthy)
|
||||
urban-lifeline-jitsi-jvb Up (healthy) 0.0.0.0:10000->10000/udp, 0.0.0.0:4443->4443/tcp
|
||||
```
|
||||
|
||||
### 步骤2:验证Jitsi Meet运行
|
||||
|
||||
在浏览器打开:
|
||||
- **主页**: http://localhost:8280/
|
||||
- **测试房间**: http://localhost:8280/test-meeting
|
||||
|
||||
如果能看到Jitsi Meet界面,说明部署成功!
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 1. JWT认证配置(已配置)
|
||||
|
||||
**Docker配置**(docker-compose.yml):
|
||||
```yaml
|
||||
jitsi-web:
|
||||
environment:
|
||||
JWT_APP_ID: urbanLifeline # 应用ID
|
||||
JWT_APP_SECRET: your-secret-key-change-in-production # JWT密钥
|
||||
```
|
||||
|
||||
**Java后端配置**(application-dev.yml):
|
||||
```yaml
|
||||
jitsi:
|
||||
app:
|
||||
id: urbanLifeline # 必须与Docker一致
|
||||
secret: your-secret-key-change-in-production # 必须与Docker一致
|
||||
server:
|
||||
url: http://localhost:8280 # Jitsi服务地址
|
||||
token:
|
||||
expiration: 7200000 # Token有效期2小时
|
||||
```
|
||||
|
||||
⚠️ **重要**:
|
||||
- `JWT_APP_ID` 和 `JWT_APP_SECRET` 在Docker和Java后端**必须完全一致**
|
||||
- 生产环境请修改 `JWT_APP_SECRET` 为强随机字符串
|
||||
- 建议使用密钥管理工具(如Vault)管理密钥
|
||||
|
||||
### 2. 修改JWT密钥(生产环境必须)
|
||||
|
||||
#### 方法A:修改Docker配置
|
||||
```bash
|
||||
# 1. 编辑 docker-compose.yml
|
||||
# 将所有 JWT_APP_SECRET 改为你的密钥
|
||||
JWT_APP_SECRET: "A8sF9dK2mP5nX7qW3tY6uZ1vB4cE0hG"
|
||||
|
||||
# 2. 同步修改 application-dev.yml
|
||||
jitsi:
|
||||
app:
|
||||
secret: "A8sF9dK2mP5nX7qW3tY6uZ1vB4cE0hG"
|
||||
|
||||
# 3. 重启服务
|
||||
docker-compose restart jitsi-web jitsi-prosody
|
||||
```
|
||||
|
||||
#### 方法B:使用环境变量(推荐生产环境)
|
||||
```bash
|
||||
# 创建 .env 文件
|
||||
echo "JITSI_JWT_SECRET=your-strong-secret-key" > .env
|
||||
|
||||
# 修改 docker-compose.yml 使用环境变量
|
||||
JWT_APP_SECRET: ${JITSI_JWT_SECRET}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试JWT验证
|
||||
|
||||
### 1. 测试无Token访问(应被拒绝)
|
||||
|
||||
在浏览器打开:http://localhost:8280/test-room
|
||||
|
||||
**预期结果**:显示"会议需要密码"或"需要登录"
|
||||
|
||||
### 2. 测试带JWT Token访问(应成功)
|
||||
|
||||
使用你的Java后端创建会议,后端会自动生成JWT Token并返回iframe URL:
|
||||
|
||||
```bash
|
||||
# 调用创建会议接口
|
||||
POST http://localhost:8180/urban-lifeline/workcase/chat/meeting/create
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"roomId": "test-room-123",
|
||||
"workcaseId": "WC001",
|
||||
"meetingName": "测试会议",
|
||||
"maxParticipants": 10
|
||||
}
|
||||
|
||||
# 响应包含带JWT的URL
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"meetingId": "xxx",
|
||||
"iframeUrl": "http://localhost:8280/workcase_WC001_1234567890?jwt=eyJhbGc..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在浏览器打开响应中的 `iframeUrl`,应该能正常进入会议。
|
||||
|
||||
---
|
||||
|
||||
## 📊 服务管理命令
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f jitsi-web # Web服务日志
|
||||
docker-compose logs -f jitsi-prosody # XMPP日志
|
||||
docker-compose logs -f jitsi-jicofo # Jicofo日志
|
||||
docker-compose logs -f jitsi-jvb # JVB日志
|
||||
|
||||
# 重启服务
|
||||
docker-compose restart jitsi-web
|
||||
|
||||
# 停止服务
|
||||
docker-compose stop jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb
|
||||
|
||||
# 删除服务(保留数据)
|
||||
docker-compose down
|
||||
|
||||
# 删除服务和数据
|
||||
docker-compose down -v
|
||||
rm -rf F:\Project\urbanLifeline\.data\docker\jitsi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见问题排查
|
||||
|
||||
### 问题1:服务启动失败
|
||||
|
||||
**症状**:
|
||||
```bash
|
||||
docker-compose ps
|
||||
# 显示 Exit (1) 或 Restarting
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 查看详细日志
|
||||
docker-compose logs jitsi-web
|
||||
|
||||
# 检查端口占用
|
||||
netstat -ano | findstr "8280"
|
||||
|
||||
# 删除容器重新启动
|
||||
docker-compose down
|
||||
docker-compose up -d jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb
|
||||
```
|
||||
|
||||
### 问题2:JWT验证失败
|
||||
|
||||
**症状**:进入会议后立即被踢出,或显示"认证失败"
|
||||
|
||||
**原因**:JWT密钥不匹配
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 1. 检查Docker配置
|
||||
docker-compose exec jitsi-prosody cat /config/prosody.cfg.lua | grep jwt
|
||||
|
||||
# 2. 检查Java配置
|
||||
# 确保 application-dev.yml 中的 jitsi.app.secret 与Docker一致
|
||||
|
||||
# 3. 重启服务
|
||||
docker-compose restart jitsi-prosody
|
||||
```
|
||||
|
||||
### 问题3:视频无法连接
|
||||
|
||||
**症状**:音频正常,但视频黑屏或连接失败
|
||||
|
||||
**原因**:UDP端口10000被防火墙阻止
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# Windows防火墙添加规则
|
||||
netsh advfirewall firewall add rule name="Jitsi JVB UDP" dir=in action=allow protocol=UDP localport=10000
|
||||
|
||||
# 或关闭防火墙测试(不推荐生产环境)
|
||||
```
|
||||
|
||||
### 问题4:健康检查失败
|
||||
|
||||
**症状**:
|
||||
```bash
|
||||
docker-compose ps
|
||||
# 显示 (unhealthy)
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查健康检查端点
|
||||
curl http://localhost:8280/
|
||||
curl http://localhost:8888/about/health # Jicofo
|
||||
curl http://localhost:8080/about/health # JVB
|
||||
|
||||
# 增加启动等待时间
|
||||
# 编辑 docker-compose.yml,修改 start_period: 120s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 公网部署(可选)
|
||||
|
||||
如果需要从公网访问,需要额外配置:
|
||||
|
||||
### 1. 配置域名和SSL
|
||||
|
||||
```yaml
|
||||
jitsi-web:
|
||||
environment:
|
||||
PUBLIC_URL: https://your-domain.com
|
||||
ENABLE_LETSENCRYPT: 1
|
||||
LETSENCRYPT_DOMAIN: your-domain.com
|
||||
LETSENCRYPT_EMAIL: your-email@example.com
|
||||
DISABLE_HTTPS: 0
|
||||
```
|
||||
|
||||
### 2. 配置JVB公网IP
|
||||
|
||||
```yaml
|
||||
jitsi-jvb:
|
||||
environment:
|
||||
DOCKER_HOST_ADDRESS: your-public-ip
|
||||
```
|
||||
|
||||
### 3. 开放防火墙端口
|
||||
|
||||
- **TCP 443**: HTTPS访问
|
||||
- **UDP 10000**: WebRTC媒体流
|
||||
- **TCP 4443**: WebRTC Fallback(可选)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 集成到项目
|
||||
|
||||
你的Java后端已经完全配置好,可以直接使用:
|
||||
|
||||
### 前端Vue调用示例
|
||||
|
||||
```typescript
|
||||
import { createVideoMeeting } from '@/api/workcase/meeting'
|
||||
|
||||
// 创建会议
|
||||
const res = await createVideoMeeting({
|
||||
roomId: 'room-123',
|
||||
workcaseId: 'WC001',
|
||||
meetingName: '技术支持会议',
|
||||
maxParticipants: 10
|
||||
})
|
||||
|
||||
// 在iframe中显示
|
||||
const iframeUrl = res.data.iframeUrl
|
||||
```
|
||||
|
||||
### 前端UniApp调用示例
|
||||
|
||||
```typescript
|
||||
import { workcaseChatAPI } from '@/api/workcase'
|
||||
|
||||
// 创建会议
|
||||
const res = await workcaseChatAPI.createVideoMeeting({
|
||||
roomId: roomId.value,
|
||||
workcaseId: workcaseId.value,
|
||||
meetingName: '工单技术支持',
|
||||
maxParticipants: 10
|
||||
})
|
||||
|
||||
// 跳转到会议页面
|
||||
uni.navigateTo({
|
||||
url: `/pages/meeting/MeetingView/MeetingView?meetingUrl=${encodeURIComponent(res.data.iframeUrl)}`
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 性能优化(可选)
|
||||
|
||||
### 1. 限制CPU和内存
|
||||
|
||||
```yaml
|
||||
jitsi-jvb:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
```
|
||||
|
||||
### 2. 配置视频质量
|
||||
|
||||
编辑 `.data/docker/jitsi/web/config/config.js`:
|
||||
|
||||
```javascript
|
||||
var config = {
|
||||
resolution: 720,
|
||||
constraints: {
|
||||
video: {
|
||||
height: { ideal: 720, max: 720, min: 180 }
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 检查清单
|
||||
|
||||
启动前确认:
|
||||
- [x] Docker Desktop已安装并运行
|
||||
- [x] 端口8280、8443、10000未被占用
|
||||
- [x] application-dev.yml配置正确
|
||||
- [x] JWT密钥在Docker和Java后端一致
|
||||
|
||||
启动后确认:
|
||||
- [ ] 4个容器都是Up状态
|
||||
- [ ] 访问http://localhost:8280能看到Jitsi界面
|
||||
- [ ] Java后端能成功创建会议并生成JWT
|
||||
- [ ] 前端能正常嵌入iframe并进入会议
|
||||
|
||||
---
|
||||
|
||||
## 🆘 获取帮助
|
||||
|
||||
如遇问题,收集以下信息:
|
||||
1. Docker服务状态:`docker-compose ps`
|
||||
2. 服务日志:`docker-compose logs jitsi-web`
|
||||
3. 健康检查:`curl http://localhost:8280/`
|
||||
4. Java后端日志中的JWT Token生成情况
|
||||
|
||||
**祝部署顺利!** 🚀
|
||||
BIN
docs/qrcode.jpg
Normal file
BIN
docs/qrcode.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
BIN
docs/qrcode.png
Normal file
BIN
docs/qrcode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
177
docs/代码重构-视频会议API规范化.md
Normal file
177
docs/代码重构-视频会议API规范化.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# 代码重构:视频会议 API 规范化
|
||||
|
||||
## 重构日期
|
||||
2025-12-26
|
||||
|
||||
## 重构原因
|
||||
原代码将类型定义和 API 调用混在一个独立的 `meeting.ts` 文件中,不符合项目规范。需要按照以下规范重构:
|
||||
|
||||
1. **类型定义规范**:所有 DTO/VO 类型应放在 `types/workcase/` 目录下
|
||||
2. **API 调用规范**:使用 `shared/api` 的 `api` 对象发送请求
|
||||
3. **API 组织规范**:按业务模块组织成对象形式(如 `workcaseAPI`、`workcaseChatAPI`)
|
||||
4. **代码复用规范**:避免重复定义,视频会议属于聊天室模块
|
||||
|
||||
## 重构内容
|
||||
|
||||
### 1. 类型定义迁移
|
||||
|
||||
**原位置**:`api/workcase/meeting.ts`(已删除)
|
||||
|
||||
**新位置**:`types/workcase/chatRoom.ts`
|
||||
|
||||
类型定义已经存在于 `chatRoom.ts` 中,无需创建新文件:
|
||||
- `TbVideoMeetingDTO` (line 68-88)
|
||||
- `VideoMeetingVO` (line 220-241)
|
||||
- `CreateMeetingParam` (line 279-285)
|
||||
|
||||
### 2. API 方法整合
|
||||
|
||||
**原文件**:`api/workcase/meeting.ts`(已删除)
|
||||
- 独立的函数式 API 调用
|
||||
- 使用 `http.post/get` 发送请求
|
||||
- 类型定义和 API 混在一起
|
||||
|
||||
**新文件**:`api/workcase/workcaseChat.ts`(已更新)
|
||||
|
||||
新增 6 个视频会议方法到 `workcaseChatAPI` 对象(line 227-276):
|
||||
|
||||
```typescript
|
||||
// ====================== 视频会议管理(Jitsi Meet) ======================
|
||||
|
||||
async createVideoMeeting(meeting: TbVideoMeetingDTO): Promise<ResultDomain<VideoMeetingVO>>
|
||||
async getVideoMeetingInfo(meetingId: string): Promise<ResultDomain<VideoMeetingVO>>
|
||||
async getActiveMeeting(roomId: string): Promise<ResultDomain<VideoMeetingVO>>
|
||||
async joinVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>>
|
||||
async startVideoMeeting(meetingId: string): Promise<ResultDomain<boolean>>
|
||||
async endVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>>
|
||||
```
|
||||
|
||||
### 3. 导入语句更新
|
||||
|
||||
**文件**:`api/workcase/workcaseChat.ts` (line 3-15)
|
||||
|
||||
新增导入:
|
||||
```typescript
|
||||
import type {
|
||||
// ... 现有导入
|
||||
TbVideoMeetingDTO, // 新增
|
||||
VideoMeetingVO // 新增
|
||||
} from '@/types/workcase'
|
||||
```
|
||||
|
||||
### 4. 导出配置更新
|
||||
|
||||
**文件**:`api/workcase/index.ts`
|
||||
|
||||
```typescript
|
||||
// 移除
|
||||
- export * from './meeting'
|
||||
|
||||
// 保留
|
||||
export * from './workcase'
|
||||
export * from './workcaseChat'
|
||||
```
|
||||
|
||||
### 5. Vue 组件引用更新
|
||||
|
||||
**文件**:`components/chatRoom/chatRoom/ChatRoom.vue`
|
||||
|
||||
**修改前**:
|
||||
```typescript
|
||||
import { createVideoMeeting, getActiveMeeting, endVideoMeeting } from '@/api/workcase/meeting'
|
||||
|
||||
// 使用
|
||||
await createVideoMeeting({ ... })
|
||||
await getActiveMeeting(props.roomId)
|
||||
await endVideoMeeting(currentMeetingId.value)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```typescript
|
||||
import { workcaseChatAPI } from '@/api/workcase'
|
||||
|
||||
// 使用
|
||||
await workcaseChatAPI.createVideoMeeting({ ... })
|
||||
await workcaseChatAPI.getActiveMeeting(props.roomId)
|
||||
await workcaseChatAPI.endVideoMeeting(currentMeetingId.value)
|
||||
```
|
||||
|
||||
## 重构优势
|
||||
|
||||
### 1. 符合项目规范
|
||||
- ✅ 类型定义集中在 `types/` 目录
|
||||
- ✅ API 调用使用 `shared/api`
|
||||
- ✅ API 按业务模块组织
|
||||
|
||||
### 2. 避免代码重复
|
||||
- ✅ 复用已有的类型定义
|
||||
- ✅ 统一的 API 调用风格
|
||||
|
||||
### 3. 更好的可维护性
|
||||
- ✅ 代码组织清晰,职责分明
|
||||
- ✅ 类型定义和 API 调用分离
|
||||
- ✅ 按业务模块聚合,易于查找
|
||||
|
||||
### 4. 统一的开发体验
|
||||
- ✅ 与其他 API 调用方式一致
|
||||
- ✅ 自动类型推断
|
||||
- ✅ 统一的错误处理
|
||||
|
||||
## 受影响的文件
|
||||
|
||||
### 删除的文件
|
||||
- `packages/workcase/src/api/workcase/meeting.ts`
|
||||
|
||||
### 修改的文件
|
||||
1. `packages/workcase/src/api/workcase/workcaseChat.ts` - 新增 6 个视频会议方法
|
||||
2. `packages/workcase/src/api/workcase/index.ts` - 移除 meeting 导出
|
||||
3. `packages/workcase/src/components/chatRoom/chatRoom/ChatRoom.vue` - 更新 API 调用
|
||||
|
||||
### 保持不变的文件
|
||||
- `packages/workcase/src/types/workcase/chatRoom.ts` - 类型定义已存在
|
||||
- `packages/workcase_wechat/api/workcase/workcaseChat.ts` - UniApp 端已符合规范
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **Vue Web 端测试**
|
||||
- 测试创建视频会议功能
|
||||
- 测试加入已有会议功能
|
||||
- 测试结束会议功能
|
||||
- 测试自动检测活跃会议
|
||||
|
||||
2. **UniApp 端测试**
|
||||
- 测试 MeetingView 页面导航
|
||||
- 测试视频会议页面显示
|
||||
- 测试结束会议返回聊天室
|
||||
|
||||
3. **类型检查**
|
||||
```bash
|
||||
# 运行 TypeScript 类型检查
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **错误处理增强**
|
||||
- 添加统一的错误提示
|
||||
- 添加会议状态校验
|
||||
|
||||
2. **用户体验优化**
|
||||
- 添加会议加载状态提示
|
||||
- 添加会议连接失败重试
|
||||
|
||||
3. **性能优化**
|
||||
- 会议状态使用 WebSocket 实时同步
|
||||
- 离开页面自动结束会议
|
||||
|
||||
## 参考文档
|
||||
|
||||
- 项目 API 规范:参考 `workcase.ts` 和 `workcaseChat.ts`
|
||||
- 类型定义规范:参考 `types/workcase/` 目录
|
||||
- Vue 组件规范:参考现有聊天室组件
|
||||
|
||||
---
|
||||
|
||||
**重构人员**:Claude Code
|
||||
**审核状态**:待审核
|
||||
**版本**:v1.0
|
||||
310
docs/功能实现-会议通知消息.md
Normal file
310
docs/功能实现-会议通知消息.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# 功能实现:视频会议预约模式(Reservation Model)
|
||||
|
||||
## 实现日期
|
||||
2025-12-26
|
||||
|
||||
## 功能概述
|
||||
实现视频会议"预约+按需创建"架构:用户创建会议预约时不立即创建Jitsi会议室,仅在首个用户在允许入会时间窗口内加入时,通过Redis双检锁机制创建Jitsi会议室。
|
||||
|
||||
## 架构模型
|
||||
|
||||
### 预约模式 (Reservation Model)
|
||||
```
|
||||
用户创建会议 → 保存预约信息(scheduled)→ 发送会议通知消息(meetingId)
|
||||
↓
|
||||
首个用户加入 → 时间窗口校验 → Redis分布式锁 → 创建Jitsi会议室 → 更新状态(ongoing)
|
||||
↓
|
||||
后续用户加入 → 直接获取已创建的会议室URL
|
||||
```
|
||||
|
||||
### 时间窗口规则
|
||||
- **提前入会时间**: `start_time - advance` 分钟
|
||||
- **允许入会窗口**: `[提前入会时间, end_time]`
|
||||
- **默认advance**: 5分钟
|
||||
|
||||
## 实现文件
|
||||
|
||||
### 后端修改
|
||||
|
||||
**VideoMeetingServiceImpl.java**
|
||||
- 位置:`urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/`
|
||||
- 修改内容:
|
||||
1. 新增导入:`TbChatRoomMessageDTO`、`ChatRoomService`、`JSONObject`
|
||||
2. 注入依赖:`ChatRoomService`
|
||||
3. 修改 `createMeeting()` 方法:插入数据库成功后调用 `sendMeetingNotification()`
|
||||
4. 新增私有方法:`sendMeetingNotification()`
|
||||
|
||||
## 详细实现
|
||||
|
||||
### 1. 依赖注入
|
||||
|
||||
```java
|
||||
@Autowired
|
||||
private ChatRoomService chatRoomService;
|
||||
```
|
||||
|
||||
### 2. 调用时机
|
||||
|
||||
在 `createMeeting()` 方法中,会议记录插入数据库成功后:
|
||||
|
||||
```java
|
||||
// 8. 插入数据库
|
||||
int rows = videoMeetingMapper.insertVideoMeeting(meetingDTO);
|
||||
if (rows > 0) {
|
||||
logger.info("视频会议创建成功: meetingId={}, jitsiRoomName={}",
|
||||
meetingId, jitsiRoomName);
|
||||
|
||||
// 9. 发送会议通知消息到聊天室
|
||||
sendMeetingNotification(meetingDTO, userName);
|
||||
|
||||
// 10. 返回VO
|
||||
VideoMeetingVO meetingVO = new VideoMeetingVO();
|
||||
BeanUtils.copyProperties(meetingDTO, meetingVO);
|
||||
return ResultDomain.success("创建会议成功", meetingVO);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 消息发送实现
|
||||
|
||||
**sendMeetingNotification() 方法 (VideoMeetingServiceImpl.java:396-442)**
|
||||
|
||||
```java
|
||||
/**
|
||||
* 发送会议通知消息到聊天室
|
||||
* @param meetingDTO 会议信息
|
||||
* @param creatorName 创建者名称
|
||||
*/
|
||||
private void sendMeetingNotification(TbVideoMeetingDTO meetingDTO, String creatorName) {
|
||||
try {
|
||||
logger.info("发送会议通知消息: roomId={}, meetingId={}",
|
||||
meetingDTO.getRoomId(), meetingDTO.getMeetingId());
|
||||
|
||||
// 构建消息内容
|
||||
TbChatRoomMessageDTO message = new TbChatRoomMessageDTO();
|
||||
message.setMessageId(IdUtil.generateUUID());
|
||||
message.setRoomId(meetingDTO.getRoomId());
|
||||
message.setSenderId(meetingDTO.getCreator());
|
||||
message.setSenderType(meetingDTO.getCreatorType());
|
||||
message.setSenderName(creatorName);
|
||||
message.setMessageType("meet"); // 会议类型消息
|
||||
message.setContent(meetingDTO.getIframeUrl()); // 会议URL作为内容
|
||||
message.setStatus("sent");
|
||||
message.setReadCount(0);
|
||||
message.setSendTime(new Date());
|
||||
|
||||
// 构建扩展信息(会议详情)
|
||||
JSONObject contentExtra = new JSONObject();
|
||||
contentExtra.put("meetingId", meetingDTO.getMeetingId());
|
||||
contentExtra.put("meetingName", meetingDTO.getMeetingName());
|
||||
contentExtra.put("jitsiRoomName", meetingDTO.getJitsiRoomName());
|
||||
contentExtra.put("iframeUrl", meetingDTO.getIframeUrl());
|
||||
contentExtra.put("maxParticipants", meetingDTO.getMaxParticipants());
|
||||
contentExtra.put("creatorName", creatorName);
|
||||
contentExtra.put("workcaseId", meetingDTO.getWorkcaseId());
|
||||
message.setContentExtra(contentExtra);
|
||||
|
||||
// 发送消息
|
||||
ResultDomain<TbChatRoomMessageDTO> sendResult = chatRoomService.sendMessage(message);
|
||||
if (sendResult.getSuccess()) {
|
||||
logger.info("会议通知消息发送成功: messageId={}", message.getMessageId());
|
||||
} else {
|
||||
logger.warn("会议通知消息发送失败: {}", sendResult.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 消息发送失败不影响会议创建
|
||||
logger.error("发送会议通知消息异常: roomId={}, error={}",
|
||||
meetingDTO.getRoomId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 消息数据结构
|
||||
|
||||
### 消息字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| messageId | String | 消息ID(UUID) |
|
||||
| roomId | String | 聊天室ID |
|
||||
| senderId | String | 发送者ID(会议创建者) |
|
||||
| senderType | String | 发送者类型(guest/agent) |
|
||||
| senderName | String | 发送者名称 |
|
||||
| **messageType** | **String** | **"meet"(会议消息类型)** |
|
||||
| **content** | **String** | **会议iframe URL** |
|
||||
| contentExtra | JSONObject | 会议详细信息(见下表) |
|
||||
| status | String | "sent" |
|
||||
| readCount | Integer | 0 |
|
||||
| sendTime | Date | 发送时间 |
|
||||
|
||||
### contentExtra 详细信息
|
||||
|
||||
```json
|
||||
{
|
||||
"meetingId": "会议ID",
|
||||
"meetingName": "会议名称",
|
||||
"jitsiRoomName": "Jitsi房间名",
|
||||
"iframeUrl": "会议URL",
|
||||
"maxParticipants": 10,
|
||||
"creatorName": "创建者名称",
|
||||
"workcaseId": "工单ID"
|
||||
}
|
||||
```
|
||||
|
||||
## 前端渲染建议
|
||||
|
||||
### Vue Web 端
|
||||
|
||||
在 `ChatRoom.vue` 中添加会议消息卡片渲染:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-if="message.messageType === 'meet'" class="meeting-card">
|
||||
<div class="meeting-card-header">
|
||||
<Video :size="20" />
|
||||
<span>{{ message.contentExtra.meetingName }}</span>
|
||||
</div>
|
||||
<div class="meeting-card-body">
|
||||
<p>发起人:{{ message.contentExtra.creatorName }}</p>
|
||||
<p>最多参与人数:{{ message.contentExtra.maxParticipants }}</p>
|
||||
</div>
|
||||
<div class="meeting-card-footer">
|
||||
<button @click="joinMeeting(message.contentExtra.meetingId)">
|
||||
加入会议
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Video } from 'lucide-vue-next'
|
||||
|
||||
const joinMeeting = async (meetingId) => {
|
||||
// 调用加入会议API
|
||||
const res = await workcaseChatAPI.joinVideoMeeting(meetingId)
|
||||
if (res.code === 0) {
|
||||
// 打开会议iframe
|
||||
meetingUrl.value = res.data.iframeUrl
|
||||
showMeeting.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### UniApp 小程序端
|
||||
|
||||
在 `chatRoom.uvue` 中添加会议消息卡片:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view v-if="message.messageType === 'meet'" class="meeting-card">
|
||||
<view class="meeting-card-header">
|
||||
<text class="icon-video">📹</text>
|
||||
<text>{{ message.contentExtra.meetingName }}</text>
|
||||
</view>
|
||||
<view class="meeting-card-body">
|
||||
<text>发起人:{{ message.contentExtra.creatorName }}</text>
|
||||
<text>最多 {{ message.contentExtra.maxParticipants }} 人</text>
|
||||
</view>
|
||||
<button @tap="joinMeeting(message.contentExtra.meetingId)" class="join-btn">
|
||||
加入会议
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function joinMeeting(meetingId: string) {
|
||||
// 调用加入会议API
|
||||
workcaseChatAPI.joinVideoMeeting(meetingId).then(res => {
|
||||
if (res.success && res.data) {
|
||||
// 跳转到会议页面
|
||||
uni.navigateTo({
|
||||
url: `/pages/meeting/Meeting?meetingUrl=${encodeURIComponent(res.data.iframeUrl)}&meetingId=${meetingId}`
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 设计考虑
|
||||
|
||||
### 1. 异步发送
|
||||
消息发送在独立的 try-catch 块中,失败不影响会议创建流程
|
||||
|
||||
### 2. 完整信息
|
||||
contentExtra 包含会议所有关键信息,前端可灵活使用
|
||||
|
||||
### 3. 类型明确
|
||||
messageType 使用 "meet" 标识会议消息,方便前端过滤和渲染
|
||||
|
||||
### 4. URL 即内容
|
||||
content 字段直接存储会议URL,方便快速访问
|
||||
|
||||
### 5. 日志追踪
|
||||
完整的日志记录,便于问题排查
|
||||
|
||||
## 测试要点
|
||||
|
||||
### 1. 会议创建测试
|
||||
```bash
|
||||
POST /urban-lifeline/workcase/chat/meeting/create
|
||||
{
|
||||
"roomId": "test-room-123",
|
||||
"workcaseId": "WC001",
|
||||
"meetingName": "技术支持会议",
|
||||
"maxParticipants": 10
|
||||
}
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 返回会议创建成功
|
||||
- ✅ 数据库 tb_video_meeting 表插入会议记录
|
||||
- ✅ 数据库 tb_chat_room_message 表插入类型为 "meet" 的消息
|
||||
- ✅ 消息的 content 字段包含会议URL
|
||||
- ✅ 消息的 contentExtra 包含会议详细信息
|
||||
|
||||
### 2. 前端卡片渲染测试
|
||||
- ✅ 聊天消息列表中显示会议卡片
|
||||
- ✅ 卡片展示会议名称、发起人、参与人数等信息
|
||||
- ✅ 点击"加入会议"按钮能正确跳转
|
||||
|
||||
### 3. 多人加入测试
|
||||
- ✅ 创建者加入会议(主持人权限)
|
||||
- ✅ 其他成员通过卡片加入会议(普通权限)
|
||||
- ✅ 非聊天室成员无法加入
|
||||
|
||||
### 4. 异常情况测试
|
||||
- ✅ 消息发送失败不影响会议创建
|
||||
- ✅ 会议创建失败不会发送消息
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **消息类型**:messageType 为 "meet",而非 "meeting"(根据用户需求)
|
||||
|
||||
2. **权限控制**:
|
||||
- 只有聊天室成员才能创建会议
|
||||
- 只有聊天室成员才能加入会议
|
||||
|
||||
3. **事务处理**:
|
||||
- 会议创建在事务中(@Transactional)
|
||||
- 消息发送在独立的 try-catch,失败不回滚会议创建
|
||||
|
||||
4. **前端适配**:
|
||||
- Web端和小程序端需分别实现会议卡片渲染
|
||||
- 建议使用统一的样式和交互
|
||||
|
||||
5. **扩展性**:
|
||||
- contentExtra 可根据需要添加更多字段
|
||||
- 建议前端做好字段缺失的容错处理
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Jitsi Meet 视频会议集成总结](./项目总结-Jitsi-Meet视频会议功能.md)
|
||||
- [Docker 部署指南](./Jitsi-Meet-Docker部署指南.md)
|
||||
- [代码重构 - 视频会议API规范化](./代码重构-视频会议API规范化.md)
|
||||
|
||||
---
|
||||
|
||||
**实现人员**:Claude Code
|
||||
**审核状态**:待审核
|
||||
**版本**:v1.0
|
||||
475
docs/项目总结-Jitsi-Meet视频会议功能.md
Normal file
475
docs/项目总结-Jitsi-Meet视频会议功能.md
Normal file
@@ -0,0 +1,475 @@
|
||||
# 🎉 Jitsi Meet 视频会议功能 - 完整实现总结
|
||||
|
||||
## ✅ 已完成的工作
|
||||
|
||||
### 一、后端开发(Java Spring Boot)
|
||||
|
||||
#### 1. Service接口层(2个接口)
|
||||
- ✅ `VideoMeetingService.java` - 视频会议业务接口
|
||||
- ✅ `JitsiTokenService.java` - JWT Token服务接口
|
||||
|
||||
#### 2. Service实现层(2个实现)
|
||||
- ✅ `VideoMeetingServiceImpl.java` - 核心业务逻辑(约400行)
|
||||
- 会议创建(验证权限、生成JWT、构建iframe URL)
|
||||
- 会议权限验证(检查聊天室成员身份)
|
||||
- 用户专属URL生成(每个用户独立JWT Token)
|
||||
- 会议状态管理(开始/结束)
|
||||
- 活跃会议检测
|
||||
|
||||
- ✅ `JitsiTokenServiceImpl.java` - JWT Token生成服务
|
||||
- 符合Jitsi Meet标准的JWT生成
|
||||
- iframe URL构建(带配置参数)
|
||||
- 房间名生成规则
|
||||
|
||||
#### 3. Controller层(6个REST API)
|
||||
```
|
||||
POST /workcase/chat/meeting/create - 创建会议
|
||||
GET /workcase/chat/meeting/{meetingId} - 获取会议信息
|
||||
POST /workcase/chat/meeting/{meetingId}/join - 加入会议
|
||||
POST /workcase/chat/meeting/{meetingId}/start - 开始会议
|
||||
POST /workcase/chat/meeting/{meetingId}/end - 结束会议
|
||||
GET /workcase/chat/meeting/room/{roomId}/active - 获取活跃会议
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 二、前端Vue开发
|
||||
|
||||
#### 1. API封装
|
||||
- ✅ `meeting.ts` - 完整的会议API封装(6个方法)
|
||||
|
||||
#### 2. ChatRoom.vue组件增强
|
||||
- ✅ 添加roomId、workcaseId Props
|
||||
- ✅ 实现会议创建逻辑(handleStartMeeting)
|
||||
- ✅ 实现会议结束逻辑(handleEndMeeting)
|
||||
- ✅ 实现活跃会议检测(checkActiveMeeting)
|
||||
- ✅ 会议iframe显示(带头部控制栏)
|
||||
- ✅ 按钮状态管理(loading、disabled)
|
||||
|
||||
#### 3. 样式优化
|
||||
- ✅ 会议容器样式(渐变头部)
|
||||
- ✅ 关闭按钮样式(半透明效果)
|
||||
- ✅ 按钮禁用状态
|
||||
|
||||
---
|
||||
|
||||
### 三、前端UniApp开发
|
||||
|
||||
#### 1. MeetingView.uvue页面
|
||||
- ✅ 自定义导航栏
|
||||
- ✅ web-view全屏显示
|
||||
- ✅ 结束会议功能
|
||||
- ✅ 退出确认弹窗
|
||||
|
||||
#### 2. chatRoom.uvue修改
|
||||
- ✅ 更新startMeeting函数(调用API创建会议)
|
||||
- ✅ 跳转到MeetingView页面
|
||||
|
||||
#### 3. workcaseChat.ts API
|
||||
- ✅ 添加6个会议相关API方法
|
||||
|
||||
---
|
||||
|
||||
### 四、Docker部署配置
|
||||
|
||||
#### 1. docker-compose.yml
|
||||
- ✅ 添加4个Jitsi服务(web、prosody、jicofo、jvb)
|
||||
- ✅ 配置JWT认证
|
||||
- ✅ 端口映射(8280、8443、10000/udp)
|
||||
- ✅ 数据持久化(.data/docker/jitsi)
|
||||
- ✅ 健康检查配置
|
||||
- ✅ 服务依赖关系
|
||||
|
||||
#### 2. application-dev.yml
|
||||
- ✅ 添加jitsi配置节
|
||||
- ✅ JWT密钥配置
|
||||
- ✅ 服务器地址配置
|
||||
- ✅ Token有效期配置
|
||||
|
||||
---
|
||||
|
||||
### 五、文档和脚本
|
||||
|
||||
#### 1. 文档
|
||||
- ✅ `Jitsi-Meet本地部署指南.md` - npm start方式(开发测试)
|
||||
- ✅ `Jitsi-Meet-Docker部署指南.md` - Docker方式(生产推荐)
|
||||
- ✅ `项目总结.md` - 本文档
|
||||
|
||||
#### 2. 启动脚本
|
||||
- ✅ `启动Jitsi视频会议服务.bat` - 一键启动
|
||||
- ✅ `停止Jitsi视频会议服务.bat` - 一键停止
|
||||
|
||||
---
|
||||
|
||||
## 📂 修改的文件清单
|
||||
|
||||
### 后端(6个文件)
|
||||
```
|
||||
✅ VideoMeetingService.java (新建)
|
||||
F:\Project\urbanLifeline\urbanLifelineServ\apis\api-workcase\src\main\java\org\xyzh\api\workcase\service\
|
||||
|
||||
✅ JitsiTokenService.java (新建)
|
||||
F:\Project\urbanLifeline\urbanLifelineServ\apis\api-workcase\src\main\java\org\xyzh\api\workcase\service\
|
||||
|
||||
✅ VideoMeetingServiceImpl.java (新建)
|
||||
F:\Project\urbanLifeline\urbanLifelineServ\workcase\src\main\java\org\xyzh\workcase\service\
|
||||
|
||||
✅ JitsiTokenServiceImpl.java (新建)
|
||||
F:\Project\urbanLifeline\urbanLifelineServ\workcase\src\main\java\org\xyzh\workcase\service\
|
||||
|
||||
✅ WorkcaseChatContorller.java (修改:添加会议接口)
|
||||
F:\Project\urbanLifeline\urbanLifelineServ\workcase\src\main\java\org\xyzh\workcase\controller\
|
||||
|
||||
✅ application-dev.yml (修改:添加jitsi配置)
|
||||
F:\Project\urbanLifeline\urbanLifelineServ\workcase\src\main\resources\
|
||||
```
|
||||
|
||||
### 前端Vue(3个文件)
|
||||
```
|
||||
✅ meeting.ts (新建)
|
||||
F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase\src\api\workcase\
|
||||
|
||||
✅ ChatRoom.vue (修改:添加会议功能)
|
||||
F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase\src\components\chatRoom\chatRoom\
|
||||
|
||||
✅ ChatRoom.scss (修改:添加会议样式)
|
||||
F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase\src\components\chatRoom\chatRoom\
|
||||
```
|
||||
|
||||
### 前端UniApp(3个文件)
|
||||
```
|
||||
✅ MeetingView.uvue (新建)
|
||||
F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase_wechat\pages\meeting\MeetingView\
|
||||
|
||||
✅ chatRoom.uvue (修改:更新startMeeting)
|
||||
F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase_wechat\pages\chatRoom\chatRoom\
|
||||
|
||||
✅ workcaseChat.ts (修改:添加会议API)
|
||||
F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase_wechat\api\workcase\
|
||||
```
|
||||
|
||||
### Docker配置(2个文件)
|
||||
```
|
||||
✅ docker-compose.yml (修改:添加4个Jitsi服务)
|
||||
F:\Project\urbanLifeline\urbanLifelineServ\.bin\docker\urbanlifeline\
|
||||
|
||||
✅ application-dev.yml (修改:添加jitsi配置)
|
||||
F:\Project\urbanLifeline\urbanLifelineServ\workcase\src\main\resources\
|
||||
```
|
||||
|
||||
### 文档和脚本(5个文件)
|
||||
```
|
||||
✅ Jitsi-Meet本地部署指南.md
|
||||
✅ Jitsi-Meet-Docker部署指南.md
|
||||
✅ 项目总结.md
|
||||
✅ 启动Jitsi视频会议服务.bat
|
||||
✅ 停止Jitsi视频会议服务.bat
|
||||
```
|
||||
|
||||
**总计:19个文件(12个新建,7个修改)**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始(3步搞定)
|
||||
|
||||
### 步骤1:启动Jitsi Meet服务
|
||||
|
||||
**方法A:使用启动脚本(推荐)**
|
||||
```bash
|
||||
# 双击运行
|
||||
F:\Project\urbanLifeline\启动Jitsi视频会议服务.bat
|
||||
```
|
||||
|
||||
**方法B:手动启动**
|
||||
```bash
|
||||
cd F:\Project\urbanLifeline\urbanLifelineServ\.bin\docker\urbanlifeline
|
||||
docker-compose up -d jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb
|
||||
```
|
||||
|
||||
**验证**:浏览器打开 http://localhost:8280/
|
||||
|
||||
---
|
||||
|
||||
### 步骤2:启动Java后端
|
||||
|
||||
```bash
|
||||
# 确保配置正确
|
||||
# application-dev.yml 中的 jitsi 配置已自动添加
|
||||
|
||||
# 启动后端服务
|
||||
# 使用你的IDE或命令行启动 workcase-service
|
||||
```
|
||||
|
||||
**验证**:访问 http://localhost:8180/swagger-ui.html 查看会议接口
|
||||
|
||||
---
|
||||
|
||||
### 步骤3:启动前端并测试
|
||||
|
||||
#### Vue前端测试
|
||||
```bash
|
||||
# 启动Vue前端
|
||||
npm run dev
|
||||
|
||||
# 在聊天室页面点击"发起会议"按钮
|
||||
# 应该能看到Jitsi Meet会议界面
|
||||
```
|
||||
|
||||
#### UniApp测试
|
||||
```bash
|
||||
# 使用HBuilderX打开项目
|
||||
# 运行到小程序/App
|
||||
# 在聊天室点击"发起会议"
|
||||
# 跳转到全屏会议页面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 核心特性
|
||||
|
||||
### 1. 安全性
|
||||
- ✅ JWT Token身份验证(每个用户独立Token)
|
||||
- ✅ 聊天室成员权限校验
|
||||
- ✅ Token有效期控制(2小时)
|
||||
- ✅ 主持人权限区分(客服=主持人)
|
||||
|
||||
### 2. 用户体验
|
||||
- ✅ 页面刷新后会议不丢失(活跃会议检测)
|
||||
- ✅ 会议创建loading状态
|
||||
- ✅ 按钮禁用状态(会议进行中不可重复创建)
|
||||
- ✅ 会议头部控制栏(显示状态+关闭按钮)
|
||||
- ✅ UniApp独立会议页面(可切换聊天/会议)
|
||||
|
||||
### 3. 扩展性
|
||||
- ✅ 支持主持人权限
|
||||
- ✅ 支持Jitsi配置项扩展
|
||||
- ✅ 预留会议参与者记录功能
|
||||
- ✅ Docker部署,易于扩展
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 重要配置说明
|
||||
|
||||
### JWT密钥配置
|
||||
|
||||
**Docker配置**(docker-compose.yml):
|
||||
```yaml
|
||||
JWT_APP_ID: urbanLifeline
|
||||
JWT_APP_SECRET: your-secret-key-change-in-production
|
||||
```
|
||||
|
||||
**Java后端配置**(application-dev.yml):
|
||||
```yaml
|
||||
jitsi:
|
||||
app:
|
||||
id: urbanLifeline # 必须与Docker一致
|
||||
secret: your-secret-key-change-in-production # 必须与Docker一致
|
||||
```
|
||||
|
||||
⚠️ **生产环境必须修改密钥**:
|
||||
1. 生成强随机字符串(至少32位)
|
||||
2. 同时修改Docker和Java配置
|
||||
3. 重启Jitsi服务和Java后端
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 1. 测试Jitsi服务
|
||||
```bash
|
||||
# 访问Jitsi主页
|
||||
http://localhost:8280/
|
||||
|
||||
# 测试创建房间(应被拦截,需要JWT)
|
||||
http://localhost:8280/test-room
|
||||
```
|
||||
|
||||
### 2. 测试后端接口
|
||||
```bash
|
||||
# 使用Postman或curl测试
|
||||
POST http://localhost:8180/urban-lifeline/workcase/chat/meeting/create
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <your-token>
|
||||
|
||||
{
|
||||
"roomId": "test-room-123",
|
||||
"workcaseId": "WC001",
|
||||
"meetingName": "测试会议",
|
||||
"maxParticipants": 10
|
||||
}
|
||||
|
||||
# 响应应包含带JWT的iframeUrl
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"meetingId": "xxx",
|
||||
"iframeUrl": "http://localhost:8280/workcase_WC001_xxx?jwt=eyJhbGc..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 测试前端集成
|
||||
- Vue: 在聊天室点击"发起会议"
|
||||
- UniApp: 在聊天室点击"发起会议"
|
||||
- 验证会议iframe能正常显示
|
||||
- 验证多人能同时加入会议
|
||||
|
||||
---
|
||||
|
||||
## 🔧 常见问题
|
||||
|
||||
### Q1: Docker启动失败?
|
||||
**A**: 检查端口占用和Docker状态
|
||||
```bash
|
||||
# 检查Docker
|
||||
docker info
|
||||
|
||||
# 检查端口
|
||||
netstat -ano | findstr "8280"
|
||||
netstat -ano | findstr "10000"
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs jitsi-web
|
||||
```
|
||||
|
||||
### Q2: JWT验证失败?
|
||||
**A**: 确保密钥一致
|
||||
```bash
|
||||
# 检查Docker配置
|
||||
docker-compose exec jitsi-prosody cat /config/prosody.cfg.lua | grep jwt
|
||||
|
||||
# 检查Java配置
|
||||
cat application-dev.yml | grep -A 5 "jitsi:"
|
||||
|
||||
# 密钥必须完全一致
|
||||
```
|
||||
|
||||
### Q3: 视频连接不上?
|
||||
**A**: 检查UDP端口10000
|
||||
```bash
|
||||
# Windows防火墙
|
||||
netsh advfirewall firewall add rule name="Jitsi JVB" dir=in action=allow protocol=UDP localport=10000
|
||||
|
||||
# 或临时关闭防火墙测试
|
||||
```
|
||||
|
||||
### Q4: 前端iframe显示空白?
|
||||
**A**: 检查跨域和URL
|
||||
```bash
|
||||
# 浏览器控制台查看错误
|
||||
# 确保iframeUrl是http://localhost:8280开头
|
||||
# 检查JWT Token是否正确生成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 项目结构
|
||||
|
||||
```
|
||||
urbanLifeline/
|
||||
├── .data/docker/jitsi/ # Jitsi数据目录
|
||||
│ ├── web/
|
||||
│ ├── prosody/
|
||||
│ ├── jicofo/
|
||||
│ └── jvb/
|
||||
├── docs/ # 文档目录
|
||||
│ ├── Jitsi-Meet本地部署指南.md
|
||||
│ ├── Jitsi-Meet-Docker部署指南.md
|
||||
│ └── 项目总结.md
|
||||
├── urbanLifelineServ/
|
||||
│ ├── .bin/docker/urbanlifeline/
|
||||
│ │ └── docker-compose.yml # 包含Jitsi配置
|
||||
│ ├── apis/api-workcase/
|
||||
│ │ └── src/main/java/org/xyzh/api/workcase/
|
||||
│ │ └── service/
|
||||
│ │ ├── VideoMeetingService.java
|
||||
│ │ └── JitsiTokenService.java
|
||||
│ └── workcase/
|
||||
│ ├── src/main/java/org/xyzh/workcase/
|
||||
│ │ ├── controller/WorkcaseChatContorller.java
|
||||
│ │ └── service/
|
||||
│ │ ├── VideoMeetingServiceImpl.java
|
||||
│ │ └── JitsiTokenServiceImpl.java
|
||||
│ └── src/main/resources/
|
||||
│ └── application-dev.yml
|
||||
├── urbanLifelineWeb/
|
||||
│ └── packages/
|
||||
│ ├── workcase/
|
||||
│ │ └── src/
|
||||
│ │ ├── api/workcase/meeting.ts
|
||||
│ │ └── components/chatRoom/chatRoom/
|
||||
│ │ ├── ChatRoom.vue
|
||||
│ │ └── ChatRoom.scss
|
||||
│ └── workcase_wechat/
|
||||
│ ├── api/workcase/workcaseChat.ts
|
||||
│ └── pages/
|
||||
│ ├── chatRoom/chatRoom/chatRoom.uvue
|
||||
│ └── meeting/MeetingView/MeetingView.uvue
|
||||
├── 启动Jitsi视频会议服务.bat
|
||||
└── 停止Jitsi视频会议服务.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步建议
|
||||
|
||||
### 短期优化
|
||||
1. **会议录制功能**
|
||||
- 配置Jibri录制组件
|
||||
- 存储录制文件到MinIO
|
||||
- 提供录制回放功能
|
||||
|
||||
2. **会议统计**
|
||||
- 记录参与者加入/离开时间
|
||||
- 生成会议时长报告
|
||||
- 导出会议数据
|
||||
|
||||
3. **界面优化**
|
||||
- 自定义Jitsi Meet界面主题
|
||||
- 添加品牌Logo
|
||||
- 优化移动端体验
|
||||
|
||||
### 长期规划
|
||||
1. **生产部署**
|
||||
- 配置域名和SSL证书
|
||||
- 配置公网IP和防火墙
|
||||
- 负载均衡和高可用
|
||||
|
||||
2. **功能增强**
|
||||
- 屏幕共享优化
|
||||
- 虚拟背景
|
||||
- 会议白板功能
|
||||
- AI转录字幕
|
||||
|
||||
3. **监控告警**
|
||||
- Prometheus监控
|
||||
- Grafana仪表板
|
||||
- 告警通知
|
||||
|
||||
---
|
||||
|
||||
## ✨ 总结
|
||||
|
||||
### 已实现功能
|
||||
✅ 完整的Jitsi Meet视频会议功能
|
||||
✅ JWT认证和权限控制
|
||||
✅ Docker一键部署
|
||||
✅ 前后端完整集成
|
||||
✅ Vue和UniApp双端支持
|
||||
✅ 详细的文档和脚本
|
||||
|
||||
### 技术亮点
|
||||
🌟 每个用户独立JWT Token(安全性高)
|
||||
🌟 活跃会议检测(用户体验好)
|
||||
🌟 Docker部署(易于维护)
|
||||
🌟 微服务架构(易于扩展)
|
||||
|
||||
### 项目价值
|
||||
💡 真正的企业级视频会议解决方案
|
||||
💡 完全自主可控,无第三方依赖
|
||||
💡 可扩展性强,支持二次开发
|
||||
💡 开发效率高,5分钟即可部署
|
||||
|
||||
---
|
||||
|
||||
**开发完成!祝使用愉快!** 🎉
|
||||
Submodule jitsi-meet deleted from 6549d47233
@@ -121,17 +121,20 @@ CREATE TABLE workcase.tb_video_meeting(
|
||||
workcase_id VARCHAR(50) NOT NULL, -- 关联工单ID
|
||||
meeting_name VARCHAR(200) NOT NULL, -- 会议名称
|
||||
meeting_password VARCHAR(50) DEFAULT NULL, -- 会议密码(可选)
|
||||
description VARCHAR(500) DEFAULT NULL, -- 会议模式
|
||||
jwt_token TEXT DEFAULT NULL, -- JWT Token(用于身份验证)
|
||||
jitsi_room_name VARCHAR(200) NOT NULL, -- Jitsi房间名(格式:workcase_{workcase_id}_{timestamp})
|
||||
jitsi_server_url VARCHAR(500) NOT NULL DEFAULT 'https://meet.jit.si', -- Jitsi服务器地址
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'scheduled', -- 状态:scheduled-已安排 ongoing-进行中 ended-已结束 cancelled-已取消
|
||||
creator_id VARCHAR(50) NOT NULL, -- 创建者ID
|
||||
creator_type VARCHAR(20) NOT NULL, -- 创建者类型:guest-来客 agent-客服
|
||||
creator_name VARCHAR(100) NOT NULL, -- 创建者名称
|
||||
participant_count INTEGER NOT NULL DEFAULT 0, -- 参与人数
|
||||
max_participants INTEGER DEFAULT 10, -- 最大参与人数
|
||||
start_time TIMESTAMPTZ DEFAULT NULL, -- 实际开始时间
|
||||
end_time TIMESTAMPTZ DEFAULT NULL, -- 实际结束时间
|
||||
start_time TIMESTAMPTZ NOT NULL, -- 定义会议开始时间
|
||||
end_time TIMESTAMPTZ NOT NULL, -- 定义会议结束时间
|
||||
advance INTEGER DEFAULT 5, -- 提前入会时间(分钟)
|
||||
actual_start_time TIMESTAMPTZ DEFAULT NULL, -- 真正会议开始时间
|
||||
actual_end_time TIMESTAMPTZ DEFAULT NULL, -- 真正会议结束时间
|
||||
duration_seconds INTEGER DEFAULT 0, -- 会议时长(秒)
|
||||
iframe_url TEXT DEFAULT NULL, -- iframe嵌入URL(生成后存储)
|
||||
config JSONB DEFAULT NULL, -- Jitsi配置项(自定义配置)
|
||||
|
||||
@@ -47,7 +47,8 @@ INSERT INTO sys.tb_sys_module (
|
||||
('MODULE-0008', 'module_agent', '智能体', '智能体管理', 'system', NULL, now(), false),
|
||||
('MODULE-0005', 'module_knowledge', '知识库', '知识文档管理', 'system', NULL, now(), false),
|
||||
('MODULE-0006', 'module_bidding', '招投标', '招投标业务管理', 'system', NULL, now(), false),
|
||||
('MODULE-0007', 'module_workcase', '智能客服', '客服工单管理', 'system', NULL, now(), false);
|
||||
('MODULE-0007', 'module_workcase', '智能客服', '客服工单管理', 'system', NULL, now(), false),
|
||||
('MODULE-0009', 'module_meeting', '视频会议', 'Jitsi Meet视频会议管理', 'system', NULL, now(), false);
|
||||
|
||||
-- =============================
|
||||
-- 4. 初始化系统权限
|
||||
@@ -161,7 +162,14 @@ INSERT INTO sys.tb_sys_permission (
|
||||
('PERM-0722', 'perm_workcase_ticket_update', '工单更新', 'workcase:ticket:update', '更新工单', 'module_workcase', true, 'system', NULL, now(), false),
|
||||
('PERM-0723', 'perm_workcase_ticket_view', '工单查看', 'workcase:ticket:view', '查看工单详情和列表', 'module_workcase', true, 'system', NULL, now(), false),
|
||||
('PERM-0724', 'perm_workcase_ticket_process', '工单处理', 'workcase:ticket:process', '工单处理过程管理', 'module_workcase', true, 'system', NULL, now(), false),
|
||||
('PERM-0725', 'perm_workcase_ticket_device', '工单设备', 'workcase:ticket:device', '工单设备管理', 'module_workcase', true, 'system', NULL, now(), false);
|
||||
('PERM-0725', 'perm_workcase_ticket_device', '工单设备', 'workcase:ticket:device', '工单设备管理', 'module_workcase', true, 'system', NULL, now(), false),
|
||||
|
||||
-- 视频会议模块权限(Jitsi Meet)
|
||||
('PERM-0730', 'perm_meeting_create', '创建会议', 'meeting:create:own', '创建视频会议', 'module_meeting', true, 'system', NULL, now(), false),
|
||||
('PERM-0731', 'perm_meeting_join', '加入会议', 'meeting:join:any', '加入视频会议', 'module_meeting', true, 'system', NULL, now(), false),
|
||||
('PERM-0732', 'perm_meeting_url', '获取会议链接', 'meeting:url:any', '获取会议加入链接', 'module_meeting', true, 'system', NULL, now(), false),
|
||||
('PERM-0733', 'perm_meeting_token', '获取会议令牌', 'meeting:token:any', '获取会议参与令牌', 'module_meeting', true, 'system', NULL, now(), false);
|
||||
|
||||
-- =============================
|
||||
-- 5. 初始化视图(菜单)
|
||||
-- =============================
|
||||
@@ -332,7 +340,12 @@ INSERT INTO sys.tb_sys_role_permission (
|
||||
('RP-U-0013', 'role_user', 'perm_file_upload', 'system', NULL, now(), false),
|
||||
('RP-U-0014', 'role_user', 'perm_file_download', 'system', NULL, now(), false),
|
||||
('RP-U-0015', 'role_user', 'perm_message_view', 'system', NULL, now(), false),
|
||||
('RP-U-0016', 'role_user', 'perm_config_view', 'system', NULL, now(), false);
|
||||
('RP-U-0016', 'role_user', 'perm_config_view', 'system', NULL, now(), false),
|
||||
--- 视频会议权限
|
||||
('RP-U-0050', 'role_user', 'perm_meeting_create', 'system', NULL, now(), false),
|
||||
('RP-U-0051', 'role_user', 'perm_meeting_join', 'system', NULL, now(), false),
|
||||
('RP-U-0052', 'role_user', 'perm_meeting_url', 'system', NULL, now(), false),
|
||||
('RP-U-0053', 'role_user', 'perm_meeting_token', 'system', NULL, now(), false);
|
||||
|
||||
-- 访客权限(基础菜单 + workcase聊天和工单全部接口权限)
|
||||
INSERT INTO sys.tb_sys_role_permission (
|
||||
@@ -366,7 +379,12 @@ INSERT INTO sys.tb_sys_role_permission (
|
||||
('RP-G-0042', 'role_guest', 'perm_workcase_ticket_update', 'system', NULL, now(), false),
|
||||
('RP-G-0043', 'role_guest', 'perm_workcase_ticket_view', 'system', NULL, now(), false),
|
||||
('RP-G-0044', 'role_guest', 'perm_workcase_ticket_process', 'system', NULL, now(), false),
|
||||
('RP-G-0045', 'role_guest', 'perm_workcase_ticket_device', 'system', NULL, now(), false);
|
||||
('RP-G-0045', 'role_guest', 'perm_workcase_ticket_device', 'system', NULL, now(), false),
|
||||
--- 视频会议权限
|
||||
('RP-G-0050', 'role_guest', 'perm_meeting_create', 'system', NULL, now(), false),
|
||||
('RP-G-0051', 'role_guest', 'perm_meeting_join', 'system', NULL, now(), false),
|
||||
('RP-G-0052', 'role_guest', 'perm_meeting_url', 'system', NULL, now(), false),
|
||||
('RP-G-0053', 'role_guest', 'perm_meeting_token', 'system', NULL, now(), false);
|
||||
|
||||
-- =============================
|
||||
-- 7. 视图权限关联
|
||||
|
||||
@@ -74,24 +74,249 @@ services:
|
||||
# 管理员账户配置
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin123
|
||||
|
||||
|
||||
# Console 地址配置
|
||||
MINIO_CONSOLE_ADDRESS: ":9001"
|
||||
MINIO_ADDRESS: ":9000"
|
||||
|
||||
|
||||
# 时区设置
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
|
||||
volumes:
|
||||
# 数据持久化到主机目录
|
||||
- ../../../.data/docker/minio/data:/data
|
||||
- ../../../.data/docker/minio/config:/root/.minio
|
||||
|
||||
|
||||
command: server /data --console-address ":9001"
|
||||
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# ====================== Jitsi Meet 视频会议服务 ======================
|
||||
|
||||
jitsi-web:
|
||||
image: jitsi/web:stable-9584
|
||||
container_name: urban-lifeline-jitsi-web
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- urban-lifeline
|
||||
ports:
|
||||
- "8280:80" # 保留原 HTTP 端口
|
||||
- "8443:443" # 保留原 HTTPS 端口(仅保留映射,实际禁用 HTTPS)
|
||||
environment:
|
||||
# 基础配置(局域网访问)
|
||||
TZ: Asia/Shanghai
|
||||
PUBLIC_URL: http://192.168.0.253:8280
|
||||
|
||||
# 核心修复:解决 WebSocket 协议/URL 错误(局域网 IP)
|
||||
BOSH_URL_BASE: http://jitsi-prosody:5280
|
||||
WEBSOCKET_URL: ws://192.168.0.253:8280/xmpp-websocket
|
||||
|
||||
# XMPP 配置(完全保留原设置)
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_AUTH_DOMAIN: auth.meet.jitsi
|
||||
XMPP_BOSH_URL_BASE: http://jitsi-prosody:5280
|
||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||
XMPP_GUEST_DOMAIN: guest.meet.jitsi
|
||||
|
||||
# Jicofo 配置(完全保留原设置)
|
||||
JICOFO_COMPONENT_SECRET: jicofo-secret
|
||||
JICOFO_AUTH_USER: focus
|
||||
|
||||
# JVB 配置(完全保留原设置)
|
||||
JVB_AUTH_USER: jvb
|
||||
JVB_AUTH_PASSWORD: jvb-password
|
||||
|
||||
# JWT 认证配置(完全保留原设置)
|
||||
ENABLE_AUTH: 1
|
||||
ENABLE_GUESTS: 1
|
||||
AUTH_TYPE: jwt
|
||||
JWT_APP_ID: urbanLifeline
|
||||
JWT_APP_SECRET: urbanLifelinejitsi
|
||||
JWT_ACCEPTED_ISSUERS: urbanLifeline
|
||||
JWT_ACCEPTED_AUDIENCES: jitsi
|
||||
JWT_ASAP_KEYSERVER: https://192.168.0.253:8280/
|
||||
JWT_ALLOW_EMPTY: 0
|
||||
JWT_AUTH_TYPE: token
|
||||
JWT_TOKEN_AUTH_MODULE: token_verification
|
||||
|
||||
# 界面/功能配置(完全保留原设置)
|
||||
ENABLE_RECORDING: 0
|
||||
ENABLE_TRANSCRIPTIONS: 0
|
||||
ENABLE_SUBDOMAINS: 0
|
||||
ENABLE_XMPP_WEBSOCKET: 1
|
||||
ENABLE_SCTP: 1
|
||||
DISABLE_HTTPS: 1
|
||||
|
||||
# 日志/HTTPS 配置
|
||||
ENABLE_LETSENCRYPT: 0
|
||||
LETSENCRYPT_DOMAIN: 192.168.0.253
|
||||
|
||||
volumes:
|
||||
- ../../../.data/docker/jitsi/web:/config
|
||||
- ../../../.data/docker/jitsi/web/crontabs:/var/spool/cron/crontabs
|
||||
- ../../../.data/docker/jitsi/transcripts:/usr/share/jitsi-meet/transcripts
|
||||
depends_on:
|
||||
- jitsi-prosody
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# XMPP 服务(Prosody)- 完全保留原配置
|
||||
jitsi-prosody:
|
||||
image: jitsi/prosody:stable-9584
|
||||
container_name: urban-lifeline-jitsi-prosody
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- urban-lifeline
|
||||
expose:
|
||||
- "5222" # XMPP客户端连接(内部)
|
||||
- "5347" # XMPP组件连接(内部)
|
||||
- "5280" # BOSH/WebSocket(内部)
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
# XMPP域配置(完全保留)
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_AUTH_DOMAIN: auth.meet.jitsi
|
||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||
XMPP_GUEST_DOMAIN: guest.meet.jitsi
|
||||
|
||||
# Jicofo组件密钥(完全保留)
|
||||
JICOFO_COMPONENT_SECRET: jicofo-secret
|
||||
JICOFO_AUTH_USER: focus
|
||||
JICOFO_AUTH_PASSWORD: focus-password
|
||||
|
||||
# JVB认证(完全保留)
|
||||
JVB_AUTH_USER: jvb
|
||||
JVB_AUTH_PASSWORD: jvb-password
|
||||
|
||||
# JWT认证(完全保留)
|
||||
ENABLE_AUTH: 1
|
||||
ENABLE_GUESTS: 1
|
||||
AUTH_TYPE: jwt
|
||||
JWT_APP_ID: urbanLifeline
|
||||
JWT_APP_SECRET: urbanLifelinejitsi
|
||||
JWT_ACCEPTED_ISSUERS: urbanLifeline
|
||||
JWT_ACCEPTED_AUDIENCES: jitsi
|
||||
JWT_ALLOW_EMPTY: 0
|
||||
JWT_AUTH_TYPE: token
|
||||
JWT_TOKEN_AUTH_MODULE: token_verification
|
||||
|
||||
# 日志配置(完全保留)
|
||||
LOG_LEVEL: info
|
||||
|
||||
# 公共URL(局域网访问)
|
||||
PUBLIC_URL: http://192.168.0.253:8280
|
||||
|
||||
volumes:
|
||||
- ../../../.data/docker/jitsi/prosody/config:/config
|
||||
- ../../../.data/docker/jitsi/prosody/prosody-plugins-custom:/prosody-plugins-custom
|
||||
healthcheck:
|
||||
test: ["CMD", "prosodyctl", "status"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
|
||||
# 会议焦点控制器(Jicofo)- 完全保留原配置
|
||||
jitsi-jicofo:
|
||||
image: jitsi/jicofo:stable-9584
|
||||
container_name: urban-lifeline-jitsi-jicofo
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- urban-lifeline
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
# XMPP配置(完全保留)
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_AUTH_DOMAIN: auth.meet.jitsi
|
||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||
XMPP_SERVER: jitsi-prosody
|
||||
|
||||
# Jicofo认证(完全保留)
|
||||
JICOFO_COMPONENT_SECRET: jicofo-secret
|
||||
JICOFO_AUTH_USER: focus
|
||||
JICOFO_AUTH_PASSWORD: focus-password
|
||||
|
||||
# JWT配置(完全保留)
|
||||
AUTH_TYPE: jwt
|
||||
|
||||
# JVB配置(完全保留)
|
||||
JVB_BREWERY_MUC: jvbbrewery
|
||||
|
||||
# 日志级别(完全保留)
|
||||
JICOFO_ENABLE_HEALTH_CHECKS: "true"
|
||||
|
||||
volumes:
|
||||
- ../../../.data/docker/jitsi/jicofo:/config
|
||||
depends_on:
|
||||
- jitsi-prosody
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8888/about/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
|
||||
# 视频桥接服务(JVB)- 仅修复 WebSocket 相关,保留IP/端口
|
||||
jitsi-jvb:
|
||||
image: jitsi/jvb:stable-9584
|
||||
container_name: urban-lifeline-jitsi-jvb
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- urban-lifeline
|
||||
ports:
|
||||
- "10000:10000/udp" # 保留原 UDP 端口
|
||||
- "4443:4443/tcp" # 保留原 TCP 端口
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
# XMPP配置(完全保留)
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_AUTH_DOMAIN: auth.meet.jitsi
|
||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||
XMPP_SERVER: jitsi-prosody
|
||||
|
||||
# JVB认证(完全保留)
|
||||
JVB_AUTH_USER: jvb
|
||||
JVB_AUTH_PASSWORD: jvb-password
|
||||
|
||||
# JVB配置(完全保留)
|
||||
JVB_BREWERY_MUC: jvbbrewery
|
||||
JVB_PORT: 10000
|
||||
JVB_STUN_SERVERS: stun.l.google.com:19302,stun1.l.google.com:19302
|
||||
|
||||
# 本地IP配置(局域网IP - 关键配置!)
|
||||
DOCKER_HOST_ADDRESS: 192.168.0.253
|
||||
JVB_ADVERTISE_IPS: 192.168.0.253
|
||||
|
||||
# 启用统计(完全保留)
|
||||
JVB_ENABLE_APIS: rest,colibri
|
||||
|
||||
# 性能优化(完全保留)
|
||||
JVB_TCP_HARVESTER_DISABLED: "false"
|
||||
JVB_TCP_PORT: 4443
|
||||
JVB_TCP_MAPPED_PORT: 4443
|
||||
|
||||
volumes:
|
||||
- ../../../.data/docker/jitsi/jvb:/config
|
||||
depends_on:
|
||||
- jitsi-prosody
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/about/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
|
||||
@@ -34,6 +34,9 @@ public class TbVideoMeetingDTO extends BaseDTO {
|
||||
@Schema(description = "会议密码")
|
||||
private String meetingPassword;
|
||||
|
||||
@Schema(description = "会议模式")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "JWT Token")
|
||||
private String jwtToken;
|
||||
|
||||
@@ -46,9 +49,6 @@ public class TbVideoMeetingDTO extends BaseDTO {
|
||||
@Schema(description = "状态:scheduled-已安排 ongoing-进行中 ended-已结束 cancelled-已取消")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "创建者ID")
|
||||
private String creatorId;
|
||||
|
||||
@Schema(description = "创建者类型:guest-来客 agent-客服")
|
||||
private String creatorType;
|
||||
|
||||
@@ -61,6 +61,15 @@ public class TbVideoMeetingDTO extends BaseDTO {
|
||||
@Schema(description = "最大参与人数")
|
||||
private Integer maxParticipants;
|
||||
|
||||
@Schema(description = "定义会议开始时间")
|
||||
private Date startTime;
|
||||
|
||||
@Schema(description = "定义会议结束时间")
|
||||
private Date endTime;
|
||||
|
||||
@Schema(description = "提前入会时间(分钟)")
|
||||
private Integer advance;
|
||||
|
||||
@Schema(description = "实际开始时间")
|
||||
private Date actualStartTime;
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
package org.xyzh.api.workcase.service;
|
||||
|
||||
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
|
||||
import org.xyzh.api.workcase.dto.TbMeetingParticipantDTO;
|
||||
import org.xyzh.api.workcase.dto.TbMeetingTranscriptionDTO;
|
||||
import org.xyzh.api.workcase.vo.VideoMeetingVO;
|
||||
import org.xyzh.api.workcase.vo.MeetingParticipantVO;
|
||||
import org.xyzh.api.workcase.vo.MeetingTranscriptionVO;
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.core.page.PageRequest;
|
||||
|
||||
/**
|
||||
* @description 视频会议服务接口,管理Jitsi Meet会议、参与者和转录
|
||||
* @filename MeetService.java
|
||||
* @author cascade
|
||||
* @copyright xyzh
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
public interface MeetService {
|
||||
|
||||
// ========================= 会议管理 ==========================
|
||||
|
||||
/**
|
||||
* @description 创建视频会议
|
||||
* @param meeting 会议信息
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<TbVideoMeetingDTO> createMeeting(TbVideoMeetingDTO meeting);
|
||||
|
||||
/**
|
||||
* @description 更新会议信息
|
||||
* @param meeting 会议信息
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<TbVideoMeetingDTO> updateMeeting(TbVideoMeetingDTO meeting);
|
||||
|
||||
/**
|
||||
* @description 开始会议
|
||||
* @param meetingId 会议ID
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<TbVideoMeetingDTO> startMeeting(String meetingId);
|
||||
|
||||
/**
|
||||
* @description 结束会议
|
||||
* @param meetingId 会议ID
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<TbVideoMeetingDTO> endMeeting(String meetingId);
|
||||
|
||||
/**
|
||||
* @description 删除会议
|
||||
* @param meetingId 会议ID
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<Boolean> deleteMeeting(String meetingId);
|
||||
|
||||
/**
|
||||
* @description 根据ID获取会议
|
||||
* @param meetingId 会议ID
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<TbVideoMeetingDTO> getMeetingById(String meetingId);
|
||||
|
||||
/**
|
||||
* @description 获取会议列表/分页
|
||||
* @param pageRequest 分页请求
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<VideoMeetingVO> getMeetingPage(PageRequest<TbVideoMeetingDTO> pageRequest);
|
||||
|
||||
/**
|
||||
* @description 生成会议加入链接/iframe URL
|
||||
* @param meetingId 会议ID
|
||||
* @param userId 用户ID
|
||||
* @param userName 用户名称
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<String> generateMeetingJoinUrl(String meetingId, String userId, String userName);
|
||||
|
||||
/**
|
||||
* @description 生成会议JWT Token
|
||||
* @param meetingId 会议ID
|
||||
* @param userId 用户ID
|
||||
* @param isModerator 是否主持人
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<String> generateMeetingToken(String meetingId, String userId, boolean isModerator);
|
||||
|
||||
// ========================= 参与者管理 ==========================
|
||||
|
||||
/**
|
||||
* @description 参与者加入会议
|
||||
* @param participant 参与者信息
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<TbMeetingParticipantDTO> joinMeeting(TbMeetingParticipantDTO participant);
|
||||
|
||||
/**
|
||||
* @description 参与者离开会议
|
||||
* @param participantId 参与者ID
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<Boolean> leaveMeeting(String participantId);
|
||||
|
||||
/**
|
||||
* @description 获取会议参与者列表
|
||||
* @param meetingId 会议ID
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<MeetingParticipantVO> getMeetingParticipantList(String meetingId);
|
||||
|
||||
/**
|
||||
* @description 更新参与者信息
|
||||
* @param participant 参与者信息
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<TbMeetingParticipantDTO> updateParticipant(TbMeetingParticipantDTO participant);
|
||||
|
||||
/**
|
||||
* @description 设置参与者为主持人
|
||||
* @param participantId 参与者ID
|
||||
* @param isModerator 是否主持人
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<Boolean> setModerator(String participantId, boolean isModerator);
|
||||
|
||||
// ========================= 转录管理 ==========================
|
||||
|
||||
/**
|
||||
* @description 添加转录记录
|
||||
* @param transcription 转录内容
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<TbMeetingTranscriptionDTO> addTranscription(TbMeetingTranscriptionDTO transcription);
|
||||
|
||||
/**
|
||||
* @description 获取会议转录列表/分页
|
||||
* @param pageRequest 分页请求
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<MeetingTranscriptionVO> getTranscriptionPage(PageRequest<TbMeetingTranscriptionDTO> pageRequest);
|
||||
|
||||
/**
|
||||
* @description 获取会议完整转录文本
|
||||
* @param meetingId 会议ID
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<String> getFullTranscriptionText(String meetingId);
|
||||
|
||||
/**
|
||||
* @description 删除转录记录
|
||||
* @param transcriptionId 转录ID
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<Boolean> deleteTranscription(String transcriptionId);
|
||||
|
||||
// ========================= 会议统计 ==========================
|
||||
|
||||
/**
|
||||
* @description 获取会议统计信息(参与人数、时长等)
|
||||
* @param meetingId 会议ID
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<VideoMeetingVO> getMeetingStatistics(String meetingId);
|
||||
|
||||
}
|
||||
@@ -33,6 +33,10 @@ public class VideoMeetingVO extends BaseVO {
|
||||
@Schema(description = "会议密码")
|
||||
private String meetingPassword;
|
||||
|
||||
@Schema(description = "会议模式")
|
||||
private String description;
|
||||
|
||||
|
||||
@Schema(description = "JWT Token(用于身份验证)")
|
||||
private String jwtToken;
|
||||
|
||||
@@ -45,9 +49,6 @@ public class VideoMeetingVO extends BaseVO {
|
||||
@Schema(description = "状态:scheduled-已安排 ongoing-进行中 ended-已结束 cancelled-已取消")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "创建者ID")
|
||||
private String creatorId;
|
||||
|
||||
@Schema(description = "创建者类型:guest-来客 agent-客服")
|
||||
private String creatorType;
|
||||
|
||||
@@ -59,6 +60,17 @@ public class VideoMeetingVO extends BaseVO {
|
||||
|
||||
@Schema(description = "最大参与人数")
|
||||
private Integer maxParticipants;
|
||||
|
||||
@Schema(description = "定义会议开始时间", format = "date-time")
|
||||
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date startTime;
|
||||
|
||||
@Schema(description = "定义会议结束时间", format = "date-time")
|
||||
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date endTime;
|
||||
|
||||
@Schema(description = "提前入会时间(分钟)")
|
||||
private Integer advance;
|
||||
|
||||
@Schema(description = "实际开始时间", format = "date-time")
|
||||
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
|
||||
@@ -2,8 +2,11 @@ package org.xyzh.common.exception.handler;
|
||||
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.authorization.AuthorizationDeniedException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
@@ -26,17 +29,16 @@ import java.util.stream.Collectors;
|
||||
* @copyright yslg
|
||||
* @since 2025-12-17
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
/**
|
||||
* 业务异常
|
||||
*/
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
public ResultDomain<?> handleBusinessException(BusinessException e) {
|
||||
log.warn("业务异常: {}", e.getMessage());
|
||||
logger.warn("业务异常: {}", e.getMessage());
|
||||
return ResultDomain.failure(e.getCode(), e.getMessage());
|
||||
}
|
||||
|
||||
@@ -49,7 +51,7 @@ public class GlobalExceptionHandler {
|
||||
String message = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
.collect(Collectors.joining("; "));
|
||||
log.warn("参数校验失败: {}", message);
|
||||
logger.warn("参数校验失败: {}", message);
|
||||
return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), message);
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ public class GlobalExceptionHandler {
|
||||
String message = violations.stream()
|
||||
.map(ConstraintViolation::getMessage)
|
||||
.collect(Collectors.joining("; "));
|
||||
log.warn("参数校验失败: {}", message);
|
||||
logger.warn("参数校验失败: {}", message);
|
||||
return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), message);
|
||||
}
|
||||
|
||||
@@ -76,7 +78,7 @@ public class GlobalExceptionHandler {
|
||||
String message = e.getFieldErrors().stream()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
.collect(Collectors.joining("; "));
|
||||
log.warn("参数绑定失败: {}", message);
|
||||
logger.warn("参数绑定失败: {}", message);
|
||||
return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), message);
|
||||
}
|
||||
|
||||
@@ -87,7 +89,7 @@ public class GlobalExceptionHandler {
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
public ResultDomain<?> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) {
|
||||
String message = "缺少必要参数: " + e.getParameterName();
|
||||
log.warn(message);
|
||||
logger.warn(message);
|
||||
return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), message);
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ public class GlobalExceptionHandler {
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
public ResultDomain<?> handleMissingServletRequestPartException(MissingServletRequestPartException e) {
|
||||
String message = "缺少必要参数: " + e.getRequestPartName();
|
||||
log.warn(message);
|
||||
logger.warn(message);
|
||||
return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), message);
|
||||
}
|
||||
|
||||
@@ -108,17 +110,27 @@ public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
public ResultDomain<?> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
|
||||
log.warn("文件上传大小超限: {}", e.getMessage());
|
||||
logger.warn("文件上传大小超限: {}", e.getMessage());
|
||||
return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), "上传文件大小超过限制");
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限不足异常
|
||||
*/
|
||||
@ExceptionHandler(AuthorizationDeniedException.class)
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
public ResultDomain<?> handleAuthorizationDeniedException(AuthorizationDeniedException e) {
|
||||
logger.warn("权限不足: {}", e.getMessage());
|
||||
return ResultDomain.failure(HttpStatus.FORBIDDEN.value(), "权限不足");
|
||||
}
|
||||
|
||||
/**
|
||||
* 其他未捕获异常
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
public ResultDomain<?> handleException(Exception e) {
|
||||
log.error("系统异常: ", e);
|
||||
logger.error("系统异常: ", e);
|
||||
return ResultDomain.failure(HttpStatus.INTERNAL_SERVER_ERROR.value(), "系统异常,请联系管理员");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.xyzh.common.utils.validation;
|
||||
|
||||
import org.xyzh.common.utils.validation.method.FieldCompareValidateMethod;
|
||||
import org.xyzh.common.utils.validation.method.InSetValidateMethod;
|
||||
import org.xyzh.common.utils.validation.method.MinFieldsValidateMethod;
|
||||
import org.xyzh.common.utils.validation.method.ObjectValidateMethod;
|
||||
@@ -10,6 +11,7 @@ import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
@@ -380,4 +382,32 @@ public class ValidationUtils {
|
||||
.validateMethod(new InSetValidateMethod(fieldLabel, allowedValues))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 创建字段比较校验参数(比较对象中的两个字段)
|
||||
* @param field1Name 第一个字段名称
|
||||
* @param field2Name 第二个字段名称
|
||||
* @param fieldLabel 字段标签
|
||||
* @param compareFunction 比较函数:(field1Value, field2Value) -> Boolean,返回true表示通过
|
||||
* @param errorMessage 自定义错误消息
|
||||
* @return ValidationParam
|
||||
*/
|
||||
public static ValidationParam fieldCompare(String field1Name,
|
||||
String field2Name,
|
||||
String fieldLabel,
|
||||
BiFunction<Object, Object, Boolean> compareFunction,
|
||||
String errorMessage) {
|
||||
return ValidationParam.builder()
|
||||
.fieldName(field1Name)
|
||||
.fieldLabel(fieldLabel)
|
||||
.required(false)
|
||||
.validateMethod(new FieldCompareValidateMethod(
|
||||
field1Name,
|
||||
field2Name,
|
||||
fieldLabel,
|
||||
compareFunction,
|
||||
errorMessage
|
||||
))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package org.xyzh.common.utils.validation.method;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* @description 字段比较校验方法,用于比较对象中的两个字段
|
||||
* @filename FieldCompareValidateMethod.java
|
||||
* @author Claude Code
|
||||
* @copyright xyzh
|
||||
* @since 2025-12-26
|
||||
*/
|
||||
public class FieldCompareValidateMethod implements ObjectValidateMethod {
|
||||
|
||||
/**
|
||||
* 第一个字段名称
|
||||
*/
|
||||
private final String field1Name;
|
||||
|
||||
/**
|
||||
* 第二个字段名称
|
||||
*/
|
||||
private final String field2Name;
|
||||
|
||||
/**
|
||||
* 字段标签(用于错误消息)
|
||||
*/
|
||||
private final String fieldLabel;
|
||||
|
||||
/**
|
||||
* 比较函数:(field1Value, field2Value) -> Boolean
|
||||
* 返回 true 表示校验通过,false 表示校验失败
|
||||
*/
|
||||
private final BiFunction<Object, Object, Boolean> compareFunction;
|
||||
|
||||
/**
|
||||
* 自定义错误消息
|
||||
*/
|
||||
private final String customErrorMessage;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param field1Name 第一个字段名称
|
||||
* @param field2Name 第二个字段名称
|
||||
* @param fieldLabel 字段标签
|
||||
* @param compareFunction 比较函数
|
||||
* @param customErrorMessage 自定义错误消息
|
||||
*/
|
||||
public FieldCompareValidateMethod(String field1Name,
|
||||
String field2Name,
|
||||
String fieldLabel,
|
||||
BiFunction<Object, Object, Boolean> compareFunction,
|
||||
String customErrorMessage) {
|
||||
this.field1Name = field1Name;
|
||||
this.field2Name = field2Name;
|
||||
this.fieldLabel = fieldLabel;
|
||||
this.compareFunction = compareFunction;
|
||||
this.customErrorMessage = customErrorMessage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean validate(Object targetObject) {
|
||||
if (targetObject == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取第一个字段的值
|
||||
Object field1Value = getFieldValue(targetObject, field1Name);
|
||||
|
||||
// 获取第二个字段的值
|
||||
Object field2Value = getFieldValue(targetObject, field2Name);
|
||||
|
||||
// 如果任意字段为null,跳过校验
|
||||
if (field1Value == null || field2Value == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 执行比较函数
|
||||
return compareFunction.apply(field1Value, field2Value);
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("字段比较校验失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getErrorMessage() {
|
||||
if (customErrorMessage != null && !customErrorMessage.trim().isEmpty()) {
|
||||
return customErrorMessage;
|
||||
}
|
||||
return fieldLabel + "校验失败";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "字段比较校验";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段值(支持嵌套字段)
|
||||
*
|
||||
* @param obj 对象
|
||||
* @param fieldName 字段名称(支持 "field" 或 "nested.field")
|
||||
* @return 字段值
|
||||
*/
|
||||
private Object getFieldValue(Object obj, String fieldName) throws Exception {
|
||||
if (fieldName == null || fieldName.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 支持嵌套字段访问(如 "user.name")
|
||||
String[] fieldParts = fieldName.split("\\.");
|
||||
Object currentObj = obj;
|
||||
|
||||
for (String part : fieldParts) {
|
||||
if (currentObj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取字段
|
||||
Field field = findField(currentObj.getClass(), part);
|
||||
if (field == null) {
|
||||
throw new NoSuchFieldException("字段不存在: " + part);
|
||||
}
|
||||
|
||||
// 设置可访问
|
||||
field.setAccessible(true);
|
||||
|
||||
// 获取字段值
|
||||
currentObj = field.get(currentObj);
|
||||
}
|
||||
|
||||
return currentObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找字段(包括父类)
|
||||
*
|
||||
* @param clazz 类
|
||||
* @param fieldName 字段名称
|
||||
* @return 字段
|
||||
*/
|
||||
private Field findField(Class<?> clazz, String fieldName) {
|
||||
Class<?> currentClass = clazz;
|
||||
while (currentClass != null) {
|
||||
try {
|
||||
return currentClass.getDeclaredField(fieldName);
|
||||
} catch (NoSuchFieldException e) {
|
||||
currentClass = currentClass.getSuperclass();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import org.xyzh.api.workcase.dto.TbChatRoomDTO;
|
||||
import org.xyzh.api.workcase.dto.TbChatRoomMemberDTO;
|
||||
import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO;
|
||||
import org.xyzh.api.workcase.dto.TbCustomerServiceDTO;
|
||||
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
|
||||
import org.xyzh.api.workcase.dto.TbWordCloudDTO;
|
||||
import org.xyzh.api.workcase.service.ChatRoomService;
|
||||
import org.xyzh.api.workcase.service.WorkcaseChatService;
|
||||
@@ -24,16 +25,20 @@ import org.xyzh.api.workcase.vo.ChatMemberVO;
|
||||
import org.xyzh.api.workcase.vo.ChatRoomMessageVO;
|
||||
import org.xyzh.api.workcase.vo.ChatRoomVO;
|
||||
import org.xyzh.api.workcase.vo.CustomerServiceVO;
|
||||
import org.xyzh.api.workcase.vo.VideoMeetingVO;
|
||||
import org.xyzh.common.auth.utils.JwtTokenUtil;
|
||||
import org.xyzh.common.auth.utils.LoginUtil;
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.core.page.PageRequest;
|
||||
import org.xyzh.common.utils.validation.ValidationParam;
|
||||
import org.xyzh.common.utils.validation.ValidationResult;
|
||||
import org.xyzh.common.utils.validation.ValidationUtils;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -274,12 +279,38 @@ public class WorkcaseChatContorller {
|
||||
private org.xyzh.api.workcase.service.VideoMeetingService videoMeetingService;
|
||||
|
||||
@Operation(summary = "创建视频会议")
|
||||
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||
@PreAuthorize("hasAuthority('meeting:create:own')")
|
||||
@PostMapping("/meeting/create")
|
||||
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> createVideoMeeting(
|
||||
@RequestBody org.xyzh.api.workcase.dto.TbVideoMeetingDTO meetingDTO) {
|
||||
public ResultDomain<VideoMeetingVO> createVideoMeeting(
|
||||
@RequestBody TbVideoMeetingDTO meetingDTO) {
|
||||
ValidationResult vr = ValidationUtils.validate(meetingDTO, Arrays.asList(
|
||||
ValidationUtils.requiredString("roomId", "聊天室ID")
|
||||
ValidationUtils.requiredString("roomId", "聊天室ID"),
|
||||
ValidationUtils.requiredString("meetingName", "会议名称"),
|
||||
// 校验开始时间不为空
|
||||
ValidationParam.builder()
|
||||
.fieldName("startTime")
|
||||
.fieldLabel("会议开始时间")
|
||||
.required()
|
||||
.build(),
|
||||
// 校验结束时间不为空
|
||||
ValidationParam.builder()
|
||||
.fieldName("endTime")
|
||||
.fieldLabel("会议结束时间")
|
||||
.required()
|
||||
.build(),
|
||||
// 校验开始时间小于结束时间(使用 fieldCompare 比较两个字段)
|
||||
ValidationUtils.fieldCompare(
|
||||
"startTime",
|
||||
"endTime",
|
||||
"会议时间",
|
||||
(startTime, endTime) -> {
|
||||
if (startTime instanceof Date && endTime instanceof Date) {
|
||||
return ((Date) startTime).before((Date) endTime);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
"会议开始时间不能晚于结束时间"
|
||||
)
|
||||
));
|
||||
if (!vr.isValid()) {
|
||||
return ResultDomain.failure(vr.getAllErrors());
|
||||
@@ -293,9 +324,9 @@ public class WorkcaseChatContorller {
|
||||
}
|
||||
|
||||
@Operation(summary = "获取会议信息")
|
||||
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||
@PreAuthorize("hasAuthority('meeting:url:any')")
|
||||
@GetMapping("/meeting/{meetingId}")
|
||||
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> getMeetingInfo(
|
||||
public ResultDomain<VideoMeetingVO> getMeetingInfo(
|
||||
@PathVariable(value = "meetingId") String meetingId) {
|
||||
String userId = LoginUtil.getCurrentUserId();
|
||||
|
||||
@@ -307,9 +338,9 @@ public class WorkcaseChatContorller {
|
||||
}
|
||||
|
||||
@Operation(summary = "加入会议(生成用户专属JWT)")
|
||||
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||
@PreAuthorize("hasAuthority('meeting:join:any')")
|
||||
@PostMapping("/meeting/{meetingId}/join")
|
||||
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> joinMeeting(
|
||||
public ResultDomain<VideoMeetingVO> joinMeeting(
|
||||
@PathVariable(value = "meetingId") String meetingId) {
|
||||
String userId = LoginUtil.getCurrentUserId();
|
||||
|
||||
@@ -328,7 +359,7 @@ public class WorkcaseChatContorller {
|
||||
}
|
||||
|
||||
@Operation(summary = "开始会议")
|
||||
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||
@PreAuthorize("hasAuthority('meeting:create:own')")
|
||||
@PostMapping("/meeting/{meetingId}/start")
|
||||
public ResultDomain<Boolean> startMeeting(@PathVariable(value = "meetingId") String meetingId) {
|
||||
try {
|
||||
@@ -339,9 +370,9 @@ public class WorkcaseChatContorller {
|
||||
}
|
||||
|
||||
@Operation(summary = "结束会议")
|
||||
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||
@PreAuthorize("hasAuthority('meeting:create:own')")
|
||||
@PostMapping("/meeting/{meetingId}/end")
|
||||
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> endMeeting(
|
||||
public ResultDomain<VideoMeetingVO> endMeeting(
|
||||
@PathVariable(value = "meetingId") String meetingId) {
|
||||
try {
|
||||
return videoMeetingService.endMeeting(meetingId);
|
||||
@@ -351,9 +382,9 @@ public class WorkcaseChatContorller {
|
||||
}
|
||||
|
||||
@Operation(summary = "获取聊天室当前活跃会议")
|
||||
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||
@PreAuthorize("hasAuthority('meeting:url:any')")
|
||||
@GetMapping("/meeting/room/{roomId}/active")
|
||||
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> getActiveMeetingByRoom(
|
||||
public ResultDomain<VideoMeetingVO> getActiveMeetingByRoom(
|
||||
@PathVariable(value = "roomId") String roomId) {
|
||||
try {
|
||||
return videoMeetingService.getActiveMeetingByRoom(roomId);
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
package org.xyzh.workcase.service;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.dubbo.config.annotation.DubboService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
|
||||
import org.xyzh.api.workcase.dto.TbMeetingParticipantDTO;
|
||||
import org.xyzh.api.workcase.dto.TbMeetingTranscriptionDTO;
|
||||
import org.xyzh.api.workcase.service.MeetService;
|
||||
import org.xyzh.api.workcase.vo.VideoMeetingVO;
|
||||
import org.xyzh.api.workcase.vo.MeetingParticipantVO;
|
||||
import org.xyzh.api.workcase.vo.MeetingTranscriptionVO;
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.core.page.PageDomain;
|
||||
import org.xyzh.common.core.page.PageParam;
|
||||
import org.xyzh.common.core.page.PageRequest;
|
||||
import org.xyzh.common.utils.id.IdUtil;
|
||||
import org.xyzh.workcase.mapper.TbVideoMeetingMapper;
|
||||
import org.xyzh.workcase.mapper.TbMeetingParticipantMapper;
|
||||
import org.xyzh.workcase.mapper.TbMeetingTranscriptionMapper;
|
||||
|
||||
/**
|
||||
* @description 视频会议服务实现类(伪代码)
|
||||
* @filename MeetServiceImpl.java
|
||||
* @author cascade
|
||||
* @copyright xyzh
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
@DubboService(version = "1.0.0", group = "workcase", timeout = 30000, retries = 0)
|
||||
public class MeetServiceImpl implements MeetService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(MeetServiceImpl.class);
|
||||
|
||||
@Autowired
|
||||
private TbVideoMeetingMapper videoMeetingMapper;
|
||||
|
||||
@Autowired
|
||||
private TbMeetingParticipantMapper meetingParticipantMapper;
|
||||
|
||||
@Autowired
|
||||
private TbMeetingTranscriptionMapper meetingTranscriptionMapper;
|
||||
|
||||
// TODO: 注入Jitsi配置和JWT工具类
|
||||
// @Autowired
|
||||
// private JitsiConfig jitsiConfig;
|
||||
// @Autowired
|
||||
// private JwtTokenUtil jwtTokenUtil;
|
||||
|
||||
// ========================= 会议管理 ==========================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ResultDomain<TbVideoMeetingDTO> createMeeting(TbVideoMeetingDTO meeting) {
|
||||
logger.info("创建会议: roomId={}, meetingName={}", meeting.getRoomId(), meeting.getMeetingName());
|
||||
|
||||
// TODO: 生成唯一的Jitsi房间名
|
||||
// String jitsiRoomName = "meet_" + IdUtil.generateUUID().replace("-", "");
|
||||
|
||||
if (meeting.getMeetingId() == null || meeting.getMeetingId().isEmpty()) {
|
||||
meeting.setMeetingId(IdUtil.generateUUID());
|
||||
}
|
||||
if (meeting.getOptsn() == null || meeting.getOptsn().isEmpty()) {
|
||||
meeting.setOptsn(IdUtil.getOptsn());
|
||||
}
|
||||
if (meeting.getStatus() == null || meeting.getStatus().isEmpty()) {
|
||||
meeting.setStatus("scheduled");
|
||||
}
|
||||
// TODO: 设置Jitsi相关配置
|
||||
// meeting.setJitsiRoomName(jitsiRoomName);
|
||||
// meeting.setJitsiServerUrl(jitsiConfig.getServerUrl());
|
||||
|
||||
int rows = videoMeetingMapper.insertVideoMeeting(meeting);
|
||||
if (rows > 0) {
|
||||
logger.info("会议创建成功: meetingId={}", meeting.getMeetingId());
|
||||
return ResultDomain.success("创建成功", meeting);
|
||||
}
|
||||
return ResultDomain.failure("创建失败");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbVideoMeetingDTO> updateMeeting(TbVideoMeetingDTO meeting) {
|
||||
logger.info("更新会议: meetingId={}", meeting.getMeetingId());
|
||||
|
||||
TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meeting.getMeetingId());
|
||||
if (existing == null) {
|
||||
return ResultDomain.failure("会议不存在");
|
||||
}
|
||||
|
||||
int rows = videoMeetingMapper.updateVideoMeeting(meeting);
|
||||
if (rows > 0) {
|
||||
TbVideoMeetingDTO updated = videoMeetingMapper.selectVideoMeetingById(meeting.getMeetingId());
|
||||
return ResultDomain.success("更新成功", updated);
|
||||
}
|
||||
return ResultDomain.failure("更新失败");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbVideoMeetingDTO> startMeeting(String meetingId) {
|
||||
logger.info("开始会议: meetingId={}", meetingId);
|
||||
|
||||
TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meetingId);
|
||||
if (existing == null) {
|
||||
return ResultDomain.failure("会议不存在");
|
||||
}
|
||||
if ("ongoing".equals(existing.getStatus())) {
|
||||
return ResultDomain.failure("会议已在进行中");
|
||||
}
|
||||
|
||||
TbVideoMeetingDTO meeting = new TbVideoMeetingDTO();
|
||||
meeting.setMeetingId(meetingId);
|
||||
meeting.setStatus("ongoing");
|
||||
meeting.setActualStartTime(new Date());
|
||||
|
||||
int rows = videoMeetingMapper.updateVideoMeeting(meeting);
|
||||
if (rows > 0) {
|
||||
TbVideoMeetingDTO updated = videoMeetingMapper.selectVideoMeetingById(meetingId);
|
||||
return ResultDomain.success("会议已开始", updated);
|
||||
}
|
||||
return ResultDomain.failure("开始会议失败");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbVideoMeetingDTO> endMeeting(String meetingId) {
|
||||
logger.info("结束会议: meetingId={}", meetingId);
|
||||
|
||||
TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meetingId);
|
||||
if (existing == null) {
|
||||
return ResultDomain.failure("会议不存在");
|
||||
}
|
||||
|
||||
TbVideoMeetingDTO meeting = new TbVideoMeetingDTO();
|
||||
meeting.setMeetingId(meetingId);
|
||||
meeting.setStatus("ended");
|
||||
meeting.setActualEndTime(new Date());
|
||||
|
||||
// TODO: 计算会议时长
|
||||
// if (existing.getActualStartTime() != null) {
|
||||
// long durationMs = new Date().getTime() - existing.getActualStartTime().getTime();
|
||||
// meeting.setDurationSeconds((int)(durationMs / 1000));
|
||||
// }
|
||||
|
||||
int rows = videoMeetingMapper.updateVideoMeeting(meeting);
|
||||
if (rows > 0) {
|
||||
// TODO: 更新所有参与者离开时间
|
||||
// updateAllParticipantsLeaveTime(meetingId);
|
||||
|
||||
TbVideoMeetingDTO updated = videoMeetingMapper.selectVideoMeetingById(meetingId);
|
||||
return ResultDomain.success("会议已结束", updated);
|
||||
}
|
||||
return ResultDomain.failure("结束会议失败");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<Boolean> deleteMeeting(String meetingId) {
|
||||
logger.info("删除会议: meetingId={}", meetingId);
|
||||
|
||||
TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meetingId);
|
||||
if (existing == null) {
|
||||
return ResultDomain.failure("会议不存在");
|
||||
}
|
||||
|
||||
TbVideoMeetingDTO meeting = new TbVideoMeetingDTO();
|
||||
meeting.setMeetingId(meetingId);
|
||||
int rows = videoMeetingMapper.deleteVideoMeeting(meeting);
|
||||
if (rows > 0) {
|
||||
return ResultDomain.success("删除成功", true);
|
||||
}
|
||||
return ResultDomain.failure("删除失败");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbVideoMeetingDTO> getMeetingById(String meetingId) {
|
||||
TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId);
|
||||
if (meeting != null) {
|
||||
return ResultDomain.success("查询成功", meeting);
|
||||
}
|
||||
return ResultDomain.failure("会议不存在");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<VideoMeetingVO> getMeetingPage(PageRequest<TbVideoMeetingDTO> pageRequest) {
|
||||
TbVideoMeetingDTO filter = pageRequest.getFilter();
|
||||
if (filter == null) {
|
||||
filter = new TbVideoMeetingDTO();
|
||||
}
|
||||
|
||||
PageParam pageParam = pageRequest.getPageParam();
|
||||
List<VideoMeetingVO> list = videoMeetingMapper.selectVideoMeetingPage(filter, pageParam);
|
||||
long total = videoMeetingMapper.countVideoMeetings(filter);
|
||||
pageParam.setTotal((int) total);
|
||||
|
||||
PageDomain<VideoMeetingVO> pageDomain = new PageDomain<>(pageParam, list);
|
||||
return ResultDomain.success("查询成功", pageDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<String> generateMeetingJoinUrl(String meetingId, String userId, String userName) {
|
||||
logger.info("生成会议加入链接: meetingId={}, userId={}", meetingId, userId);
|
||||
|
||||
TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId);
|
||||
if (meeting == null) {
|
||||
return ResultDomain.failure("会议不存在");
|
||||
}
|
||||
|
||||
// TODO: 生成Jitsi iframe URL
|
||||
// String jwtToken = generateMeetingToken(meetingId, userId, false).getData();
|
||||
// String baseUrl = meeting.getJitsiServerUrl();
|
||||
// String roomName = meeting.getJitsiRoomName();
|
||||
// String iframeUrl = String.format("%s/%s?jwt=%s#userInfo.displayName=%s",
|
||||
// baseUrl, roomName, jwtToken, URLEncoder.encode(userName, "UTF-8"));
|
||||
|
||||
String iframeUrl = "TODO: 生成Jitsi iframe URL";
|
||||
return ResultDomain.success("生成成功", iframeUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<String> generateMeetingToken(String meetingId, String userId, boolean isModerator) {
|
||||
logger.info("生成会议JWT: meetingId={}, userId={}, isModerator={}", meetingId, userId, isModerator);
|
||||
|
||||
TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId);
|
||||
if (meeting == null) {
|
||||
return ResultDomain.failure("会议不存在");
|
||||
}
|
||||
|
||||
// TODO: 使用Jitsi JWT规范生成Token
|
||||
// JitsiTokenPayload payload = new JitsiTokenPayload();
|
||||
// payload.setRoom(meeting.getJitsiRoomName());
|
||||
// payload.setModerator(isModerator);
|
||||
// payload.setUserId(userId);
|
||||
// String token = jwtTokenUtil.generateJitsiToken(payload);
|
||||
|
||||
String token = "TODO: 生成Jitsi JWT Token";
|
||||
return ResultDomain.success("生成成功", token);
|
||||
}
|
||||
|
||||
// ========================= 参与者管理 ==========================
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbMeetingParticipantDTO> joinMeeting(TbMeetingParticipantDTO participant) {
|
||||
logger.info("参与者加入会议: meetingId={}, userId={}", participant.getMeetingId(), participant.getUserId());
|
||||
|
||||
// 检查会议是否存在
|
||||
TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(participant.getMeetingId());
|
||||
if (meeting == null) {
|
||||
return ResultDomain.failure("会议不存在");
|
||||
}
|
||||
|
||||
if (participant.getParticipantId() == null || participant.getParticipantId().isEmpty()) {
|
||||
participant.setParticipantId(IdUtil.generateUUID());
|
||||
}
|
||||
if (participant.getOptsn() == null || participant.getOptsn().isEmpty()) {
|
||||
participant.setOptsn(IdUtil.getOptsn());
|
||||
}
|
||||
participant.setJoinTime(new Date());
|
||||
|
||||
int rows = meetingParticipantMapper.insertMeetingParticipant(participant);
|
||||
if (rows > 0) {
|
||||
// 更新会议参与人数
|
||||
TbVideoMeetingDTO updateMeeting = new TbVideoMeetingDTO();
|
||||
updateMeeting.setMeetingId(participant.getMeetingId());
|
||||
updateMeeting.setParticipantCount(meeting.getParticipantCount() != null ? meeting.getParticipantCount() + 1 : 1);
|
||||
videoMeetingMapper.updateVideoMeeting(updateMeeting);
|
||||
|
||||
return ResultDomain.success("加入成功", participant);
|
||||
}
|
||||
return ResultDomain.failure("加入失败");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<Boolean> leaveMeeting(String participantId) {
|
||||
logger.info("参与者离开会议: participantId={}", participantId);
|
||||
|
||||
TbMeetingParticipantDTO existing = meetingParticipantMapper.selectMeetingParticipantById(participantId);
|
||||
if (existing == null) {
|
||||
return ResultDomain.failure("参与者不存在");
|
||||
}
|
||||
|
||||
TbMeetingParticipantDTO participant = new TbMeetingParticipantDTO();
|
||||
participant.setParticipantId(participantId);
|
||||
participant.setLeaveTime(new Date());
|
||||
|
||||
// TODO: 计算参与时长
|
||||
// if (existing.getJoinTime() != null) {
|
||||
// long durationMs = new Date().getTime() - existing.getJoinTime().getTime();
|
||||
// participant.setDurationSeconds((int)(durationMs / 1000));
|
||||
// }
|
||||
|
||||
int rows = meetingParticipantMapper.updateMeetingParticipant(participant);
|
||||
if (rows > 0) {
|
||||
return ResultDomain.success("离开成功", true);
|
||||
}
|
||||
return ResultDomain.failure("离开失败");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<MeetingParticipantVO> getMeetingParticipantList(String meetingId) {
|
||||
TbMeetingParticipantDTO filter = new TbMeetingParticipantDTO();
|
||||
filter.setMeetingId(meetingId);
|
||||
List<MeetingParticipantVO> list = meetingParticipantMapper.selectMeetingParticipantList(filter);
|
||||
return ResultDomain.success("查询成功", list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbMeetingParticipantDTO> updateParticipant(TbMeetingParticipantDTO participant) {
|
||||
logger.info("更新参与者: participantId={}", participant.getParticipantId());
|
||||
|
||||
TbMeetingParticipantDTO existing = meetingParticipantMapper.selectMeetingParticipantById(participant.getParticipantId());
|
||||
if (existing == null) {
|
||||
return ResultDomain.failure("参与者不存在");
|
||||
}
|
||||
|
||||
int rows = meetingParticipantMapper.updateMeetingParticipant(participant);
|
||||
if (rows > 0) {
|
||||
TbMeetingParticipantDTO updated = meetingParticipantMapper.selectMeetingParticipantById(participant.getParticipantId());
|
||||
return ResultDomain.success("更新成功", updated);
|
||||
}
|
||||
return ResultDomain.failure("更新失败");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<Boolean> setModerator(String participantId, boolean isModerator) {
|
||||
logger.info("设置主持人: participantId={}, isModerator={}", participantId, isModerator);
|
||||
|
||||
TbMeetingParticipantDTO participant = new TbMeetingParticipantDTO();
|
||||
participant.setParticipantId(participantId);
|
||||
participant.setIsModerator(isModerator);
|
||||
|
||||
int rows = meetingParticipantMapper.updateMeetingParticipant(participant);
|
||||
if (rows > 0) {
|
||||
return ResultDomain.success("设置成功", true);
|
||||
}
|
||||
return ResultDomain.failure("设置失败");
|
||||
}
|
||||
|
||||
// ========================= 转录管理 ==========================
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbMeetingTranscriptionDTO> addTranscription(TbMeetingTranscriptionDTO transcription) {
|
||||
logger.info("添加转录记录: meetingId={}, speakerId={}", transcription.getMeetingId(), transcription.getSpeakerId());
|
||||
|
||||
if (transcription.getTranscriptionId() == null || transcription.getTranscriptionId().isEmpty()) {
|
||||
transcription.setTranscriptionId(IdUtil.generateUUID());
|
||||
}
|
||||
if (transcription.getOptsn() == null || transcription.getOptsn().isEmpty()) {
|
||||
transcription.setOptsn(IdUtil.getOptsn());
|
||||
}
|
||||
|
||||
int rows = meetingTranscriptionMapper.insertMeetingTranscription(transcription);
|
||||
if (rows > 0) {
|
||||
return ResultDomain.success("添加成功", transcription);
|
||||
}
|
||||
return ResultDomain.failure("添加失败");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<MeetingTranscriptionVO> getTranscriptionPage(PageRequest<TbMeetingTranscriptionDTO> pageRequest) {
|
||||
TbMeetingTranscriptionDTO filter = pageRequest.getFilter();
|
||||
if (filter == null) {
|
||||
filter = new TbMeetingTranscriptionDTO();
|
||||
}
|
||||
|
||||
PageParam pageParam = pageRequest.getPageParam();
|
||||
List<MeetingTranscriptionVO> list = meetingTranscriptionMapper.selectMeetingTranscriptionPage(filter, pageParam);
|
||||
long total = meetingTranscriptionMapper.countMeetingTranscriptions(filter);
|
||||
pageParam.setTotal((int) total);
|
||||
|
||||
PageDomain<MeetingTranscriptionVO> pageDomain = new PageDomain<>(pageParam, list);
|
||||
return ResultDomain.success("查询成功", pageDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<String> getFullTranscriptionText(String meetingId) {
|
||||
logger.info("获取完整转录文本: meetingId={}", meetingId);
|
||||
|
||||
TbMeetingTranscriptionDTO filter = new TbMeetingTranscriptionDTO();
|
||||
filter.setMeetingId(meetingId);
|
||||
filter.setIsFinal(true);
|
||||
List<MeetingTranscriptionVO> list = meetingTranscriptionMapper.selectMeetingTranscriptionList(filter);
|
||||
|
||||
// TODO: 拼接转录文本
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (MeetingTranscriptionVO transcription : list) {
|
||||
// 格式:[说话人名称] 内容
|
||||
sb.append("[").append(transcription.getSpeakerName()).append("] ");
|
||||
sb.append(transcription.getContent()).append("\n");
|
||||
}
|
||||
|
||||
return ResultDomain.success("查询成功", sb.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<Boolean> deleteTranscription(String transcriptionId) {
|
||||
logger.info("删除转录记录: transcriptionId={}", transcriptionId);
|
||||
|
||||
TbMeetingTranscriptionDTO existing = meetingTranscriptionMapper.selectMeetingTranscriptionById(transcriptionId);
|
||||
if (existing == null) {
|
||||
return ResultDomain.failure("转录记录不存在");
|
||||
}
|
||||
|
||||
TbMeetingTranscriptionDTO transcription = new TbMeetingTranscriptionDTO();
|
||||
transcription.setTranscriptionId(transcriptionId);
|
||||
int rows = meetingTranscriptionMapper.deleteMeetingTranscription(transcription);
|
||||
if (rows > 0) {
|
||||
return ResultDomain.success("删除成功", true);
|
||||
}
|
||||
return ResultDomain.failure("删除失败");
|
||||
}
|
||||
|
||||
// ========================= 会议统计 ==========================
|
||||
|
||||
@Override
|
||||
public ResultDomain<VideoMeetingVO> getMeetingStatistics(String meetingId) {
|
||||
logger.info("获取会议统计: meetingId={}", meetingId);
|
||||
|
||||
TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId);
|
||||
if (meeting == null) {
|
||||
return ResultDomain.failure("会议不存在");
|
||||
}
|
||||
|
||||
// TODO: 查询并组装统计信息
|
||||
// - 参与人数
|
||||
// - 会议时长
|
||||
// - 转录记录数
|
||||
// - 各参与者参与时长等
|
||||
|
||||
TbMeetingParticipantDTO participantFilter = new TbMeetingParticipantDTO();
|
||||
participantFilter.setMeetingId(meetingId);
|
||||
List<MeetingParticipantVO> participants = meetingParticipantMapper.selectMeetingParticipantList(participantFilter);
|
||||
|
||||
VideoMeetingVO vo = new VideoMeetingVO();
|
||||
vo.setMeetingId(meeting.getMeetingId());
|
||||
vo.setMeetingName(meeting.getMeetingName());
|
||||
vo.setStatus(meeting.getStatus());
|
||||
vo.setParticipantCount(participants.size());
|
||||
vo.setActualStartTime(meeting.getActualStartTime());
|
||||
vo.setActualEndTime(meeting.getActualEndTime());
|
||||
vo.setDurationSeconds(meeting.getDurationSeconds());
|
||||
|
||||
// TODO: 格式化时长
|
||||
// if (meeting.getDurationSeconds() != null) {
|
||||
// vo.setDurationFormatted(formatDuration(meeting.getDurationSeconds()));
|
||||
// }
|
||||
|
||||
return ResultDomain.success("查询成功", vo);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.xyzh.workcase.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.apache.dubbo.config.annotation.DubboService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -7,19 +8,25 @@ import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.xyzh.api.workcase.dto.TbChatRoomMemberDTO;
|
||||
import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO;
|
||||
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
|
||||
import org.xyzh.api.workcase.service.ChatRoomService;
|
||||
import org.xyzh.api.workcase.service.JitsiTokenService;
|
||||
import org.xyzh.api.workcase.service.VideoMeetingService;
|
||||
import org.xyzh.api.workcase.vo.ChatMemberVO;
|
||||
import org.xyzh.api.workcase.vo.VideoMeetingVO;
|
||||
import org.xyzh.common.auth.utils.LoginUtil;
|
||||
import org.xyzh.common.core.domain.LoginDomain;
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.utils.id.IdUtil;
|
||||
import org.xyzh.workcase.mapper.TbChatRoomMemberMapper;
|
||||
import org.xyzh.workcase.mapper.TbVideoMeetingMapper;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* @description 视频会议服务实现类
|
||||
@@ -41,13 +48,21 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
@Autowired
|
||||
private JitsiTokenService jitsiTokenService;
|
||||
|
||||
@Autowired
|
||||
private ChatRoomService chatRoomService;
|
||||
|
||||
// 会议创建锁映射表:每个meetingId对应一个ReentrantLock
|
||||
private final ConcurrentHashMap<String, ReentrantLock> meetingLocks = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ResultDomain<VideoMeetingVO> createMeeting(TbVideoMeetingDTO meetingDTO) {
|
||||
// 获取当前用户ID
|
||||
String userId = LoginUtil.getCurrentUserId();
|
||||
logger.info("创建视频会议: roomId={}, workcaseId={}, userId={}",
|
||||
meetingDTO.getRoomId(), meetingDTO.getWorkcaseId(), userId);
|
||||
LoginDomain loginDomain = LoginUtil.getCurrentLogin();
|
||||
String userId = loginDomain.getUser().getUserId();
|
||||
logger.info("创建视频会议预约: roomId={}, workcaseId={}, userId={}, startTime={}, endTime={}",
|
||||
meetingDTO.getRoomId(), meetingDTO.getWorkcaseId(), userId,
|
||||
meetingDTO.getStartTime(), meetingDTO.getEndTime());
|
||||
|
||||
try {
|
||||
// 1. 验证用户是否为聊天室成员
|
||||
@@ -57,18 +72,25 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
return ResultDomain.failure("您不是聊天室成员,无法创建会议");
|
||||
}
|
||||
|
||||
// 2. 检查聊天室是否已有进行中的会议
|
||||
TbVideoMeetingDTO existingMeetingFilter = new TbVideoMeetingDTO();
|
||||
existingMeetingFilter.setRoomId(meetingDTO.getRoomId());
|
||||
existingMeetingFilter.setStatus("ongoing");
|
||||
List<VideoMeetingVO> existingMeetings = videoMeetingMapper.selectVideoMeetingList(existingMeetingFilter);
|
||||
// 2. 检查聊天室是否已有时间冲突的会议
|
||||
TbVideoMeetingDTO conflictFilter = new TbVideoMeetingDTO();
|
||||
conflictFilter.setRoomId(meetingDTO.getRoomId());
|
||||
conflictFilter.setStatus("scheduled"); // 只检查已安排的会议
|
||||
List<VideoMeetingVO> existingMeetings = videoMeetingMapper.selectVideoMeetingList(conflictFilter);
|
||||
|
||||
if (existingMeetings != null && !existingMeetings.isEmpty()) {
|
||||
logger.warn("聊天室已有进行中的会议: roomId={}", meetingDTO.getRoomId());
|
||||
return ResultDomain.failure("聊天室已有进行中的会议,请稍后再试");
|
||||
for (VideoMeetingVO existing : existingMeetings) {
|
||||
// 检查时间是否冲突
|
||||
if (isTimeConflict(meetingDTO.getStartTime(), meetingDTO.getEndTime(),
|
||||
existing.getStartTime(), existing.getEndTime())) {
|
||||
logger.warn("会议时间冲突: roomId={}, existingMeetingId={}",
|
||||
meetingDTO.getRoomId(), existing.getMeetingId());
|
||||
return ResultDomain.failure("该时间段已有会议安排,请选择其他时间");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 生成会议ID和房间名
|
||||
// 3. 生成会议ID和房间名(房间名暂时生成,真正创建Jitsi时会重新生成)
|
||||
String meetingId = IdUtil.generateUUID();
|
||||
String jitsiRoomName = jitsiTokenService.generateRoomName(meetingDTO.getWorkcaseId());
|
||||
|
||||
@@ -78,32 +100,21 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
memberFilter.setUserId(userId);
|
||||
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(memberFilter);
|
||||
|
||||
String userName = "用户";
|
||||
String userType = "guest";
|
||||
String userName = loginDomain.getUserInfo().getUsername();
|
||||
String userType = "guest".equals(loginDomain.getUser().getStatus())?"guest":"user";
|
||||
if (members != null && !members.isEmpty()) {
|
||||
ChatMemberVO member = members.get(0);
|
||||
userName = member.getUserName();
|
||||
userType = member.getUserType();
|
||||
}
|
||||
|
||||
// 5. 生成创建者的JWT Token(创建者默认为主持人)
|
||||
String jwtToken = jitsiTokenService.generateJwtToken(
|
||||
jitsiRoomName,
|
||||
userId,
|
||||
userName,
|
||||
true // 创建者为主持人
|
||||
);
|
||||
|
||||
// 6. 构建iframe URL
|
||||
String iframeUrl = jitsiTokenService.buildIframeUrl(jitsiRoomName, jwtToken, meetingDTO.getConfig());
|
||||
|
||||
// 7. 填充会议信息
|
||||
// 5. 填充会议预约信息(不生成JWT Token和iframe URL)
|
||||
meetingDTO.setMeetingId(meetingId);
|
||||
meetingDTO.setJitsiRoomName(jitsiRoomName);
|
||||
meetingDTO.setJwtToken(jwtToken); // 存储创建者的token(可选)
|
||||
meetingDTO.setIframeUrl(iframeUrl);
|
||||
meetingDTO.setStatus("scheduled");
|
||||
meetingDTO.setCreatorId(userId);
|
||||
meetingDTO.setJwtToken(null); // 预约阶段不生成token
|
||||
meetingDTO.setIframeUrl(null); // 预约阶段不生成URL
|
||||
meetingDTO.setStatus("scheduled"); // 状态为已安排
|
||||
meetingDTO.setCreator(userId);
|
||||
meetingDTO.setCreatorType(userType);
|
||||
meetingDTO.setCreatorName(userName);
|
||||
meetingDTO.setParticipantCount(0);
|
||||
@@ -113,24 +124,31 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
meetingDTO.setMaxParticipants(10);
|
||||
}
|
||||
|
||||
// 8. 插入数据库
|
||||
if (meetingDTO.getAdvance() == null) {
|
||||
meetingDTO.setAdvance(5); // 默认提前5分钟可入会
|
||||
}
|
||||
|
||||
// 6. 插入数据库
|
||||
int rows = videoMeetingMapper.insertVideoMeeting(meetingDTO);
|
||||
if (rows > 0) {
|
||||
logger.info("视频会议创建成功: meetingId={}, jitsiRoomName={}",
|
||||
meetingId, jitsiRoomName);
|
||||
logger.info("视频会议预约创建成功: meetingId={}, jitsiRoomName={}, startTime={}, endTime={}",
|
||||
meetingId, jitsiRoomName, meetingDTO.getStartTime(), meetingDTO.getEndTime());
|
||||
|
||||
// 9. 返回VO
|
||||
// 7. 发送会议通知消息到聊天室(内容为meetingId而非URL)
|
||||
sendMeetingNotification(meetingDTO, userName);
|
||||
|
||||
// 8. 返回VO
|
||||
VideoMeetingVO meetingVO = new VideoMeetingVO();
|
||||
BeanUtils.copyProperties(meetingDTO, meetingVO);
|
||||
return ResultDomain.success("创建会议成功", meetingVO);
|
||||
return ResultDomain.success("创建会议预约成功", meetingVO);
|
||||
} else {
|
||||
logger.error("插入会议记录失败: meetingId={}", meetingId);
|
||||
return ResultDomain.failure("创建会议失败");
|
||||
return ResultDomain.failure("创建会议预约失败");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("创建视频会议异常: roomId={}, error={}",
|
||||
logger.error("创建视频会议预约异常: roomId={}, error={}",
|
||||
meetingDTO.getRoomId(), e.getMessage(), e);
|
||||
return ResultDomain.failure("创建会议失败: " + e.getMessage());
|
||||
return ResultDomain.failure("创建会议预约失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +213,7 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ResultDomain<VideoMeetingVO> generateUserMeetingUrl(String meetingId, String userId) {
|
||||
logger.info("生成用户专属会议URL: meetingId={}, userId={}", meetingId, userId);
|
||||
|
||||
@@ -218,23 +237,96 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
return ResultDomain.failure("您无权访问此会议");
|
||||
}
|
||||
|
||||
// 3. 获取用户信息
|
||||
// 3. 检查会议时间窗口(仅对scheduled状态的会议检查)
|
||||
if ("scheduled".equals(meeting.getStatus())) {
|
||||
Date now = new Date();
|
||||
|
||||
// 计算提前入会时间点(开始时间 - advance 分钟)
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTime(meeting.getStartTime());
|
||||
calendar.add(Calendar.MINUTE, -meeting.getAdvance());
|
||||
Date advanceTime = calendar.getTime();
|
||||
|
||||
// 检查当前时间是否在允许入会的时间窗口内
|
||||
if (now.before(advanceTime)) {
|
||||
logger.warn("会议未到入会时间: meetingId={}, 当前时间={}, 提前入会时间={}",
|
||||
meetingId, now, advanceTime);
|
||||
return ResultDomain.failure("会议未到入会时间,请在 " + advanceTime + " 之后加入");
|
||||
}
|
||||
|
||||
if (now.after(meeting.getEndTime())) {
|
||||
logger.warn("会议已结束: meetingId={}, 当前时间={}, 结束时间={}",
|
||||
meetingId, now, meeting.getEndTime());
|
||||
return ResultDomain.failure("会议已结束");
|
||||
}
|
||||
|
||||
// 4. 使用ReentrantLock进行双检锁:首次用户入会时创建Jitsi会议室
|
||||
// 获取或创建该会议的锁对象
|
||||
ReentrantLock lock = meetingLocks.computeIfAbsent(meetingId, k -> new ReentrantLock());
|
||||
|
||||
logger.info("尝试获取会议创建锁: meetingId={}", meetingId);
|
||||
lock.lock(); // 阻塞等待获取锁
|
||||
try {
|
||||
logger.info("成功获取会议创建锁: meetingId={}, userId={}", meetingId, userId);
|
||||
|
||||
// 双重检查:再次查询数据库确认会议状态(防止其他线程已创建)
|
||||
List<VideoMeetingVO> recheck = videoMeetingMapper.selectVideoMeetingList(filter);
|
||||
if (recheck != null && !recheck.isEmpty()) {
|
||||
VideoMeetingVO recheckMeeting = recheck.get(0);
|
||||
|
||||
if ("scheduled".equals(recheckMeeting.getStatus())) {
|
||||
logger.info("首次创建Jitsi会议室: meetingId={}", meetingId);
|
||||
|
||||
// 更新会议状态为进行中
|
||||
TbVideoMeetingDTO updateDTO = new TbVideoMeetingDTO();
|
||||
updateDTO.setMeetingId(meetingId);
|
||||
updateDTO.setStatus("ongoing");
|
||||
updateDTO.setActualStartTime(new Date());
|
||||
|
||||
int rows = videoMeetingMapper.updateVideoMeeting(updateDTO);
|
||||
if (rows > 0) {
|
||||
logger.info("Jitsi会议室创建成功,会议状态已更新: meetingId={}", meetingId);
|
||||
meeting.setStatus("ongoing");
|
||||
meeting.setActualStartTime(new Date());
|
||||
} else {
|
||||
logger.error("更新会议状态失败: meetingId={}", meetingId);
|
||||
return ResultDomain.failure("创建会议失败");
|
||||
}
|
||||
} else {
|
||||
logger.info("会议已被其他用户创建: meetingId={}, status={}",
|
||||
meetingId, recheckMeeting.getStatus());
|
||||
meeting.setStatus(recheckMeeting.getStatus());
|
||||
meeting.setActualStartTime(recheckMeeting.getActualStartTime());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
logger.info("释放会议创建锁: meetingId={}", meetingId);
|
||||
|
||||
// 清理锁对象:如果没有其他线程在等待,则移除锁
|
||||
if (!lock.hasQueuedThreads()) {
|
||||
meetingLocks.remove(meetingId);
|
||||
logger.debug("清理会议锁对象: meetingId={}", meetingId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 获取用户信息
|
||||
TbChatRoomMemberDTO memberFilter = new TbChatRoomMemberDTO();
|
||||
memberFilter.setRoomId(meeting.getRoomId());
|
||||
memberFilter.setUserId(userId);
|
||||
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(memberFilter);
|
||||
|
||||
String userName = "用户";
|
||||
boolean isModerator = false;
|
||||
// 会议创建人才是主持人
|
||||
boolean isModerator = userId.equals(meeting.getCreator());
|
||||
|
||||
if (members != null && !members.isEmpty()) {
|
||||
ChatMemberVO member = members.get(0);
|
||||
userName = member.getUserName();
|
||||
// 客服人员设为主持人
|
||||
isModerator = "agent".equals(member.getUserType());
|
||||
}
|
||||
|
||||
// 4. 生成用户专属JWT Token
|
||||
// 6. 生成用户专属JWT Token
|
||||
String userJwtToken = jitsiTokenService.generateJwtToken(
|
||||
meeting.getJitsiRoomName(),
|
||||
userId,
|
||||
@@ -242,18 +334,19 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
isModerator
|
||||
);
|
||||
|
||||
// 5. 构建用户专属iframe URL
|
||||
// 7. 构建用户专属iframe URL
|
||||
String userIframeUrl = jitsiTokenService.buildIframeUrl(
|
||||
meeting.getJitsiRoomName(),
|
||||
userJwtToken,
|
||||
meeting.getConfig()
|
||||
);
|
||||
|
||||
// 6. 更新VO
|
||||
// 8. 更新VO
|
||||
meeting.setJwtToken(userJwtToken);
|
||||
meeting.setIframeUrl(userIframeUrl);
|
||||
|
||||
logger.info("生成用户专属会议URL成功: meetingId={}, userId={}", meetingId, userId);
|
||||
logger.info("生成用户专属会议URL成功: meetingId={}, userId={}, status={}",
|
||||
meetingId, userId, meeting.getStatus());
|
||||
return ResultDomain.success("生成用户专属会议URL成功", meeting);
|
||||
} catch (Exception e) {
|
||||
logger.error("生成用户专属会议URL异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
|
||||
@@ -383,4 +476,62 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送会议通知消息到聊天室
|
||||
* @param meetingDTO 会议信息
|
||||
* @param creatorName 创建者名称
|
||||
*/
|
||||
private void sendMeetingNotification(TbVideoMeetingDTO meetingDTO, String creatorName) {
|
||||
logger.info("发送会议通知消息: roomId={}, meetingId={}",
|
||||
meetingDTO.getRoomId(), meetingDTO.getMeetingId());
|
||||
|
||||
// 构建消息内容
|
||||
TbChatRoomMessageDTO message = new TbChatRoomMessageDTO();
|
||||
message.setMessageId(IdUtil.generateUUID());
|
||||
message.setRoomId(meetingDTO.getRoomId());
|
||||
message.setSenderId(meetingDTO.getCreator());
|
||||
message.setSenderType(meetingDTO.getCreatorType());
|
||||
message.setSenderName(creatorName);
|
||||
message.setMessageType("meet"); // 会议类型消息
|
||||
message.setContent(meetingDTO.getMeetingId()); // 会议ID作为内容(前端根据ID查询会议详情)
|
||||
message.setStatus("sent");
|
||||
message.setReadCount(0);
|
||||
message.setSendTime(new Date());
|
||||
|
||||
// 构建扩展信息(会议详情)
|
||||
JSONObject contentExtra = new JSONObject();
|
||||
contentExtra.put("meetingId", meetingDTO.getMeetingId());
|
||||
contentExtra.put("meetingName", meetingDTO.getMeetingName());
|
||||
contentExtra.put("jitsiRoomName", meetingDTO.getJitsiRoomName());
|
||||
contentExtra.put("startTime", meetingDTO.getStartTime());
|
||||
contentExtra.put("endTime", meetingDTO.getEndTime());
|
||||
contentExtra.put("advance", meetingDTO.getAdvance());
|
||||
contentExtra.put("maxParticipants", meetingDTO.getMaxParticipants());
|
||||
contentExtra.put("creatorName", creatorName);
|
||||
contentExtra.put("workcaseId", meetingDTO.getWorkcaseId());
|
||||
contentExtra.put("status", meetingDTO.getStatus());
|
||||
message.setContentExtra(contentExtra);
|
||||
|
||||
// 发送消息
|
||||
ResultDomain<TbChatRoomMessageDTO> sendResult = chatRoomService.sendMessage(message);
|
||||
if (sendResult.getSuccess()) {
|
||||
logger.info("会议通知消息发送成功: messageId={}", message.getMessageId());
|
||||
} else {
|
||||
logger.warn("会议通知消息发送失败: {}", sendResult.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查时间是否冲突
|
||||
* @param start1 时间段1开始时间
|
||||
* @param end1 时间段1结束时间
|
||||
* @param start2 时间段2开始时间
|
||||
* @param end2 时间段2结束时间
|
||||
* @return 是否冲突
|
||||
*/
|
||||
private boolean isTimeConflict(Date start1, Date end1, Date start2, Date end2) {
|
||||
// 时间段1的结束时间 > 时间段2的开始时间 AND 时间段1的开始时间 < 时间段2的结束时间
|
||||
return end1.after(start2) && start1.before(end2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,4 +92,20 @@ logging:
|
||||
console: UTF-8
|
||||
file: UTF-8
|
||||
level:
|
||||
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: TRACE
|
||||
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: TRACE
|
||||
|
||||
# ================== Jitsi Meet 视频会议配置 ==================
|
||||
jitsi:
|
||||
app:
|
||||
# 应用ID(必须与Docker配置中的JWT_APP_ID一致)
|
||||
id: urbanLifeline
|
||||
# JWT密钥(必须与Docker配置中的JWT_APP_SECRET一致)
|
||||
# 警告:生产环境请修改为强随机字符串,并妥善保管
|
||||
# 注意:HS256算法要求密钥长度至少32字节(256 bits)
|
||||
secret: urbanLifeline-jitsi-secret-key-2025-production-safe-hs256
|
||||
server:
|
||||
# Jitsi Meet服务器地址(Docker部署在本地8280端口)
|
||||
url: http://192.168.0.253:8280
|
||||
token:
|
||||
# JWT Token有效期(毫秒)- 默认2小时
|
||||
expiration: 7200000
|
||||
@@ -12,7 +12,7 @@
|
||||
<result column="message_type" property="messageType" jdbcType="VARCHAR"/>
|
||||
<result column="content" property="content" jdbcType="VARCHAR"/>
|
||||
<result column="files" property="files" jdbcType="ARRAY" typeHandler="org.xyzh.common.jdbc.handler.StringArrayTypeHandler"/>
|
||||
<result column="content_extra" property="contentExtra" jdbcType="OTHER" typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
|
||||
<result column="content_extra" property="contentExtra" jdbcType="OTHER" typeHandler="org.xyzh.common.jdbc.handler.FastJson2TypeHandler"/>
|
||||
<result column="reply_to_msg_id" property="replyToMsgId" jdbcType="VARCHAR"/>
|
||||
<result column="is_ai_message" property="isAiMessage" jdbcType="BOOLEAN"/>
|
||||
<result column="ai_message_id" property="aiMessageId" jdbcType="VARCHAR"/>
|
||||
@@ -34,7 +34,7 @@
|
||||
<result column="message_type" property="messageType" jdbcType="VARCHAR"/>
|
||||
<result column="content" property="content" jdbcType="VARCHAR"/>
|
||||
<result column="files" property="files" jdbcType="ARRAY" typeHandler="org.xyzh.common.jdbc.handler.StringArrayTypeHandler"/>
|
||||
<result column="content_extra" property="contentExtra" jdbcType="OTHER" typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
|
||||
<result column="content_extra" property="contentExtra" jdbcType="OTHER" typeHandler="org.xyzh.common.jdbc.handler.FastJson2TypeHandler"/>
|
||||
<result column="reply_to_msg_id" property="replyToMsgId" jdbcType="VARCHAR"/>
|
||||
<result column="is_ai_message" property="isAiMessage" jdbcType="BOOLEAN"/>
|
||||
<result column="ai_message_id" property="aiMessageId" jdbcType="VARCHAR"/>
|
||||
@@ -66,7 +66,7 @@
|
||||
#{optsn}, #{messageId}, #{roomId}, #{senderId}, #{senderType}, #{senderName}, #{content}, #{creator}
|
||||
<if test="messageType != null">, #{messageType}</if>
|
||||
<if test="files != null">, #{files, typeHandler=org.xyzh.common.jdbc.handler.StringArrayTypeHandler}</if>
|
||||
<if test="contentExtra != null">, #{contentExtra, typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler}</if>
|
||||
<if test="contentExtra != null">, #{contentExtra, typeHandler=org.xyzh.common.jdbc.handler.FastJson2TypeHandler}::jsonb</if>
|
||||
<if test="replyToMsgId != null">, #{replyToMsgId}</if>
|
||||
<if test="isAiMessage != null">, #{isAiMessage}</if>
|
||||
<if test="aiMessageId != null">, #{aiMessageId}</if>
|
||||
|
||||
@@ -9,21 +9,24 @@
|
||||
<result column="workcase_id" property="workcaseId" jdbcType="VARCHAR"/>
|
||||
<result column="meeting_name" property="meetingName" jdbcType="VARCHAR"/>
|
||||
<result column="meeting_password" property="meetingPassword" jdbcType="VARCHAR"/>
|
||||
<result column="description" property="description" jdbcType="VARCHAR"/>
|
||||
<result column="jwt_token" property="jwtToken" jdbcType="VARCHAR"/>
|
||||
<result column="jitsi_room_name" property="jitsiRoomName" jdbcType="VARCHAR"/>
|
||||
<result column="jitsi_server_url" property="jitsiServerUrl" jdbcType="VARCHAR"/>
|
||||
<result column="status" property="status" jdbcType="VARCHAR"/>
|
||||
<result column="creator_id" property="creatorId" jdbcType="VARCHAR"/>
|
||||
<result column="creator" property="creator" jdbcType="VARCHAR"/>
|
||||
<result column="creator_type" property="creatorType" jdbcType="VARCHAR"/>
|
||||
<result column="creator_name" property="creatorName" jdbcType="VARCHAR"/>
|
||||
<result column="participant_count" property="participantCount" jdbcType="INTEGER"/>
|
||||
<result column="max_participants" property="maxParticipants" jdbcType="INTEGER"/>
|
||||
<result column="start_time" property="actualStartTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="end_time" property="actualEndTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="start_time" property="startTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="end_time" property="endTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="advance" property="advance" jdbcType="INTEGER"/>
|
||||
<result column="actual_start_time" property="actualStartTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="actual_end_time" property="actualEndTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="duration_seconds" property="durationSeconds" jdbcType="INTEGER"/>
|
||||
<result column="iframe_url" property="iframeUrl" jdbcType="VARCHAR"/>
|
||||
<result column="config" property="config" jdbcType="OTHER" typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
|
||||
<result column="creator" property="creator" jdbcType="VARCHAR"/>
|
||||
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="delete_time" property="deleteTime" jdbcType="TIMESTAMP"/>
|
||||
@@ -37,17 +40,20 @@
|
||||
<result column="workcase_id" property="workcaseId" jdbcType="VARCHAR"/>
|
||||
<result column="meeting_name" property="meetingName" jdbcType="VARCHAR"/>
|
||||
<result column="meeting_password" property="meetingPassword" jdbcType="VARCHAR"/>
|
||||
<result column="description" property="description" jdbcType="VARCHAR"/>
|
||||
<result column="jwt_token" property="jwtToken" jdbcType="VARCHAR"/>
|
||||
<result column="jitsi_room_name" property="jitsiRoomName" jdbcType="VARCHAR"/>
|
||||
<result column="jitsi_server_url" property="jitsiServerUrl" jdbcType="VARCHAR"/>
|
||||
<result column="status" property="status" jdbcType="VARCHAR"/>
|
||||
<result column="creator_id" property="creatorId" jdbcType="VARCHAR"/>
|
||||
<result column="creator_type" property="creatorType" jdbcType="VARCHAR"/>
|
||||
<result column="creator_name" property="creatorName" jdbcType="VARCHAR"/>
|
||||
<result column="participant_count" property="participantCount" jdbcType="INTEGER"/>
|
||||
<result column="max_participants" property="maxParticipants" jdbcType="INTEGER"/>
|
||||
<result column="start_time" property="actualStartTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="end_time" property="actualEndTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="start_time" property="startTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="end_time" property="endTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="advance" property="advance" jdbcType="INTEGER"/>
|
||||
<result column="actual_start_time" property="actualStartTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="actual_end_time" property="actualEndTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="duration_seconds" property="durationSeconds" jdbcType="INTEGER"/>
|
||||
<result column="iframe_url" property="iframeUrl" jdbcType="VARCHAR"/>
|
||||
<result column="config" property="config" jdbcType="OTHER" typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
|
||||
@@ -59,29 +65,38 @@
|
||||
</resultMap>
|
||||
|
||||
<sql id="Base_Column_List">
|
||||
meeting_id, optsn, room_id, workcase_id, meeting_name, meeting_password, jwt_token,
|
||||
jitsi_room_name, jitsi_server_url, status, creator_id, creator_type, creator_name,
|
||||
participant_count, max_participants, start_time, end_time, duration_seconds, iframe_url,
|
||||
meeting_id, optsn, room_id, workcase_id, meeting_name, meeting_password, description, jwt_token,
|
||||
jitsi_room_name, jitsi_server_url, status, creator_type, creator_name,
|
||||
participant_count, max_participants, start_time, end_time, advance,
|
||||
actual_start_time, actual_end_time, duration_seconds, iframe_url,
|
||||
config, creator, create_time, update_time, delete_time, deleted
|
||||
</sql>
|
||||
|
||||
<insert id="insertVideoMeeting" parameterType="org.xyzh.api.workcase.dto.TbVideoMeetingDTO">
|
||||
INSERT INTO workcase.tb_video_meeting (
|
||||
optsn, meeting_id, room_id, workcase_id, meeting_name, jitsi_room_name, creator_id, creator_type, creator_name, creator
|
||||
optsn, meeting_id, room_id, workcase_id, meeting_name, jitsi_room_name, creator_type, creator_name, creator
|
||||
<if test="meetingPassword != null">, meeting_password</if>
|
||||
<if test="description != null">, description</if>
|
||||
<if test="jwtToken != null">, jwt_token</if>
|
||||
<if test="jitsiServerUrl != null">, jitsi_server_url</if>
|
||||
<if test="status != null">, status</if>
|
||||
<if test="maxParticipants != null">, max_participants</if>
|
||||
<if test="startTime != null">, start_time</if>
|
||||
<if test="endTime != null">, end_time</if>
|
||||
<if test="advance != null">, advance</if>
|
||||
<if test="iframeUrl != null">, iframe_url</if>
|
||||
<if test="config != null">, config</if>
|
||||
) VALUES (
|
||||
#{optsn}, #{meetingId}, #{roomId}, #{workcaseId}, #{meetingName}, #{jitsiRoomName}, #{creatorId}, #{creatorType}, #{creatorName}, #{creator}
|
||||
#{optsn}, #{meetingId}, #{roomId}, #{workcaseId}, #{meetingName}, #{jitsiRoomName}, #{creatorType}, #{creatorName}, #{creator}
|
||||
<if test="meetingPassword != null">, #{meetingPassword}</if>
|
||||
<if test="description != null">, #{description}</if>
|
||||
<if test="jwtToken != null">, #{jwtToken}</if>
|
||||
<if test="jitsiServerUrl != null">, #{jitsiServerUrl}</if>
|
||||
<if test="status != null">, #{status}</if>
|
||||
<if test="maxParticipants != null">, #{maxParticipants}</if>
|
||||
<if test="startTime != null">, #{startTime}</if>
|
||||
<if test="endTime != null">, #{endTime}</if>
|
||||
<if test="advance != null">, #{advance}</if>
|
||||
<if test="iframeUrl != null">, #{iframeUrl}</if>
|
||||
<if test="config != null">, #{config, typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler}</if>
|
||||
)
|
||||
@@ -92,11 +107,15 @@
|
||||
<set>
|
||||
<if test="meetingName != null and meetingName != ''">meeting_name = #{meetingName},</if>
|
||||
<if test="meetingPassword != null">meeting_password = #{meetingPassword},</if>
|
||||
<if test="description != null">description = #{description},</if>
|
||||
<if test="jwtToken != null">jwt_token = #{jwtToken},</if>
|
||||
<if test="status != null and status != ''">status = #{status},</if>
|
||||
<if test="participantCount != null">participant_count = #{participantCount},</if>
|
||||
<if test="actualStartTime != null">start_time = #{actualStartTime},</if>
|
||||
<if test="actualEndTime != null">end_time = #{actualEndTime},</if>
|
||||
<if test="startTime != null">start_time = #{startTime},</if>
|
||||
<if test="endTime != null">end_time = #{endTime},</if>
|
||||
<if test="advance != null">advance = #{advance},</if>
|
||||
<if test="actualStartTime != null">actual_start_time = #{actualStartTime},</if>
|
||||
<if test="actualEndTime != null">actual_end_time = #{actualEndTime},</if>
|
||||
<if test="durationSeconds != null">duration_seconds = #{durationSeconds},</if>
|
||||
<if test="iframeUrl != null">iframe_url = #{iframeUrl},</if>
|
||||
<if test="config != null">config = #{config, typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler},</if>
|
||||
@@ -126,7 +145,7 @@
|
||||
<if test="filter.workcaseId != null and filter.workcaseId != ''">AND workcase_id = #{filter.workcaseId}</if>
|
||||
<if test="filter.meetingName != null and filter.meetingName != ''">AND meeting_name LIKE CONCAT('%', #{filter.meetingName}, '%')</if>
|
||||
<if test="filter.status != null and filter.status != ''">AND status = #{filter.status}</if>
|
||||
<if test="filter.creatorId != null and filter.creatorId != ''">AND creator_id = #{filter.creatorId}</if>
|
||||
<if test="filter.creator != null and filter.creator != ''">AND creator = #{filter.creator}</if>
|
||||
<if test="filter.creatorType != null and filter.creatorType != ''">AND creator_type = #{filter.creatorType}</if>
|
||||
AND deleted = false
|
||||
</where>
|
||||
@@ -142,7 +161,7 @@
|
||||
<if test="filter.workcaseId != null and filter.workcaseId != ''">AND workcase_id = #{filter.workcaseId}</if>
|
||||
<if test="filter.meetingName != null and filter.meetingName != ''">AND meeting_name LIKE CONCAT('%', #{filter.meetingName}, '%')</if>
|
||||
<if test="filter.status != null and filter.status != ''">AND status = #{filter.status}</if>
|
||||
<if test="filter.creatorId != null and filter.creatorId != ''">AND creator_id = #{filter.creatorId}</if>
|
||||
<if test="filter.creator != null and filter.creator != ''">AND creator = #{filter.creator}</if>
|
||||
<if test="filter.creatorType != null and filter.creatorType != ''">AND creator_type = #{filter.creatorType}</if>
|
||||
AND deleted = false
|
||||
</where>
|
||||
@@ -159,7 +178,7 @@
|
||||
<if test="filter.workcaseId != null and filter.workcaseId != ''">AND workcase_id = #{filter.workcaseId}</if>
|
||||
<if test="filter.meetingName != null and filter.meetingName != ''">AND meeting_name LIKE CONCAT('%', #{filter.meetingName}, '%')</if>
|
||||
<if test="filter.status != null and filter.status != ''">AND status = #{filter.status}</if>
|
||||
<if test="filter.creatorId != null and filter.creatorId != ''">AND creator_id = #{filter.creatorId}</if>
|
||||
<if test="filter.creator != null and filter.creator != ''">AND creator = #{filter.creator}</if>
|
||||
<if test="filter.creatorType != null and filter.creatorType != ''">AND creator_type = #{filter.creatorType}</if>
|
||||
AND deleted = false
|
||||
</where>
|
||||
|
||||
@@ -154,7 +154,6 @@ jwt_token -- JWT Token(身份验证)
|
||||
jitsi_room_name -- Jitsi房间名(格式:workcase_{workcase_id}_{timestamp})
|
||||
jitsi_server_url -- Jitsi服务器地址(默认:https://meet.jit.si)
|
||||
status -- 状态:scheduled ongoing ended cancelled
|
||||
creator_id -- 创建者ID
|
||||
creator_type -- 创建者类型:guest-来客 agent-客服
|
||||
creator_name -- 创建者名称
|
||||
participant_count -- 参与人数
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { http } from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 创建视频会议参数
|
||||
*/
|
||||
export interface CreateMeetingParams {
|
||||
roomId: string
|
||||
workcaseId?: string
|
||||
meetingName: string
|
||||
maxParticipants?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频会议VO
|
||||
*/
|
||||
export interface VideoMeetingVO {
|
||||
meetingId: string
|
||||
roomId: string
|
||||
workcaseId?: string
|
||||
meetingName: string
|
||||
meetingPassword?: string
|
||||
jwtToken: string
|
||||
jitsiRoomName: string
|
||||
jitsiServerUrl: string
|
||||
status: string
|
||||
creatorId: string
|
||||
creatorType: string
|
||||
creatorName: string
|
||||
participantCount: number
|
||||
maxParticipants: number
|
||||
actualStartTime?: string
|
||||
actualEndTime?: string
|
||||
durationSeconds?: number
|
||||
durationFormatted?: string
|
||||
iframeUrl: string
|
||||
config?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建视频会议
|
||||
*/
|
||||
export const createVideoMeeting = (params: CreateMeetingParams) => {
|
||||
return http.post<VideoMeetingVO>('/workcase/chat/meeting/create', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会议信息
|
||||
*/
|
||||
export const getMeetingInfo = (meetingId: string) => {
|
||||
return http.get<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天室活跃会议
|
||||
*/
|
||||
export const getActiveMeeting = (roomId: string) => {
|
||||
return http.get<VideoMeetingVO>(`/workcase/chat/meeting/room/${roomId}/active`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会议(生成用户专属JWT)
|
||||
*/
|
||||
export const joinMeeting = (meetingId: string) => {
|
||||
return http.post<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}/join`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始会议
|
||||
*/
|
||||
export const startVideoMeeting = (meetingId: string) => {
|
||||
return http.post(`/workcase/chat/meeting/${meetingId}/start`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束会议
|
||||
*/
|
||||
export const endVideoMeeting = (meetingId: string) => {
|
||||
return http.post<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}/end`)
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import type {
|
||||
TbChatRoomMessageDTO,
|
||||
TbCustomerServiceDTO,
|
||||
TbWordCloudDTO,
|
||||
TbVideoMeetingDTO,
|
||||
ChatRoomVO,
|
||||
ChatMemberVO,
|
||||
ChatRoomMessageVO,
|
||||
CustomerServiceVO
|
||||
CustomerServiceVO,
|
||||
VideoMeetingVO
|
||||
} from '@/types/workcase'
|
||||
|
||||
/**
|
||||
@@ -220,5 +222,55 @@ export const workcaseChatAPI = {
|
||||
async getWordCloudPage(pageRequest: PageRequest<TbWordCloudDTO>): Promise<ResultDomain<TbWordCloudDTO>> {
|
||||
const response = await api.post<TbWordCloudDTO>(`${this.baseUrl}/wordcloud/page`, pageRequest)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// ====================== 视频会议管理(Jitsi Meet) ======================
|
||||
|
||||
/**
|
||||
* 创建视频会议
|
||||
*/
|
||||
async createVideoMeeting(meeting: TbVideoMeetingDTO): Promise<ResultDomain<VideoMeetingVO>> {
|
||||
const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/create`, meeting)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取会议信息
|
||||
*/
|
||||
async getVideoMeetingInfo(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> {
|
||||
const response = await api.get<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取聊天室活跃会议
|
||||
*/
|
||||
async getActiveMeeting(roomId: string): Promise<ResultDomain<VideoMeetingVO>> {
|
||||
const response = await api.get<VideoMeetingVO>(`${this.baseUrl}/meeting/room/${roomId}/active`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 加入会议(生成用户专属JWT)
|
||||
*/
|
||||
async joinVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> {
|
||||
const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}/join`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 开始会议
|
||||
*/
|
||||
async startVideoMeeting(meetingId: string): Promise<ResultDomain<boolean>> {
|
||||
const response = await api.post<boolean>(`${this.baseUrl}/meeting/${meetingId}/start`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 结束会议
|
||||
*/
|
||||
async endVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> {
|
||||
const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}/end`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ChatRoom } from './chatRoom/ChatRoom.vue';
|
||||
@@ -1 +0,0 @@
|
||||
export * from './chatRoom'
|
||||
@@ -71,6 +71,7 @@ export interface TbVideoMeetingDTO extends BaseDTO {
|
||||
workcaseId?: string
|
||||
meetingName?: string
|
||||
meetingPassword?: string
|
||||
description?: string
|
||||
jwtToken?: string
|
||||
jitsiRoomName?: string
|
||||
jitsiServerUrl?: string
|
||||
@@ -80,6 +81,12 @@ export interface TbVideoMeetingDTO extends BaseDTO {
|
||||
creatorName?: string
|
||||
participantCount?: number
|
||||
maxParticipants?: number
|
||||
/** 预定开始时间 */
|
||||
startTime?: string
|
||||
/** 预定结束时间 */
|
||||
endTime?: string
|
||||
/** 提前入会时间(分钟) */
|
||||
advance?: number
|
||||
actualStartTime?: string
|
||||
actualEndTime?: string
|
||||
durationSeconds?: number
|
||||
@@ -223,17 +230,23 @@ export interface VideoMeetingVO extends BaseVO {
|
||||
workcaseId?: string
|
||||
meetingName?: string
|
||||
meetingPassword?: string
|
||||
description?: string
|
||||
jwtToken?: string
|
||||
jitsiRoomName?: string
|
||||
jitsiServerUrl?: string
|
||||
status?: string
|
||||
creatorId?: string
|
||||
creatorType?: string
|
||||
creatorName?: string
|
||||
participantCount?: number
|
||||
maxParticipants?: number
|
||||
startTime?: string
|
||||
// 预定开始时间
|
||||
startTime?: string
|
||||
// 预定结束时间
|
||||
endTime?: string
|
||||
// 提前入会时间(分钟)
|
||||
advance?: number
|
||||
actualStartTime?: string
|
||||
actualEndTime?: string
|
||||
durationSeconds?: number
|
||||
durationFormatted?: string
|
||||
iframeUrl?: string
|
||||
@@ -279,6 +292,9 @@ export interface SendMessageParam {
|
||||
export interface CreateMeetingParam {
|
||||
roomId: string
|
||||
workcaseId: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
advance?: number
|
||||
meetingName?: string
|
||||
meetingPassword?: string
|
||||
maxParticipants?: number
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
ref="chatRoomRef"
|
||||
:messages="messages"
|
||||
:current-user-id="loginDomain.user.userId"
|
||||
:room-id="currentRoomId"
|
||||
:workcase-id="currentWorkcaseId"
|
||||
:room-name="currentRoom?.roomName"
|
||||
:meeting-url="currentMeetingUrl"
|
||||
:show-meeting="showMeetingIframe"
|
||||
@@ -163,7 +165,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { ElButton, ElInput, ElDialog, ElMessage } from 'element-plus'
|
||||
import { Search, FileText, MessageSquare, MessageCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { ChatRoom } from '@/components/chatRoom'
|
||||
import ChatRoom from './chatRoom/ChatRoom.vue'
|
||||
import WorkcaseDetail from '@/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue'
|
||||
import { workcaseChatAPI } from '@/api/workcase'
|
||||
import { fileAPI } from 'shared/api/file'
|
||||
@@ -248,6 +250,7 @@ const showWorkcaseCreator = ref(false)
|
||||
// Jitsi Meet会议相关
|
||||
const currentMeetingUrl = ref('')
|
||||
const showMeetingIframe = ref(false)
|
||||
const currentMeetingId = ref<string | null>(null)
|
||||
|
||||
// ChatRoom组件引用
|
||||
const chatRoomRef = ref<InstanceType<typeof ChatRoom> | null>(null)
|
||||
@@ -512,10 +515,50 @@ const onWorkcaseCreated = (workcaseId: string) => {
|
||||
const startMeeting = async () => {
|
||||
if (!currentRoomId.value) return
|
||||
|
||||
// TODO: 调用后端API创建Jitsi会议
|
||||
const meetingId = 'meeting-' + currentRoomId.value + '-' + Date.now()
|
||||
currentMeetingUrl.value = `https://meet.jit.si/${meetingId}`
|
||||
showMeetingIframe.value = true
|
||||
try {
|
||||
// 先检查是否有活跃会议
|
||||
const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value)
|
||||
if (activeResult.success && activeResult.data) {
|
||||
// 已有活跃会议,直接加入
|
||||
currentMeetingId.value = activeResult.data.meetingId!
|
||||
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
|
||||
if (joinResult.success && joinResult.data?.iframeUrl) {
|
||||
currentMeetingUrl.value = joinResult.data.iframeUrl
|
||||
showMeetingIframe.value = true
|
||||
} else {
|
||||
ElMessage.error(joinResult.message || '加入会议失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 没有活跃会议,创建新会议
|
||||
const createResult = await workcaseChatAPI.createVideoMeeting({
|
||||
roomId: currentRoomId.value,
|
||||
meetingName: currentRoom.value?.roomName || '视频会议'
|
||||
})
|
||||
|
||||
if (createResult.success && createResult.data) {
|
||||
currentMeetingId.value = createResult.data.meetingId!
|
||||
|
||||
// 开始会议
|
||||
await workcaseChatAPI.startVideoMeeting(currentMeetingId.value!)
|
||||
|
||||
// 加入会议获取iframe URL
|
||||
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
|
||||
if (joinResult.success && joinResult.data?.iframeUrl) {
|
||||
currentMeetingUrl.value = joinResult.data.iframeUrl
|
||||
showMeetingIframe.value = true
|
||||
ElMessage.success('会议已创建')
|
||||
} else {
|
||||
ElMessage.error(joinResult.message || '获取会议链接失败')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(createResult.message || '创建会议失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发起会议失败:', error)
|
||||
ElMessage.error('发起会议失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动聊天消息到底部
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
.meeting-card {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&--scheduled {
|
||||
border-left: 4px solid #409eff;
|
||||
}
|
||||
|
||||
&--ongoing {
|
||||
border-left: 4px solid #67c23a;
|
||||
}
|
||||
|
||||
&--ended {
|
||||
border-left: 4px solid #909399;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.meeting-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.meeting-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.meeting-card-status {
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-scheduled {
|
||||
background-color: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.status-ongoing {
|
||||
background-color: #f0f9ff;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.status-ended {
|
||||
background-color: #f4f4f5;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meeting-card-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
|
||||
div {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.meeting-card-content {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.meeting-card-desc {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.meeting-card-action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
|
||||
.meeting-card-countdown {
|
||||
font-size: 14px;
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<!-- 消息会议卡片 -->
|
||||
<div class="meeting-card" :class="`meeting-card--${meeting.status}`">
|
||||
<div class="meeting-card-header">
|
||||
<div class="meeting-card-title">{{ meeting.meetingName }}</div>
|
||||
<div class="meeting-card-status">
|
||||
<span v-if="meeting.status === 'scheduled'" class="status-badge status-scheduled">预定</span>
|
||||
<span v-else-if="meeting.status === 'ongoing'" class="status-badge status-ongoing">进行中</span>
|
||||
<span v-else-if="meeting.status === 'ended'" class="status-badge status-ended">已结束</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meeting-card-time">
|
||||
<div>开始时间:{{ formatDateTime(meeting.startTime) }}</div>
|
||||
<div>结束时间:{{ formatDateTime(meeting.endTime) }}</div>
|
||||
<div v-if="meeting.advance">提前入会:{{ meeting.advance }}分钟</div>
|
||||
</div>
|
||||
<div v-if="meeting.description" class="meeting-card-content">
|
||||
<div class="meeting-card-desc">{{ meeting.description }}</div>
|
||||
</div>
|
||||
<div class="meeting-card-action">
|
||||
<span class="meeting-card-countdown">{{ countdownText }}</span>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="!canJoinMeeting"
|
||||
@click="handleJoinMeeting"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElButton, ElMessage } from 'element-plus'
|
||||
import type { VideoMeetingVO } from '@/types'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
interface Props {
|
||||
meeting: VideoMeetingVO
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
join: [meetingId: string]
|
||||
}>()
|
||||
|
||||
// 当前时间,每秒更新
|
||||
const currentTime = ref(Date.now())
|
||||
let timer: number | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// 每秒更新当前时间
|
||||
timer = window.setInterval(() => {
|
||||
currentTime.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
function formatDateTime(dateStr?: string): string {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算倒计时文本
|
||||
*/
|
||||
const countdownText = computed(() => {
|
||||
const { meeting } = props
|
||||
if (!meeting || !meeting.startTime || !meeting.endTime) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const advanceMinutes = meeting.advance || 0
|
||||
const now = currentTime.value
|
||||
const startTime = new Date(meeting.startTime).getTime()
|
||||
const endTime = new Date(meeting.endTime).getTime()
|
||||
|
||||
// 检查时间解析是否有效
|
||||
if (isNaN(startTime) || isNaN(endTime)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const advanceTime = startTime - advanceMinutes * 60 * 1000
|
||||
|
||||
if (meeting.status === 'ended') {
|
||||
return '会议已结束'
|
||||
}
|
||||
|
||||
if (now < advanceTime) {
|
||||
// 未到提前入会时间
|
||||
const leftMs = advanceTime - now
|
||||
const leftMinutes = Math.floor(leftMs / 60000)
|
||||
const leftSeconds = Math.floor((leftMs % 60000) / 1000)
|
||||
|
||||
if (leftMinutes >= 60) {
|
||||
const hours = Math.floor(leftMinutes / 60)
|
||||
const mins = leftMinutes % 60
|
||||
return `距离入会:${hours}小时${mins}分钟`
|
||||
} else if (leftMinutes > 0) {
|
||||
return `距离入会:${leftMinutes}分${leftSeconds}秒`
|
||||
} else {
|
||||
return `距离入会:${leftSeconds}秒`
|
||||
}
|
||||
} else if (now < startTime) {
|
||||
// 在提前入会时间窗口内,但未到开始时间
|
||||
return '可以入会'
|
||||
} else if (now < endTime) {
|
||||
// 会议进行中
|
||||
if (meeting.status === 'ongoing') {
|
||||
return '会议进行中'
|
||||
} else {
|
||||
return '可以入会'
|
||||
}
|
||||
} else {
|
||||
// 已超过结束时间
|
||||
return '会议已超时'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否可以加入会议
|
||||
*/
|
||||
const canJoinMeeting = computed(() => {
|
||||
const { meeting } = props
|
||||
if (!meeting || !meeting.startTime || !meeting.endTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (meeting.status === 'ended') {
|
||||
return false
|
||||
}
|
||||
|
||||
const advanceMinutes = meeting.advance || 0
|
||||
const now = currentTime.value
|
||||
const startTime = new Date(meeting.startTime).getTime()
|
||||
const endTime = new Date(meeting.endTime).getTime()
|
||||
|
||||
// 检查时间解析是否有效
|
||||
if (isNaN(startTime) || isNaN(endTime)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const advanceTime = startTime - advanceMinutes * 60 * 1000
|
||||
|
||||
// 在允许入会的时间窗口内(提前入会时间 ~ 结束时间)
|
||||
return now >= advanceTime && now <= endTime
|
||||
})
|
||||
|
||||
/**
|
||||
* 按钮文本
|
||||
*/
|
||||
const buttonText = computed(() => {
|
||||
const { meeting } = props
|
||||
if (meeting.status === 'ended') {
|
||||
return '会议已结束'
|
||||
}
|
||||
if (!canJoinMeeting.value) {
|
||||
return '未到入会时间'
|
||||
}
|
||||
return '加入会议'
|
||||
})
|
||||
|
||||
/**
|
||||
* 加入会议
|
||||
*/
|
||||
async function handleJoinMeeting() {
|
||||
if (!props.meeting.meetingId) {
|
||||
ElMessage.error('会议ID不存在')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canJoinMeeting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 发出事件让父组件处理
|
||||
emit('join', props.meeting.meetingId)
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import './MeetingCard.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
title="创建视频会议"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<ElFormItem label="会议名称" prop="meetingName">
|
||||
<ElInput
|
||||
v-model="formData.meetingName"
|
||||
placeholder="请输入会议名称"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="开始时间" prop="startTime" required>
|
||||
<ElDatePicker
|
||||
v-model="formData.startTime"
|
||||
type="datetime"
|
||||
placeholder="选择开始时间"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:disabled-date="disabledDate"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="结束时间" prop="endTime" required>
|
||||
<ElDatePicker
|
||||
v-model="formData.endTime"
|
||||
type="datetime"
|
||||
placeholder="选择结束时间"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:disabled-date="disabledDate"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提前入会" prop="advance">
|
||||
<ElInputNumber
|
||||
v-model="formData.advance"
|
||||
:min="0"
|
||||
:max="60"
|
||||
placeholder="提前入会时间(分钟)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">用户可在会议开始前N分钟加入</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="会议密码" prop="meetingPassword">
|
||||
<ElInput
|
||||
v-model="formData.meetingPassword"
|
||||
placeholder="可选,留空则无密码"
|
||||
clearable
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="最大人数" prop="maxParticipants">
|
||||
<ElInputNumber
|
||||
v-model="formData.maxParticipants"
|
||||
:min="2"
|
||||
:max="100"
|
||||
placeholder="最大参与人数"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="handleClose">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||
创建会议
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import {
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElDatePicker,
|
||||
ElButton,
|
||||
ElMessage,
|
||||
type FormInstance,
|
||||
type FormRules
|
||||
} from 'element-plus'
|
||||
import { workcaseChatAPI } from '@/api/workcase'
|
||||
import type { CreateMeetingParam } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
roomId: string
|
||||
workcaseId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: [meetingId: string]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
|
||||
// 对话框显示状态
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive<CreateMeetingParam>({
|
||||
roomId: props.roomId,
|
||||
workcaseId: props.workcaseId,
|
||||
meetingName: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
advance: 5,
|
||||
meetingPassword: '',
|
||||
maxParticipants: 10
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules: FormRules = {
|
||||
meetingName: [
|
||||
{ max: 50, message: '会议名称不能超过50个字符', trigger: 'blur' }
|
||||
],
|
||||
startTime: [
|
||||
{ required: true, message: '请选择开始时间', trigger: 'change' }
|
||||
],
|
||||
endTime: [
|
||||
{ required: true, message: '请选择结束时间', trigger: 'change' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value && formData.startTime) {
|
||||
const start = new Date(formData.startTime).getTime()
|
||||
const end = new Date(value).getTime()
|
||||
if (end <= start) {
|
||||
callback(new Error('结束时间必须晚于开始时间'))
|
||||
} else if (end - start < 5 * 60 * 1000) {
|
||||
callback(new Error('会议时长不能少于5分钟'))
|
||||
} else if (end - start > 24 * 60 * 60 * 1000) {
|
||||
callback(new Error('会议时长不能超过24小时'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
advance: [
|
||||
{ type: 'number', min: 0, max: 60, message: '提前入会时间范围为0-60分钟', trigger: 'blur' }
|
||||
],
|
||||
meetingPassword: [
|
||||
{ max: 20, message: '密码不能超过20个字符', trigger: 'blur' }
|
||||
],
|
||||
maxParticipants: [
|
||||
{ type: 'number', min: 2, max: 100, message: '参与人数范围为2-100人', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 禁用过去的日期
|
||||
const disabledDate = (date: Date) => {
|
||||
return date.getTime() < Date.now() - 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
// 验证表单
|
||||
await formRef.value.validate()
|
||||
|
||||
submitting.value = true
|
||||
console.log(props.roomId)
|
||||
// 调用API创建会议
|
||||
const result = await workcaseChatAPI.createVideoMeeting({
|
||||
...formData,
|
||||
roomId: props.roomId,
|
||||
workcaseId: props.workcaseId
|
||||
})
|
||||
|
||||
if (result.success && result.data) {
|
||||
ElMessage.success('会议创建成功')
|
||||
emit('success', result.data.meetingId!)
|
||||
handleClose()
|
||||
} else {
|
||||
ElMessage.error(result.message || '创建会议失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建会议失败:', error)
|
||||
if (error instanceof Error && error.message !== 'Validation failed') {
|
||||
ElMessage.error('创建会议失败,请重试')
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
// 重置表单
|
||||
formRef.value?.resetFields()
|
||||
formData.meetingName = ''
|
||||
formData.startTime = ''
|
||||
formData.endTime = ''
|
||||
formData.advance = 5
|
||||
formData.meetingPassword = ''
|
||||
formData.maxParticipants = 10
|
||||
|
||||
dialogVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -469,3 +469,136 @@ $brand-color-hover: #004488;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==================== 视频会议弹窗 ====================
|
||||
.meeting-modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.meeting-modal {
|
||||
width: 90vw;
|
||||
max-width: 1200px;
|
||||
height: 80vh;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: modalFadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.meeting-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meeting-modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meeting-modal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.minimize-btn,
|
||||
.meeting-modal .close-meeting-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.meeting-modal .close-meeting-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.8);
|
||||
}
|
||||
|
||||
.meeting-modal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.meeting-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 最小化悬浮按钮 ====================
|
||||
.meeting-float-btn {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||
cursor: pointer;
|
||||
z-index: 999;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
animation: floatPulse 2s infinite;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 30px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,6 @@
|
||||
<div v-if="loadingMore" class="loading-more">加载中...</div>
|
||||
<div v-else-if="!hasMore" class="loading-more">没有更多消息了</div>
|
||||
|
||||
<!-- Jitsi Meet会议iframe -->
|
||||
<div v-if="showMeeting && meetingUrl" class="meeting-container">
|
||||
<div class="meeting-header">
|
||||
<span>视频会议进行中</span>
|
||||
<button class="close-meeting-btn" @click="handleEndMeeting">
|
||||
<X :size="20" />
|
||||
结束会议
|
||||
</button>
|
||||
</div>
|
||||
<IframeView :src="meetingUrl" class="meeting-iframe" />
|
||||
</div>
|
||||
|
||||
<!-- 聊天消息列表 -->
|
||||
<div class="messages-list">
|
||||
<div
|
||||
@@ -46,30 +34,39 @@
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="message-content-wrapper">
|
||||
<div class="message-bubble">
|
||||
<div
|
||||
class="message-text"
|
||||
v-html="renderMarkdown(message.content || '')"
|
||||
></div>
|
||||
<!-- 会议消息卡片 -->
|
||||
<template v-if="message.messageType === 'meet'">
|
||||
<MeetingCard :meeting="getMeetingData(message.contentExtra)" @join="handleJoinMeeting" />
|
||||
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="message.files && message.files.length > 0" class="message-files">
|
||||
<!-- 普通消息气泡 -->
|
||||
<template v-else>
|
||||
<div class="message-bubble">
|
||||
<div
|
||||
v-for="file in message.files"
|
||||
:key="file"
|
||||
class="file-item"
|
||||
@click="$emit('download-file', file)"
|
||||
>
|
||||
<div class="file-icon">
|
||||
<FileText :size="16" />
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">附件</div>
|
||||
class="message-text"
|
||||
v-html="renderMarkdown(message.content || '')"
|
||||
></div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="message.files && message.files.length > 0" class="message-files">
|
||||
<div
|
||||
v-for="file in message.files"
|
||||
:key="file"
|
||||
class="file-item"
|
||||
@click="$emit('download-file', file)"
|
||||
>
|
||||
<div class="file-icon">
|
||||
<FileText :size="16" />
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">附件</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
|
||||
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,15 +129,56 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 创建会议对话框 -->
|
||||
<MeetingCreate
|
||||
v-model="showMeetingCreate"
|
||||
:room-id="roomId"
|
||||
:workcase-id="workcaseId || ''"
|
||||
@success="handleMeetingCreated"
|
||||
/>
|
||||
|
||||
<!-- 视频会议弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showMeeting && meetingUrl" class="meeting-modal-mask">
|
||||
<div class="meeting-modal">
|
||||
<div class="meeting-modal-header">
|
||||
<span class="meeting-modal-title">
|
||||
<Video :size="18" />
|
||||
视频会议进行中
|
||||
</span>
|
||||
<div class="meeting-modal-actions">
|
||||
<button class="minimize-btn" @click="minimizeMeeting" title="最小化">
|
||||
<Minus :size="18" />
|
||||
</button>
|
||||
<button class="close-meeting-btn" @click="handleEndMeeting" title="结束会议">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meeting-modal-body">
|
||||
<IframeView :src="meetingUrl" class="meeting-iframe" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 最小化的会议悬浮按钮 -->
|
||||
<div v-if="meetingMinimized && meetingUrl" class="meeting-float-btn" @click="restoreMeeting">
|
||||
<Video :size="20" />
|
||||
<span>返回会议</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted } from 'vue'
|
||||
import { FileText, Video, Paperclip, Send, X } from 'lucide-vue-next'
|
||||
import { FileText, Video, Paperclip, Send, X, Minus } from 'lucide-vue-next'
|
||||
import IframeView from 'shared/components/iframe/IframeView.vue'
|
||||
import type { ChatRoomMessageVO } from '@/types/workcase'
|
||||
import { createVideoMeeting, getActiveMeeting, endVideoMeeting } from '@/api/workcase/meeting'
|
||||
import MeetingCreate from '../MeetingCreate/MeetingCreate.vue'
|
||||
import MeetingCard from '../MeetingCard/MeetingCard.vue'
|
||||
import type { ChatRoomMessageVO, VideoMeetingVO } from '@/types/workcase'
|
||||
import { workcaseChatAPI } from '@/api/workcase'
|
||||
|
||||
interface Props {
|
||||
messages: ChatRoomMessageVO[]
|
||||
@@ -173,32 +211,38 @@ const showMeeting = ref(false)
|
||||
const meetingUrl = ref('')
|
||||
const currentMeetingId = ref('')
|
||||
const meetingLoading = ref(false)
|
||||
const showMeetingCreate = ref(false)
|
||||
const meetingMinimized = ref(false)
|
||||
|
||||
// 创建并加入会议
|
||||
const handleStartMeeting = async () => {
|
||||
try {
|
||||
meetingLoading.value = true
|
||||
// 最小化会议
|
||||
const minimizeMeeting = () => {
|
||||
meetingMinimized.value = true
|
||||
showMeeting.value = false
|
||||
}
|
||||
|
||||
// 创建会议
|
||||
const createRes = await createVideoMeeting({
|
||||
roomId: props.roomId,
|
||||
workcaseId: props.workcaseId,
|
||||
meetingName: `工单 ${props.workcaseId || props.roomId} 技术支持`,
|
||||
maxParticipants: 10
|
||||
})
|
||||
// 恢复会议窗口
|
||||
const restoreMeeting = () => {
|
||||
meetingMinimized.value = false
|
||||
showMeeting.value = true
|
||||
}
|
||||
|
||||
if (createRes.code === 0 && createRes.data) {
|
||||
currentMeetingId.value = createRes.data.meetingId
|
||||
meetingUrl.value = createRes.data.iframeUrl
|
||||
showMeeting.value = true
|
||||
} else {
|
||||
console.error('创建会议失败:', createRes.message)
|
||||
// 打开创建会议对话框
|
||||
const handleStartMeeting = () => {
|
||||
// 先检查是否有活跃会议
|
||||
checkActiveMeeting().then(() => {
|
||||
if (!showMeeting.value) {
|
||||
// 没有活跃会议,打开创建对话框
|
||||
showMeetingCreate.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建会议异常:', error)
|
||||
} finally {
|
||||
meetingLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 会议创建成功回调
|
||||
// 定时会议创建后不自动进入,只显示成功消息
|
||||
const handleMeetingCreated = async (meetingId: string) => {
|
||||
showMeetingCreate.value = false
|
||||
// 定时会议创建成功后不自动加入,用户可以在会议开始时间前后手动加入
|
||||
// 会议消息会通过后端发送到聊天室,用户可以点击消息卡片加入
|
||||
}
|
||||
|
||||
// 结束会议
|
||||
@@ -206,10 +250,11 @@ const handleEndMeeting = async () => {
|
||||
if (!currentMeetingId.value) return
|
||||
|
||||
try {
|
||||
await endVideoMeeting(currentMeetingId.value)
|
||||
await workcaseChatAPI.endVideoMeeting(currentMeetingId.value)
|
||||
showMeeting.value = false
|
||||
meetingUrl.value = ''
|
||||
currentMeetingId.value = ''
|
||||
meetingMinimized.value = false
|
||||
} catch (error) {
|
||||
console.error('结束会议失败:', error)
|
||||
}
|
||||
@@ -218,7 +263,7 @@ const handleEndMeeting = async () => {
|
||||
// 检查是否有活跃会议
|
||||
const checkActiveMeeting = async () => {
|
||||
try {
|
||||
const res = await getActiveMeeting(props.roomId)
|
||||
const res = await workcaseChatAPI.getActiveMeeting(props.roomId)
|
||||
if (res.code === 0 && res.data) {
|
||||
currentMeetingId.value = res.data.meetingId
|
||||
meetingUrl.value = res.data.iframeUrl
|
||||
@@ -322,6 +367,48 @@ const formatTime = (time?: string) => {
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
// 处理从MeetingCard发出的加入会议事件
|
||||
const handleJoinMeeting = async (meetingId: string) => {
|
||||
try {
|
||||
// 调用加入会议接口获取iframe URL
|
||||
const joinRes = await workcaseChatAPI.joinVideoMeeting(meetingId)
|
||||
if (joinRes.success && joinRes.data) {
|
||||
// 检查会议状态
|
||||
const meetingData = joinRes.data
|
||||
if (meetingData.status === 'ended') {
|
||||
// 会议已结束,提示用户
|
||||
alert('该会议已结束')
|
||||
return
|
||||
}
|
||||
|
||||
if (!meetingData.iframeUrl) {
|
||||
console.error('加入会议失败: 未获取到会议地址')
|
||||
alert('加入会议失败:未获取到会议地址')
|
||||
return
|
||||
}
|
||||
|
||||
currentMeetingId.value = meetingId
|
||||
meetingUrl.value = meetingData.iframeUrl
|
||||
showMeeting.value = true
|
||||
meetingMinimized.value = false
|
||||
} else {
|
||||
console.error('加入会议失败:', joinRes.message)
|
||||
alert(joinRes.message || '加入会议失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加入会议失败:', error)
|
||||
alert('加入会议失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取会议数据(将contentExtra转换为VideoMeetingVO)
|
||||
function getMeetingData(contentExtra: Record<string, any> | undefined): VideoMeetingVO {
|
||||
if (!contentExtra) {
|
||||
return {} as VideoMeetingVO
|
||||
}
|
||||
return contentExtra as VideoMeetingVO
|
||||
}
|
||||
|
||||
// Markdown渲染函数
|
||||
const renderMarkdown = (text: string): string => {
|
||||
if (!text) return ''
|
||||
@@ -366,6 +366,11 @@
|
||||
max-width: 480rpx;
|
||||
}
|
||||
|
||||
// 会议卡片需要更宽的空间
|
||||
.message-content.meeting-card-wrapper {
|
||||
max-width: 600rpx;
|
||||
}
|
||||
|
||||
.self-row .message-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,13 @@
|
||||
</view>
|
||||
<text class="sender-name">{{ msg.senderName || '客服' }}</text>
|
||||
</view>
|
||||
<view class="message-content">
|
||||
<!-- 会议消息卡片 -->
|
||||
<view class="message-content meeting-card-wrapper" v-if="msg.messageType === 'meet' && msg.contentExtra">
|
||||
<MeetingCard :meeting="getMeetingData(msg.contentExtra)" @join="handleJoinMeeting" />
|
||||
<text class="message-time">{{ formatTime(msg.sendTime) }}</text>
|
||||
</view>
|
||||
<!-- 普通消息 -->
|
||||
<view class="message-content" v-else>
|
||||
<view class="bubble other-bubble">
|
||||
<rich-text :nodes="renderMarkdown(msg.content || '')" class="message-rich-text"></rich-text>
|
||||
</view>
|
||||
@@ -73,7 +79,13 @@
|
||||
</view>
|
||||
<!-- 自己消息(右侧) -->
|
||||
<view class="message-row self-row" v-else>
|
||||
<view class="message-content">
|
||||
<!-- 会议消息卡片 -->
|
||||
<view class="message-content meeting-card-wrapper" v-if="msg.messageType === 'meet' && msg.contentExtra">
|
||||
<MeetingCard :meeting="getMeetingData(msg.contentExtra)" @join="handleJoinMeeting" />
|
||||
<text class="message-time">{{ formatTime(msg.sendTime) }}</text>
|
||||
</view>
|
||||
<!-- 普通消息 -->
|
||||
<view class="message-content" v-else>
|
||||
<view class="bubble self-bubble">
|
||||
<text class="message-text">{{ msg.content }}</text>
|
||||
</view>
|
||||
@@ -107,7 +119,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import type { ChatRoomMessageVO, CustomerVO, ChatMemberVO, TbChatRoomMessageDTO } from '@/types/workcase'
|
||||
import MeetingCard from '../../meeting/meetingCard/MeetingCard.uvue'
|
||||
import type { ChatRoomMessageVO, CustomerVO, ChatMemberVO, TbChatRoomMessageDTO, VideoMeetingVO } from '@/types/workcase'
|
||||
import { workcaseChatAPI } from '@/api/workcase'
|
||||
import { wsClient } from '@/utils/websocket'
|
||||
import { WS_HOST } from '@/config'
|
||||
@@ -396,6 +409,14 @@ function formatTime(time?: string): string {
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 获取会议数据(将contentExtra转换为VideoMeetingVO)
|
||||
function getMeetingData(contentExtra: Record<string, any> | undefined): VideoMeetingVO {
|
||||
if (!contentExtra) {
|
||||
return {} as VideoMeetingVO
|
||||
}
|
||||
return contentExtra as VideoMeetingVO
|
||||
}
|
||||
|
||||
// Markdown渲染函数(返回富文本HTML)
|
||||
function renderMarkdown(text: string): string {
|
||||
if (!text) return ''
|
||||
@@ -531,43 +552,65 @@ function handleWorkcaseAction() {
|
||||
}
|
||||
}
|
||||
|
||||
// 发起会议
|
||||
// 发起会议 - 跳转到会议创建页面
|
||||
async function startMeeting() {
|
||||
// 先检查是否有活跃会议
|
||||
try {
|
||||
uni.showLoading({ title: '创建会议中...' })
|
||||
|
||||
// 调用后端API创建会议
|
||||
const res = await workcaseChatAPI.createVideoMeeting({
|
||||
roomId: roomId.value,
|
||||
workcaseId: workcaseId.value,
|
||||
meetingName: `工单 ${workcaseId.value || roomId.value} 技术支持`,
|
||||
maxParticipants: 10
|
||||
})
|
||||
|
||||
uni.hideLoading()
|
||||
|
||||
const res = await workcaseChatAPI.getActiveMeeting(roomId.value)
|
||||
if (res.success && res.data) {
|
||||
// 已有活跃会议,直接加入
|
||||
const meetingUrl = res.data.iframeUrl
|
||||
const meetingId = res.data.meetingId
|
||||
|
||||
// 小程序/App使用webview打开会议
|
||||
uni.navigateTo({
|
||||
url: `/pages/meeting/MeetingView/MeetingView?meetingUrl=${encodeURIComponent(meetingUrl)}&meetingId=${meetingId}`,
|
||||
success: () => {
|
||||
console.log('[chatRoom] 跳转会议页面成功')
|
||||
},
|
||||
url: `/pages/meeting/Meeting?meetingUrl=${encodeURIComponent(meetingUrl)}&meetingId=${meetingId}`,
|
||||
fail: (err) => {
|
||||
console.error('[chatRoom] 跳转会议页面失败:', err)
|
||||
uni.showToast({ title: '打开会议失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[chatRoom] 无活跃会议,跳转创建页面')
|
||||
}
|
||||
|
||||
// 没有活跃会议,跳转到创建会议页面
|
||||
uni.navigateTo({
|
||||
url: `/pages/meeting/MeetingCreate?roomId=${roomId.value}&workcaseId=${workcaseId.value || ''}`,
|
||||
fail: (err) => {
|
||||
console.error('[chatRoom] 跳转创建会议页面失败:', err)
|
||||
uni.showToast({ title: '打开创建会议页面失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 加入会议(从MeetingCard点击加入)
|
||||
async function handleJoinMeeting(meetingId: string) {
|
||||
try {
|
||||
// 调用加入会议接口获取iframe URL
|
||||
const joinRes = await workcaseChatAPI.joinMeeting(meetingId)
|
||||
if (joinRes.success && joinRes.data?.iframeUrl) {
|
||||
const meetingUrl = joinRes.data.iframeUrl
|
||||
// 跳转到会议页面
|
||||
uni.navigateTo({
|
||||
url: `/pages/meeting/Meeting?meetingUrl=${encodeURIComponent(meetingUrl)}&meetingId=${meetingId}`,
|
||||
fail: (err) => {
|
||||
console.error('[chatRoom] 跳转会议页面失败:', err)
|
||||
uni.showToast({ title: '打开会议失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '创建会议失败', icon: 'none' })
|
||||
uni.showToast({
|
||||
title: joinRes.message || '加入会议失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
console.error('[chatRoom] 创建会议失败:', e)
|
||||
uni.showToast({ title: '创建会议失败', icon: 'none' })
|
||||
} catch (error) {
|
||||
console.error('[chatRoom] 加入会议失败:', error)
|
||||
uni.showToast({
|
||||
title: '加入会议失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,31 +15,29 @@
|
||||
padding: 0 16px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.meeting-nav .nav-back {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
.meeting-nav .nav-back .back-icon {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.meeting-nav .nav-title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
.end-btn {
|
||||
color: #ff4444;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
.meeting-nav .nav-right .end-btn {
|
||||
color: #ff4444;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<view class="meeting-create-page">
|
||||
<view class="page-header">
|
||||
<text class="page-title">创建视频会议</text>
|
||||
</view>
|
||||
|
||||
<view class="form-container">
|
||||
<!-- 会议名称 -->
|
||||
<view class="form-item">
|
||||
<view class="form-label">
|
||||
<text class="label-text">会议名称</text>
|
||||
</view>
|
||||
<input
|
||||
v-model="formData.meetingName"
|
||||
class="form-input"
|
||||
placeholder="请输入会议名称"
|
||||
maxlength="50"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 开始时间 -->
|
||||
<view class="form-item required">
|
||||
<view class="form-label">
|
||||
<text class="label-text">开始时间</text>
|
||||
<text class="required-star">*</text>
|
||||
</view>
|
||||
<picker
|
||||
mode="multiSelector"
|
||||
:value="startTimePickerValue"
|
||||
:range="timePickerRange"
|
||||
@change="handleStartTimeChange"
|
||||
>
|
||||
<view class="picker-display">
|
||||
<text :class="formData.startTime ? '' : 'placeholder'">
|
||||
{{ formData.startTime || '请选择开始时间' }}
|
||||
</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 结束时间 -->
|
||||
<view class="form-item required">
|
||||
<view class="form-label">
|
||||
<text class="label-text">结束时间</text>
|
||||
<text class="required-star">*</text>
|
||||
</view>
|
||||
<picker
|
||||
mode="multiSelector"
|
||||
:value="endTimePickerValue"
|
||||
:range="timePickerRange"
|
||||
@change="handleEndTimeChange"
|
||||
>
|
||||
<view class="picker-display">
|
||||
<text :class="formData.endTime ? '' : 'placeholder'">
|
||||
{{ formData.endTime || '请选择结束时间' }}
|
||||
</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 提前入会 -->
|
||||
<view class="form-item">
|
||||
<view class="form-label">
|
||||
<text class="label-text">提前入会(分钟)</text>
|
||||
</view>
|
||||
<input
|
||||
v-model.number="formData.advance"
|
||||
class="form-input"
|
||||
type="number"
|
||||
placeholder="提前入会时间"
|
||||
/>
|
||||
<view class="form-tip">
|
||||
<text>用户可在会议开始前N分钟加入</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 会议密码 -->
|
||||
<view class="form-item">
|
||||
<view class="form-label">
|
||||
<text class="label-text">会议密码</text>
|
||||
</view>
|
||||
<input
|
||||
v-model="formData.meetingPassword"
|
||||
class="form-input"
|
||||
type="text"
|
||||
password
|
||||
placeholder="可选,留空则无密码"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 最大人数 -->
|
||||
<view class="form-item">
|
||||
<view class="form-label">
|
||||
<text class="label-text">最大人数</text>
|
||||
</view>
|
||||
<input
|
||||
v-model.number="formData.maxParticipants"
|
||||
class="form-input"
|
||||
type="number"
|
||||
placeholder="最大参与人数"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<view class="form-footer">
|
||||
<button class="btn btn-cancel" @click="handleCancel">取消</button>
|
||||
<button class="btn btn-submit" :loading="submitting" @click="handleSubmit">创建会议</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { workcaseChatAPI } from '../../api/workcase/workcaseChat'
|
||||
import type { CreateMeetingParam } from '../../types/workcase/chatRoom'
|
||||
|
||||
// 路由参数
|
||||
const roomId = ref('')
|
||||
const workcaseId = ref('')
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive<CreateMeetingParam>({
|
||||
roomId: '',
|
||||
workcaseId: '',
|
||||
meetingName: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
advance: 5,
|
||||
meetingPassword: '',
|
||||
maxParticipants: 10
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// 时间选择器数据
|
||||
const startTimePickerValue = ref([0, 0, 0, 0])
|
||||
const endTimePickerValue = ref([0, 0, 0, 0])
|
||||
|
||||
// 生成时间选择器范围
|
||||
const timePickerRange = computed(() => {
|
||||
const now = new Date()
|
||||
const currentYear = now.getFullYear()
|
||||
const currentMonth = now.getMonth()
|
||||
const currentDay = now.getDate()
|
||||
|
||||
// 日期范围:今天到未来30天
|
||||
const dates: string[] = []
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = new Date(currentYear, currentMonth, currentDay + i)
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
dates.push(`${month}-${day}`)
|
||||
}
|
||||
|
||||
// 小时:00-23
|
||||
const hours: string[] = []
|
||||
for (let i = 0; i < 24; i++) {
|
||||
hours.push(String(i).padStart(2, '0'))
|
||||
}
|
||||
|
||||
// 分钟:00、15、30、45
|
||||
const minutes = ['00', '15', '30', '45']
|
||||
|
||||
return [dates, hours, minutes]
|
||||
})
|
||||
|
||||
// 页面加载时获取参数
|
||||
onLoad((options: any) => {
|
||||
if (options.roomId) {
|
||||
roomId.value = options.roomId
|
||||
formData.roomId = options.roomId
|
||||
}
|
||||
if (options.workcaseId) {
|
||||
workcaseId.value = options.workcaseId
|
||||
formData.workcaseId = options.workcaseId
|
||||
}
|
||||
})
|
||||
|
||||
// 处理开始时间选择
|
||||
function handleStartTimeChange(e: any) {
|
||||
const val = e.detail.value
|
||||
startTimePickerValue.value = val
|
||||
|
||||
const now = new Date()
|
||||
const selectedDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + val[0])
|
||||
const year = selectedDate.getFullYear()
|
||||
const month = String(selectedDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(selectedDate.getDate()).padStart(2, '0')
|
||||
const hour = timePickerRange.value[1][val[1]]
|
||||
const minute = timePickerRange.value[2][val[2]]
|
||||
|
||||
formData.startTime = `${year}-${month}-${day} ${hour}:${minute}:00`
|
||||
}
|
||||
|
||||
// 处理结束时间选择
|
||||
function handleEndTimeChange(e: any) {
|
||||
const val = e.detail.value
|
||||
endTimePickerValue.value = val
|
||||
|
||||
const now = new Date()
|
||||
const selectedDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + val[0])
|
||||
const year = selectedDate.getFullYear()
|
||||
const month = String(selectedDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(selectedDate.getDate()).padStart(2, '0')
|
||||
const hour = timePickerRange.value[1][val[1]]
|
||||
const minute = timePickerRange.value[2][val[2]]
|
||||
|
||||
formData.endTime = `${year}-${month}-${day} ${hour}:${minute}:00`
|
||||
}
|
||||
|
||||
// 验证表单
|
||||
function validateForm(): boolean {
|
||||
if (!formData.startTime) {
|
||||
uni.showToast({
|
||||
title: '请选择开始时间',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!formData.endTime) {
|
||||
uni.showToast({
|
||||
title: '请选择结束时间',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const start = new Date(formData.startTime).getTime()
|
||||
const end = new Date(formData.endTime).getTime()
|
||||
|
||||
if (start < Date.now()) {
|
||||
uni.showToast({
|
||||
title: '开始时间不能早于当前时间',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (end <= start) {
|
||||
uni.showToast({
|
||||
title: '结束时间必须晚于开始时间',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (end - start < 5 * 60 * 1000) {
|
||||
uni.showToast({
|
||||
title: '会议时长不能少于5分钟',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (end - start > 24 * 60 * 60 * 1000) {
|
||||
uni.showToast({
|
||||
title: '会议时长不能超过24小时',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (formData.advance !== undefined && (formData.advance < 0 || formData.advance > 60)) {
|
||||
uni.showToast({
|
||||
title: '提前入会时间范围为0-60分钟',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (formData.maxParticipants !== undefined && (formData.maxParticipants < 2 || formData.maxParticipants > 100)) {
|
||||
uni.showToast({
|
||||
title: '参与人数范围为2-100人',
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
submitting.value = true
|
||||
|
||||
const result = await workcaseChatAPI.createVideoMeeting(formData)
|
||||
|
||||
if (result.success && result.data) {
|
||||
uni.showToast({
|
||||
title: '会议创建成功',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 延迟返回,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: result.message || '创建会议失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建会议失败:', error)
|
||||
uni.showToast({
|
||||
title: '创建会议失败,请重试',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
function handleCancel() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.meeting-create-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f7fa;
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #fff;
|
||||
padding: 32rpx;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
background-color: #fff;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
padding: 24rpx 32rpx;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.form-item.required .label-text::after {
|
||||
content: ' *';
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
font-size: 28rpx;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.required-star {
|
||||
color: #f56c6c;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 16rpx 24rpx;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
padding: 16rpx 24rpx;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.picker-display .placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.form-tip text {
|
||||
font-size: 24rpx;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 32rpx;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #ebeef5;
|
||||
box-shadow: 0 -2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 24rpx 0;
|
||||
border-radius: 8rpx;
|
||||
font-size: 32rpx;
|
||||
text-align: center;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background-color: #f5f7fa;
|
||||
color: #606266;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: #409eff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-submit[loading] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
/* UniApp
|
||||
/SCSSLW(Us7 */
|
||||
|
||||
.meeting-card {
|
||||
padding: 24rpx 32rpx;
|
||||
border: 2rpx solid #e4e7ed;
|
||||
border-radius: 16rpx;
|
||||
background-color: #fff;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.meeting-card--scheduled {
|
||||
border-left: 8rpx solid #409eff;
|
||||
}
|
||||
|
||||
.meeting-card--ongoing {
|
||||
border-left: 8rpx solid #67c23a;
|
||||
}
|
||||
|
||||
.meeting-card--ended {
|
||||
border-left: 8rpx solid #909399;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.meeting-card-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.meeting-card-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.meeting-card-status {
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-scheduled {
|
||||
background-color: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.status-ongoing {
|
||||
background-color: #f0f9ff;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.status-ended {
|
||||
background-color: #f4f4f5;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.meeting-card-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.meeting-card-time text {
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.meeting-card-content {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.meeting-card-desc {
|
||||
font-size: 28rpx;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.meeting-card-action {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 24rpx;
|
||||
border-top: 2rpx solid #ebeef5;
|
||||
}
|
||||
|
||||
.meeting-card-countdown {
|
||||
font-size: 28rpx;
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meeting-card-action button {
|
||||
padding: 8rpx 24rpx;
|
||||
font-size: 28rpx;
|
||||
min-width: 160rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.meeting-card-action button[disabled] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.meeting-card-action button text {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<!-- 消息会议卡片 -->
|
||||
<view :class="['meeting-card', meeting.status ? `meeting-card--${meeting.status}` : '']">
|
||||
<view class="meeting-card-header">
|
||||
<view class="meeting-card-title">{{ meeting.meetingName || '未命名会议' }}</view>
|
||||
<view class="meeting-card-status">
|
||||
<text v-if="meeting.status === 'scheduled'" class="status-badge status-scheduled">预定</text>
|
||||
<text v-else-if="meeting.status === 'ongoing'" class="status-badge status-ongoing">进行中</text>
|
||||
<text v-else-if="meeting.status === 'ended'" class="status-badge status-ended">已结束</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="meeting-card-time">
|
||||
<text>开始时间:{{ formatDateTime(meeting.startTime) }}</text>
|
||||
<text>结束时间:{{ formatDateTime(meeting.endTime) }}</text>
|
||||
<text v-if="meeting.advance">提前入会:{{ meeting.advance }}分钟</text>
|
||||
</view>
|
||||
<view v-if="meeting.description" class="meeting-card-content">
|
||||
<text class="meeting-card-desc">{{ meeting.description }}</text>
|
||||
</view>
|
||||
<view class="meeting-card-action">
|
||||
<text class="meeting-card-countdown">{{ countdownText }}</text>
|
||||
<button
|
||||
type="primary"
|
||||
:disabled="!canJoinMeeting"
|
||||
@click="handleJoinMeeting"
|
||||
size="mini"
|
||||
>
|
||||
<text>{{ buttonText }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { VideoMeetingVO } from '../../../types/workcase/chatRoom'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
interface Props {
|
||||
meeting: VideoMeetingVO
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
console.log("meeting", JSON.stringify(props.meeting))
|
||||
const emit = defineEmits<{
|
||||
join: [meetingId: string]
|
||||
}>()
|
||||
|
||||
// 当前时间,每秒更新
|
||||
const currentTime = ref(Date.now())
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// 每秒更新当前时间
|
||||
timer = setInterval(() => {
|
||||
currentTime.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer !== null) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
function formatDateTime(dateStr?: string): string {
|
||||
if (!dateStr) return ''
|
||||
// iOS和小程序兼容性处理:将空格替换为T
|
||||
const iosCompatibleTime = dateStr.replace(' ', 'T')
|
||||
const date = new Date(iosCompatibleTime)
|
||||
if (isNaN(date.getTime())) return dateStr // 如果解析失败,返回原字符串
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算倒计时文本
|
||||
*/
|
||||
const countdownText = computed((): string => {
|
||||
const meeting = props.meeting
|
||||
if (!meeting) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 检查必要字段
|
||||
if (!meeting.startTime || !meeting.endTime) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// advance 默认为 0
|
||||
const advanceMinutes = meeting.advance || 0
|
||||
|
||||
const now = currentTime.value
|
||||
// iOS和小程序兼容性处理
|
||||
const startTime = new Date(meeting.startTime.replace(' ', 'T')).getTime()
|
||||
const endTime = new Date(meeting.endTime.replace(' ', 'T')).getTime()
|
||||
|
||||
// 检查时间解析是否有效
|
||||
if (isNaN(startTime) || isNaN(endTime)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const advanceTime = startTime - advanceMinutes * 60 * 1000
|
||||
|
||||
if (meeting.status === 'ended') {
|
||||
return '会议已结束'
|
||||
}
|
||||
|
||||
if (now < advanceTime) {
|
||||
// 未到提前入会时间
|
||||
const leftMs = advanceTime - now
|
||||
const leftMinutes = Math.floor(leftMs / 60000)
|
||||
const leftSeconds = Math.floor((leftMs % 60000) / 1000)
|
||||
|
||||
if (leftMinutes >= 60) {
|
||||
const hours = Math.floor(leftMinutes / 60)
|
||||
const mins = leftMinutes % 60
|
||||
return `距离入会:${hours}小时${mins}分钟`
|
||||
} else if (leftMinutes > 0) {
|
||||
return `距离入会:${leftMinutes}分${leftSeconds}秒`
|
||||
} else {
|
||||
return `距离入会:${leftSeconds}秒`
|
||||
}
|
||||
} else if (now < startTime) {
|
||||
// 在提前入会时间窗口内,但未到开始时间
|
||||
return '可以入会'
|
||||
} else if (now < endTime) {
|
||||
// 会议进行中
|
||||
if (meeting.status === 'ongoing') {
|
||||
return '会议进行中'
|
||||
} else {
|
||||
return '可以入会'
|
||||
}
|
||||
} else {
|
||||
// 已超过结束时间
|
||||
return '会议已超时'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否可以加入会议
|
||||
*/
|
||||
const canJoinMeeting = computed((): boolean => {
|
||||
const meeting = props.meeting
|
||||
if (!meeting) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!meeting.startTime || !meeting.endTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (meeting.status === 'ended') {
|
||||
return false
|
||||
}
|
||||
|
||||
const advanceMinutes = meeting.advance || 0
|
||||
const now = currentTime.value
|
||||
// iOS和小程序兼容性处理
|
||||
const startTime = new Date(meeting.startTime.replace(' ', 'T')).getTime()
|
||||
const endTime = new Date(meeting.endTime.replace(' ', 'T')).getTime()
|
||||
|
||||
// 检查时间解析是否有效
|
||||
if (isNaN(startTime) || isNaN(endTime)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const advanceTime = startTime - advanceMinutes * 60 * 1000
|
||||
|
||||
// 在允许入会的时间窗口内(提前入会时间 ~ 结束时间)
|
||||
return now >= advanceTime && now <= endTime
|
||||
})
|
||||
|
||||
/**
|
||||
* 按钮文本
|
||||
*/
|
||||
const buttonText = computed((): string => {
|
||||
const meeting = props.meeting
|
||||
if (meeting.status === 'ended') {
|
||||
return '会议已结束'
|
||||
}
|
||||
if (!canJoinMeeting.value) {
|
||||
return '未到入会时间'
|
||||
}
|
||||
return '加入会议'
|
||||
})
|
||||
|
||||
/**
|
||||
* 加入会议
|
||||
*/
|
||||
function handleJoinMeeting() {
|
||||
if (!props.meeting.meetingId) {
|
||||
uni.showToast({
|
||||
title: '会议ID不存在',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!canJoinMeeting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('join', props.meeting.meetingId)
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import './MeetingCard.scss';
|
||||
</style>
|
||||
@@ -72,6 +72,7 @@ export interface TbVideoMeetingDTO extends BaseDTO {
|
||||
workcaseId?: string
|
||||
meetingName?: string
|
||||
meetingPassword?: string
|
||||
description?: string
|
||||
jwtToken?: string
|
||||
jitsiRoomName?: string
|
||||
jitsiServerUrl?: string
|
||||
@@ -81,6 +82,12 @@ export interface TbVideoMeetingDTO extends BaseDTO {
|
||||
creatorName?: string
|
||||
participantCount?: number
|
||||
maxParticipants?: number
|
||||
/** 预定开始时间 */
|
||||
startTime?: string
|
||||
/** 预定结束时间 */
|
||||
endTime?: string
|
||||
/** 提前入会时间(分钟) */
|
||||
advance?: number
|
||||
actualStartTime?: string
|
||||
actualEndTime?: string
|
||||
durationSeconds?: number
|
||||
@@ -214,6 +221,10 @@ export interface ChatMemberVO extends BaseVO {
|
||||
leaveTime?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频会议VO
|
||||
* 用于前端展示Jitsi Meet会议信息
|
||||
*/
|
||||
/**
|
||||
* 视频会议VO
|
||||
* 用于前端展示Jitsi Meet会议信息
|
||||
@@ -224,17 +235,23 @@ export interface VideoMeetingVO extends BaseVO {
|
||||
workcaseId?: string
|
||||
meetingName?: string
|
||||
meetingPassword?: string
|
||||
description?: string
|
||||
jwtToken?: string
|
||||
jitsiRoomName?: string
|
||||
jitsiServerUrl?: string
|
||||
status?: string
|
||||
creatorId?: string
|
||||
creatorType?: string
|
||||
creatorName?: string
|
||||
participantCount?: number
|
||||
maxParticipants?: number
|
||||
startTime?: string
|
||||
// 预定开始时间
|
||||
startTime?: string
|
||||
// 预定结束时间
|
||||
endTime?: string
|
||||
// 提前入会时间(分钟)
|
||||
advance?: number
|
||||
actualStartTime?: string
|
||||
actualEndTime?: string
|
||||
durationSeconds?: number
|
||||
durationFormatted?: string
|
||||
iframeUrl?: string
|
||||
@@ -280,6 +297,9 @@ export interface SendMessageParam {
|
||||
export interface CreateMeetingParam {
|
||||
roomId: string
|
||||
workcaseId: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
advance?: number
|
||||
meetingName?: string
|
||||
meetingPassword?: string
|
||||
maxParticipants?: number
|
||||
|
||||
Reference in New Issue
Block a user