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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,4 +9,6 @@ public interface MeetingAuthorizationService {
void assertCanViewMeeting(Meeting meeting, AndroidAuthContext authContext); void assertCanViewMeeting(Meeting meeting, AndroidAuthContext authContext);
void assertCanManageRealtimeMeeting(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; import java.util.List;
public interface MeetingCommandService { 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); void deleteMeeting(Long id);

View File

@ -1,5 +1,6 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.biz.MeetingAccessService; 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"); 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 @Override
public void assertCanExportMeeting(Meeting meeting, LoginUser loginUser) { public void assertCanExportMeeting(Meeting meeting, LoginUser loginUser) {
if (isPlatformAdmin(loginUser)) { if (isPlatformAdmin(loginUser)) {

View File

@ -1,5 +1,6 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingAccessService;
@ -38,6 +39,22 @@ public class MeetingAuthorizationServiceImpl implements MeetingAuthorizationServ
meetingAccessService.assertCanManageRealtimeMeeting(meeting, requireUser(authContext)); 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) { private boolean allowAnonymous(AndroidAuthContext authContext) {
return authContext != null && authContext.isAnonymous(); 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.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys; import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
@ -61,12 +62,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @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); RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), 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); meetingService.save(meeting);
AiTask asrTask = new AiTask(); AiTask asrTask = new AiTask();
@ -111,12 +112,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @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); RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), 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); meetingService.save(meeting);
meetingDomainSupport.createSummaryTask( meetingDomainSupport.createSummaryTask(
meeting.getId(), meeting.getId(),

View File

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

View File

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

View File

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

View File

@ -1,14 +1,13 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingMapper;
import com.unisbase.security.LoginUser;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
class MeetingAccessServiceImplTest { class MeetingAccessServiceImplTest {
@ -16,32 +15,41 @@ class MeetingAccessServiceImplTest {
private final MeetingAccessServiceImpl service = new MeetingAccessServiceImpl(mock(MeetingMapper.class)); private final MeetingAccessServiceImpl service = new MeetingAccessServiceImpl(mock(MeetingMapper.class));
@Test @Test
void previewPasswordShouldBeOptionalWhenMeetingHasNoPassword() { void allowsRealtimeControlFromSourcePlatform() {
Meeting meeting = new Meeting(); Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, MeetingConstants.SOURCE_ANDROID);
meeting.setAccessPassword(" "); LoginUser loginUser = buildLoginUser();
assertFalse(service.isPreviewPasswordRequired(meeting)); assertDoesNotThrow(() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_ANDROID));
assertDoesNotThrow(() -> service.assertCanPreviewMeeting(meeting, null));
} }
@Test @Test
void previewPasswordShouldRejectMissingOrWrongPassword() { void rejectsCrossPlatformRealtimeControl() {
Meeting meeting = new Meeting(); Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, MeetingConstants.SOURCE_ANDROID);
meeting.setAccessPassword("123456"); LoginUser loginUser = buildLoginUser();
RuntimeException missingError = assertThrows(RuntimeException.class, () -> service.assertCanPreviewMeeting(meeting, null)); assertThrows(RuntimeException.class,
assertEquals("Access password is required", missingError.getMessage()); () -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB));
RuntimeException wrongError = assertThrows(RuntimeException.class, () -> service.assertCanPreviewMeeting(meeting, "654321"));
assertEquals("Access password is incorrect", wrongError.getMessage());
} }
@Test @Test
void previewPasswordShouldAllowTrimmedMatch() { void rejectsRealtimeControlForOfflineMeeting() {
Meeting meeting = new Meeting(); Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingConstants.SOURCE_WEB);
meeting.setAccessPassword(" 123456 "); LoginUser loginUser = buildLoginUser();
assertTrue(service.isPreviewPasswordRequired(meeting)); assertThrows(RuntimeException.class,
assertDoesNotThrow(() -> service.assertCanPreviewMeeting(meeting, " 123456 ")); () -> 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[]; participantIds?: number[];
tags: string; tags: string;
audioUrl: string; audioUrl: string;
meetingType?: "OFFLINE" | "REALTIME";
meetingSource?: "WEB" | "ANDROID";
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string; audioSaveMessage?: string;
accessPassword?: string; accessPassword?: string;

View File

@ -56,11 +56,18 @@ import {SysUser} from '../../types';
const { Text, Title } = Typography; const { Text, Title } = Typography;
const { Option } = Select; const { Option } = Select;
const CURRENT_PLATFORM = 'WEB' as const;
const PAUSED_DISPLAY_STATUS = 5; const PAUSED_DISPLAY_STATUS = 5;
const REALTIME_ACTIVE_DISPLAY_STATUS = 6; const REALTIME_ACTIVE_DISPLAY_STATUS = 6;
const REALTIME_IDLE_DISPLAY_STATUS = 7; 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"]) => const isPausedRealtimeSessionStatus = (status?: RealtimeMeetingSessionStatus["status"]) =>
status === 'PAUSED_EMPTY' || status === 'PAUSED_RESUMABLE'; 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 isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS; const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS;
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS; const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item);
const crossPlatformHint = `该实时会议需在${getRealtimeSourceLabel(item)}继续`;
return ( return (
<List.Item> <List.Item>
@ -247,6 +256,25 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
{progress?.message || '等待引擎调度...'} {progress?.message || '等待引擎调度...'}
</Text> </Text>
</div> </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 ? ( ) : isPaused ? (
<div style={{ <div style={{
fontSize: '12px', fontSize: '12px',
@ -399,6 +427,12 @@ const Meetings: React.FC = () => {
return; return;
} }
if (!canControlRealtimeFromCurrentPlatform(meeting)) {
message.info(`该实时会议需在${getRealtimeSourceLabel(meeting)}继续,当前仅支持查看详情`);
navigate(`/meetings/${meeting.id}`);
return;
}
if (canOpenRealtimeSession(meeting.realtimeSessionStatus)) { if (canOpenRealtimeSession(meeting.realtimeSessionStatus)) {
navigate(`/meeting-live-session/${meeting.id}`); navigate(`/meeting-live-session/${meeting.id}`);
return; return;

View File

@ -33,6 +33,7 @@ import {
const { Text, Title } = Typography; const { Text, Title } = Typography;
const SAMPLE_RATE = 16000; const SAMPLE_RATE = 16000;
const CHUNK_SIZE = 1280; const CHUNK_SIZE = 1280;
const CURRENT_PLATFORM = "WEB" as const;
type WsSpeaker = string | { name?: string; user_id?: string | number } | undefined; type WsSpeaker = string | { name?: string; user_id?: string | number } | undefined;
type WsMessage = { type WsMessage = {
@ -229,6 +230,7 @@ export function RealtimeAsrSession() {
); );
const statusColor = recording ? "#1677ff" : connecting || finishing ? "#faad14" : "#94a3b8"; const statusColor = recording ? "#1677ff" : connecting || finishing ? "#faad14" : "#94a3b8";
const hasRemoteActiveConnection = Boolean(sessionStatus?.activeConnection) && !recording && !connecting; const hasRemoteActiveConnection = Boolean(sessionStatus?.activeConnection) && !recording && !connecting;
const canControlCurrentMeeting = !meeting?.meetingSource || meeting.meetingSource === CURRENT_PLATFORM;
useEffect(() => { useEffect(() => {
if (!meetingId || Number.isNaN(meetingId)) { if (!meetingId || Number.isNaN(meetingId)) {
@ -248,6 +250,17 @@ export function RealtimeAsrSession() {
]); ]);
const detail = detailRes.data.data; const detail = detailRes.data.data;
const realtimeStatus = statusRes.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); setMeeting(detail);
setSessionStatus(realtimeStatus); setSessionStatus(realtimeStatus);
const fallbackDraft = buildDraftFromStatus(meetingId, detail, realtimeStatus); const fallbackDraft = buildDraftFromStatus(meetingId, detail, realtimeStatus);
@ -429,6 +442,10 @@ export function RealtimeAsrSession() {
if (!meetingId || pausing || finishing || (!recording && !connecting)) { if (!meetingId || pausing || finishing || (!recording && !connecting)) {
return; return;
} }
if (!canControlCurrentMeeting) {
message.error("当前会议不允许在 Web 端继续实时控制");
return;
}
setPausing(true); setPausing(true);
setStatusText("暂停识别中..."); setStatusText("暂停识别中...");
@ -460,6 +477,10 @@ export function RealtimeAsrSession() {
message.error("未找到实时识别配置,请返回创建页重新进入"); message.error("未找到实时识别配置,请返回创建页重新进入");
return; return;
} }
if (!canControlCurrentMeeting) {
message.error("当前会议不允许在 Web 端继续实时控制");
return;
}
if (recording || connecting) { if (recording || connecting) {
return; return;
} }
@ -571,6 +592,10 @@ export function RealtimeAsrSession() {
if (!meetingId || completeOnceRef.current) { if (!meetingId || completeOnceRef.current) {
return; return;
} }
if (!canControlCurrentMeeting) {
message.error("当前会议不允许在 Web 端继续实时控制");
return;
}
completeOnceRef.current = true; completeOnceRef.current = true;
setFinishing(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 { Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Pagination, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, message } from "antd";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons"; import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
@ -7,7 +7,7 @@ import { createTenant, deleteTenant, listTenants, updateTenant } from "@/api";
import { useDict } from "@/hooks/useDict"; import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission"; import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader"; 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 { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import type { SysTenant } from "@/types"; import type { SysTenant } from "@/types";
@ -21,38 +21,40 @@ export default function Tenants() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [data, setData] = useState<SysTenant[]>([]); const [data, setData] = useState<SysTenant[]>([]);
const [total, setTotal] = useState(0); 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 [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysTenant | null>(null); const [editing, setEditing] = useState<SysTenant | null>(null);
const [adminAccountTouched, setAdminAccountTouched] = useState(false); const [adminAccountTouched, setAdminAccountTouched] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [searchForm] = Form.useForm();
const watchedTenantCode = Form.useWatch("tenantCode", form); const watchedTenantCode = Form.useWatch("tenantCode", form);
const loadData = async (currentParams = params) => { const loadData = useCallback(async (params = queryParams) => {
setLoading(true); setLoading(true);
try { try {
const result = await listTenants(currentParams); const result = await listTenants(params);
setData(result.records || []); setData(result.records || []);
setTotal(result.total || 0); setTotal(result.total || 0);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [queryParams]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [params.current, params.size]); }, [loadData, queryParams.current, queryParams.size]);
const handleSearch = () => { const handleSearch = (values: any) => {
const nextParams = { ...params, current: 1 }; setQueryParams((prev) => ({ ...prev, ...values, current: 1 }));
setParams(nextParams);
loadData(nextParams);
}; };
const handleReset = () => { const handleReset = () => {
const resetParams = { current: 1, size: 12, name: "", code: "" }; searchForm.resetFields();
setParams(resetParams); setQueryParams({ current: 1, size: 12, name: "", code: "" });
loadData(resetParams); };
const handlePageChange = (page: number, size: number) => {
setQueryParams((prev) => ({ ...prev, current: page, size }));
}; };
const openCreate = () => { const openCreate = () => {
@ -79,15 +81,18 @@ export default function Tenants() {
}, [adminAccountTouched, drawerOpen, editing, form, watchedTenantCode]); }, [adminAccountTouched, drawerOpen, editing, form, watchedTenantCode]);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try {
await deleteTenant(id); await deleteTenant(id);
message.success(t("common.success")); message.success(t("common.success"));
loadData(); loadData();
} catch {
}
}; };
const submit = async () => { const submit = async () => {
try {
const values = await form.validateFields(); const values = await form.validateFields();
setSaving(true); setSaving(true);
try {
const payload = { const payload = {
...values, ...values,
defaultAdminUsername: values.defaultAdminUsername?.trim(), defaultAdminUsername: values.defaultAdminUsername?.trim(),
@ -110,65 +115,102 @@ export default function Tenants() {
const statusItem = statusDict.find((dictItem) => dictItem.itemValue === String(item.status)); const statusItem = statusDict.find((dictItem) => dictItem.itemValue === String(item.status));
return ( return (
<List.Item> <List.Item style={{ height: '100%' }}>
<Card <Card
hoverable hoverable
className="tenant-card shadow-sm border-0" 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={[ 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: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[]} ].filter(Boolean) as React.ReactNode[]}
> >
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: 16 }}> <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={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0, fontSize: "16px" }} ellipsis={{ tooltip: item.tenantName }}>{item.tenantName}</Title> <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> </div>
<Text type="secondary" style={{ fontSize: "12px" }} className="tabular-nums">CODE: {item.tenantCode}</Text> <Text type="secondary" style={{ fontSize: "12px" }} className="tabular-nums">CODE: {item.tenantCode}</Text>
</div> </div>
</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%" }}> <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" }}>
<div style={{ display: "flex", alignItems: "center", color: "#595959" }}><PhoneOutlined style={{ marginRight: 8, color: "#bfbfbf" }} /><Text className="tabular-nums">{item.contactPhone || "-"}</Text></div> <UserOutlined style={{ marginRight: 8, color: "#bfbfbf" }} />
</Space> <Text ellipsis={{ tooltip: item.contactName || "-" }}>{item.contactName || "-"}</Text>
</div> </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 && ( {item.remark && (
<> <>
<Divider style={{ margin: "12px 0" }} /> <Divider style={{ margin: "12px 0" }} />
<Paragraph ellipsis={{ rows: 2, tooltip: item.remark }} style={{ margin: 0, fontSize: "12px", color: "#8c8c8c", height: "36px" }}>{item.remark}</Paragraph> <Paragraph ellipsis={{ rows: 2, tooltip: item.remark }} style={{ margin: 0, fontSize: "12px", color: "#8c8c8c", height: "36px" }}>
{item.remark}
</Paragraph>
</> </>
)} )}
</div>
</Card> </Card>
</List.Item> </List.Item>
); );
}; };
return ( return (
<div className="app-page"> <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")} /> <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" } }}> <div className="flex-shrink-0">
<Space wrap size="middle" className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}> <Card className="app-page__filter-card" styles={{ body: { padding: "16px" } }} style={{ marginBottom: '16px' }}>
<Space wrap size="middle" className="app-page__toolbar"> <div className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
<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 /> <Form form={searchForm} layout="inline" onFinish={handleSearch} className="app-page__toolbar">
<Input placeholder={t("tenants.tenantCode")} style={{ width: 180, borderRadius: "6px" }} value={params.code} onChange={(event) => setParams({ ...params, code: event.target.value })} allowClear /> <Form.Item name="name">
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch} style={{ borderRadius: "6px" }}>{t("common.search")}</Button> <Input placeholder={t("tenants.tenantName")} prefix={<SearchOutlined />} allowClear style={{ width: 200 }} />
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset} style={{ borderRadius: "6px" }}>{t("common.reset")}</Button> </Form.Item>
</Space> <Form.Item name="code">
{can("sys_tenant:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate} style={{ borderRadius: "6px" }}>{t("common.create")}</Button>} <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> </Space>
</Form.Item>
</Form>
{can("sys_tenant:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{t("common.create")}</Button>}
</div>
</Card> </Card>
</div>
<Card <Card
className="app-page__content-card shadow-sm" className="app-page__content-card"
style={{ flex: 1, minHeight: 0 }} style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}
styles={{ body: { padding: 0, 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 <List
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
loading={loading} loading={loading}
@ -178,15 +220,28 @@ export default function Tenants() {
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }}
/> />
</div> </div>
<AppPagination
current={params.current} <div style={{ flexShrink: 0, borderTop: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)' }}>
pageSize={params.size} <Pagination
total={total} {...getStandardPagination(total, queryParams.current, queryParams.size, handlePageChange)}
onChange={(page, size) => setParams({ ...params, current: page, size: size || params.size })} className="app-global-pagination"
style={{
margin: 0,
padding: "12px 24px",
borderRadius: "0 0 16px 16px"
}}
/> />
</div>
</Card> </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"> <Form form={form} layout="vertical">
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>