feat: 添加会议来源平台控制和实时会议状态处理

- 在 `MeetingAccessService` 和 `MeetingCommandService` 中添加 `assertCanControlRealtimeMeeting` 方法,支持不同平台的实时会议控制
- 更新 `createMeeting` 和 `createRealtimeMeeting` 方法,以包含 `meetingSource` 参数
- 在前端 `Meetings.tsx` 和 `RealtimeAsrSession.tsx` 中添加对跨平台实时会议的控制逻辑
- 更新数据库表结构和文档,添加 `meeting_type` 和 `meeting_source` 字段
- 更新相关测试类以验证新的控制逻辑
dev_na
chenhao 2026-04-23 17:53:12 +08:00
parent 8cdac8ad9f
commit 0b8014d1af
21 changed files with 287 additions and 97 deletions

View File

@ -347,6 +347,8 @@
| participants | TEXT | | 鍙備細浜轰俊鎭?|
| tags | VARCHAR(255) | | 鏍囩 |
| audio_url | VARCHAR(500) | | 涓撳睘闊抽璺緞 |
| meeting_type | VARCHAR(32) | | 会议类型OFFLINE / REALTIME |
| meeting_source | VARCHAR(32) | | 会议来源平台WEB / ANDROID |
| creator_id | BIGINT | | 鍙戣捣浜篒D |
| creator_name | VARCHAR(100) | | 鍙戣捣浜哄鍚?|
| latest_summary_task_id | BIGINT | | 鏈€鏂版垚鍔熸€荤粨浠诲姟ID |

View File

@ -403,6 +403,8 @@ CREATE TABLE biz_meetings (
participants TEXT,
tags VARCHAR(255),
audio_url VARCHAR(500),
meeting_type VARCHAR(32), -- OFFLINE / REALTIME
meeting_source VARCHAR(32), -- WEB / ANDROID
creator_id BIGINT, -- 发起人ID
creator_name VARCHAR(100), -- 发起人姓名
latest_summary_task_id BIGINT, -- 最新成功总结任务ID

View File

@ -1,5 +1,6 @@
package com.imeeting.controller.android;
import com.imeeting.common.MeetingConstants;
import com.imeeting.config.grpc.GrpcServerProperties;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidCreateRealtimeMeetingCommand;
@ -91,7 +92,8 @@ public class AndroidMeetingRealtimeController {
createCommand,
authContext.getTenantId(),
authContext.getUserId(),
resolveCreatorName(authContext)
resolveCreatorName(authContext),
MeetingConstants.SOURCE_ANDROID
);
RealtimeMeetingSessionStatusVO status = realtimeMeetingSessionStateService.getStatus(meeting.getId());
@ -158,7 +160,7 @@ public class AndroidMeetingRealtimeController {
public ApiResponse<RealtimeMeetingSessionStatusVO> pauseRealtimeMeeting(@PathVariable Long id, HttpServletRequest request) {
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingConstants.SOURCE_ANDROID);
return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id));
}
@ -176,7 +178,7 @@ public class AndroidMeetingRealtimeController {
@RequestBody(required = false) RealtimeMeetingCompleteDTO dto) {
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingConstants.SOURCE_ANDROID);
meetingCommandService.completeRealtimeMeeting(
id,
dto != null ? dto.getAudioUrl() : null,
@ -199,7 +201,7 @@ public class AndroidMeetingRealtimeController {
@RequestBody(required = false) AndroidOpenRealtimeGrpcSessionCommand command) {
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingConstants.SOURCE_ANDROID);
return ApiResponse.ok(androidRealtimeSessionTicketService.createSession(id, command, authContext));
}

View File

@ -1,5 +1,6 @@
package com.imeeting.controller.biz;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
@ -141,7 +142,8 @@ public class MeetingController {
command,
loginUser.getTenantId(),
loginUser.getUserId(),
resolveCreatorName(loginUser)
resolveCreatorName(loginUser),
MeetingConstants.SOURCE_WEB
));
}
@ -155,7 +157,8 @@ public class MeetingController {
command,
loginUser.getTenantId(),
loginUser.getUserId(),
resolveCreatorName(loginUser)
resolveCreatorName(loginUser),
MeetingConstants.SOURCE_WEB
));
}
@ -269,7 +272,7 @@ public class MeetingController {
public ApiResponse<Boolean> appendRealtimeTranscripts(@PathVariable Long id, @RequestBody List<RealtimeTranscriptItemDTO> items) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB);
meetingCommandService.appendRealtimeTranscripts(id, items);
return ApiResponse.ok(true);
}
@ -280,7 +283,7 @@ public class MeetingController {
public ApiResponse<RealtimeMeetingSessionStatusVO> pauseRealtimeMeeting(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB);
return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id));
}
@ -311,7 +314,7 @@ public class MeetingController {
public ApiResponse<Boolean> completeRealtimeMeeting(@PathVariable Long id, @RequestBody(required = false) RealtimeMeetingCompleteDTO dto) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB);
meetingCommandService.completeRealtimeMeeting(
id,
dto != null ? dto.getAudioUrl() : null,

View File

@ -37,6 +37,10 @@ public class MeetingVO {
private String tags;
@Schema(description = "音频地址")
private String audioUrl;
@Schema(description = "会议类型")
private String meetingType;
@Schema(description = "会议来源")
private String meetingSource;
@Schema(description = "音频保存状态")
private String audioSaveStatus;
@Schema(description = "音频保存说明")

View File

@ -35,6 +35,12 @@ public class Meeting extends BaseEntity {
@Schema(description = "音频地址")
private String audioUrl;
@Schema(description = "会议类型")
private String meetingType;
@Schema(description = "会议来源")
private String meetingSource;
@Schema(description = "音频保存状态")
private String audioSaveStatus;

View File

@ -1,6 +1,7 @@
package com.imeeting.service.android.legacy.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.dto.biz.MeetingVO;
@ -73,6 +74,8 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
joinIds(request.getAttendeeIds()),
normalizeTags(request.getTags()),
null,
MeetingConstants.TYPE_OFFLINE,
MeetingConstants.SOURCE_ANDROID,
loginUser.getTenantId(),
loginUser.getUserId(),
resolveCreatorName(loginUser),

View File

@ -18,5 +18,7 @@ public interface MeetingAccessService {
void assertCanManageRealtimeMeeting(Meeting meeting, LoginUser loginUser);
void assertCanControlRealtimeMeeting(Meeting meeting, LoginUser loginUser, String currentPlatform);
void assertCanExportMeeting(Meeting meeting, LoginUser loginUser);
}

View File

@ -9,4 +9,6 @@ public interface MeetingAuthorizationService {
void assertCanViewMeeting(Meeting meeting, AndroidAuthContext authContext);
void assertCanManageRealtimeMeeting(Meeting meeting, AndroidAuthContext authContext);
void assertCanControlRealtimeMeeting(Meeting meeting, AndroidAuthContext authContext, String currentPlatform);
}

View File

@ -10,9 +10,9 @@ import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
import java.util.List;
public interface MeetingCommandService {
MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName);
MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource);
MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName);
MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource);
void deleteMeeting(Long id);

View File

@ -1,5 +1,6 @@
package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.biz.MeetingAccessService;
@ -96,6 +97,20 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
throw new RuntimeException("No permission to manage this realtime meeting");
}
@Override
public void assertCanControlRealtimeMeeting(Meeting meeting, LoginUser loginUser, String currentPlatform) {
assertCanManageRealtimeMeeting(meeting, loginUser);
if (!MeetingConstants.TYPE_REALTIME.equalsIgnoreCase(meeting.getMeetingType())) {
throw new RuntimeException("Current meeting is not a realtime meeting");
}
if (meeting.getMeetingSource() == null || meeting.getMeetingSource().isBlank()) {
return;
}
if (!meeting.getMeetingSource().equalsIgnoreCase(currentPlatform)) {
throw new RuntimeException("Cross-platform realtime takeover is not allowed");
}
}
@Override
public void assertCanExportMeeting(Meeting meeting, LoginUser loginUser) {
if (isPlatformAdmin(loginUser)) {

View File

@ -1,5 +1,6 @@
package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.service.biz.MeetingAccessService;
@ -38,6 +39,22 @@ public class MeetingAuthorizationServiceImpl implements MeetingAuthorizationServ
meetingAccessService.assertCanManageRealtimeMeeting(meeting, requireUser(authContext));
}
@Override
public void assertCanControlRealtimeMeeting(Meeting meeting, AndroidAuthContext authContext, String currentPlatform) {
if (allowAnonymous(authContext)) {
if (!MeetingConstants.TYPE_REALTIME.equalsIgnoreCase(meeting.getMeetingType())) {
throw new RuntimeException("Current meeting is not a realtime meeting");
}
if (meeting.getMeetingSource() != null
&& !meeting.getMeetingSource().isBlank()
&& !meeting.getMeetingSource().equalsIgnoreCase(currentPlatform)) {
throw new RuntimeException("Cross-platform realtime takeover is not allowed");
}
return;
}
meetingAccessService.assertCanControlRealtimeMeeting(meeting, requireUser(authContext), currentPlatform);
}
private boolean allowAnonymous(AndroidAuthContext authContext) {
return authContext != null && authContext.isAnonymous();
}

View File

@ -3,6 +3,7 @@ package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
@ -61,12 +62,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName) {
public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) {
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
command.getAudioUrl(), tenantId, creatorId, creatorName, hostUserId, hostName, 0);
command.getAudioUrl(), MeetingConstants.TYPE_OFFLINE, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
meetingService.save(meeting);
AiTask asrTask = new AiTask();
@ -111,12 +112,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName) {
public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) {
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
null, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
null, MeetingConstants.TYPE_REALTIME, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
meetingService.save(meeting);
meetingDomainSupport.createSummaryTask(
meeting.getId(),

View File

@ -49,13 +49,16 @@ public class MeetingDomainSupport {
private String uploadPath;
public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags,
String audioUrl, Long tenantId, Long creatorId, String creatorName,
String audioUrl, String meetingType, String meetingSource,
Long tenantId, Long creatorId, String creatorName,
Long hostUserId, String hostName, int status) {
Meeting meeting = new Meeting();
meeting.setTitle(title);
meeting.setMeetingTime(meetingTime);
meeting.setParticipants(participants);
meeting.setTags(tags);
meeting.setMeetingType(meetingType);
meeting.setMeetingSource(meetingSource);
meeting.setCreatorId(creatorId);
meeting.setCreatorName(creatorName);
meeting.setHostUserId(hostUserId);
@ -265,6 +268,8 @@ public class MeetingDomainSupport {
vo.setMeetingTime(meeting.getMeetingTime());
vo.setTags(meeting.getTags());
vo.setAudioUrl(meeting.getAudioUrl());
vo.setMeetingType(meeting.getMeetingType());
vo.setMeetingSource(meeting.getMeetingSource());
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
vo.setAccessPassword(meeting.getAccessPassword());

View File

@ -1,6 +1,7 @@
package com.imeeting.service.biz.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
@ -48,7 +49,7 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
}
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB);
realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, loginUser.getTenantId(), loginUser.getUserId());
realtimeMeetingSessionStateService.assertCanOpenSession(meetingId);

View File

@ -1,6 +1,7 @@
package com.imeeting.service.realtime.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys;
import com.imeeting.config.grpc.GrpcServerProperties;
import com.imeeting.dto.android.AndroidAuthContext;
@ -92,7 +93,7 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
throw new RuntimeException("Meeting ID is required");
}
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingConstants.SOURCE_ANDROID);
realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, meeting.getTenantId(), meeting.getCreatorId());
RealtimeMeetingSessionStatusVO currentStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
RealtimeMeetingResumeConfig currentResumeConfig = currentStatus == null ? null : currentStatus.getResumeConfig();

View File

@ -1,14 +1,13 @@
package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.MeetingMapper;
import com.unisbase.security.LoginUser;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class MeetingAccessServiceImplTest {
@ -16,32 +15,41 @@ class MeetingAccessServiceImplTest {
private final MeetingAccessServiceImpl service = new MeetingAccessServiceImpl(mock(MeetingMapper.class));
@Test
void previewPasswordShouldBeOptionalWhenMeetingHasNoPassword() {
Meeting meeting = new Meeting();
meeting.setAccessPassword(" ");
void allowsRealtimeControlFromSourcePlatform() {
Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, MeetingConstants.SOURCE_ANDROID);
LoginUser loginUser = buildLoginUser();
assertFalse(service.isPreviewPasswordRequired(meeting));
assertDoesNotThrow(() -> service.assertCanPreviewMeeting(meeting, null));
assertDoesNotThrow(() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_ANDROID));
}
@Test
void previewPasswordShouldRejectMissingOrWrongPassword() {
Meeting meeting = new Meeting();
meeting.setAccessPassword("123456");
void rejectsCrossPlatformRealtimeControl() {
Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, MeetingConstants.SOURCE_ANDROID);
LoginUser loginUser = buildLoginUser();
RuntimeException missingError = assertThrows(RuntimeException.class, () -> service.assertCanPreviewMeeting(meeting, null));
assertEquals("Access password is required", missingError.getMessage());
RuntimeException wrongError = assertThrows(RuntimeException.class, () -> service.assertCanPreviewMeeting(meeting, "654321"));
assertEquals("Access password is incorrect", wrongError.getMessage());
assertThrows(RuntimeException.class,
() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB));
}
@Test
void previewPasswordShouldAllowTrimmedMatch() {
Meeting meeting = new Meeting();
meeting.setAccessPassword(" 123456 ");
void rejectsRealtimeControlForOfflineMeeting() {
Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingConstants.SOURCE_WEB);
LoginUser loginUser = buildLoginUser();
assertTrue(service.isPreviewPasswordRequired(meeting));
assertDoesNotThrow(() -> service.assertCanPreviewMeeting(meeting, " 123456 "));
assertThrows(RuntimeException.class,
() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB));
}
private Meeting buildMeeting(String meetingType, String meetingSource) {
Meeting meeting = new Meeting();
meeting.setTenantId(100L);
meeting.setCreatorId(200L);
meeting.setMeetingType(meetingType);
meeting.setMeetingSource(meetingSource);
return meeting;
}
private LoginUser buildLoginUser() {
return new LoginUser(200L, 100L, "tester", false, false, null);
}
}

View File

@ -16,6 +16,8 @@ export interface MeetingVO {
participantIds?: number[];
tags: string;
audioUrl: string;
meetingType?: "OFFLINE" | "REALTIME";
meetingSource?: "WEB" | "ANDROID";
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string;
accessPassword?: string;

View File

@ -56,11 +56,18 @@ import {SysUser} from '../../types';
const { Text, Title } = Typography;
const { Option } = Select;
const CURRENT_PLATFORM = 'WEB' as const;
const PAUSED_DISPLAY_STATUS = 5;
const REALTIME_ACTIVE_DISPLAY_STATUS = 6;
const REALTIME_IDLE_DISPLAY_STATUS = 7;
const isRealtimeMeetingCandidate = (item: MeetingVO) => item.status === 0 && !item.audioUrl;
const isRealtimeMeetingCandidate = (item: MeetingVO) =>
item.meetingType === 'REALTIME' || (!item.meetingType && item.status === 0 && !item.audioUrl);
const canControlRealtimeFromCurrentPlatform = (item: MeetingVO) =>
!item.meetingSource || item.meetingSource === CURRENT_PLATFORM;
const getRealtimeSourceLabel = (item: MeetingVO) => item.meetingSource === 'ANDROID' ? '安卓端' : 'Web 端';
const isPausedRealtimeSessionStatus = (status?: RealtimeMeetingSessionStatus["status"]) =>
status === 'PAUSED_EMPTY' || status === 'PAUSED_RESUMABLE';
@ -188,6 +195,8 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS;
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item);
const crossPlatformHint = `该实时会议需在${getRealtimeSourceLabel(item)}继续`;
return (
<List.Item>
@ -247,6 +256,25 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
{progress?.message || '等待引擎调度...'}
</Text>
</div>
) : isCrossPlatformRealtime ? (
<div style={{
fontSize: '12px',
color: '#8c8c8c',
display: 'flex',
alignItems: 'center',
background: '#f5f5f5',
padding: '6px 10px',
borderRadius: 6,
marginTop: 4,
width: '100%',
boxSizing: 'border-box',
overflow: 'hidden'
}}>
<InfoCircleOutlined style={{ marginRight: 6, flexShrink: 0 }} />
<Text ellipsis style={{ color: 'inherit', fontSize: '12px', fontWeight: 500, flex: 1, minWidth: 0 }}>
{crossPlatformHint}
</Text>
</div>
) : isPaused ? (
<div style={{
fontSize: '12px',
@ -399,6 +427,12 @@ const Meetings: React.FC = () => {
return;
}
if (!canControlRealtimeFromCurrentPlatform(meeting)) {
message.info(`该实时会议需在${getRealtimeSourceLabel(meeting)}继续,当前仅支持查看详情`);
navigate(`/meetings/${meeting.id}`);
return;
}
if (canOpenRealtimeSession(meeting.realtimeSessionStatus)) {
navigate(`/meeting-live-session/${meeting.id}`);
return;

View File

@ -33,6 +33,7 @@ import {
const { Text, Title } = Typography;
const SAMPLE_RATE = 16000;
const CHUNK_SIZE = 1280;
const CURRENT_PLATFORM = "WEB" as const;
type WsSpeaker = string | { name?: string; user_id?: string | number } | undefined;
type WsMessage = {
@ -229,6 +230,7 @@ export function RealtimeAsrSession() {
);
const statusColor = recording ? "#1677ff" : connecting || finishing ? "#faad14" : "#94a3b8";
const hasRemoteActiveConnection = Boolean(sessionStatus?.activeConnection) && !recording && !connecting;
const canControlCurrentMeeting = !meeting?.meetingSource || meeting.meetingSource === CURRENT_PLATFORM;
useEffect(() => {
if (!meetingId || Number.isNaN(meetingId)) {
@ -248,6 +250,17 @@ export function RealtimeAsrSession() {
]);
const detail = detailRes.data.data;
const realtimeStatus = statusRes.data.data;
if (detail.meetingType && detail.meetingType !== "REALTIME") {
message.warning("当前会议不是实时会议,无法进入实时控制页");
navigate(`/meetings/${meetingId}`);
return;
}
if (detail.meetingSource && detail.meetingSource !== CURRENT_PLATFORM) {
const sourceLabel = detail.meetingSource === "ANDROID" ? "安卓端" : "Web 端";
message.warning(`该实时会议需在${sourceLabel}继续,当前仅支持查看详情`);
navigate(`/meetings/${meetingId}`);
return;
}
setMeeting(detail);
setSessionStatus(realtimeStatus);
const fallbackDraft = buildDraftFromStatus(meetingId, detail, realtimeStatus);
@ -429,6 +442,10 @@ export function RealtimeAsrSession() {
if (!meetingId || pausing || finishing || (!recording && !connecting)) {
return;
}
if (!canControlCurrentMeeting) {
message.error("当前会议不允许在 Web 端继续实时控制");
return;
}
setPausing(true);
setStatusText("暂停识别中...");
@ -460,6 +477,10 @@ export function RealtimeAsrSession() {
message.error("未找到实时识别配置,请返回创建页重新进入");
return;
}
if (!canControlCurrentMeeting) {
message.error("当前会议不允许在 Web 端继续实时控制");
return;
}
if (recording || connecting) {
return;
}
@ -571,6 +592,10 @@ export function RealtimeAsrSession() {
if (!meetingId || completeOnceRef.current) {
return;
}
if (!canControlCurrentMeeting) {
message.error("当前会议不允许在 Web 端继续实时控制");
return;
}
completeOnceRef.current = true;
setFinishing(true);

View File

@ -1,5 +1,5 @@
import { Avatar, Button, Card, Col, DatePicker, Divider, Drawer, Empty, Form, Input, List, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, message } from "antd";
import { useEffect, useState } from "react";
import { Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Pagination, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, message } from "antd";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
@ -7,7 +7,7 @@ import { createTenant, deleteTenant, listTenants, updateTenant } from "@/api";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import AppPagination from "@/components/shared/AppPagination";
import { getStandardPagination } from "@/utils/pagination";
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import type { SysTenant } from "@/types";
@ -21,38 +21,40 @@ export default function Tenants() {
const [saving, setSaving] = useState(false);
const [data, setData] = useState<SysTenant[]>([]);
const [total, setTotal] = useState(0);
const [params, setParams] = useState({ current: 1, size: 12, name: "", code: "" });
const [queryParams, setQueryParams] = useState({ current: 1, size: 12, name: "", code: "" });
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysTenant | null>(null);
const [adminAccountTouched, setAdminAccountTouched] = useState(false);
const [form] = Form.useForm();
const [searchForm] = Form.useForm();
const watchedTenantCode = Form.useWatch("tenantCode", form);
const loadData = async (currentParams = params) => {
const loadData = useCallback(async (params = queryParams) => {
setLoading(true);
try {
const result = await listTenants(currentParams);
const result = await listTenants(params);
setData(result.records || []);
setTotal(result.total || 0);
} finally {
setLoading(false);
}
};
}, [queryParams]);
useEffect(() => {
loadData();
}, [params.current, params.size]);
}, [loadData, queryParams.current, queryParams.size]);
const handleSearch = () => {
const nextParams = { ...params, current: 1 };
setParams(nextParams);
loadData(nextParams);
const handleSearch = (values: any) => {
setQueryParams((prev) => ({ ...prev, ...values, current: 1 }));
};
const handleReset = () => {
const resetParams = { current: 1, size: 12, name: "", code: "" };
setParams(resetParams);
loadData(resetParams);
searchForm.resetFields();
setQueryParams({ current: 1, size: 12, name: "", code: "" });
};
const handlePageChange = (page: number, size: number) => {
setQueryParams((prev) => ({ ...prev, current: page, size }));
};
const openCreate = () => {
@ -79,15 +81,18 @@ export default function Tenants() {
}, [adminAccountTouched, drawerOpen, editing, form, watchedTenantCode]);
const handleDelete = async (id: number) => {
await deleteTenant(id);
message.success(t("common.success"));
loadData();
try {
await deleteTenant(id);
message.success(t("common.success"));
loadData();
} catch {
}
};
const submit = async () => {
const values = await form.validateFields();
setSaving(true);
try {
const values = await form.validateFields();
setSaving(true);
const payload = {
...values,
defaultAdminUsername: values.defaultAdminUsername?.trim(),
@ -110,65 +115,102 @@ export default function Tenants() {
const statusItem = statusDict.find((dictItem) => dictItem.itemValue === String(item.status));
return (
<List.Item>
<List.Item style={{ height: '100%' }}>
<Card
hoverable
className="tenant-card shadow-sm border-0"
style={{ borderRadius: "12px", overflow: "hidden" }}
style={{ borderRadius: "12px", overflow: "hidden", height: "100%", display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column' } }}
actions={[
can("sys_tenant:update") && <Tooltip title={t("common.edit")} key="edit-tip"><EditOutlined key="edit" onClick={() => openEdit(item)} style={{ color: "#1677ff" }} /></Tooltip>,
can("sys_tenant:delete") && <Popconfirm key="delete-pop" title={t("tenantsExt.deleteConfirm", { name: item.tenantName })} okText={t("common.confirm")} cancelText={t("common.cancel")} onConfirm={() => handleDelete(item.id)}><DeleteOutlined key="delete" style={{ color: "#ff4d4f" }} /></Popconfirm>
can("sys_tenant:delete") && (
<Popconfirm
key="delete-pop"
title={t("tenantsExt.deleteConfirm", { name: item.tenantName })}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
onConfirm={() => handleDelete(item.id)}
>
<DeleteOutlined key="delete" style={{ color: "#ff4d4f" }} />
</Popconfirm>
)
].filter(Boolean) as React.ReactNode[]}
>
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: 16 }}>
<Avatar size={48} icon={<ShopOutlined />} style={{ backgroundColor: item.status === 1 ? "#e6f4ff" : "#fff1f0", color: item.status === 1 ? "#1677ff" : "#ff4d4f", marginRight: 12, borderRadius: "8px" }} />
<Avatar
size={48}
icon={<ShopOutlined />}
style={{ backgroundColor: item.status === 1 ? "#e6f4ff" : "#fff1f0", color: item.status === 1 ? "#1677ff" : "#ff4d4f", marginRight: 12, borderRadius: "8px" }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0, fontSize: "16px" }} ellipsis={{ tooltip: item.tenantName }}>{item.tenantName}</Title>
<Tag color={item.status === 1 ? "green" : "red"} style={{ margin: 0, borderRadius: "4px" }}>{statusItem ? statusItem.itemLabel : item.status === 1 ? "Enabled" : "Disabled"}</Tag>
<Tag color={item.status === 1 ? "green" : "red"} style={{ margin: 0, borderRadius: "4px" }}>
{statusItem ? statusItem.itemLabel : item.status === 1 ? "Enabled" : "Disabled"}
</Tag>
</div>
<Text type="secondary" style={{ fontSize: "12px" }} className="tabular-nums">CODE: {item.tenantCode}</Text>
</div>
</div>
<div className="card-content" style={{ fontSize: "13px" }}>
<div className="card-content" style={{ fontSize: "13px", flex: 1 }}>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<div style={{ display: "flex", alignItems: "center", color: "#595959" }}><UserOutlined style={{ marginRight: 8, color: "#bfbfbf" }} /><Text ellipsis={{ tooltip: item.contactName || "-" }}>{item.contactName || "-"}</Text></div>
<div style={{ display: "flex", alignItems: "center", color: "#595959" }}><PhoneOutlined style={{ marginRight: 8, color: "#bfbfbf" }} /><Text className="tabular-nums">{item.contactPhone || "-"}</Text></div>
<div style={{ display: "flex", alignItems: "center", color: "#595959" }}>
<UserOutlined style={{ marginRight: 8, color: "#bfbfbf" }} />
<Text ellipsis={{ tooltip: item.contactName || "-" }}>{item.contactName || "-"}</Text>
</div>
<div style={{ display: "flex", alignItems: "center", color: "#595959" }}>
<PhoneOutlined style={{ marginRight: 8, color: "#bfbfbf" }} />
<Text className="tabular-nums">{item.contactPhone || "-"}</Text>
</div>
</Space>
{item.remark && (
<>
<Divider style={{ margin: "12px 0" }} />
<Paragraph ellipsis={{ rows: 2, tooltip: item.remark }} style={{ margin: 0, fontSize: "12px", color: "#8c8c8c", height: "36px" }}>
{item.remark}
</Paragraph>
</>
)}
</div>
{item.remark && (
<>
<Divider style={{ margin: "12px 0" }} />
<Paragraph ellipsis={{ rows: 2, tooltip: item.remark }} style={{ margin: 0, fontSize: "12px", color: "#8c8c8c", height: "36px" }}>{item.remark}</Paragraph>
</>
)}
</Card>
</List.Item>
);
};
return (
<div className="app-page">
<PageHeader title={t("tenants.title")} subtitle={t("tenants.subtitle")} />
<div className="app-page" style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', padding: '24px' }}>
<div className="flex-shrink-0">
<PageHeader title={t("tenants.title")} subtitle={t("tenants.subtitle")} />
</div>
<Card className="app-page__filter-card border-0" style={{ borderRadius: "12px" }} styles={{ body: { padding: "16px" } }}>
<Space wrap size="middle" className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
<Space wrap size="middle" className="app-page__toolbar">
<Input placeholder={t("tenants.tenantName")} prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />} style={{ width: 220, borderRadius: "6px" }} value={params.name} onChange={(event) => setParams({ ...params, name: event.target.value })} allowClear />
<Input placeholder={t("tenants.tenantCode")} style={{ width: 180, borderRadius: "6px" }} value={params.code} onChange={(event) => setParams({ ...params, code: event.target.value })} allowClear />
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch} style={{ borderRadius: "6px" }}>{t("common.search")}</Button>
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset} style={{ borderRadius: "6px" }}>{t("common.reset")}</Button>
</Space>
{can("sys_tenant:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate} style={{ borderRadius: "6px" }}>{t("common.create")}</Button>}
</Space>
</Card>
<div className="flex-shrink-0">
<Card className="app-page__filter-card" styles={{ body: { padding: "16px" } }} style={{ marginBottom: '16px' }}>
<div className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
<Form form={searchForm} layout="inline" onFinish={handleSearch} className="app-page__toolbar">
<Form.Item name="name">
<Input placeholder={t("tenants.tenantName")} prefix={<SearchOutlined />} allowClear style={{ width: 200 }} />
</Form.Item>
<Form.Item name="code">
<Input placeholder={t("tenants.tenantCode")} allowClear style={{ width: 150 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">{t("common.search")}</Button>
<Button onClick={handleReset}>{t("common.reset")}</Button>
</Space>
</Form.Item>
</Form>
{can("sys_tenant:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{t("common.create")}</Button>}
</div>
</Card>
</div>
<Card
className="app-page__content-card shadow-sm"
style={{ flex: 1, minHeight: 0 }}
className="app-page__content-card"
style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflowX: "hidden", overflowY: "auto", padding: "24px" }}>
<div style={{ flex: 1, overflowY: "auto", padding: "24px" }}>
<List
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
loading={loading}
@ -178,15 +220,28 @@ export default function Tenants() {
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }}
/>
</div>
<AppPagination
current={params.current}
pageSize={params.size}
total={total}
onChange={(page, size) => setParams({ ...params, current: page, size: size || params.size })}
/>
<div style={{ flexShrink: 0, borderTop: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)' }}>
<Pagination
{...getStandardPagination(total, queryParams.current, queryParams.size, handlePageChange)}
className="app-global-pagination"
style={{
margin: 0,
padding: "12px 24px",
borderRadius: "0 0 16px 16px"
}}
/>
</div>
</Card>
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Drawer
title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={480}
destroyOnClose
footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}
>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>