Compare commits

...

10 Commits

Author SHA1 Message Date
duoaohui
96fca438f4 视频连线 2026-05-28 10:15:07 +08:00
duoaohui
a3f19c6245 视频连线 2026-05-27 15:04:50 +08:00
duoaohui
db7ea0b44a 视频连线 2026-05-27 14:38:55 +08:00
duoaohui
3dc4fa6563 视频连线 2026-05-27 12:55:42 +08:00
duoaohui
533f0b1e36 视频连线 2026-05-27 12:52:11 +08:00
duoaohui
ad1cfa2f0b 视频连线 2026-05-27 12:51:18 +08:00
4969677be1 上传文件至 frontend/src/static 2026-05-27 11:51:03 +08:00
duoaohui
a70f6d1b93 视频连线 2026-05-27 11:49:57 +08:00
duoaohui
f13a2e51ec 视频连线 2026-05-26 22:54:55 +08:00
duoaohui
601366d2a8 视频连线 2026-05-26 22:50:11 +08:00
20 changed files with 265 additions and 57 deletions

View File

@@ -81,7 +81,7 @@ var MeetingLeftReasonMap = map[string]string{
// 会议默认配置(应用层强制约束) // 会议默认配置(应用层强制约束)
const ( const (
MeetingMVPMaxMembers = 8 // MVP 阶段单会议硬上限 MeetingMVPMaxMembers = 1000000 // MVP 阶段单会议硬上限
MeetingRoomCodeLength = 9 // 会议号去连字符后的长度XXX-XXX-XXX MeetingRoomCodeLength = 9 // 会议号去连字符后的长度XXX-XXX-XXX
MeetingRoomCodeRetryMax = 3 // 生成会议号冲突重试上限 MeetingRoomCodeRetryMax = 3 // 生成会议号冲突重试上限
MeetingPasswordMaxAttempts = 5 // 密码连续错误锁定阈值 MeetingPasswordMaxAttempts = 5 // 密码连续错误锁定阈值

View File

@@ -64,7 +64,7 @@ type MeetingChatDTO struct {
type CreateMeetingRoomRequest struct { type CreateMeetingRoomRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"` // 会议标题 Title string `json:"title" binding:"required,min=1,max=200"` // 会议标题
Password string `json:"password" binding:"omitempty,min=4,max=20"` // 可选入会密码(纯文本入参,服务端 bcrypt 哈希存储) Password string `json:"password" binding:"omitempty,min=4,max=20"` // 可选入会密码(纯文本入参,服务端 bcrypt 哈希存储)
MaxMembers int `json:"max_members" binding:"omitempty,min=2,max=8"` // 可选上限MVP 硬上限 8留参数为 Phase 2e-3 扩展) MaxMembers int `json:"max_members" binding:"omitempty,min=2"` // 可选上限
} }
// CreateMeetingRoomResponse 创建会议响应 // CreateMeetingRoomResponse 创建会议响应

View File

@@ -249,8 +249,7 @@ func (ctl *MeetingController) CloudLawCreateMeeting(c *gin.Context) {
return return
} }
room, _, _, err := ctl.meetingService.CreateRoomForInternal(c.Request.Context(), hostUser.ID, &dto.CreateMeetingRoomRequest{ room, _, _, err := ctl.meetingService.CreateRoomForInternal(c.Request.Context(), hostUser.ID, &dto.CreateMeetingRoomRequest{
Title: title, Title: title,
MaxMembers: 2,
}) })
if err != nil { if err != nil {
ctl.handleError(c, err, "创建云律会议失败") ctl.handleError(c, err, "创建云律会议失败")

View File

@@ -13,7 +13,7 @@ type MeetingRoom struct {
HostID int64 `json:"host_id" gorm:"not null;index:idx_meeting_rooms_host_status,priority:1"` // 主持人用户 ID HostID int64 `json:"host_id" gorm:"not null;index:idx_meeting_rooms_host_status,priority:1"` // 主持人用户 ID
Type int `json:"type" gorm:"not null;default:1"` // 会议类型1=即时2=预约 Type int `json:"type" gorm:"not null;default:1"` // 会议类型1=即时2=预约
PasswordHash *string `json:"-" gorm:"size:255"` // 入会密码 bcrypt 哈希NULL 表示无密码JSON 始终剥离 PasswordHash *string `json:"-" gorm:"size:255"` // 入会密码 bcrypt 哈希NULL 表示无密码JSON 始终剥离
MaxMembers int `json:"max_members" gorm:"not null;default:50"` // 房间最大成员数MVP 应用层强制 8 MaxMembers int `json:"max_members" gorm:"not null;default:50"` // 房间最大成员数
Status int `json:"status" gorm:"not null;default:0;index:idx_meeting_rooms_host_status,priority:2"` // 状态0=未开始1=进行中2=已结束 Status int `json:"status" gorm:"not null;default:0;index:idx_meeting_rooms_host_status,priority:2"` // 状态0=未开始1=进行中2=已结束
ScheduledAt *time.Time `json:"scheduled_at" gorm:"type:timestamp(0)"` // 预约开始时间,即时会议为 NULL ScheduledAt *time.Time `json:"scheduled_at" gorm:"type:timestamp(0)"` // 预约开始时间,即时会议为 NULL
StartedAt *time.Time `json:"started_at" gorm:"type:timestamp(0)"` // 实际开始时间 StartedAt *time.Time `json:"started_at" gorm:"type:timestamp(0)"` // 实际开始时间

View File

@@ -398,13 +398,6 @@ func (s *MeetingService) JoinRoomForInternal(ctx context.Context, userID int64,
routerID, _ := s.mediaOrchestrator.ResolveRouterID(code) routerID, _ := s.mediaOrchestrator.ResolveRouterID(code)
return room, existing, routerID, nil return room, existing, routerID, nil
} }
activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
if err != nil {
return nil, nil, "", err
}
if int(activeCount) >= room.MaxMembers {
return nil, nil, "", ErrMeetingFull
}
participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant) participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant)
if err != nil { if err != nil {
if errors.Is(err, dao.ErrAlreadyInMeeting) { if errors.Is(err, dao.ErrAlreadyInMeeting) {
@@ -635,14 +628,6 @@ func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, passw
s.redis.Del(ctx, redisPasswordAttemptPrefix+code+":"+strconv.FormatInt(userID, 10)) s.redis.Del(ctx, redisPasswordAttemptPrefix+code+":"+strconv.FormatInt(userID, 10))
} }
activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
if err != nil {
return nil, nil, "", err
}
if int(activeCount) >= room.MaxMembers {
return nil, nil, "", ErrMeetingFull
}
participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant) participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant)
if err != nil { if err != nil {
return nil, nil, "", err return nil, nil, "", err

View File

@@ -440,7 +440,7 @@ COMMENT ON COLUMN meeting_rooms.title IS '会议标题';
COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID引用 auth_users.id'; COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID引用 auth_users.id';
COMMENT ON COLUMN meeting_rooms.type IS '会议类型1=即时MVP 仅此2=预约Phase 2e-3'; COMMENT ON COLUMN meeting_rooms.type IS '会议类型1=即时MVP 仅此2=预约Phase 2e-3';
COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希NULL 表示无密码'; COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希NULL 表示无密码';
COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数MVP 应用层强制 8schema 默认 50 供后续扩展'; COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数schema 默认 50';
COMMENT ON COLUMN meeting_rooms.status IS '0=未开始仅预约1=进行中2=已结束'; COMMENT ON COLUMN meeting_rooms.status IS '0=未开始仅预约1=进行中2=已结束';
COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL'; COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL';
COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间host 首次加入时回填'; COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间host 首次加入时回填';

View File

@@ -29,7 +29,7 @@ COMMENT ON COLUMN meeting_rooms.title IS '会议标题';
COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID引用 auth_users.id'; COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID引用 auth_users.id';
COMMENT ON COLUMN meeting_rooms.type IS '会议类型1=即时MVP 仅此2=预约Phase 2e-3'; COMMENT ON COLUMN meeting_rooms.type IS '会议类型1=即时MVP 仅此2=预约Phase 2e-3';
COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希NULL 表示无密码'; COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希NULL 表示无密码';
COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数MVP 应用层强制 8schema 默认 50 供后续扩展'; COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数schema 默认 50';
COMMENT ON COLUMN meeting_rooms.status IS '0=未开始仅预约1=进行中2=已结束'; COMMENT ON COLUMN meeting_rooms.status IS '0=未开始仅预约1=进行中2=已结束';
COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL'; COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL';
COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间host 首次加入时回填'; COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间host 首次加入时回填';

View File

@@ -28,7 +28,7 @@
<line x1="8" y1="23" x2="16" y2="23"></line> <line x1="8" y1="23" x2="16" y2="23"></line>
</svg> </svg>
</view> </view>
<text class="label">{{ audioEnabled ? '静音' : '解除' }}</text> <text class="label">{{ audioEnabled ? '静音' : '开麦' }}</text>
</button> </button>
<button class="btn" :class="{ active: videoEnabled, loading: videoLoading }" :disabled="videoLoading" @click="emitIf('cam-toggle')"> <button class="btn" :class="{ active: videoEnabled, loading: videoLoading }" :disabled="videoLoading" @click="emitIf('cam-toggle')">
@@ -42,11 +42,14 @@
<path d="M16 16v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v4m0 0l6-4v10"></path> <path d="M16 16v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v4m0 0l6-4v10"></path>
</svg> </svg>
</view> </view>
<text class="label">{{ videoEnabled ? '停止视频' : '开启视频' }}</text> <text class="label">
<text class="label-desktop">{{ videoEnabled ? '停止视频' : '开启视频' }}</text>
<text class="label-mobile">视频</text>
</text>
</button> </button>
<button <button
class="btn" class="btn btn-screen"
:class="{ active: screenSharing, loading: screenLoading }" :class="{ active: screenSharing, loading: screenLoading }"
:disabled="screenLoading || screenDisabled" :disabled="screenLoading || screenDisabled"
:title="screenDisabledReason || ''" :title="screenDisabledReason || ''"
@@ -70,7 +73,10 @@
<line x1="12" y1="7" x2="12" y2="14"></line> <line x1="12" y1="7" x2="12" y2="14"></line>
</svg> </svg>
</view> </view>
<text class="label">{{ screenSharing ? '停止共享' : '共享屏幕' }}</text> <text class="label">
<text class="label-desktop">{{ screenSharing ? '停止共享' : '共享屏幕' }}</text>
<text class="label-mobile">共享</text>
</text>
</button> </button>
<!-- 录制按钮Phase B仅主持人可见recording=true 时红色脉动上传中禁用 --> <!-- 录制按钮Phase B仅主持人可见recording=true 时红色脉动上传中禁用 -->
@@ -90,7 +96,10 @@
<circle cx="12" cy="12" r="3"></circle> <circle cx="12" cy="12" r="3"></circle>
</svg> </svg>
</view> </view>
<text class="label">{{ recording ? '停止录制' : '开始录制' }}</text> <text class="label">
<text class="label-desktop">{{ recording ? '停止录制' : '开始录制' }}</text>
<text class="label-mobile">录制</text>
</text>
</button> </button>
<button class="btn" @click="emitIf('invite')"> <button class="btn" @click="emitIf('invite')">
@@ -251,6 +260,7 @@ const emitIf = (name) => {
letter-spacing: 0.5rpx; letter-spacing: 0.5rpx;
color: inherit; color: inherit;
} }
.label-mobile { display: none; }
.btn-leave .icon { background: #EF4444; } .btn-leave .icon { background: #EF4444; }
.btn-leave:hover .icon { background: #DC2626; } .btn-leave:hover .icon { background: #DC2626; }
@@ -284,9 +294,91 @@ const emitIf = (name) => {
} }
.badge.badge-red { background: #EF4444; } .badge.badge-red { background: #EF4444; }
@media (max-width: 420px) { @media (max-width: 750px) {
.btn { min-width: 88rpx; } .toolbar {
.icon { width: 64rpx; height: 64rpx; } position: fixed;
.label { font-size: 20rpx; } left: 14rpx;
right: 14rpx;
bottom: calc(12rpx + env(safe-area-inset-bottom));
justify-content: space-between;
gap: 6rpx;
box-sizing: border-box;
padding: 14rpx 14rpx;
background: rgba(7, 15, 30, 0.9);
backdrop-filter: blur(18px);
border: 1rpx solid rgba(148, 163, 184, 0.18);
border-radius: 32rpx;
box-shadow: 0 12rpx 34rpx rgba(0, 0, 0, 0.42);
overflow-x: hidden;
overflow-y: hidden;
}
.btn {
min-width: 0;
flex: 1 1 0;
gap: 6rpx;
padding: 4rpx 0;
border-radius: 18rpx;
}
.btn:hover { background: transparent; }
.btn:active { background: rgba(255, 255, 255, 0.08); }
.btn-screen {
display: none;
}
.icon {
width: 56rpx;
height: 56rpx;
background: rgba(51, 65, 85, 0.92);
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.08);
}
.icon svg {
width: 40rpx;
height: 40rpx;
}
.btn.active .icon { background: #2563EB; }
.btn[disabled] .icon {
background: rgba(30, 41, 59, 0.72);
color: rgba(255, 255, 255, 0.38);
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.05);
}
.btn[disabled] .label { color: rgba(255, 255, 255, 0.36); }
.btn-leave .icon { background: #EF4444; }
.btn-leave .label { color: #FECACA; }
.label {
max-width: 100%;
font-size: 19rpx;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.label-desktop { display: none; }
.label-mobile { display: inline; }
.badge {
top: -8rpx;
right: -8rpx;
min-width: 28rpx;
height: 28rpx;
font-size: 16rpx;
}
}
@media (max-width: 380px) {
.toolbar {
left: 10rpx;
right: 10rpx;
padding-left: 8rpx;
padding-right: 8rpx;
}
.icon {
width: 50rpx;
height: 50rpx;
}
.icon svg {
width: 36rpx;
height: 36rpx;
}
.label {
font-size: 17rpx;
}
} }
</style> </style>

View File

@@ -49,12 +49,14 @@ const props = defineProps({
audioTrack: { type: Object, default: null }, audioTrack: { type: Object, default: null },
videoTrack: { type: Object, default: null }, videoTrack: { type: Object, default: null },
isSpeaking: { type: Boolean, default: false }, isSpeaking: { type: Boolean, default: false },
compact: { type: Boolean, default: false } compact: { type: Boolean, default: false },
videoFit: { type: String, default: 'cover' }
}) })
const mediaBox = ref(null) const mediaBox = ref(null)
const hasVideo = computed(() => !!props.videoTrack && props.videoEnabled) const hasVideo = computed(() => !!props.videoTrack && props.videoEnabled)
const normalizedVideoFit = computed(() => props.videoFit === 'contain' ? 'contain' : 'cover')
const avatarInitial = computed(() => { const avatarInitial = computed(() => {
const s = (props.name || '').trim() const s = (props.name || '').trim()
@@ -82,7 +84,7 @@ const ensureMediaEl = (tagName) => {
if (tagName === 'video') { if (tagName === 'video') {
el.style.width = '100%' el.style.width = '100%'
el.style.height = '100%' el.style.height = '100%'
el.style.objectFit = 'cover' el.style.objectFit = normalizedVideoFit.value
el.style.background = '#0B1220' el.style.background = '#0B1220'
el.style.display = 'block' el.style.display = 'block'
} }
@@ -134,6 +136,7 @@ const applyVideo = (retry = 0) => {
} }
const el = ensureMediaEl('video') const el = ensureMediaEl('video')
if (!el) return if (!el) return
el.style.objectFit = normalizedVideoFit.value
// 关键:<video> 必须 muted 才能在 Chrome / 微信内核里自动播放。 // 关键:<video> 必须 muted 才能在 Chrome / 微信内核里自动播放。
// 音频在独立的 <audio> 元素上播放,所以此处 muted 不会让对方静音, // 音频在独立的 <audio> 元素上播放,所以此处 muted 不会让对方静音,
// 同时彻底解决"进会黑屏,必须点切换按钮才出图"的问题。 // 同时彻底解决"进会黑屏,必须点切换按钮才出图"的问题。
@@ -183,7 +186,7 @@ const applyAudio = (retry = 0) => {
// #endif // #endif
} }
watch(() => [props.videoTrack, props.videoEnabled], () => nextTick(() => applyVideo()), { immediate: true }) watch(() => [props.videoTrack, props.videoEnabled, props.videoFit], () => nextTick(() => applyVideo()), { immediate: true })
watch(() => [props.audioTrack, props.audioEnabled, props.isLocal], () => nextTick(() => applyAudio()), { immediate: true }) watch(() => [props.audioTrack, props.audioEnabled, props.isLocal], () => nextTick(() => applyAudio()), { immediate: true })
// 修复刚进入会议时画面不显示、必须点切换才出图的问题: // 修复刚进入会议时画面不显示、必须点切换才出图的问题:
@@ -350,4 +353,25 @@ onBeforeUnmount(() => {
.tile.compact .footer { padding: 8rpx 12rpx; } .tile.compact .footer { padding: 8rpx 12rpx; }
.tile.compact .name { font-size: 20rpx; } .tile.compact .name { font-size: 20rpx; }
.tile.compact .tag { font-size: 18rpx; padding: 2rpx 8rpx; } .tile.compact .tag { font-size: 18rpx; padding: 2rpx 8rpx; }
@media (max-width: 750px) {
.footer {
left: 12rpx;
right: 12rpx;
bottom: 12rpx;
padding: 8rpx 12rpx;
border-radius: 14rpx;
background: rgba(2, 6, 23, 0.62);
backdrop-filter: blur(10px);
}
.name {
font-size: 20rpx;
}
.tile.compact .footer {
left: 8rpx;
right: 8rpx;
bottom: 8rpx;
padding: 6rpx 10rpx;
}
}
</style> </style>

View File

@@ -90,7 +90,7 @@ export const MEETING_HOST_AUTO_REASON_LABEL = {
// ==================== 默认配置常量 ==================== // ==================== 默认配置常量 ====================
export const MEETING_MVP_MAX_MEMBERS = 8 // MVP 单会议硬上限 export const MEETING_MVP_MAX_MEMBERS = 1000000 // MVP 单会议硬上限
export const MEETING_HOST_GRACE_SECONDS = 120 // 主持人掉线宽限期Task 8 export const MEETING_HOST_GRACE_SECONDS = 120 // 主持人掉线宽限期Task 8
export const MEETING_EMPTY_ROOM_TTL_SECONDS = 300 // 空房销毁 TTLTask 8 export const MEETING_EMPTY_ROOM_TTL_SECONDS = 300 // 空房销毁 TTLTask 8
export const MEETING_WS_ACK_TIMEOUT_MS = 10000 // WS 事件 ACK 等待超时Task 9 export const MEETING_WS_ACK_TIMEOUT_MS = 10000 // WS 事件 ACK 等待超时Task 9

View File

@@ -9,7 +9,7 @@
{ {
"path": "pages/index/index", "path": "pages/index/index",
"style": { "style": {
"navigationBarTitleText": "EchoChat", "navigationBarTitleText": "赏讼视频会议系统",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
@@ -227,7 +227,7 @@
}, },
"globalStyle": { "globalStyle": {
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"navigationBarTitleText": "EchoChat", "navigationBarTitleText": "赏讼视频会议系统",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F8FAFC" "backgroundColor": "#F8FAFC"
} }

View File

@@ -20,9 +20,9 @@
<!-- 顶部品牌区域 --> <!-- 顶部品牌区域 -->
<view class="brand-section"> <view class="brand-section">
<view class="logo-box"> <view class="logo-box">
<text class="logo-letter">E</text> <image class="brand-logo" src="/static/shangsong.png" mode="aspectFit" />
</view> </view>
<text class="brand-name">EchoChat</text> <text class="brand-name">赏讼视频会议系统</text>
<text class="brand-slogan">连接无限沟通无界</text> <text class="brand-slogan">连接无限沟通无界</text>
</view> </view>
@@ -80,8 +80,14 @@
<view class="oauth-line"></view> <view class="oauth-line"></view>
</view> </view>
<view class="oauth-buttons"> <view class="oauth-buttons">
<button class="oauth-btn oauth-wechat" :disabled="oauthLoading" @tap="oauthLogin('wechat')">微信登录</button> <button class="oauth-btn oauth-wechat" :disabled="oauthLoading" @tap="oauthLogin('wechat')">
<button class="oauth-btn oauth-qq" :disabled="oauthLoading" @tap="oauthLogin('qq')">QQ登录</button> <image class="oauth-icon" src="/static/weixin.png" mode="aspectFit" />
<text>微信登录</text>
</button>
<button class="oauth-btn oauth-qq" :disabled="oauthLoading" @tap="oauthLogin('qq')">
<image class="oauth-icon" src="/static/qq.png" mode="aspectFit" />
<text>QQ登录</text>
</button>
</view> </view>
</view> </view>
@@ -272,17 +278,14 @@ export default {
.logo-box { .logo-box {
width: 112rpx; width: 112rpx;
height: 112rpx; height: 112rpx;
border-radius: 24rpx;
background-color: #2563EB;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.logo-letter { .brand-logo {
font-size: 52rpx; width: 112rpx;
font-weight: 700; height: 112rpx;
color: #FFFFFF;
} }
.brand-name { .brand-name {
@@ -433,6 +436,10 @@ export default {
font-size: 28rpx; font-size: 28rpx;
color: #FFFFFF; color: #FFFFFF;
border: none; border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
} }
.oauth-btn::after { .oauth-btn::after {
@@ -447,6 +454,11 @@ export default {
background-color: #12B7F5; background-color: #12B7F5;
} }
.oauth-icon {
width: 52rpx;
height: 52rpx;
}
/* ---- 底部链接 ---- */ /* ---- 底部链接 ---- */
.link-row { .link-row {
display: flex; display: flex;

View File

@@ -20,10 +20,10 @@
<!-- 顶部品牌区域 --> <!-- 顶部品牌区域 -->
<view class="brand-section"> <view class="brand-section">
<view class="logo-box"> <view class="logo-box">
<text class="logo-letter">E</text> <image class="brand-logo" src="/static/shangsong.png" mode="aspectFit" />
</view> </view>
<text class="brand-name">创建账号</text> <text class="brand-name">创建账号</text>
<text class="brand-slogan">加入 EchoChat开启高效沟通</text> <text class="brand-slogan">加入赏讼视频会议系统开启高效沟通</text>
</view> </view>
<!-- 表单卡片 --> <!-- 表单卡片 -->
@@ -271,17 +271,14 @@ export default {
.logo-box { .logo-box {
width: 96rpx; width: 96rpx;
height: 96rpx; height: 96rpx;
border-radius: 20rpx;
background-color: #2563EB;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.logo-letter { .brand-logo {
font-size: 44rpx; width: 96rpx;
font-weight: 700; height: 96rpx;
color: #FFFFFF;
} }
.brand-name { .brand-name {

View File

@@ -59,6 +59,7 @@
:video-enabled="true" :video-enabled="true"
:audio-track="null" :audio-track="null"
:video-track="screenTile.videoTrack" :video-track="screenTile.videoTrack"
video-fit="contain"
:is-speaking="false" :is-speaking="false"
/> />
</view> </view>
@@ -241,6 +242,7 @@ const networkLevel = ref(4) // 持续接入 getStats 后动态更新Task 16
const startedAt = ref(0) const startedAt = ref(0)
const nowTs = ref(Date.now()) const nowTs = ref(Date.now())
let timerHandle = null let timerHandle = null
let viewportCleanup = null
/** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */ /** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */
const videoCol = ref(null) const videoCol = ref(null)
@@ -765,6 +767,15 @@ const redirectHome = () => {
uni.reLaunch({ url: '/pages/index/index' }) uni.reLaunch({ url: '/pages/index/index' })
} }
const syncViewportHeight = () => {
// #ifdef H5
const height = window.visualViewport?.height || window.innerHeight
if (height > 0) {
document.documentElement.style.setProperty('--meeting-vh', `${height}px`)
}
// #endif
}
// ============ 生命周期 ============ // ============ 生命周期 ============
let stateStopWatch = null let stateStopWatch = null
@@ -787,6 +798,22 @@ onMounted(() => {
// P2-3 修复onLoad 已 redirectTo 跳转时,避免本页继续初始化定时器与 watcher // P2-3 修复onLoad 已 redirectTo 跳转时,避免本页继续初始化定时器与 watcher
if (redirectingToJoin) return if (redirectingToJoin) return
// #ifdef H5
syncViewportHeight()
const viewport = window.visualViewport
const onViewportChange = () => syncViewportHeight()
window.addEventListener('resize', onViewportChange)
window.addEventListener('orientationchange', onViewportChange)
viewport?.addEventListener?.('resize', onViewportChange)
viewport?.addEventListener?.('scroll', onViewportChange)
viewportCleanup = () => {
window.removeEventListener('resize', onViewportChange)
window.removeEventListener('orientationchange', onViewportChange)
viewport?.removeEventListener?.('resize', onViewportChange)
viewport?.removeEventListener?.('scroll', onViewportChange)
}
// #endif
const startIso = meetingStore.currentRoom?.started_at || meetingStore.currentRoom?.created_at const startIso = meetingStore.currentRoom?.started_at || meetingStore.currentRoom?.created_at
startedAt.value = startIso ? Date.parse(startIso) : Date.now() startedAt.value = startIso ? Date.parse(startIso) : Date.now()
timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000) timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000)
@@ -807,6 +834,7 @@ onBeforeUnmount(() => {
clearInterval(timerHandle) clearInterval(timerHandle)
timerHandle = null timerHandle = null
} }
if (viewportCleanup) { viewportCleanup(); viewportCleanup = null }
if (stateStopWatch) { stateStopWatch(); stateStopWatch = null } if (stateStopWatch) { stateStopWatch(); stateStopWatch = null }
}) })
@@ -832,8 +860,18 @@ onUnload(() => {
inset: 0; inset: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
height: 100svh;
height: 100dvh;
height: var(--meeting-vh, 100dvh);
min-width: 100vw; min-width: 100vw;
min-height: 100vh; min-height: 100vh;
min-height: 100svh;
min-height: 100dvh;
min-height: var(--meeting-vh, 100dvh);
max-height: 100vh;
max-height: 100svh;
max-height: 100dvh;
max-height: var(--meeting-vh, 100dvh);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #0F172A; background: #0F172A;
@@ -1038,6 +1076,10 @@ onUnload(() => {
.chat-col.open { width: 640rpx; } .chat-col.open { width: 640rpx; }
@media (max-width: 750px) { @media (max-width: 750px) {
.body {
padding-bottom: calc(142rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
/* 小屏幕:聊天面板改为全屏浮层,避免挤压视频 */ /* 小屏幕:聊天面板改为全屏浮层,避免挤压视频 */
.chat-col { position: fixed; top: 0; right: 0; bottom: 0; z-index: 205; } .chat-col { position: fixed; top: 0; right: 0; bottom: 0; z-index: 205; }
.chat-col.open { width: 100vw; } .chat-col.open { width: 100vw; }

BIN
frontend/src/static/qq.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -1294,7 +1294,14 @@ export const useMeetingStore = defineStore('meeting', () => {
const stream = await navigator.mediaDevices.getDisplayMedia({ const stream = await navigator.mediaDevices.getDisplayMedia({
// 屏幕共享对帧率要求低、清晰度要求高,控制带宽优先保证清晰度 // 屏幕共享对帧率要求低、清晰度要求高,控制带宽优先保证清晰度
video: { frameRate: { ideal: 15, max: 30 } }, video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 15, max: 15 },
cursor: 'always',
displaySurface: 'monitor',
logicalSurface: true
},
audio: false audio: false
}) })
const track = stream.getVideoTracks()[0] const track = stream.getVideoTracks()[0]
@@ -1313,6 +1320,17 @@ export const useMeetingStore = defineStore('meeting', () => {
producer = await _engine.produce({ producer = await _engine.produce({
kind: 'video', kind: 'video',
track, track,
encodings: [
{
maxBitrate: 2500000,
maxFramerate: 15
}
],
codecOptions: {
videoGoogleStartBitrate: 1500,
videoGoogleMinBitrate: 800,
videoGoogleMaxBitrate: 2500
},
appData: { screen: true } appData: { screen: true }
}) })
} catch (err) { } catch (err) {
@@ -1321,6 +1339,12 @@ export const useMeetingStore = defineStore('meeting', () => {
throw err throw err
} }
localProducers.screen = producer.id localProducers.screen = producer.id
if (typeof _engine.tuneProducerEncoding === 'function') {
_engine.tuneProducerEncoding(producer.id, {
maxBitrate: 2500000,
maxFramerate: 15
}).catch((err) => _log('warn', '[Meeting] 屏幕共享编码参数设置失败', err))
}
// 浏览器原生"停止共享"按钮:用户从系统 UI 取消时 track 进入 ended // 浏览器原生"停止共享"按钮:用户从系统 UI 取消时 track 进入 ended
// 此时本地 producer.on('trackended') 也会触发关闭,这里加一层保险 // 此时本地 producer.on('trackended') 也会触发关闭,这里加一层保险

View File

@@ -314,6 +314,27 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
return producer return producer
} }
const tuneProducerEncoding = async (producerId, encodingPatch = {}) => {
const producer = producers.get(producerId)
const sender = producer && producer.rtpSender
if (!sender || typeof sender.getParameters !== 'function' || typeof sender.setParameters !== 'function') {
return false
}
try {
const params = sender.getParameters() || {}
params.encodings = Array.isArray(params.encodings) && params.encodings.length
? params.encodings
: [{}]
params.encodings = params.encodings.map((encoding) => ({ ...encoding, ...encodingPatch }))
await sender.setParameters(params)
logger('info', '[MediaEngine] producer 编码参数已更新', { producerId, encodingPatch })
return true
} catch (e) {
logger('warn', '[MediaEngine] producer 编码参数更新失败', e)
return false
}
}
/** /**
* 订阅远端 Producer请求后端创建 Consumer → 本地 consume → 等 track 挂好后 resume * 订阅远端 Producer请求后端创建 Consumer → 本地 consume → 等 track 挂好后 resume
* *
@@ -480,6 +501,7 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
closeProducer, closeProducer,
pauseProducer, pauseProducer,
resumeProducer, resumeProducer,
tuneProducerEncoding,
closeConsumer, closeConsumer,
close, close,
getDevice: () => device, getDevice: () => device,

View File

@@ -55,7 +55,7 @@ export async function createWebRtcTransport(params: {
enableUdp: true, enableUdp: true,
enableTcp: true, enableTcp: true,
preferUdp: true, preferUdp: true,
initialAvailableOutgoingBitrate: 1_000_000, initialAvailableOutgoingBitrate: 4_000_000,
appData: { appData: {
userId: params.userId, userId: params.userId,
direction: params.direction, direction: params.direction,
@@ -63,6 +63,17 @@ export async function createWebRtcTransport(params: {
}, },
}); });
if (params.direction === 'send') {
try {
await transport.setMaxIncomingBitrate(4_000_000);
} catch (err) {
log.warn(
{ transportId: transport.id, err: err instanceof Error ? err.message : String(err) },
'set max incoming bitrate failed',
);
}
}
transport.observer.once('close', () => { transport.observer.once('close', () => {
transportMap.delete(transport.id); transportMap.delete(transport.id);
log.info( log.info(