feat: 添加会议来源平台控制和实时会议状态处理
- 在 `MeetingAccessService` 和 `MeetingCommandService` 中添加 `assertCanControlRealtimeMeeting` 方法,支持不同平台的实时会议控制 - 更新 `createMeeting` 和 `createRealtimeMeeting` 方法,以包含 `meetingSource` 参数 - 在前端 `Meetings.tsx` 和 `RealtimeAsrSession.tsx` 中添加对跨平台实时会议的控制逻辑 - 更新数据库表结构和文档,添加 `meeting_type` 和 `meeting_source` 字段 - 更新相关测试类以验证新的控制逻辑dev_na
parent
8cdac8ad9f
commit
0b8014d1af
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = "音频保存说明")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue